Crash-safe ownership journal for installer-managed entries in a shared settings file
Use when a CLI installer adds a fixed set of entries, such as permission allow rules, to a settings file the user also edits by hand. Covers classifying pre-existing entries as not owned, never touching deny lists, an intent then commit journal with a commit witness, compare-before-remove uninstall that preserves later user edits, conflict reporting, and recovery of a partial uninstall.
Crash-safe ownership journal for installer-managed settings entries
When this applies
A CLI installer writes a small fixed set of entries into a settings file that the developer also edits by hand. The common case is a permissions section with an allow list and a deny list. The installer must later support repair, which restores what it owns, and uninstall, which removes only what it owns. There is no transaction spanning the installer state and the settings file, and the process can crash between the two writes.
Failures this prevents
- Uninstall by set difference deletes an allow the developer had before install because it happens to equal an installer rule.
- Writing settings before recording ownership leaks permissions after a crash. The rules exist, nothing owns them, every later uninstall leaves them in place.
- Repair silently re-grants a permission the developer removed on purpose.
- Any command rewriting the deny list, even to dedupe, changes a security decision the developer made.
- Uninstall restores a pre-install backup or writes from an install-time snapshot and destroys every edit the developer made after install.
- Uninstall removes a rule the developer edited into something else, because it matched loosely.
Ownership model
Classify every managed rule at plan time against the current settings:
- Present in the allow list already: PREEXISTING. Never owned, never removed.
- Matched by an explicit deny, exact match at minimum: DENIED_SKIPPED. Never written.
- Absent: PENDING_ADD, becomes OWNED once the write is proven to have landed.
The deny list is read for classification only. No command adds, removes or reorders deny entries. If a deny later overlaps an owned allow, repair stops re-adding the allow and leaves the existing entry in place; deny wins at evaluation time and removing the allow is an unrequested mutation. Uninstall still removes owned entries regardless of denies.
An owned effect is any node the installer introduced: each allow entry, the installer namespaced witness key if used, and any container such as the permissions object or the allow array if the installer created it.
Write order
Journal first, then settings, then journal again.
- Read settings and compute the plan.
- Write a PENDING journal record: temp file, fsync, rename, directory fsync.
- Write settings atomically the same way, carrying a commit witness.
- Rewrite the journal as COMMITTED with the final state of each rule.
A crash after step 2 leaves a journal claiming rules that are not present. That is harmless: repair re-applies, uninstall finds nothing to remove. A crash after step 3 is resolved by the witness on the next run. The reverse order, settings first, has no safe recovery because ownership is lost.
Journal record
Key the journal by the target settings file so user scope and project scope do not collide. Record:
- schema version
- a random operation token generated per operation
- each managed rule as its verbatim string, never an index into the current version rule list
- the pre-write classification of each rule
- pre-write file identity or content hash
- the planned post-write content hash, computable because the exact bytes are known before writing
- a state per rule: PENDINGADD, OWNED, PREEXISTING, DENIEDSKIPPED, RELEASED, UNINSTALLING
State transitions are monotonic. A pending state never overwrites a committed one on recovery.
Commit witness
Preferred: write the install token into an installer namespaced key inside the settings file in the same atomic replacement as the rules. Token present means the write landed; token absent means it did not. No developer edit can forge either verdict.
Fallback when extra keys are not allowed: compare the current settings hash to the planned and pre-write hashes in the pending record. Planned match means landed. Pre-write match means not landed. Neither means a third party wrote in between, often the host application reformatting the file. Then infer per rule: recorded absent before and present now becomes OWNED; absent now is re-applied by install or repair and dropped by uninstall.
The whole-file hash stops being useful as soon as the developer edits anything, so every decision that matters after install is made per effect, not per file.
Recovery
Run recovery at the start of every command, including status. Resolve every pending record before computing a new plan so all commands see one consistent state. A pending intent is always completed in its own direction; a new command never reverses it. Clean stale temp files named with the operation token.
Read-modify-write safety
Always operate on the live document. Parse it, change only the targeted nodes, serialize preserving key order and other keys, and never write from a cached copy. Snapshot file identity at read time, verify it is unchanged immediately before the rename, and on mismatch re-read and recompute with a bounded number of retries. Keep any pre-install backup for diagnostics only; never restore it.
Uninstall: compare before remove
- Run recovery.
- Read the live document and build a removal plan. For each owned effect: locate the node, compare the current value byte for byte with the recorded value. Match means planned removal. Absent or different means CONFLICT_MISSING and nothing is touched; an edited owned rule is now a developer artifact and the installer cannot prove any new string derived from it. Never fuzzy match. Duplicated means remove the smaller of recorded count and current count. A container is removed only if it is empty and still matches.
- Write an UNINSTALLING intent carrying the full plan: per effect observed state and planned action, pre-write hash, planned post-write hash.
- Write settings atomically with the identity check above.
- Finalize the journal with the report, then delete or archive it.
Ownership conflict report, per effect: removed, left pre-existing, missing, duplicated, witness missing. Conflicts never block removal of the other still-matching effects. Default is success with warnings; a strict flag turns conflicts into a non-zero exit.
Recovery of a partial uninstall
Recovery re-executes compare-before-remove against the live document using the stored plan, which makes it idempotent:
- planned removal, still present: the settings write did not land, remove now
- planned removal, now absent: counts as removed, whether the write landed or the developer removed it in the window, since the end state is identical
- recorded conflict: stays a conflict, still untouched
- intent written but no settings change: identical to a clean uninstall
- settings replaced but journal not finalized: finalize with the reconstructed report
Because the report is reconstructed from the plan, recovery never reports a rule the installer removed itself as a developer conflict. Repair invoked after a crashed uninstall first completes the uninstall and then reports not installed rather than resurrecting the rules.
Repair
Re-add OWNED rules that are missing unless the rule is now denied or marked RELEASED. The installer cannot watch a deliberate deletion happen, so any command that loads settings and finds an owned rule missing outside a pending window records a RELEASED marker. An explicit user-invoked repair may still re-add and report. Silent or automatic repair must respect the marker.
On version upgrade, drive removals from the journal, not the new rule list. Owned rules that dropped out of the set are removed; new rules are classified fresh.
Test matrix to run before shipping
Inject a crash at each of four points: after the pending journal write, after the settings temp write but before rename, after the settings rename, and after the committed journal write. Cross each point with a pre-existing rule, an exact deny, a developer duplicate of a managed rule, and a host reformat of the settings file between crash and recovery.
For uninstall add: owned rule edited, owned rule deleted, unrelated key changed, adjacent allow added, and a concurrent write between read and rename, each crossed with a crash after intent, after rename and after finalize. Assert that unrelated edits survive, the edited rule survives, the conflict list is exact, no still-matching owned effect remains, the deny list is byte-identical, and a second uninstall is a no-op with the same report.