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, positive-witness recovery of a partial uninstall, and guaranteed exits from every journal state. 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 fields 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 tied to a 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 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 tied to device and inode across commands is invalidated by every atomic editor 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.
- A conflict record has no exit, so the journal is never deleted and every later uninstall reports a conflict that no longer exists.
- The ambiguous recovery branch never disposes of its intent, so install, repair and uninstall all refuse forever once a host strips the marker.
- Recovery cannot tell a crash before the rename from a landed rename whose marker the host later stripped, and the optimistic reading deletes a rule the developer re-added.
- A newer build retires an old-generation record while its string is still in the file, orphaning the 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 never overwrites an existing OWNED or UNVERIFIED record with PREEXISTING.
Terminal states are RELEASED and RETIRED only. Every non-terminal state must have a documented exit, listed under Uninstall below. The journal is deleted only when every record is terminal.
Identify each record by exact value, the installer generation that introduced it, and an attempt sequence, so a reinstall after a conflict never collides with the lingering record. Uninstall removes as many exact copies of a value as it holds OWNED records for that value, capped at the number present.
RETIRED is reachable only from RELEASED or RELEASEDWITHCONFLICT, never directly from OWNED. Every command first treats OWNED records whose generation is older than the running build and whose value is absent from the current managed set as removal candidates, exactly as uninstall does, and retires them only after that removal attempt has reached a released state.
File identity has two scopes. Across commands, record only the resolved real path; if it now resolves elsewhere, treat every owned record as UNVERIFIED and report. Within one command, additionally pin device and inode from the plan read. Never compare device and inode across commands.
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, never use a create-exclusive lock file, and mark the descriptor close-on-exec.
- Read the live document, compute the plan, snapshot the container hash, the byte digest and the within-command identity. The container hash is over the sorted multiset of canonical values in the allow container.
- Ensure a marker exists. If the installer-namespaced marker field is absent from the live file, write one in a marker-only rename before recording any intent. A crash after this step leaves only a stray field that the next run overwrites. Without this step, recovery cannot distinguish a crash before the rename from a landed rename whose marker was stripped afterwards.
- Write the intent record atomically: temp file, fsync, rename, directory fsync. It holds a fresh random per-operation witness, the marker value observed before this write, each effect as verbatim value with classification and recorded count, the pre-write hash and the planned post-write hash.
- Re-read the settings file immediately before writing and abort if its byte digest or within-command identity differs from the plan snapshot. Write the settings file atomically in the real target directory. The same rename that changes the rules also writes this operation's witness into the marker field; uninstall writes its own fresh witness rather than merely deleting the install witness, so its landed verdict is a positive value match. Before the rename, re-parse the produced bytes and assert every untouched field is structurally unchanged and each touched array equals its prior contents plus or minus exactly the planned occurrences; abort otherwise. After the rename, re-read once and report if the identity differs.
- Mark the intent committed. For uninstall, additionally mark records released, then strip the marker field in a second rename that touches nothing else, then delete the journal if every record is terminal.
Recovery verdicts
Decide the verdict before touching any rule. Compare marker values, never mere presence of the field, because an older generation's witness may remain.
- Live marker equals this intent's witness: the rename landed. Finalize from the stored verdicts. Do not re-plan.
- Live marker equals the pre-write marker recorded in the intent: the rename did not land. Discard the stored plan and re-derive from the live file, since the plan may be days old and the developer may have edited since. Never replay a stored plan.
- Live marker absent or any other value: the host or the developer removed it. Fall through to the hash branch. Absence is never treated as proof that the rename did not land.
Hash branch: live hash equals the planned post-write hash means landed; finalize. Anything else, including equality with the pre-write hash, is ambiguous because a developer who re-adds every removed value reproduces that hash. In the ambiguous branch: mutate nothing; mark each planned removal that is now absent RELEASED; mark each planned removal still present UNVERIFIED; for an install intent mark each planned addition now present UNVERIFIED and each absent one discarded; commit the intent; exit with a status distinct from success. The intent is always disposed of, so later commands proceed. UNVERIFIED is non-terminal, so the journal survives and the grants remain adoptable.
If uninstall finds a pending install intent, reconcile it to a verdict first and plan removal only for OWNED records. If install finds a pending uninstall intent, reconcile per the verdict and stop; never execute a removal plan mid-install. Delete orphan temp files carrying the installer's prefix.
Uninstall: compare before remove
Plan per OWNED effect against the live parsed document, locating by exact value never by index. Search the whole document for exact and near matches so a moved value 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.
Within the allow container: one or more exact copies, remove exactly one per OWNED record. 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.
Exits from non-terminal states, applied on every uninstall before planning: PREEXISTING and DENIED become RELEASED, since there is nothing to remove. RELEASEDWITHCONFLICT with no exact copy and no near match becomes RELEASED. RELEASEDWITHCONFLICT with the near match still present stays put and is reported. UNVERIFIED stays put until an explicit adopt, which turns it OWNED, or an explicit disown, which turns it RELEASED. Both adopt and disown are separate commands that name the value and never run implicitly.
The write is a node-level edit of the live document. Never regenerate the file from the plan and never restore a pre-install snapshot.
Repair
Run recovery first, then the old-generation removal pass. 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; 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.
Bounded report
One row per managed identity, meaning each rule, the marker field and the container, plus one bucket for unrecognized values. Each row carries one class chosen by fixed priority, edited over duplicated over moved over removed over matched, and a count. Rows never carry developer text. Distinct exit status on any conflict. Recovery reruns deduplicate against the report stored in the intent.
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 developer who deletes an owned value and retypes the identical string leaves no trace, so a later uninstall removes their copy.
- Where the host strips unknown fields, the marker step is defeated and recovery degrades to the ambiguous branch, which leaves grants in place, marks them UNVERIFIED and reports. That is the safe direction, not the correct one.
- Repair re-adds an owned rule the developer deleted deliberately. The only durable refusal is an explicit deny.
- Only one settings layer is managed.
Evidence status
Reasoned analysis only. No implementation exists and no crash injection, host reformat or editor save test has been executed. Failures 12 through 15 were found by a second independent adversarial reasoning review and confirmed by tracing the event sequences against the previous version.
Tests before shipping
Crash after the marker-only rename, after the intent write, after the settings temp write, after the rename, after journal release and after the marker strip, each crossed with a pre-existing rule, an exact deny, a developer duplicate, an edited owned rule, unrelated edits elsewhere, a host reformat and a host that strips unknown fields. Add: a conflict followed by deletion of the edited rule and a second uninstall, checking the journal reaches terminal; an ambiguous recovery followed by install, repair and uninstall, checking each proceeds; a landed rename with the marker stripped and one owned value re-added, checking nothing is removed; a newer build run as install over an old-generation owned string still present, checking it is removed before retirement; a same-generation reinstall after a conflict, checking no record collision.