Repository navigation
Description
A name is claimed once — the hosted binding lifecycle contract (pkgs/id hosted_binding)
Design doc: files/designs/code-id-hosted-binding-lifecycle.md in the keep-the-four-repos-healthy goal workspace. Audit HEAD: origin/main 8e151e89. Module: pkgs/id/src/hosted_binding.rs.
Summary
hosted_binding.rs binds a Google identity (sub) to an Iroh root key — the §3 challenge/pairing ceremony (slice 1, #3808) and the §4 delayed rebind protocol (slice 2, #3809 via #4885), both landed 2026-11-03. The module's header states its §3.4 failure table and §4 no-mutation contract in prose, but the transitions of its ephemeral state were never stated as one lifecycle, and three filings in one day are one counterexample per transition:
- Consumption (id(hosted_binding): expired challenge is recorded as consumed — a second presentation returns ChallengeReplay, and create_pairing_code's documented ChallengeReplay branch is unreachable #4868):
submit_proof's expiry branch inserts the expired challenge intoconsumed_challenges(hosted_binding.rs:954-958), so a second presentation returnsChallengeReplayinstead ofChallengeExpired— against §3.4's "expired challenges die without state" (header) andChallengeExpired's own "State change: none" doc. Andcreate_pairing_code's documentedChallengeReplaybranch is unreachable: the live-map lookup returnsUnknownChallengefirst (:1099-1105), whileconsume()(:1049-1052) always removes consumed challenges from that map. - Supersession (id(hosted_binding): create_pairing_code does not supersede earlier codes — multiple live pairing codes per challenge, none revocable #4871):
create_pairing_codenever invalidates earlier codes for the same challenge (:1093-1147) andexchange_pairing_codehonours every live code (:1150-1168) — up to 10 simultaneously valid bearer codes per ceremony, none revocable, so "give me a new code" does not retire a leaked one. - Reservation (id(hosted_binding): a pending delayed rebind does not reserve its target key — another account can bind it during the 30-day wait, and effect_due_rebind silently overwrites that binding's by-key index entry #4887): a pending delayed rebind reserves the account but never its target key —
request_delayed_rebindchecks the target only againstbinding_by_irohat request time (:1269),submit_proof's conflict check likewise (:996-1000),effect_due_rebindre-checks nothing (:1462-1503), andapply_rebindunconditionally overwrites the other binding'sbinding_by_irohentry (:1538-1540). During the 30-day wait anyone can bind the target key; at effect time twoactiverecords claim oneiroh_idand the squatter's record is silently unindexed.
This audit adds a fourth surface of the same missing lifecycle: the rebind paths' shared verify_challenge_proof (:1171-1205) also consumes on expiry, and RebindError::ChallengeExpired's doc states the opposite of the ceremony side's — "it is consumed and dead." One module, two contradictory expiry semantics for the same challenge object.
The contract: every name the ceremony mints — a challenge, a pairing code, a rebind target key — is claimed exactly once, by exactly one holder, for exactly its stated lifetime, and every transition out of a live state (consumption, supersession, expiry, reservation, release) is named in the §3.4/§4 tables. Dead means the same thing on both error surfaces.
Checked and NOT stating this contract: #3809 (closed implementation slice) and #3818 (open, blocked — hosted bridge transport, gated on the #3808 contract, states no ceremony transitions). The Worker API slice (#3811) that will expose pairing over HTTP is not yet built; this contract lands before it does.
Decisions recorded
- Expiry creates no state, on both surfaces. Expired challenges are removed from the live map and NOT inserted into
consumed_challenges, insubmit_proofand inverify_challenge_proof;RebindError::ChallengeExpired's doc is amended to match §3.4 in the same change. (The docs-amended-the-other-way alternative loses: replay is a security signal, expiry is not.) - A consumed challenge reports as consumed, everywhere. Every entry point checks the consumed set before the live map (the order
submit_proofalready uses), makingcreate_pairing_code'sChallengeReplayreachable;UnknownChallengemeans never-issued or expired away. - Minting supersedes.
create_pairing_coderemoves every existing code for the challenge first — at most one live code per challenge. Rate limits unchanged. - A pending rebind reserves its target key. Reservation is a predicate over
Pendingrequests (not a second index — a second index is a second place for state to disagree, the id(hosted_binding): a pending delayed rebind does not reserve its target key — another account can bind it during the 30-day wait, and effect_due_rebind silently overwrites that binding's by-key index entry #4887 failure family).submit_proofrefuses a reserved key withIrohIdBoundElsewhere;request_delayed_rebindrefuses a reserved target withNewKeyBoundElsewhere; the fast path gains the same check. Cancel/effect releases implicitly (the predicate reads onlyPending). - Effect re-checks; the index write refuses a foreign entry.
effect_due_rebindre-checks before any mutation (NewKeyBoundElsewhere, no state change, request staysPending), andapply_rebinditself refuses to overwrite abinding_by_irohentry naming a different binding.
Phases (sub-issues of this design)
- Phase 1 — id(hosted_binding): expired challenge is recorded as consumed — a second presentation returns ChallengeReplay, and create_pairing_code's documented ChallengeReplay branch is unreachable #4868 (ADOPTED): expiry creates no state (both challenge gates) + consumed-check order everywhere; docs amended in the same change.
- Phase 2 — id(hosted_binding): create_pairing_code does not supersede earlier codes — multiple live pairing codes per challenge, none revocable #4871 (ADOPTED, independent): minting a pairing code supersedes all earlier codes for the challenge.
- Phase 3 — id(hosted_binding): a pending delayed rebind does not reserve its target key — another account can bind it during the 30-day wait, and effect_due_rebind silently overwrites that binding's by-key index entry #4887 (ADOPTED, independent): target-key reservation + effect-time recheck +
apply_rebindforeign-entry refusal. - Phase 4 — NEW (closing audit): transition-table audit of every public method, the table written into the module header beside §3.4, four required dispositions (expired pairing-code sweep;
consumed_challengesgrowth bound; reservation scan cost vs a target-key index; old-key-side symmetry confirmation), gated on Phases 1–3.
Open questions (recorded in the design doc, not phased)
- Must "expired" be sticky (a tombstone) once the Worker API (id: implement hosted Worker API + per-user DO provisioning and metering (from #2046/#2852 slice 3) #3811) maps these errors to HTTP? A client-contract question for that slice.
- Reservation notices to third parties whose ceremony is refused — a disclosure question, Drew's call if ever wanted, not a phase.
Acceptance for this design issue
All four phases closed with evidence; the module header carries the lifecycle transition table; #4868/#4871/#4887 closed by their phases (not re-filed).
Acceptance criteria
- The lifecycle contract is recorded (design doc + this issue) stating claim-once semantics for each ephemeral state in hosted_binding: pairing challenge, pairing code, rebind target key — holder, lifetime, and every named transition out of a live state (consumption, supersession, expiry, reservation, release).
- Each of the three counterexample transitions (consumption id(hosted_binding): expired challenge is recorded as consumed — a second presentation returns ChallengeReplay, and create_pairing_code's documented ChallengeReplay branch is unreachable #4868, supersession, rebind) has a named disposition in the contract.
- The phase/implementation issues (id(hosted_binding): expired challenge is recorded as consumed — a second presentation returns ChallengeReplay, and create_pairing_code's documented ChallengeReplay branch is unreachable #4868, id(hosted_binding): create_pairing_code does not supersede earlier codes — multiple live pairing codes per challenge, none revocable #4871, id(hosted_binding): a pending delayed rebind does not reserve its target key — another account can bind it during the 30-day wait, and effect_due_rebind silently overwrites that binding's by-key index entry #4887, id(hosted_binding): closing lifecycle audit — transition table for every public method + module-header contract statement (Phase 4 of #5047) #5048) are linked to this epic as native GitHub relations (sub-issues / blocked-by), not only as prose references (protocol §11).
Activity
dezren39 commentedon Nov 3, 2026
agent-note: design-architect pass 45 (2026-11-03) originated this design from the standing candidate recorded at pass 44: the pkgs/id hosted_binding lifecycle trio #4868 + #4871 + #4887, audited against origin/main 8e151e8 with every defect re-verified present at the cited lines in pkgs/id/src/hosted_binding.rs. The audit added a fourth surface to Phase 1: verify_challenge_proof (the rebind paths' shared challenge gate) also consumes on expiry, and RebindError::ChallengeExpired's doc contradicts the ceremony side's — one module, two expiry semantics.
Phases (sub-issues, verified in this issue's sub_issues list, count=4): Phase 1 = #4868, Phase 2 = #4871, Phase 3 = #4887 — all ADOPTED as filed (no parallel issues, no new packets; each already carries its queued creator triage packet). Phase 4 = #5048 (NEW closing transition-table audit, gated on Phases 1–3 + status/blocked, triage packet → pilot task 8683 queued, verified by dedupe re-read).
Decisions are recorded in the design doc (files/designs/code-id-hosted-binding-lifecycle.md in the keep-the-four-repos-healthy goal workspace) and summarised in this issue's body. No user/review: every origin carries explicit acceptance with verified laptop probes; the either/or choices the origins left open are the architect's recorded pins. #3809 (closed) and #3818 (blocked bridge) were checked and state none of this contract.
dezren39 commentedon Nov 3, 2026
Intake review
— fleet monitor intake pass (repo-fleet-monitor, 2026-11-03 12:12 CST run)
111 remaining items
dezren39 commentedon Nov 5, 2026
🤖 Agent note · handed off
dezren39 commentedon Nov 5, 2026
🤖 Agent note · handed off
dezren39 commentedon Nov 5, 2026
🤖 Agent note · handed off
opencode, so this lane does not work it directly — handing off to the opencode claimant per the worker runbook §1b. Task 9811 is at attempt 10/12 and has been handed off repeatedly without a bound session completing it.dezren39 commentedon Nov 5, 2026
🤖 Agent note · handed off
dezren39 commentedon Nov 5, 2026
🤖 Agent note · handed off
dezren39 commentedon Nov 6, 2026
agent-note:v1
task: 9811
lane: code-r
state: handed_off
note: Handed off to opencode claimant per execution-lane assignment (assigned_lane=opencode). Checkpoint handed_off recorded; parked 1800s for claimant launch. Issue #5047 re-verified open, no held/user-owned/do-not-merge/do-not-auto-edit labels.