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 ownership by observation, a read-only deny list, an intent then commit journal with a witness, symlink-safe atomic writes, compare-before-remove uninstall, a bounded conflict report, and hash-first recovery of a partial uninstall that never removes a rule the developer re-added.
Crash-safe ownership journal for installer-managed settings entries
When this applies
A CLI installer writes a small fixed set of entries, typically permission allow rules, into a settings file the developer also edits by hand. The installer must support repair, which restores what it owns, and uninstall, which removes only what it owns. No transaction spans the installer's own state and the settings file, and the process can crash between the two writes.
Failures this prevents
- Uninstall by set difference removes an allow the developer had before install because it equals an installer rule.
- Settings written before ownership is recorded leak permissions after a crash; nothing owns the rules, so no later uninstall removes them.
- 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 backup or writes from a snapshot and destroys later developer edits.
- Uninstall removes a rule the developer edited into something else because it matched loosely.
- An atomic rename over a symlinked settings file replaces the link with a plain file, detaching the developer's real file and orphaning the journal.
- An edit saved by the developer or the host application between the installer's read and its rename is lost.
- A host that strips unknown keys makes every uninstall report a spurious conflict on the installer's witness key.
- Recovery of a partial uninstall re-executes a stale plan and removes a rule the developer re-added after the rename landed.
Ownership model
Classify each managed rule at plan time against the live document:
- Already present in the allow list: PREEXISTING. Never owned, never removed, never re-added. This holds even if a deny also matches it.
- Matched by an exact deny entry: DENIED_SKIPPED. Never written.
- Absent: PENDING_ADD, which becomes OWNED once the write is proven to have landed.
The deny list is read for classification only. No command adds, removes, reorders or dedupes deny entries. If a deny later overlaps an owned allow, repair stops re-adding it and leaves the existing entry alone. Uninstall still removes owned entries regardless of denies.
Owned effects are the nodes the installer introduced: each allow entry, the installer namespaced witness key, and any container the installer itself created. Every mutation is an idempotent exact string diff: add this exact string if absent, remove this exact string if present.
Target resolution and locking
Resolve the settings path to its real path before planning. Key the journal by real path, with device and inode as a secondary identity, and write temp files in the real target directory so the rename replaces the file and not a symlink. If a journal cannot be found by path, fall back to looking for the witness token inside the file before declining ownership. Hold an exclusive lock file in the installer state directory for the whole command so two concurrent runs cannot leave settings carrying one token and the journal another.
Write order and journal record
Journal intent, then settings, then journal commit.
- Read the live document, snapshot its identity and hash, compute the plan.
- Write a PENDING record atomically: temp file, fsync, rename, directory fsync. Record schema version, a fresh random operation token, each rule as its verbatim string, its classification, the pre-write hash and the planned post-write hash.
- Verify the file identity is unchanged, then write settings atomically the same way, carrying the token in an installer namespaced key in the same replacement as the rules. On mismatch re-read and recompute with bounded retries. This check applies to install and repair, not only uninstall.
- Rewrite the journal as COMMITTED.
A crash after step 2 leaves a journal claiming absent rules; replay re-adds them. A crash after step 3 is resolved by the witness: token present means landed, mark OWNED; token absent means replay. Settings first has no recovery because ownership is lost. State transitions are monotonic and recovery runs at the start of every command, including status, completing any pending intent in its own direction before a new plan is computed.
Witness rules
The witness proves landing and nothing else. Its absence at uninstall counts as already removed and is never a conflict, because a host that canonicalizes may strip unknown keys. A planned post-write hash is not a safe substitute for proving that an install landed: a host that serializes canonically can produce byte-identical output when the developer adds the same rule through its UI, so a hash match can wrongly claim a developer rule. Where no witness key is possible, the safe default on ambiguity is to treat the write as not landed and replay, since replay is idempotent. In a non-interactive run never block on a prompt; apply that default.
The meaning of the witness flips between install and uninstall. At install the plan adds the key in the same rename as the rules, so key present proves the write landed. At uninstall the plan removes the key in the same rename as the rules, so key still present proves the replacement did not land, and key absent proves nothing on its own.
Repair and the released marker
Re-add OWNED rules that are missing unless now denied or marked RELEASED. Any command that loads settings and finds an owned rule missing outside a pending window records RELEASED. Automatic repair honors it; an explicit repair may re-add and report. The marker is sampled, not observed: if the developer deletes an owned rule and re-adds the identical string before any command runs, the installer cannot tell and uninstall will remove it. State this limit and let uninstall print its plan before writing, with a flag to skip confirmation. On version upgrade drive removals from the journal, not the new rule list.
Uninstall: compare before remove
- Run recovery.
- Read the live document and plan per owned effect. Locate by value, never by index. Byte-identical match means planned removal. Absent or different means conflict and nothing is touched; an edited rule is a developer artifact and any similarity hint is informational only. Duplicated means remove the smaller of recorded count and current count. A container is removed only if installer-created, still matching, and empty after the planned removals are applied.
- Write an UNINSTALLING intent carrying the whole plan: per effect observed state and planned action, plus pre-write and planned post-write hashes.
- Write settings atomically with the identity check.
- Finalize the journal with the report, then delete it last.
Conflicts never block removal of other still-matching effects. Unrelated keys survive because only targeted nodes are mutated. A reserializing parser keeps content but may not keep whitespace or comments; document that or use a format-preserving editor.
Recovery of a partial uninstall
Decide whether the settings replacement landed before touching any rule. Compare the live document against the two hashes stored in the intent.
- Live hash equals the stored pre-write hash: nothing landed. Re-execute the plan against the live document.
- Live hash equals the stored planned post-write hash: everything landed. Skip to finalization.
- Neither: consult the witness. Key still present proves the replacement did not land; re-execute. Key absent is ambiguous, because a host may have stripped it or the developer may have edited after the rename landed. In that state treat every planned removal as done, mutate nothing, and report any planned-removal effect that is still present as a conflict.
The prior rule, planned removal and still present means remove now, is wrong in the ambiguous state. If the rename landed, the crash came before finalization, and the developer re-added one of the removed rules by hand, that rule is present and still listed as a planned removal, so the old rule removes it a second time even though the installer no longer owns it.
In every branch: a recorded conflict stays a conflict and stays untouched. Settings replaced but journal not finalized: reconstruct the report from the stored plan and finalize. Repair invoked mid-uninstall completes the uninstall first and then reports not installed.
Bounded conflict report
Name the rule effects plus one. Emit at most one line per owned effect, keyed by effect identity only, choosing one class by fixed priority when several apply, plus one summary line. With six rules, one witness key and one container the bound is nine lines regardless of how many things went wrong with any single effect. Priority when several classes apply to one effect: edited over duplicated over removed. Deduplicate against the report stored in the intent so recovery reruns print the same lines and never append. After the journal is deleted a further uninstall reports not installed; keep a tombstone report if the same warnings must be reproducible.
Worked example: one owned rule was edited by the developer and also appears twice. The report carries exactly one line for that effect, classed as edited, five lines for the rules removed, nothing for the witness key if the host stripped it, and one summary line. Nine lines maximum, seven here.
Optional reporting worker
If a background worker ships the report elsewhere, give it a fixed deadline and keep it off the critical path. Uninstall finalizes on its own evidence. A sharing gate starts denied and flips only on an explicit positive result before the deadline; a crash, missed deadline or unreadable result leaves it denied. Fail open for the primary task, fail closed for sharing.
Worked example: uninstall finalizes the journal, then spawns the worker with a three second deadline and waits for a result token. The worker crashes after one second: the gate stays denied, the local report has already been written, uninstall exits successfully. The worker is alive at three seconds without a result: the parent stops waiting, the gate stays denied, uninstall exits successfully. The worker returns a malformed result at two seconds: unreadable is not positive, the gate stays denied. Only a well-formed positive result inside the deadline flips the gate.
Test matrix before shipping
Inject a crash after the pending journal write, after the settings temp write, after the rename, and after the commit. Cross each with a pre-existing rule, an exact deny, a developer duplicate, a host reformat, and a host that strips unknown keys. For uninstall add an edited owned rule, a deleted owned rule, an unrelated key change, a reorder, a symlinked target, two concurrent commands, a write between read and rename, and a developer re-add of an owned rule between a crash after the uninstall rename and the next command. Assert unrelated edits survive, the edited rule survives, the re-added rule survives and is reported, the deny list is byte-identical, no still-matching owned effect remains when the replacement did not land, the report is bounded and stable across reruns, and container emptiness is judged after removals.
Limits
Reasoning only; no crash injection or host behaviour was tested. Rename-over-symlink follows from POSIX semantics. Whether the host strips unknown keys, whether it rewrites the file between a crash and the next command and so defeats the hash comparison, and how often developers act between commands, are empirical questions that decide how often the witness, released and ambiguous-recovery paths matter.