|
| 1 | +# ADR 0062: Recover Ordinary Home Persistence as One Durable Pair |
| 2 | + |
| 3 | +- **Status:** Proposed |
| 4 | +- **Date:** 2026-09-06 |
| 5 | +- **Decision owners:** David Irvine / human engineering review pending |
| 6 | +- **Reviewers:** Pending; Codex drafted from the #471 source proof |
| 7 | +- **Supersedes:** none |
| 8 | +- **Superseded by:** none |
| 9 | +- **Related:** [#471](https://github.com/saorsa-labs/x0x/issues/471), |
| 10 | + [ADR 0028](./0028-authenticated-causal-predecessor-delivery.md), |
| 11 | + [ADR 0038](./0038-home-owner-certified-personal-space.md), |
| 12 | + [TOOLING](./TOOLING.md) |
| 13 | + |
| 14 | +## Context |
| 15 | + |
| 16 | +At `e16ba97a9d65022251e67ecc17f6e23d4b75699e`, ordinary saves write |
| 17 | +`home-suite-groups.json` before `named_groups.json`. The former is the |
| 18 | +owner-certified state; the latter contains inert, legacy-decodable placeholders. |
| 19 | +A failed second write returns an unchanged/error outcome, permitting memory |
| 20 | +rollback while the sidecar retains the rejected Home mutation. |
| 21 | + |
| 22 | +A local deterministic test of the actual saver and merged loader reproduced |
| 23 | +this: old memory was `home-x`, but reload returned `x-renamed-by-mutation`. |
| 24 | +Exactly one test failed at that assertion under denied outbound networking and |
| 25 | +a disposable test home. This is an I/O-fault/loader witness, not a power-loss |
| 26 | +or process-crash test. #471 remains open; this ADR is not implementation evidence. |
| 27 | + |
| 28 | +Source anchors at that commit, all in `src/server/routes/named_groups.rs`: |
| 29 | + |
| 30 | +- `persist_named_groups_mutation_unlocked`, lines 3778–3789: failure triggers |
| 31 | + per-key compare-and-restore, preserving concurrent writers (#470). |
| 32 | +- `save_named_groups_checked_unlocked`, lines 26954–27042: the split write; |
| 33 | + `AtomicWriteOutcome`, lines 27062 onward: single-file replacement facts. |
| 34 | +- `load_named_groups_merged`, line 24589: sidecar overrides placeholders. |
| 35 | +- `TreeKemNamedPersistJournal`, line 22110, and `HomeSuiteJournalTx`, line 23697: |
| 36 | + existing group/snapshot-bound journal formats, not generic map transactions. |
| 37 | +- `persist_treekem_and_named_groups_atomic_with_info`, line 22241, and |
| 38 | + `persist_named_group_info`, line 4044: existing transaction owners. |
| 39 | +- `src/server/mod.rs`, lines 612–621: paired TreeKEM recovery, orphan-sidecar |
| 40 | + cleanup, then merged load. TreeKEM recovery itself invokes the merged loader. |
| 41 | + |
| 42 | +## Decision Drivers |
| 43 | + |
| 44 | +- A rejected ordinary mutation must not reappear after a recoverable I/O fault. |
| 45 | +- Unknown replacement/durability must neither acknowledge success nor trigger |
| 46 | + rollback under a false claim that the named destination is unchanged. |
| 47 | +- Preserve pending-join exclusion, #470 CAS, owner-certified state, sidecar-first |
| 48 | + ordering, existing TreeKEM authority and frozen legacy journal bytes. |
| 49 | +- Resolve restart behavior explicitly rather than hiding it in error strings. |
| 50 | + |
| 51 | +## Considered Options |
| 52 | + |
| 53 | +1. Restore the sidecar in process, with a distinct recovery-required result and |
| 54 | + retained preimage. Smallest immediate repair; cannot survive process death |
| 55 | + during either the original split write or failed restoration. All callers |
| 56 | + must still handle uncertainty. This is a bounded interim option, not full #471. |
| 57 | +2. **Recommended:** a separate durable undo intent for ordinary paired saves, |
| 58 | + exclusive with existing TreeKEM transactions, with recovery before any merged |
| 59 | + load and success only after durable intent removal. |
| 60 | +3. Reverse the writes, copy best-effort rollback, or reuse `ReplacedNotDurable` |
| 61 | + for a partial pair. Rejected: these move the failure window, hide rollback |
| 62 | + failure, or contradict callers that rely on the new named file being visible. |
| 63 | +4. Extend/fabricate a TreeKEM journal or replace all journal formats. Rejected: |
| 64 | + ordinary saves have no honest snapshot envelope; legacy postcard decoding is |
| 65 | + frozen. A broader storage migration is unnecessary for this decision. |
| 66 | + |
| 67 | +## Decision |
| 68 | + |
| 69 | +### Ordinary transaction and commit point |
| 70 | + |
| 71 | +Recommend one versioned ordinary-pair intent at a fixed, instance-local path |
| 72 | +separate from `.journal`/`.hsjournal`; old journal formats remain byte-identical. |
| 73 | +It records transaction identity, exact prior bytes or explicit prior absence of |
| 74 | +both files, and digests of prior/candidate bytes for validation and diagnostics. |
| 75 | +Bind the intent to the exact serialized candidate and captured preimage bytes, |
| 76 | +not a reconstructed current map or a group revision/hash. The mutation snapshot |
| 77 | +and save snapshot are separate today; this distinction matters for #470 writers. |
| 78 | +Paths are fixed by the store, never supplied by the intent. Checksums detect |
| 79 | +corruption, not hostile-owner tampering; ordinary OS file protection still applies. |
| 80 | +A missing file is a preimage only on NotFound; other read errors refuse preparation. |
| 81 | + |
| 82 | +Under `named_groups_persistence_lock`, with transaction ownership established: |
| 83 | + |
| 84 | +1. Resolve earlier transactions first; capture the pair preimages and the same |
| 85 | + filtered candidate used today (unconfirmed join stubs remain excluded). |
| 86 | +2. Durably write the intent before changing either destination. Preparation |
| 87 | + failure changes no destination; uncertain intent durability blocks a new |
| 88 | + transaction until the unchanged-pair intent is resolved. |
| 89 | +3. Replace the sidecar, then the legacy view, using the existing file-fsync, |
| 90 | + rename and parent-directory-fsync primitives. Both candidate files must be |
| 91 | + durable before the transaction may reach commit cleanup. |
| 92 | +4. Remove the intent and fsync its parent. **Successful completion of this fsync |
| 93 | + is the acknowledged commit point.** Only then return committed, publish |
| 94 | + dependent effects, or clear existing install/recovery markers. |
| 95 | + |
| 96 | +Any ordinary failure before commit cleanup attempts to durably restore BOTH |
| 97 | +preimages, including removing files whose preimage was absence and fsyncing the |
| 98 | +removal. Keep the intent until restoration and its cleanup are durable. |
| 99 | +Recovery must not delete its sole evidence first. Repeating restoration is safe. |
| 100 | + |
| 101 | +Intent removal with failed parent fsync is commit-ambiguous: both new files are |
| 102 | +already durable, but a crash can retain or lose the intent. Return recovery |
| 103 | +required, withhold all success/effects and block subsequent store transactions. |
| 104 | +In the same process, retry commit cleanup/parent fsync while retaining ownership; |
| 105 | +never start an unrecorded rollback after the intent has disappeared. After a |
| 106 | +crash, a retained valid intent deterministically selects the old pair; absence |
| 107 | +selects the completed new pair. Either is allowed for an unacknowledged operation; |
| 108 | +never report that ambiguous operation definitively aborted. A committed operation |
| 109 | +cannot later be undone by this intent because its removal was acknowledged durable. |
| 110 | + |
| 111 | +### Results and recovery |
| 112 | + |
| 113 | +Keep single-file `AtomicWriteOutcome` unchanged. Ordinary save callers need a |
| 114 | +separate result distinguishing **committed**, **aborted with old pair proven |
| 115 | +durable** (including failure before any replacement), and **recovery required** |
| 116 | +with stage/cause. The last state cannot be flattened into io::Error/NotReplaced |
| 117 | +or ReplacedNotDurable. It does not assert either whole pair matches memory. |
| 118 | + |
| 119 | +A startup coordinator checks for an ordinary intent before running ANY existing |
| 120 | +journal routine that can load or write this pair. With an ordinary intent alone, |
| 121 | +validate it completely, restore both preimages, make both durable, then remove |
| 122 | +and durably clear it. Unknown versions, malformed/checksum-invalid data, uncertain |
| 123 | +file inspection or failed replay refuse startup with evidence retained. Recovery |
| 124 | +must be idempotent, including absence and interrupted cleanup. Only after this |
| 125 | +step may legacy paired recovery and orphan cleanup run, then merged state load. |
| 126 | + |
| 127 | +While recovery is required in a running process, fence dependent persistence, |
| 128 | +causal replay/publication and mutation success. Resolve the original transaction, |
| 129 | +not a new serialization of whatever happens to be in memory. After proven abort, |
| 130 | +apply existing per-key CAS to the operation's before/after maps; concurrent updates |
| 131 | +must survive, and must receive their own persistence transaction. Reads must not |
| 132 | +present uncertain staged state as committed. The exact read/mutation fencing audit |
| 133 | +is an implementation prerequisite, not provided by the current durability flag. |
| 134 | + |
| 135 | +### Composition: exactly one recovery owner |
| 136 | + |
| 137 | +Ordinary transactions must not coexist with any pending legacy TreeKEM/HomeSuite |
| 138 | +journal that can read or modify this pair, including a retained journal for |
| 139 | +another group. Existing journals contain whole-map payloads, but current paired |
| 140 | +recovery validates and merges the target group; that safeguard must remain. Under the persistence lock, resolve existing journal work first; |
| 141 | +if it cannot be resolved, refuse a new ordinary transaction. Conversely, every |
| 142 | +TreeKEM transaction entry must refuse to start while an ordinary intent is pending. |
| 143 | +This is instance-wide exclusion, not just group-ID exclusion. |
| 144 | + |
| 145 | +Existing seal/rebind transactions remain the authority for their own sidecar, |
| 146 | +named-view and snapshot writes. An internal explicit transaction context must |
| 147 | +let those paths use low-level pair writers without creating a nested ordinary |
| 148 | +intent. Journal preparation, replay and cleanup belong to that outer owner; |
| 149 | +ordinary helpers must not discard, supersede or reinterpret its journal. |
| 150 | +Recovery invokes low-level writers, never recursively starts another transaction. |
| 151 | + |
| 152 | +If both journal families are found at startup, fail closed and preserve both; |
| 153 | +do not guess an order from timestamps or per-group revisions. This is an invalid |
| 154 | +state under the proposed exclusion rule, not permission to guess which recovery |
| 155 | +decision wins. Existing paired recovery/tag/fork rules otherwise remain unchanged. |
| 156 | + |
| 157 | +### Human review and implementation gates |
| 158 | + |
| 159 | +This recommendation is **not implementation-ready or Accepted** until review |
| 160 | +settles the following explicit boundaries: |
| 161 | + |
| 162 | +- Approve abort-on-retained-intent versus commit-on-intent-absence for an |
| 163 | + unacknowledged operation, and how API/causal callers expose recovery-required. |
| 164 | + Existing callers at lines 3761/3778, 4032, 4062/4162, 7734, 20189 and 26719, |
| 165 | + and server/mod.rs 2776/2889/2960/3168/3259 need explicit handling. |
| 166 | +- Audit every pair writer, TreeKEM journal creator, recovery entry and mutation |
| 167 | + bypassing the persistence lock. Existing CAS protects against some unlocked |
| 168 | + writers; the proposed fence must not silently serialize their updates away. |
| 169 | + Mixed-journal states stay fenced until an explicit repair policy is reviewed. |
| 170 | +- Decide the exact intent encoding, size cap and version migration. Validate |
| 171 | + before mutation and fail closed on unsupported state; no fabricated snapshots. |
| 172 | +- Decide downgrade/rollback policy. Old binaries ignore a new intent and cannot |
| 173 | + honor its recovery fence. Managed rollback must resolve transactions before |
| 174 | + handing the directory to an old binary, or refuse that handoff. Arbitrary manual |
| 175 | + old-binary access during recovery is not made transaction-safe by this ADR. |
| 176 | + Inert placeholders/legacy decoding alone are not a crash-consistency guarantee. |
| 177 | + |
| 178 | +## Consequences |
| 179 | + |
| 180 | +### Positive |
| 181 | + |
| 182 | +- Ordinary rejection and restart recovery use one explicit authoritative decision. |
| 183 | +- Single-file outcomes and existing cryptographic journal formats stay truthful. |
| 184 | + |
| 185 | +### Negative / Trade-offs |
| 186 | + |
| 187 | +- Extra durable writes and full-pair preimages increase I/O and temporary disk use. |
| 188 | +- An unresolved transaction fences the store; availability yields to consistency. |
| 189 | +- Caller integration and downgrade fencing are required work, not a local catch |
| 190 | + block. Existing ambiguous disk states cannot be repaired by inventing intent. |
| 191 | + |
| 192 | +### Neutral / Operational |
| 193 | + |
| 194 | +No membership, placement, encryption, network or Home election policy changes. |
| 195 | +No Accepted ADR is edited. #471 stays open until implementation and fault/restart |
| 196 | +acceptance; this proposal neither releases v0.41.4 nor implements supervision. |
| 197 | + |
| 198 | +## Validation |
| 199 | + |
| 200 | +Keep the exact baseline regression as a required failing negative control. |
| 201 | +After implementation, its old-memory/old-merged-disk/exact-preimage assertions |
| 202 | +must pass; a successful Home rename must survive a fresh merged load. |
| 203 | + |
| 204 | +| Injected boundary | Required observation | |
| 205 | +|---|---| |
| 206 | +| Preimage read / intent create, write, file-fsync, rename, directory-fsync | No candidate destination written before durable intent; uncertain preparation remains fenced. | |
| 207 | +| Sidecar or named create/write/file-fsync/rename failure | Old pair durably restored or recovery-required; never false success/unchanged. | |
| 208 | +| Either destination directory-fsync failure | No commit acknowledgement; recovery retains a truthful stage. | |
| 209 | +| Undo write/rename/fsync; prior-absence remove/fsync | Evidence retained until old pair durable; restart repeats safely. | |
| 210 | +| Intent unlink failure | Durable candidate, pending cleanup; no success. | |
| 211 | +| Intent unlink succeeded, directory-fsync failed | Both candidate files durable; no success; test retained-intent and absent-intent crash outcomes. | |
| 212 | +| Process stops after each stage; recovery interrupted twice | Same selected pair after repeated recovery; no partial merged load. | |
| 213 | +| Concurrent same-key/cross-key update, pending join stub | #470 CAS preserves newer updates; no stub leakage or lost independent mutation. | |
| 214 | +| Existing rebind/seal journal, orphan sidecar half, mixed families | One owner; valid existing recovery succeeds; invalid coexistence fails before merged load or mutation, preserving evidence and avoiding snapshot/roster drift. | |
| 215 | +| Corrupt/unknown/oversize intent; absent preimages; downgrade attempt | Fail closed or exact absence recovery; no undocumented data loss/handoff. | |
| 216 | + |
| 217 | +Exercise a later save after an ambiguous cleanup and restart twice: a stale |
| 218 | +preimage must never overwrite a later acknowledged save. Include an unrelated-key |
| 219 | +insert and same-key serde-skipped-field update between mutation snapshot, save |
| 220 | +snapshot and rollback. A torn ordinary candidate must not be inspected by legacy |
| 221 | +recovery and spuriously classify a valid rebind as stale/forked. Mixed-family |
| 222 | +crash images must not replay either side before the conflict is resolved. |
| 223 | + |
| 224 | +Verify no success, relay, queue removal or marker clear precedes durable commit. |
| 225 | +Tests must call real persistence/recovery entry points with disposable roots and |
| 226 | +outbound network denied. Crash simulation and platform power-loss evidence must |
| 227 | +be labelled separately. Existing valid TreeKEM recovery, inert old-decoder |
| 228 | +placeholders and positive ordinary saves remain required controls. |
| 229 | + |
| 230 | +## Notes for AI-assisted work |
| 231 | + |
| 232 | +AI tools may draft this ADR, but must not mark it Accepted without human review. |
| 233 | +Accepted ADRs are immutable. Storage-format implementation waits for review under |
| 234 | +TOOLING; the unresolved boundaries above are not discretionary shortcuts. |
0 commit comments