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, an identity check on every write, a process lock, compare-before-remove uninstall, a bounded conflict report, 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, 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.
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: 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.
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
Re-execute the stored plan against the live document. Planned removal and still present: remove now. Planned removal and absent: counts as removed, since the end state is identical whoever removed it. Recorded conflict: still a conflict, still untouched. Settings replaced but journal not finalized: reconstruct the report from the plan and finalize. Repair invoked mid-uninstall completes the uninstall first and then reports not installed.
Bounded conflict report
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. The bound is the number of owned effects plus one. 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.
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.
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, and a write between read and rename. Assert unrelated edits survive, the edited rule survives, the deny list is byte-identical, no still-matching owned effect remains, 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, and how often developers act between commands, are empirical questions that decide how often the witness and released paths matter.