Skip to content

Commit 413028e

Browse files
dirvineclaude
andcommitted
fix(home): withdraw the adoption mechanism; keep election and honest reporting (#449)
Codex review of 4629117 raised three P1s and two P2s. All three P1s are properties of the Home adoption mechanism rather than bugs in it, so the mechanism is withdrawn rather than patched. What remains is wire-compatible and does NOT claim to fix #449. P1 — signed-record compatibility (owner_sync.rs:250). `HomeInvite` was inserted BEFORE `IssuanceJournal`, shifting its bincode discriminant 3 -> 4. JSON- persisted records still load, but `verify()` reconstructs different signed bytes, invalidating pre-upgrade issuance signatures even after both devices upgrade. Appending would fix the ordering, but any change to a signed value's shape sits in this hazard class and needs an old-record fixture. P1 — protocol compatibility (owner_sync.rs:183). A fifth closed-enum kind rode an unchanged protocol version 2, so an older peer cannot decode the version vector and aborts the entire owner-sync session — including unrelated names, profile and journal sync. The kind needs negotiation or a staged rollout. P1 — no trustworthy cross-device device/rider signal (sync.rs:316). The rider exclusion trusted `OwnerIssuedCert.mode`, but `apply_journal_line` materializes synced issuance records with `mode: Acp, not_after: None` (owner_sync.rs:2567-2577), and `owner_issued_certificates()` treats journal records as authoritative on ties. A Rider issued on device A therefore arrives on device B indistinguishable from a device agent and would have received an automatic Home invite — the filter was defeated in exactly the multi-device case it existed for. The certificate carries no hosting mode either, so no sound basis for a device-only auto-invite exists today, and inventing one here would have silently amended ADR-0039's mode-agnostic Home eligibility and deny-by-default rider scope. Removed: SyncKind::HomeInvite (Tier-1 returns to FOUR kinds, restoring IssuanceJournal's discriminant), SyncValue::HomeInvite, its apply arm, home_invite_is_actionable, SyncDaemonView::{apply_home_invite, reconcile_home_invites} and their DaemonView impls, mint_home_invite, is_owner_device_cert, and the associated tests. P2 FIXED — withdrawn canonical Home pointer (sync.rs:179). The publisher now carries the same `!withdrawn` guard as `find_home`. Withdrawal keeps `home` and `members_v2` populated, so a retired Home could be republished as canonical; because provisioning yields to a named canonical Home, every device would then refuse a replacement while GET /home reported `elsewhere` — the owner left with no Home and no way to get one. Regression test added. P2 MOOT — deferred invite retry: removed with the mechanism. CI — rustdoc failed on a private intra-doc link from the public HOME_POINTER_KEY; reproduced locally under RUSTDOCFLAGS="-D warnings" and fixed. The treekem_home_rename e2e failure is #316/#311-class load flakiness, not attributed to this diff. ADR-0060 rewritten (still Proposed): amends ADR-0038 only — NO ADR-0041 amendment (Tier-1 stays at four kinds) and NO ADR-0039 change. Adoption, retirement and any device-vs-rider Home eligibility rule are recorded as explicitly undecided, to be reconciled with ADR-0039 rather than bypassed. WHAT THIS DOES NOT DO: #449 is NOT fixed. A second device still holds its own Home and reports `adoption_pending`; it does not join the owner's Home, and the duplicate persists. #449 stays open. Gates on this exact tree, separate commands, actual exits: cargo fmt --all -> 0 cargo clippy --all-features --all-targets -- -D warnings -> 0 cargo clippy --all-features --lib --bins -- -D warnings -D clippy::panic -D clippy::unwrap_used -D clippy::expect_used -> 0 cargo check --workspace --all-targets -> 0 RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps -> 0 cargo nextest run --workspace --all-features --no-fail-fast -> 0 (3319 passed, 0 failed, 295 skipped) Refs #449 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ovKUXBeg5GZ51YECS9p51
1 parent 4629117 commit 413028e

5 files changed

Lines changed: 172 additions & 494 deletions

File tree

Lines changed: 91 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1-
# ADR 0060: One Home Per Owner — Cross-Device Election and Adoption
1+
# ADR 0060: The Owner's Home Is Elected, Not Per-Install
22

33
- **Status:** Proposed
44
- **Date:** 2026-09-05
55
- **Decision owners:** David Irvine (direction), Claude (drafting)
6-
- **Reviewers:**
6+
- **Reviewers:** (Codex review of PR #507 at `4629117`; Jarvis Senior Engineer ADR conformance pending)
77
- **Supersedes:** none
88
- **Superseded by:** none
9-
- **Amends:** [ADR 0038](./0038-home-owner-certified-personal-space.md)
10-
(the unit of Home), [ADR 0041](./0041-cross-machine-state-sync-tiers.md)
11-
(Tier-1 surface widened to five kinds)
12-
- **Related:** issue #449; #435 (the per-machine dedup marker), #447
13-
(certified join needs a second announce), #446;
9+
- **Amends:** [ADR 0038](./0038-home-owner-certified-personal-space.md) — the unit of
10+
Home. **No amendment to [ADR 0041](./0041-cross-machine-state-sync-tiers.md)**
11+
(the Tier-1 surface stays at four kinds) and **none to
12+
[ADR 0039](./0039-agent-harness-boundary.md)** (Home eligibility stays
13+
mode-agnostic) — see *Deliberately not decided here*.
14+
- **Related:** issue #449; #435, #447, #446, #506;
1415
`docs/design/449-single-home-per-owner.md`
1516

1617
## Context
@@ -24,123 +25,128 @@ ADR 0038 says two things that cannot both hold:
2425
2526
The first sentence makes Home a property of the *install*; the second promises
2627
it is a property of the *user*. Issue #449 is that contradiction reaching
27-
production: three daemons sharing one owner `user.key` each auto-provisioned
28-
their own Home, because dedup was a marker file in the instance data dir plus
29-
a scan of the LOCAL roster (`find_home`). Nothing consulted the owner's other
30-
devices, so an owner with N devices got N competing Homes, and `GET /home` on
31-
each device confidently returned its own duplicate — two authoritative answers,
32-
no error anywhere.
33-
34-
The transport that could have carried the answer already existed and was
35-
inert: ADR 0041 Tier-1 replicates a `HomePointer` record between enrolled owner
36-
devices, but its apply arm was a documented no-op ("cross-machine Home adoption
37-
… deliberately out of Tier-1 scope (gapcheck blocker 32)").
38-
39-
Confirming #449 surfaced three further defects. The `HomePointer` register is
40-
keyed by the constant `"home"` — one LWW slot per owner — and `mint()` takes
41-
the slot at `version + 1` whenever the value differs, on a 60s reconcile tick.
42-
Two enrolled devices with different Homes therefore fought over that slot
43-
without end, re-signing and re-persisting a record every minute each. The
44-
published pointer was also selected with `.find()` over an unordered map using
45-
a weaker predicate than `find_home`, so a device could advertise a Home it was
46-
not even a member of. And `find_home` had no `!withdrawn` filter while
47-
withdrawal keeps `members_v2` and `home` populated, so retiring a Home through
48-
the existing delete path would have wedged `GET /home` and re-provisioning
49-
permanently.
28+
production: daemons sharing one owner `user.key` each auto-provisioned their
29+
own Home, because dedup was a marker file in the instance data dir plus a scan
30+
of the LOCAL roster. `GET /home` on each device then returned its own duplicate
31+
as authoritative — several answers, no error anywhere.
32+
33+
Confirming #449 surfaced three further defects: the constant-key `("home")`
34+
Tier-1 register oscillated forever between devices holding different Homes
35+
(each 60s pass re-signing and re-persisting a record); the published pointer
36+
was selected nondeterministically with a weaker predicate than `find_home`; and
37+
`find_home` had no `!withdrawn` filter, so retiring a Home through the existing
38+
delete path would have wedged `GET /home` and re-provisioning permanently.
5039

5140
## Decision Drivers
5241

53-
- The ADR 0038 promise is per-OWNER; the implementation was per-install.
54-
- MLS state cannot be merged. Home is Hidden + MlsEncrypted, which routes to
55-
real TreeKEM; the only entries into a tree are `create` and
56-
`join_from_welcome`. There is no merge path, so the answer is
57-
join-one-and-retire-the-other, never reconcile-two.
58-
- An offline or un-synced device must still get a working Home. Suppression
42+
- ADR 0038's promise is per-OWNER; the implementation was per-install.
43+
- An offline or un-synced device must still get a working Home; suppression
5944
alone would leave an unreachable device with none, which is worse than a
6045
duplicate.
6146
- Nothing may be destroyed before a real seat in the canonical Home exists.
62-
- The owner layer must stay strictly opt-in: an install with no `user.key`
63-
provisions no Home and mints no owner-sync records.
47+
- The owner layer must stay strictly opt-in: no `user.key`, no Home, no records.
48+
- **Old-version compatibility is a hard constraint.** Owner-sync records are
49+
owner-signed and their signatures cover serialized bytes.
6450

6551
## Considered Options
6652

67-
1. **Suppress provisioning when a peer Home is known.** Necessary but not
68-
sufficient — an un-synced device knows nothing and still provisions.
69-
2. **Deterministic owner-derived `group_id`** (`H(owner_pk)`) so every device
70-
"creates" the same Home.
71-
3. **Never auto-provision**; require the owner to create Home explicitly.
72-
4. **One agent key on every device** (ADR 0043 key move), so there is only one
73-
agent and therefore one Home.
74-
5. **Optimistic provisioning + election + winner-driven adoption.**
53+
1. Suppress provisioning when a peer Home is known.
54+
2. Deterministic owner-derived `group_id` so every device "creates" the same Home.
55+
3. Never auto-provision; require the owner to create Home explicitly.
56+
4. One agent key on every device (ADR 0043 key move).
57+
5. Optimistic provisioning + election on the existing register, with adoption
58+
of the losing device deferred to a follow-up.
7559

7660
## Decision
7761

78-
We will adopt option 5: **the unit of Home is the owner, not the install.**
62+
We will adopt option 5: **the unit of Home is the owner, not the install, and
63+
the owner's canonical Home is elected on the existing Tier-1 register.**
7964

8065
- Auto-provisioning stays, but is **optimistic and subject to election**. A
81-
device provisions only when no owner device has advertised a Home; absence
82-
of a register value means "unknown", never "none exists".
66+
device provisions only when no owner device has advertised a Home. Absence of
67+
a register value means "unknown", never "none exists".
8368
- The Tier-1 `(HomePointer, "home")` register is the **canonical pointer**. A
8469
device mints into it only when the register is empty, when it is that Home's
8570
designated primary agent and the value changed, or when its own Home is
8671
strictly preferable under `(provisioned_at_ms, group_id)` — oldest wins, id
8772
breaks ties. Both devices compare the same tuples, so they elect the same
8873
winner; the value strictly decreases, so the register converges.
74+
- The publisher and the resolver share one predicate, including `!withdrawn`:
75+
a retired Home is never advertised as canonical and never resolves locally.
8976
- `GET /home` reports a **state**`local`, `adoption_pending`, `elsewhere`
9077
with 200. "The Home is on another device" is an answer, not a 404.
91-
- **Adoption is winner-driven.** Only a device seated in the canonical Home can
92-
seal `MemberAdded`, so it issues the invites. Tier-1 gains a fifth kind,
93-
`SyncKind::HomeInvite` (0x05), keyed by joiner agent hex, carrying an
94-
addressed v4 `SignedInvite`. A group id alone cannot admit a device: the join
95-
path needs the invite's `genesis_creation_nonce`, `base_state_revision` and
96-
`base_state_hash`, which `HomePointer` does not carry.
97-
- **A refused or deferred admission retries and never falls back to minting a
98-
new Home.** #447's cert-blob race delays admission; without this rule the fix
99-
reintroduces the bug under a race.
100-
- Retirement of the losing duplicate is **gated**: join first, retire second,
101-
and only when the duplicate is provably empty. Anything else stays as a
102-
`conflict` for the owner to resolve.
78+
79+
**This ADR does not decide how a losing device joins the winner's Home.** That
80+
mechanism is deferred (see below), so a second device today converges on the
81+
correct *answer* and reports it honestly, but does not yet become a member.
82+
83+
## Deliberately not decided here
84+
85+
A first implementation added a fifth Tier-1 record kind carrying an addressed
86+
invite, plus a filter excluding ADR-0039 `Rider` agents from automatic Home
87+
invites. Independent review of `4629117` found three blocking defects, and all
88+
three are properties of that mechanism rather than bugs in it:
89+
90+
1. **Signed-record compatibility.** Inserting a variant ahead of
91+
`IssuanceJournal` shifted its bincode discriminant, so `verify()`
92+
reconstructed different signed bytes and invalidated pre-upgrade issuance
93+
signatures. Appending fixes the ordering, but any change to a signed value's
94+
shape is in this hazard class.
95+
2. **Protocol compatibility.** A fifth closed-enum kind under an unchanged
96+
protocol version cannot be decoded by older peers, aborting the whole
97+
owner-sync session — including unrelated names, profile and journal sync.
98+
The new kind needs negotiation or a staged rollout.
99+
3. **No trustworthy cross-device device/rider signal.** `apply_journal_line`
100+
materializes synced issuance records with `mode: Acp`, and
101+
`owner_issued_certificates()` treats journal records as authoritative on
102+
ties. A Rider issued on device A therefore arrives on device B indis-
103+
tinguishable from a device agent. The certificate does not carry hosting
104+
mode either, so **no sound basis exists today for a device-only
105+
auto-invite** — and inventing one in the implementation would have silently
106+
amended ADR-0039's mode-agnostic Home eligibility and its deny-by-default
107+
rider scope.
108+
109+
Consequently, adoption, retirement and any device-vs-rider Home eligibility
110+
rule are **out of scope for this ADR** and must be decided explicitly — with
111+
ADR-0039 reconciled rather than bypassed — before implementation.
103112

104113
## Consequences
105114

106115
### Positive
107116

108-
- An owner with N devices converges on one Home, and a device that has not yet
109-
joined says so instead of forking.
117+
- An owner with N devices converges on one canonical *answer*, and a device
118+
that is not a member says so instead of forking.
110119
- The register war ends by construction: once every device desires the same
111-
value, `mint()`'s equality check holds the slot stable.
112-
- `find_home` becomes deterministic and withdrawal-aware, closing the wedge
113-
that any retire-based fix would otherwise have hit.
120+
value, the store's equality check holds the slot stable.
121+
- `find_home` becomes deterministic and withdrawal-aware, closing the wedge any
122+
retire-based fix would otherwise hit.
123+
- Wire-compatible: no new record kind, no change to any signed value's shape,
124+
no protocol version change.
114125

115126
### Negative / Trade-offs
116127

117-
- Tier-1 is no longer four kinds. The deny-by-default allowlist is a
118-
deliberate structural guarantee, so widening it is a real cost, paid here
119-
because no existing kind can carry join material.
120-
- Adoption requires the winning device online at least once; until then the
121-
loser holds a usable but non-canonical Home.
128+
- **#449 is not fully fixed.** A second device still holds its own Home and
129+
reports `adoption_pending`; it does not join the owner's Home. The duplicate
130+
persists.
122131
- A register naming a Home whose only member is permanently gone is sticky —
123-
no surviving device can lower it. The manual override is `POST /home/adopt`.
124-
- Dedup only reaches ENROLLED devices. Devices that merely share a `user.key`
125-
never sync, so they cannot be deduplicated; enrollment
126-
(`POST /sync/devices/enroll`) is the owner's explicit assertion that two
127-
machines are theirs, and is the trust anchor for this whole mechanism.
132+
no surviving device can lower it, and no manual override exists yet.
133+
- Election only reaches ENROLLED devices; devices merely sharing a `user.key`
134+
never sync and are unaffected.
128135

129136
### Neutral / Operational
130137

131138
- The owner layer stays opt-in and is regression-tested as such.
132-
- Mixed fleets: an old daemon keeps writing the `"home"` slot unconditionally;
133-
a new daemon simply declines to fight it.
139+
- Mixed fleets: an old daemon keeps writing the register unconditionally; a new
140+
daemon declines to fight it.
134141

135142
## Validation
136143

137-
- Two devices sharing an owner key, cross-enrolled, converge on exactly one
138-
Home; the assertion is the invariant, not which id won.
139-
- The `"home"` record's `version` stops advancing once converged (pre-fix it
144+
- The `"home"` record's version stops advancing once devices agree (pre-fix it
140145
grew once per device per minute, without end).
141-
- A second device with the owner's Home advertised provisions nothing, and
146+
- Two daemons holding the same two Homes publish the same pointer — a property
147+
the previous unordered-map selection could not provide.
148+
- A second device with the owner's Home advertised provisions nothing and
142149
`GET /home` reports `elsewhere` with 200.
143-
- A withdrawn Home never resolves as a device's Home.
144-
- An invite addressed to another agent, or expired, is refused rather than
145-
redeemed.
150+
- A withdrawn Home neither resolves locally nor is advertised as canonical.
146151
- A daemon with no `user.key` provisions no Home and mints no records.
152+
- `SyncKind::ALL.len() == 4` — the Tier-1 tripwire remains untripped.

docs/adr/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ This directory contains architecture decision records for x0x.
6767
- [ADR 0057: Local Apps Reach the Daemon via REST/WS with Filesystem Discovery; `serve()` Is the Embedded Form](./0057-embedded-serve-library-local-apps.md) (proposed 2026-08-29) — `api.port`/`api-token` discovery over the ADR-0044 plane; embedded `server::serve()` disables self-update and hands `ServerHandle` to the caller; `/apps` static hosting remains proposal-only
6868
- [ADR 0058: The Constitution Is Embedded Compile-Time in Every Binary](./0058-compile-time-embedded-constitution.md) (proposed 2026-08-29) — `include_str!` constants in the shared library surfaced via `GET /constitution`, `GET /constitution/json`, and `x0x constitution`; status constant (currently Draft) is the stage of record
6969
- [ADR 0059: Invite Authentication and Seating Provenance](./0059-invite-authentication-and-seating-provenance.md) (proposed 2026-09-02) — InviteV4 signs the whole invite minus signatures with inline id-bound inviter/owner keys; Home-join mode pins the admission owner; every invite-derived seat records local, bootstrap-stripped `invite_lineage` with deduplicated authenticated fork evidence (observability only, no eviction); amends ADR 0016 §7; the stale-base residual and old-admin-key caveat are deferred to #472
70-
- [ADR 0060: One Home Per Owner — Cross-Device Election and Adoption](./0060-one-home-per-owner.md) (proposed 2026-09-05) — the unit of Home is the OWNER, not the install (#449): auto-provisioning becomes optimistic and subject to election on the Tier-1 `("home")` register, `GET /home` reports `local`/`adoption_pending`/`elsewhere` instead of a bare 404, and adoption is winner-driven over a new fifth Tier-1 kind `SyncKind::HomeInvite` (a group id alone cannot admit a device, so `HomePointer` could elect but never seat); a deferred admission retries and never falls back to minting a second Home; amends ADR 0038 (unit of Home) and ADR 0041 (Tier-1 widened to five kinds); retirement of the losing duplicate is gated and still open
70+
- [ADR 0060: The Owner's Home Is Elected, Not Per-Install](./0060-one-home-per-owner.md) (proposed 2026-09-05) — the unit of Home is the OWNER, not the install (#449): auto-provisioning becomes optimistic and subject to election on the Tier-1 `("home")` register, publisher and resolver share one `!withdrawn` predicate, and `GET /home` reports `local`/`adoption_pending`/`elsewhere` instead of a bare 404. Amends ADR 0038 (unit of Home) ONLY — no Tier-1 surface change (ADR 0041 stays at four kinds) and no change to ADR 0039 Home eligibility. How a losing device JOINS the winner's Home, retirement of the duplicate, and any device-vs-rider eligibility rule are explicitly deferred: review of PR #507 showed the first implementation broke signed-record and protocol compatibility and had no trustworthy cross-device device/rider signal. #449 stays open.
7171

7272
## Errata (Accepted ADRs are immutable; corrections recorded here)
7373

docs/design/449-single-home-per-owner.md

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -361,8 +361,8 @@ history rows, no KV, no tasks), so the common case retires cleanly.
361361
|---|---|---|
362362
| **P0**| D3 + D4: stop the register war, make the published pointer deterministic ||
363363
| **P1**| Election + `GET /home` states + suppression at provisioning | P0 |
364-
| **P2** | Invite delivery channel (§5.4) — `SyncKind::HomeInvite`; needs the ADR-0041 amendment | P1 |
365-
| **P3** | Winner-driven adoption + D5 (`!withdrawn` filter) | P2, #447 |
364+
| **P2** | Invite delivery channel (§5.4) — WITHDRAWN, see below | P1 |
365+
| **P3** | Winner-driven adoption — WITHDRAWN. D5 (`!withdrawn` filter) landed with P1 | P2, #447 |
366366
| **P4** | Retirement gate + D6/D7 cleanup; `conflict` state and forced adopt/retire endpoints | P3 |
367367

368368
P0 is small, independently shippable, and blocks a regression that lands the
@@ -388,20 +388,33 @@ both its old duplicate and the canonical Home during adoption could answer with
388388
either. `resolve_home` additionally prefers the canonical Home whenever we are
389389
seated in it, so the transition settles deterministically.
390390

391-
Adoption rides `SyncKind::HomeInvite` (0x05), keyed by joiner agent hex.
392-
The seated device enumerates the owner's agents from synced `IssuanceJournal`
393-
records — the daemon journals its own certificate (`lib.rs:12029-12055`), so a
394-
peer's agent id is already known without any new discovery — and mints an
395-
addressed v4 invite for each agent not yet seated. `DaemonView::apply_home_invite`
396-
redeems it in `mode: "home"` with the owner pinned. Both sides are idempotent
397-
and fire every sync pass until the join lands; a refusal (#447's cert-blob
398-
race) retries and never falls back to minting.
399-
400-
**Still open (P4):** retirement. The loser keeps its duplicate group after
401-
adopting — unused, and no longer Home-resolved, but present. Retiring it needs
402-
D6 (retiring a Hidden Home publishes a public card) and D7 (history,
403-
delegations, task lists and rider grants are orphaned) fixed first, plus the
404-
`conflict` state and the forced adopt/retire endpoints.
391+
**P2/P3 WITHDRAWN after review of `4629117`.** An implementation of §5.4 as a
392+
fifth Tier-1 kind (`SyncKind::HomeInvite`) plus a `CertMode::Acp` filter was
393+
written, reviewed, and removed. Three blocking defects, all properties of the
394+
mechanism rather than bugs in it:
395+
396+
1. **Signed-record compatibility.** The new variant was inserted ahead of
397+
`IssuanceJournal`, shifting its bincode discriminant so `verify()`
398+
reconstructed different signed bytes and invalidated pre-upgrade issuance
399+
signatures. Appending fixes the ordering, but any change to a signed value's
400+
shape sits in this hazard class and needs an old-record fixture.
401+
2. **Protocol compatibility.** A fifth closed-enum kind under an unchanged
402+
protocol version 2 cannot be decoded by older peers, aborting the entire
403+
owner-sync session — including unrelated names, profile and journal sync.
404+
The kind needs negotiation or a staged rollout.
405+
3. **No trustworthy cross-device device/rider signal.** `apply_journal_line`
406+
materializes synced issuance records with `mode: Acp`
407+
(`owner_sync.rs:2567-2577`), and `owner_issued_certificates()` treats
408+
journal records as authoritative on ties. A Rider issued on device A
409+
therefore arrives on device B indistinguishable from a device agent, so the
410+
filter was defeated in exactly the multi-device case it existed for. The
411+
certificate does not carry hosting mode either. Inventing a device-only
412+
rule in the implementation would have silently amended ADR-0039's
413+
mode-agnostic Home eligibility and deny-by-default rider scope.
414+
415+
So adoption, retirement, and any device-vs-rider Home eligibility rule are
416+
deferred and must be decided explicitly — with ADR-0039 reconciled, not
417+
bypassed. **#449 remains open.**
405418

406419
Known limitation carried into P1: because a yielding device publishes nothing
407420
and only strict improvements take the slot, a register naming a Home whose

0 commit comments

Comments
 (0)