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 developer also edits by hand. Covers historical ownership, a read-only deny list, intent then commit with a per-operation witness, allow-scoped compare-before-remove uninstall, witness-first recovery of a partial uninstall, and content re-read before rename. Reasoned analysis, not executed tests.
Crash-safe ownership journal for installer-managed entries in a shared settings file
Trigger
Use this when a command-line installer writes a small fixed set of entries, typically exact-string permission allow rules, into a settings file that the developer also edits by hand and that a host application may reformat, reorder or strip unknown keys from on its own schedule. 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, the process can crash between the two writes, and recovery runs only at the start of the next installer command, possibly days later.
The failures this prevents
- Uninstall removes an allow the developer had before install because it equals an installer rule.
- Settings written before ownership is recorded leak grants after a crash, because nothing owns them.
- Partial uninstall recovery trusts a content hash that a developer re-add reproduces, so it deletes the rule the developer just re-added by hand.
- A re-install reclassifies the installer's own still-present entries as developer-authored, so they can never be removed.
- Ownership keyed by path follows a repointed symlink into a file the installer never wrote.
- A lock that survives a power loss blocks uninstall forever.
- Uninstall removes a rule the developer edited into something else, or restores a snapshot and erases unrelated edits.
- Uninstall removes a deny because the developer moved the owned string from the allow list into the deny list and the removal search covered the whole document.
- An in-place editor save between the plan read and the rename keeps the inode, passes an identity-only check, and is overwritten.
- Identity keyed by device and inode across commands is invalidated by every atomic editor save, so uninstall refuses to act after the first save.
- Repair promotes a developer-authored value to owned when a deny is lifted, or the ambiguous recovery branch deletes the journal and orphans every installer grant.
Ownership model
Classify each managed rule once, at install, against the live parsed document by exact string comparison:
- PREEXISTING: already present in the allow container. Never owned, never removed, never re-added.
- DENIED: an exact deny entry matches. Never written. The deny list is read for classification only; no command adds, removes, reorders or dedupes denies.
- PENDING_ADD: absent. Becomes OWNED once the write is proven to have landed.
- UNVERIFIED: present after a write whose landing could not be proven. Needs an explicit adopt before it may be removed, and is never silently promoted or demoted.
Ownership is historical. File content may revoke ownership but never establishes it. With no journal, matching rules are reported and left alone. A fresh install plan never overwrites an existing OWNED or UNVERIFIED journal entry with PREEXISTING; those states are exempt from reclassification.
Terminal states are RELEASED and RETIRED only. UNVERIFIED and RELEASEDWITHCONFLICT are not terminal. The journal is never deleted while a non-terminal entry exists; it persists until the developer adopts or the value is gone.
Key each journal entry by exact value plus the installer generation that introduced it, not by rule slot. When a new build changes a rule's string, the old string stays owned and removable under its own entry; retire it only when its generation is strictly older than the running build and the value is absent from the current managed set.
File identity has two scopes. Across commands, record only the resolved real path of the settings file; if that path now resolves to a different real path, treat every owned entry as UNVERIFIED and report. Within one command, additionally pin device and inode from the plan read to detect a swap during the run. Do not compare device and inode across commands: atomic editor saves, the host's own save and the installer's own rename all change the inode routinely.
Write protocol, shared by install, repair and uninstall
- Take a kernel-released advisory lock, such as flock or fcntl, on a dedicated lock file beside the journal, for the whole command. Never lock the settings file itself, because the installer's own rename replaces that inode and a second invocation would lock the new one mid-command. A create-exclusive lock file is not acceptable because it survives a crash. Mark the lock descriptor close-on-exec so a child process cannot inherit it.
- Read the live document, compute the plan, snapshot the container hash, the byte digest of the file and the within-command identity. The container hash is over the sorted multiset of canonical values in the allow container, never over file bytes and never over a set.
- Write an intent record atomically: temp file, fsync, rename, directory fsync. It holds a fresh random per-operation token, each effect as verbatim value with classification and recorded count, the pre-write hash, the planned post-write hash, and whether a token key was present in the file before this write.
- Re-read the settings file immediately before writing and abort if its byte digest or within-command identity differs from the plan snapshot. An identity check alone cannot see an in-place save, which keeps the inode. Then write the settings file atomically in the real target directory. The same rename that changes the rules also writes the token into an installer-namespaced key on install and repair, and removes it on uninstall. Before the rename, re-parse the produced bytes and assert every untouched key is unchanged and each touched array equals its prior contents minus or plus exactly the planned occurrences; abort on failure. After the rename, re-read once and report if the identity differs from what the rename produced.
- Mark the intent committed. Delete the journal only when an uninstall has completed and every entry is terminal.
Every recovery branch compares the live token value to the token stored in the intent. Presence of the token key alone proves nothing, because an earlier generation's token may still be in the file.
Install recovery: token value matches means landed, mark OWNED. Token missing or different means the write did not land; replay by re-deriving from the live file, since add-if-absent is idempotent. Any value the plan intended to add that is present anyway becomes UNVERIFIED. Repair uses exactly this protocol. If the next command is an uninstall, a pending install intent is reconciled per this verdict and not replayed; adding rules only to remove them creates a second crash window.
Uninstall: compare before remove
Plan per owned effect against the live parsed document, locating by value never by index. Search the whole document for the exact value and for near matches so that a value moved to another container is filed as moved rather than gone, but this search feeds the report only. Removal is scoped to the allow container. A value found in the deny container is never a removal candidate, whatever its ownership record says.
Owned entries are only ever created by add-if-absent, so the recorded count is one. Within the allow container: one or more exact copies, remove exactly one, since identical copies are interchangeable and any extra is the developer's. Zero exact copies with a near match such as an edited, narrowed, broadened or moved value: remove nothing, mark RELEASEDWITHCONFLICT and report; the developer now owns it. Zero exact copies and no near match: mark RELEASED. Near matching never feeds removal. A recorded conflict stays a conflict in every later branch.
The write is a node-level edit of the live document. Never restore a pre-install snapshot; it erases every unrelated edit made since install.
Recovery of a partial uninstall, witness first
Decide whether the rename landed before touching any rule. Consult the token before the hashes, because a hash equality is reproducible by a developer who re-adds a removed value, and a token is not.
- Token value still present and matching, and the intent recorded it present before the write: the rename did not land. Discard the stored plan and re-derive from the live file.
- Token absent and live hash equals the planned post-write hash: everything landed. Finalize only, and strip any leftover token key.
- Token absent otherwise, including live hash equal to the pre-write hash: ambiguous. The host may have stripped the key, or the rename landed and the developer re-added one or more values. Mutate nothing, treat every planned removal as done, report each planned-removal value still present as a conflict, record those values as UNVERIFIED rather than dropping them, and finalize with an exit status distinct from success. Because UNVERIFIED is non-terminal, the journal survives and the grants remain adoptable; re-planning here is not safe, since a developer who re-adds every removed value reproduces the pre-write hash exactly.
If the intent recorded the token as already absent before the write, branch 1 is vacuous; uninstall should write a fresh marker in its own rename rather than rely on the install-time one, or accept that only branches 2 and 3 are available.
If the next command is an install rather than a retried uninstall, recovery reconciles the journal per the verdict and stops; it never executes a removal plan in the middle of an install. Delete any orphan temp file that carries the installer's own prefix.
Repair
Run recovery first. OWNED and missing from the allow container and not denied: re-add. PREEXISTING and missing: nothing. DENIED with the deny now gone: add-if-absent; if the write lands, promote to OWNED, but if the value is already present the developer put it there, so record UNVERIFIED and do not promote. A deny added later for an OWNED rule blocks repair from re-adding; uninstall still removes the installer allow. Rules a newer build no longer manages but still owns are removed as in uninstall.
Bounded report
One row per managed identity, meaning each rule, the token key and the container, plus one bucket for unrecognized values. Each row carries one class chosen by fixed priority, edited over duplicated over removed, and a count. Rows never carry the text of a near match or of any unrecognized value; developer content is counted, not listed. Recovery reruns deduplicate against the report stored in the intent, so they never append.
Optional sharing worker
Finalize the journal, write the local report and fix the exit code before spawning the worker. Give it a fixed deadline and kill it at the deadline. A sharing gate starts denied and flips only on a well-formed positive acknowledgement observed by the parent inside the deadline. The primary task fails open; sharing fails closed.
Limits
- A lock binds only other runs of the same installer. The window between the pre-rename re-read and the rename is inherent; a host write inside it is lost, and the post-rename re-read can only detect and report it.
- A developer who deletes an owned value and retypes the identical string before any command runs leaves no trace, so a later uninstall removes their copy. Exact-string identity cannot repair this.
- Where the host strips unknown keys, the token is unavailable and recovery degrades to the ambiguous branch, which leaves grants in place and reports. That is the safe direction, not the correct one.
- Repair re-adds an owned rule the developer deleted deliberately. The only durable way for a developer to refuse a managed rule is an explicit deny.
- Only one settings layer is managed; a rule present in another layer is invisible to classification.
Evidence status
Reasoned analysis only. No implementation exists and no crash injection, host reformat or editor save test has been executed. Failures 8 through 11 were found by an independent adversarial reasoning review of the previous version and confirmed by tracing the event sequences.
Tests before shipping
Crash after the intent write, after the settings temp write, after the rename and after finalization, each crossed with a pre-existing rule, an exact deny, a developer duplicate, a host reformat and a host that strips unknown keys. Add: an owned string moved into the deny list, checking uninstall touches nothing; an in-place save between plan read and rename, checking abort; an atomic editor save between commands, checking uninstall still acts; a lifted deny with the developer having hand-added the rule, checking repair does not promote; an ambiguous recovery followed by an install, checking the journal survives and nothing becomes PREEXISTING; a developer re-add of a removed rule after a landed rename with no other change, checking the multiset hash equality does not trigger removal; an edited rule; a moved rule; a symlink repointed to another file; and a create-exclusive lock left by a simulated power loss.