carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Design: roster revision vs hold churn (host claims, G10)
2
3Status: PROPOSED, rev 3 (2026-10-20). Branch `claim-empty-cohort-done` at 500741f4. Static reading only; nothing built or run.
4
5## Rev 3
6
7Astra's round 2 on rev 2 (0a0b4b2c) returned DESIGN: revise. Each finding, its ruling and where rev 3 resolves it. Each mechanism is named, bounded, assigned to a task and has its test named; none is expanded into code here.
8
9| Astra r2 | Ruling | Mechanism | Resolution |
10|---|---|---|---|
11| 1 HIGH: crash-recovered receipts have no numeral source | N1 | `FenceTarget.RosterRevision`, persisted by `BeginFence` before any stop effect; every receipt constructor reads it from the fence | section 1.4, task 4, test N1 |
12| 2 HIGH: hold churn strands serving-park reboot recovery through persisted whole-actor comparisons | N2 | one helper, `hostbox.Actor.Bound()` (copy with `Revision` zeroed) and `hostbox.SameBinding`; persisted preparations store the bound actor | section 1.6, task 3b, test N2 |
13| 3 HIGH: fresh authority in startup replay disables the cleanup service | N3 | per-intent obsolescence in `recoverRootInventory`: a stamped binding that differs from the startup binding is settled as abandoned under the existing `AbandonRootInventory` fencing; never an assembly abort | section 3.2, task 5b, test N3 |
14| 4 MEDIUM: `installRecoveryRemote` producer missing; recovery witness has no success case | N4 | the producer joins task 3; a production-client success test with hold churn and replay, then a predecessor-only mutation | section 1.2, task 3, test N4 |
15| 5 MEDIUM: operation cold handover copies the supplied successor binding | N5 | root-side fresh check in `serveColdHandover`'s operation branch before `ColdHandover.Run`; replay refused when the journaled successor binding differs | section 3.3, task 6b, test N5 |
16| Convergence | | section 6, the invariant checklist | |
17
18## Rev 2
19
20Astra's review of rev 1 (212022a8) returned DESIGN: revise.
21
22| Astra r1 | Ruling | Resolution |
23|---|---|---|
24| 1 HIGH: digest receipts break the v0.28.4 console (purge's `StopBeforeTeardown`, wake-reset verification) | R1: the dev console stays on v0.28.4; internal comparisons bind on `Binding`; every console-verified artifact keeps `DirectoryRevision` as the numeral and carries the binding in a new additive field only where the console decoder tolerates unknown fields; no console or hostops verifier changes | section 2 C1, section 1.4, tasks 4 and 8 |
25| 2 MEDIUM: root's inventory comparison uses the startup-pinned cleanup actor | R2: separate the historical cleanup identity from the fresh authority | section 3.2, task 5, test L4 |
26| 3 MEDIUM: recovery re-stamps predecessors unchecked | R3: not an exception; predecessor-binding checks before reuse; cold handover named as the one path across a binding change | section 3.3, task 6, test L6 |
27| 4 MEDIUM: the witness did not establish the invariant | R4 | section 4 tests L0 to L7 |
28| 5 LOW: missing readers; numeric `Revision` must stay in several places | R5 | section 1.2, section 1.3 |
29| Role excluded; historical stop evidence format-only; wake-reset keeps its actor checks | R6 | section 2 tuple, section 1.4 |
30| Pre-roll check for leo | R7 | task 9 |
31
32## The gap
33
34`control/host-actors.json` (`hostactors.Roster`) has one counter, `Revision`, and `Directory.Replace` bumps it on every write (`internal/hostactors/roster.go:252`). The console's per-operation holds are roster writes, so every create or delete anywhere in the deployment moves `Revision` twice (reserve, release). Boxes bind installed state to that counter as the string `DirectoryRevision`, and root compares the stored value with the current roster at wake, launch, inventory write and handover. After any roster write, every installed instance on every claim box refuses to wake (`hostbox.ErrTypedInventory`), and an agent restart cannot help because the inventory keeps the old stamp.
35
36Fixes 1 and 2 (79db17be, 7b805720) cover the ack and the regional check. This note is the design for the rest.
37
38## 1. Survey
39
40### 1.1 Writers
41
42Every roster write goes through `Directory.Replace` (`internal/hostactors/roster.go:211-286`) except `BindCensusKey`, which bumps on its own. Both sign the whole object with the deployment root key (`release.SignRootEnvelope`, `roster.go:277`, `censuskey.go:83`) and CAS on the S3 ETag.
43
44| Writer | file:line | What changes | Bumps Revision | Membership fact |
45|---|---|---|---|---|
46| `Initialize` | hostactors/roster.go:343 | fresh roster, `Revision: 1` | n/a | yes |
47| `Enroll`, `EnrollAWS`, `EnrollAutomatic` | hostactors/enroll.go:28-80, enrollment_confirmation.go:34 | `Boxes[+ref]`; consumes the attempt | yes | yes (new row) |
48| `BeginEnrollmentAttempt` (region HA auto-enrol) | hostactors/enrollment_confirmation.go:103-124 | `EnrollmentAttempts[ref]` | yes | no |
49| `Quarantine` | hostactors/roster.go:293-306; caller hostresolve/service.go:290 | row `State`, `QuarantineRevision++`, `LossIDs` | yes, exactly one (hostops/move_loss_grant.go:312 admits `before+1`) | yes |
50| Readmission | hostresolve/readmit.go:131-136 | row `State: active` | yes | yes |
51| Retire | no writer in the tree; `Validate` accepts the state (roster.go:122), `Replace` refuses un-retiring (roster.go:270) | row `State: retired` | yes, when one lands | yes |
52| `SetBoxRole`, `SetGroupRole` | hostactors/roles.go:76-91, 114-138; console/apiboxroles.go:147,187 | row `Role`, `Regions[r].Role` | yes | placement only |
53| `PublishFloor` | hostactors/publication.go:14-78 | `Publication`, `FloorSHA256` (two writes: :31, :77); after publication `checkMembership` refuses any floor change (membership.go:241) | yes, twice | yes |
54| `BindCensusKey` | hostactors/censuskey.go:25-95 (`next.Revision++` at :78) | row `CensusPublicKey` (write-once) | yes | yes |
55| Hold reserve (`ReserveLifecycle` -> `Reserve`) | hostactors/lifecycle.go:135-155, census.go:246-294 (:293); caller hostops/production_held_start.go:32 | `Holds[+id]` | yes | no |
56| Hold release (`ReleaseFinishedHold`, `CancelHold`) | hostactors/lifecycle.go:157-180, reservations.go:69-110; callers console/claim_hold_recovery.go:40,46, production_held_start.go:35,51 | `Holds[-id]` | yes | no |
57| Activation reserve (`Reserve(activation=true)`) | census.go:246-294 (:281); console/hostactivation.go:193 | `Activations[+id]` | yes | no |
58| Activation release, cancel | hostactors/activation_complete.go:13-57, activation_publication.go:22-50; console/hostreports.go:91, hostactivation.go:330 | `Activations[-id]` | yes | no |
59| Activation runner phases configure, home, enroll-console | claimactivation/runner.go:194, 203, 268 | `Regions`+`RequestLimitNS`; `HomeRegion` (write-once with claims on, roster.go:216-217); `Consoles[id]` | yes | yes |
60
61Not roster writes: `Invite` (invitation.go:30) and `InviteConsole` (console_invitation.go:15) write their own objects.
62
63So it is not only holds. Activations are written on every platform promote (`reserveHostUpdate`) and released by the console's report loop; enrollment attempts are written by region HA autoscaling; role changes are operator actions. Under today's binding every one of them strands every installed instance in the deployment.
64
65### 1.2 Readers of `Revision` and `DirectoryRevision`
66
67"Treatment" is the design's answer. `Binding` is the per-box digest of section 2; "numeral" is `strconv.FormatUint(actor.Revision, 10)` as today.
68
69Box agent (pinned once at assembly):
70
71| Reader | file:line | Compares | Protects | Treatment |
72|---|---|---|---|---|
73| assembly pin | host/claimassembly.go:179-180 (`Reconcile.DirectoryRevision`, census), :381 (root's reconcile census) | stamp source for every inventory this process writes | the installation is made by this enrollment under this roster | stamp `actor.Binding`; stays pinned (fail closed after a binding change until restart, section 3.2) |
74| `claimBoundIdentity` | host/claimmoveparks.go:97-110; wired at claimassembly.go:186-188 (data store), :243 (leases), :273 (move fence, move parks) | fresh actor == pinned actor, whole struct | the regional engine, moves and leases act only as the verified enrollment | `hostbox.SameBinding` (section 1.6) |
75| `VerifyRegional` | host/claimassembly.go:281-301 | `sameVerifiedActor` since 7b805720 | same | `hostbox.SameBinding` replaces `sameVerifiedActor` |
76| install, route register | hostbox/reconcile_adapter.go:139; inventory_registration.go:80, 140, 249 | stored inventory == pinned stamp | an inventory this process did not write is refused | opaque string, unchanged code |
77| local census | hostbox/empty_census.go:99-135, 370 (`EmptyInstallationCensus.CheckLocal`, `Initialize`) | same, against the startup pin | same | unchanged code; the pin is a binding |
78| retirement | hostbox/reconcile_adapter.go:301 | inventory == fence target | the retirement parks only what root's inventory names | record to record, unchanged |
79| sanity | hostbox/identity.go:86 (`Revision > 0`); host/claimfilesseed_select.go:38; claimservingpark_transport.go:158; claimservingparkroot_linux.go:75; claimservingparksource_linux.go:33 (actor check, beside its inventory check at :41); claimservingreboot_prepare_linux.go:187 | non-zero | a roster row was read | `Binding != ""`; at claimservingreboot_prepare_linux.go:187 the `Revision == 0` clause is dropped because the stored actor is bound (section 1.6) |
80| launch owner | host/claimconstruction.go:327 (`FenceTarget.DirectoryRevision` from the construction actor), claimroleassembly.go:58 | stamp into launch and fence records | a launch is tied to the enrollment that made it | stamp `actor.Binding` and `RosterRevision: actor.Revision` |
81| stop request producers | host/claimwakestop.go:308-313 (fallback `fmt.Sprint(actor.Revision)` when no launch record), claimstop_writers.go:239-241 (`local.Actor.Revision`), relocationeffects.go:275, claimservinglegacy.go:48 | produce the expected stamp | the stop names the installation's binding | produce `actor.Binding` as `ExpectedDirectoryRevision` and `actor.Revision` as `RosterRevision` (section 1.4) |
82| remote recovery producer (rev 3, N4) | host/claimrecovery_remote.go:54 (`Next.DirectoryRevision: fmt.Sprint(actor.Revision)`) | successor stamp sent to root's `RecoveryRebind` | root re-stamps only to the current binding | produce `actor.Binding`; in task 3, tested by N4 |
83| stop, retire, receipts | host/claimstop.go:223, 256, 269, 335, 399, 412, 433; claimstop_writers.go:39, 86, 109, 119; claimstop_recovery.go:64, 82; claimstop_transport.go:207-211; claimretire_transport.go:145, 180; claimjournal_proof.go:106, 114; rootmigration_evidence.go:40; claimservinglegacy.go:48-49 | request == inventory == receipt (record to record) | a stop receipt proves the stop of exactly the installation it names | inventory, target and request slots carry the binding; receipt comparisons move to `receipt.Binding`; receipt numerals come from the fence (section 1.4) |
84| execution authority | host/rootexecution_authority.go:79 -> rootparticipation/record.go:27 (`ExecutionAuthority.DirectoryRevision` string) -> hostexec/rootbroker.go:254 (non-empty) | stamp into the lease authority | forensic | carries `Binding`; the participation ledger's other revisions stay numeric |
85| journal digests and validation | hostbox/journalservice.go:184, 208 (v1 tuple, frozen); journalmigration_linux.go:606, 680 (`legacyToken`); hostguard/fence.go:42; hostbox/stopinventory_db.go:42; generationpayload.go:56 (its `p.Revision` is the journal counter) | hashed or non-empty as a string | the journal request is bound to its target | opaque; no code change, no protocol version. `FenceTarget.RosterRevision` is not in the v1 tuple and is never hashed (section 1.4) |
86
87Root (fresh actor against the stored stamp):
88
89| Reader | file:line | Compares | Protects | Treatment |
90|---|---|---|---|---|
91| inventory write | host/rootinventory.go:61-67; actor from `InventoryActor` = `actorForRoot` (claimcleanup_identity.go:70-74) | `in.DirectoryRevision == actor` | root records only inventories made under the roster it sees | `== actor.Binding`, with `InventoryActor` always fresh (section 3.2) |
92| wake | host/rootinventory_state_linux.go:38 (remote), claimroleassembly_linux.go:373 (local), :47, :128 (target == inventory) | same | the instance wakes only under the roster it was installed under | `== actor.Binding` |
93| launch authorize | host/claimlaunch_authority.go:82 (actor is `a.Identity.Verify`, claimlaunch_production.go:70), claimlaunch_root.go:180, 216 | fresh == `FenceTarget` | a launch runs only for the enrollment that reserved it | `== actor.Binding` |
94| serving handover | host/claimservinghandover.go:29-32; `servingPriorRevision` :136-139; hostbox/servinghandover.go:50, 83, 140 | `prior <= fresh`, `next == fresh`; re-stamps | a swap re-binds the installation to the current roster | prior and next both `== actor.Binding` (section 3.3) |
95| serving park, legacy | host/claimservingparkroot_linux.go:131, claimservingparksource_linux.go:41, claimservinglegacyroot_linux.go:44, 65, 102 | `prior <= fresh`, inventory == fresh | same | equality with `actor.Binding` |
96| serving park reboot preparation (rev 3, N2) | host/claimservingreboot_prepare_linux.go:95 (`nextActor != actor`), :109 (stores `Actor: actor`), :136 (`reflect.DeepEqual(prior, first)` over the persisted preparation, which holds `Active.Actor`), :187 (sanity); claimservingreboot_commit_linux.go:30 (`reflect.DeepEqual(prepared, fresh)`) | whole actor, including `Revision`, live and persisted | the reboot resumes the same park under the same authority | `SameBinding` at :95; the preparation stores `actor.Bound()` so both `DeepEqual`s compare bound actors (section 1.6) |
97| recovery rebind, handover recovery | host/rootrecovery_rebind.go:68, 75, 136 (re-stamp at :35); claimhandover_recovery.go:217, 224, 243 | successor stamp = fresh; predecessor stamp never checked (:75, :217) | an authorized recovery adopts the predecessor's artifacts | predecessor must equal `actor.Binding` before reuse (section 3.3) |
98| startup replay (rev 3, N3) | host/rootinventory_startup.go:45-66 (`recoverRootInventoryStartup`, `errRootMigrationBusy` at :65), rootinventory.go:363-420 (`recoverRootInventory`: rebind intents at :378, inventory writes at :399, abandonment predicates), claimconstruction.go:235 | pending intents replayed through `InventoryActor` | interrupted root work is finished or abandoned before the service starts | per-intent obsolescence settlement (section 3.2) |
99| cold handover, serving branch | claimhandover_transport.go:56-70 (`Validate` -> `validateServingHandover`, fresh) | fresh successor | | unchanged |
100| cold handover, operation branch (rev 3, N5) | claimhandover_transport.go:55, 71 (`handover.Run` with no fresh check); hostbox/coldhandover.go:66 (`Run`), :92-98 (journal replay, `j.Successor != next`), :122 (barrier copies `next.DirectoryRevision`), :336 (successor inventory copies it) | the supplied successor stamp is copied, never verified | the prior is parked under its own stamp and a successor is started | root-side fresh successor check before `Run` (section 3.3) |
101| generations | host/rootgeneration_inventory.go:227 (non-empty), rootgeneration_parked.go:48, rootgeneration_handover.go:101, rootexecution_settle.go:96 | inventory == target | record to record | opaque, unchanged |
102| startup census | host/rootinventory_startup.go:45, :47 | pinned to the startup actor | same as the agent's census | stamp `actor.Binding`; `InventoryActor` fresh |
103| migration preflight and fingerprint | host/rootmigration.go:59, 84-93; rootmigration_census.go:70-79 | plan and report pin `roster.Revision` | the migration census is for one membership | numeric `Revision` stays |
104
105Console and hostops:
106
107| Reader | file:line | Compares | Protects | Treatment |
108|---|---|---|---|---|
109| backfill census `expectedRevision` | hostops/backfill.go:187-193; callers backfill_publish.go:32, 370, 437 and backfill.go:327; `Census()` for production create uses 0 (backfill.go:274-275) | `roster.Revision == plan.DirectoryRevision`, else `ErrPending` | the plan and every box report were taken against one membership | numeric `Revision` stays (deferred, section 3.4) |
110| census reports | hostops/backfill.go:242, backfill_publish.go:169, 339, 395; claimactivation/backfill.go:159, 171, 203, 214, 246, 265 | `report.DirectoryRevision == roster.Revision` (uint64) | a box-signed report is for this membership | numeric, unchanged |
111| stop evidence (purge, moves) | hostops/evidence.go:118 (`decode`, tolerant), :123-124 (`0 < recorded <= roster.Revision`); reached from `StopBeforeTeardown` (evidence.go:297, called by `FirstPhase` at production_create.go:351-353) and `verifyEvidence` | sanity bound plus signature, enrollment, op, host, incarnation, fence | never an authority | unchanged code; receipts keep a numeral there |
112| wake-reset evidence | hostops/wake_reset_transport.go:286-300 (active, unfenced actor), :312 (`decode`), :318-319 (bound); wired by console/hostwakereset_production.go:16 | same | same | unchanged code; the actor check stays |
113| move loss grant | hostops/move_loss_grant.go:312-317 | the quarantine write is exactly `before.Revision+1` | the loss grant's roster write is scoped | numeric, unchanged |
114| relocation start | hostactors/census.go:296-321, :425-440 (`CensusBoxRelocation`, activations in scope); hostrelocate/worker.go:341, 358; hostclaim/relocation.go:476, 511 | two-read consistency; forensic intent | a relocation starts against one roster | numeric, unchanged |
115| moves fence binding | hostfence/claim_fence.go:32 -> hostclaim/fence.go:75 -> relocating.go:136, 306 | audit metadata | forensic | numeric, unchanged |
116| roles | console/apiboxroles.go:153, 193 | `next != o` | logging | unchanged |
117| activation CLI | claimactivation/runner.go:132, 143, 322; host/activation_command_linux.go:117, 237; activation_console_report_linux.go:240 | operator-supplied expected revision | offline CAS; claims off | numeric, unchanged |
118| region HA | writers only | | | |
119
120### 1.3 Numeric `Revision` stays
121
122Per R5 these keep the roster write counter: relocation's two-read consistency check and start intent, the moves fence audit metadata, the activation CLI pins, the backfill and census pins, the migration preflight and snapshot fingerprint, the participation ledger's revisions, the loss grant's `+1` scope, and (rev 3) the receipt numeral carried by `FenceTarget.RosterRevision`. `Revision` keeps its one honest meaning, a signed write counter; `Binding` is a second value derived from the same signed read.
123
124### 1.4 Artifacts a console verifies, their decoders, and the receipt numeral (R1, N1)
125
126The only box-produced artifact with a stamp that a console reads is the stop receipt (`host.StopReceipt`, claimstop.go:112; bucket keys claimstop.go:505-507 `evidence/` and `serving-evidence/`, claimretire_transport.go:201 `-retirement.json`). Relocation's `Release` and `probe_stop` journals reference a receipt by key and sha256 (hostclaim/relocation.go:86-87, relocation_probestop.go:18-19); they never embed it. Census reports carry a uint64 numeral and are unchanged. Execution acks carry no stamp.
127
128| Decoder | file:line | Strict | Reads the receipt | Gets the `binding` field |
129|---|---|---|---|---|
130| `hostops.decode` | hostops/record.go:32-40 | no (trailing data only) | yes: evidence.go:118, wake_reset_transport.go:312 | yes: a v0.28.4 console ignores it |
131| `decodeClaim` | hostops/lifecycle_grant.go:115-120 | yes | no: the claim object references receipts by key | n/a |
132| `decodeMoveLossIntent` | hostops/move_loss_owner.go:115-117 | yes | no | n/a |
133| box-side receipt decoders | host/claimstop_transport.go:163, claimretire_transport.go:114, relocationreleases.go:42, `verifyRetirementReceipt` (claimretire_transport.go:174) | yes | yes | yes: agent and root are one binary on a box |
134| root's fence record | hostguard/record.go:98 (strict) | yes | holds the `FenceTarget` | gets `RosterRevision`; same binary |
135
136The contract:
137
138- **One persisted numeral source (N1).** `hostguard.FenceTarget` gains `RosterRevision uint64 json:"roster_revision,omitempty"`. The agent supplies it in `StopRequest.RosterRevision` and `WritersStoppedRequest.RosterRevision` (box-internal, strict decoders at claimstop_transport.go:89 and claimstop_writers.go:143) from `actor.Revision` of the roster it read (claimwakestop.go:313, relocationeffects.go:275, claimservinglegacy.go:48, claimstop_writers.go:247). Root copies it into the target it builds (claimstop.go:223 cleanup target, :269 stop target, :399 no-process target) and `BeginFence` (hostguard/fence.go:82) persists it in the fence record before any stop effect. The stop coordinator refuses a zero `RosterRevision` on a stop target; `FenceTarget.validate` (fence.go:42) is otherwise unchanged so launch and journal targets need not carry it. Launch owners built from an actor (claimconstruction.go:327, claimroleassembly.go:58) carry it too, so a stop derived from a launch record has it without a fallback.
139- **Every receipt constructor reads the fence.** Normal (claimstop.go:335), recovered (claimstop_recovery.go:82, from the `FenceTarget` that `PendingStopFences` and `RecoverStopped` already pass through `ResumeFence`) and no-process (claimstop.go:433): `DirectoryRevision: strconv.FormatUint(target.RosterRevision, 10)` and `Binding: target.DirectoryRevision`. The retirement copy (claimretire_transport.go:201) re-uploads the fence's receipt bytes and needs nothing. A legacy fence with no `RosterRevision` cannot produce a receipt and is refused by `RecoverStopped`; task 9 shows none exists.
140- `StopReceipt.Binding string json:"binding,omitempty"` carries the installation's binding. The box-internal receipt comparisons move to it: claimstop_transport.go:207, claimretire_transport.go:180, claimstop_recovery.go (the receipt it builds), rootmigration_evidence.go:40, and claimjournal_proof.go:106 (`wanted.Binding = inventory.DirectoryRevision`) with `legacyJournalProofMatch` (:114) digesting `Binding` in place of `DirectoryRevision`. The receipt's signature covers the raw bytes (`hostevidence.Verify`, evidence.go:115), so the added field is signed.
141- Since `Revision` only grows, v0.28.4's `0 < recorded <= roster.Revision` holds for every later roster, for request-driven and crash-recovered receipts alike.
142- `hostops.StopReceipt` (evidence.go:26-54), `evidence.go`, `wake_reset_transport.go` and `record.go` are not changed in this branch. The fix report records `git diff v0.28.4..HEAD --stat` for those three files as empty.
143- Historical stop evidence is format-only on the stamp (R6). Wake-reset still requires an active, unfenced actor (wake_reset_transport.go:286-300).
144
145### 1.5 Findings
146
1471. The roster is strict-decoded (`release.VerifyRootEnvelope` -> `strictCanonicalJSON`, release/rootenvelope.go:72-90). A new roster field, even `omitempty`, makes every older binary refuse `Directory.Read` once first written; the I2 pattern (censuskey.go:49-52) gates such a write on a published floor. C1 adds no roster field. The `Roster` struct is byte-identical between v0.28.4 and this branch.
1482. `DirectoryRevision` is a string everywhere it is stored and is hashed as a string in the frozen journal v1 tuple. Inside the box only `servingPriorRevision` parses it as a number; outside the box only the two stop-evidence bounds do, and they keep receiving a numeral.
1493. Handover recovery and recovery rebind re-stamp a predecessor without checking its stamp (rootrecovery_rebind.go:75, :136; claimhandover_recovery.go:217, :243). Rev 2 makes recovery require equality; rev 3 adds the operation cold handover's successor check.
1504. In instance-control mode root's `InventoryActor` is the startup-pinned cleanup actor (claimassembly.go:382; rootinventory_startup.go:47). Rev 2 splits the two; rev 3 keeps the startup replay from aborting the assembly when the pin and the fresh actor disagree.
1515. `hostbox.Actor` is compared whole in six places (section 1.6), and one of them is persisted. Any whole-actor comparison carries `Revision` implicitly.
152
153### 1.6 Whole-actor comparisons and the one helper (N2)
154
155`hostbox.Actor.Bound() Actor` returns the actor with `Revision` set to zero; `hostbox.SameBinding(a, b Actor) bool` is `a.Bound() == b.Bound()`. It replaces `sameVerifiedActor` (claimassembly.go:294-300), which is deleted. Every place that compares a whole actor, or anything containing one (grep: struct equality on `hostbox.Actor`, `reflect.DeepEqual` over a struct holding one):
156
157| Site | file:line | Today | Rev 3 |
158|---|---|---|---|
159| regional enrollment | host/claimassembly.go:289 (`sameVerifiedActor`) | ignores `Revision` | `SameBinding` |
160| bound identity (regional store, leases, move fence, move parks) | host/claimmoveparks.go:107 (`current != expected`) | whole actor | `SameBinding` |
161| evidence wiring | host/claim_evidence_wiring.go:61 (`cleanupEnrollment.actor != actor`) | whole actor | `SameBinding` |
162| reboot preparation, immediate reread | host/claimservingreboot_prepare_linux.go:95 (`nextActor != actor`) | whole actor | `SameBinding` |
163| reboot preparation, persisted | claimservingreboot_prepare_linux.go:109 (stores `Actor: actor` in `servingParkRebootAuthority`), :136 (`reflect.DeepEqual(prior, first)`), :187 (`Revision == 0`) | persisted whole actor | store `actor.Bound()`; the `DeepEqual` is then binding-aware; the sanity clause becomes `Binding == ""` |
164| reboot commit | host/claimservingreboot_commit_linux.go:30 (`reflect.DeepEqual(prepared, fresh)`) | persisted whole actor | same, through the stored bound actor |
165
166Structs that hold an actor but compare fields, not the whole value (no change): claimmoveaccess.go:33 (`.Box`, `.Region`), claimwakestop.go:23 `claimStopLocal` (`.Box`, `.Enrollment`, `.Region`), claimfilesseed_stage.go:26 (`.Box`, `.Region`), claimcleanup_identity.go:17 (fields), hostbox/identity.go:105 (fields). A preparation persisted before this change (whole actor with `Revision`) never equals a bound one and is refused; task 9 shows none exists.
167
168## 2. Options
169
170Common to all three: the roster stays root-signed; a hold write re-signs the whole object with the console's root key (console/claim_runtime.go:75), as today; boxes hold only the public key. Nothing new is signed by any option beyond the receipt field above, which rides under the receipt's existing box signature.
171
172### A. Holds stop bumping `Revision`; the S3 ETag tracks hold changes
173
174| Aspect | A |
175|---|---|
176| Code | console side only: `Replace`, `CensusBoxRelocation`, hostactors tests that assert `+1` |
177| v0.28.4 console | its `Replace` keeps bumping on holds; the dev console stays on v0.28.4 (R1), so A does not fix the dev trial at all |
178| Migration | none |
179| Security | membership writes for other boxes, console enrolments and role changes still strand every installed instance; region HA replacements would do so chronically |
180
181Rejected under R1.
182
183### B. Holds move to their own object
184
185| Aspect | B |
186|---|---|
187| Code | hostactors `Reserve`, `Start`, `CheckHold`, `CancelHold`, `ReleaseFinishedHold`, `Validate` (the hold/activation non-overlap rule at roster.go:173-179 spans both objects), console hold recovery, `PublishFloor`'s gate |
188| v0.28.4 console | writes holds into the roster for the whole dev trial; the overlap rule cannot be atomic across two objects |
189| Security | fixes only holds, like A, at ten times the cost |
190
191Rejected.
192
193### C. Boxes bind to a membership binding computed from the roster
194
195C1, a per-box digest, no roster change. `hostactors.BoxBinding(r Roster, ref string) (string, bool)` is sha256 over a frozen v1 tuple: the deployment facts (`DeploymentBucket`, `HomeRegion`, `RequestLimitNS`, `Publication`, `FloorSHA256`) and the row's identity and state (`Enrollment`, `Namespace`, `BoxID`, `Region`, `Pool`, `Cloud`, `EvidenceSHA256`, `State`, `QuarantineRevision`, `CensusPublicKey`). Not in the tuple: `Revision`, `Holds`, `Activations`, `EnrollmentAttempts`, `Consoles`, `Regions`, other rows, `Role` (R6; roles.go:12-15 keeps existing instances and `boxchoice.ExcludeRoles` enforces placement), `Label`, `Group`, `LossIDs` (covered by `QuarantineRevision`), `EnrolledAt`.
196
197C2, a stored counter. Rejected: a new roster field needs the I2 floor gate before its first write, which the v0.28.4 dev console cannot pass.
198
199| Aspect | C1 |
200|---|---|
201| Code, box and root | `hostbox.Actor` keeps `Revision` (numeral) and gains `Binding`, `Bound()` and `SameBinding`; `productionClaimRoster.Actor` (claimproduction.go:396) and `verifyClaimCleanupEnrollment` (claimcleanup_identity.go:38) compute `Binding` from the roster they read; the 22 `fmt.Sprint(actor.Revision)` / `strconv.FormatUint(actor.Revision, 10)` stamp and compare sites plus claimrecovery_remote.go:54 use `actor.Binding`; `servingPriorRevision` becomes equality; the sanity checks add `Binding != ""`; the six whole-actor comparisons use `SameBinding`; `InventoryActor` is always fresh; startup replay settles obsolete intents; recovery and the operation cold handover check bindings; the receipt carries a fence-persisted numeral plus `binding` |
202| Code, console and hostops | none (R1) |
203| v0.28.4 console, dev | compatible in any order: the console's hold writes move `Revision`, which no internal comparison reads; the receipts it verifies keep a numeral. Order for dev: leo only. Flagship is claims-off |
204| v0.28.4 box | stays broken by any console's holds until it takes the release |
205| Migration | an inventory, fence target, launch record, receipt, persisted preparation or pending intent stamped under the old scheme never equals a binding: refused, or (pending intents) settled as obsolete at startup. Task 9 proves there is nothing to migrate before the roll |
206| Signing | unchanged |
207| Security, must invalidate | retire (`State`), re-enrol (`Enrollment`), quarantine (`State`, `QuarantineRevision`), readmission (`QuarantineRevision` only grows), floor publish (`FloorSHA256`, `Publication`), home region (`HomeRegion`), census key bind. Same-reference re-enrolment and census-key rotation are refused by the normal writers (enroll.go:51-56, censuskey.go:66-71); tests simulate them by direct signed writes and say so |
208| Security, must not invalidate | holds, activations, enrollment attempts, console rows, regions, other boxes, this box's role or label |
209| Ordering checks | inside the box, `servingPriorRevision` becomes equality. Outside the box the two bounds are untouched and keep seeing numerals |
210
211## 3. Recommendation
212
213C1, the per-box binding digest, no roster change, with the R1 compatibility contract.
214
215**Invariant.** Installed state binds to `BoxBinding(roster, box)`, the digest of the deployment's binding facts (bucket, home region, request limit, publication, floor) and this box's row identity and state (enrollment, namespace, box id, region, pool, cloud identity, evidence digest, state, quarantine revision, census key). `BoxBinding` changes only when one of those facts changes. Holds, activations, enrollment attempts, consoles, regions, other boxes' rows, this box's role and label, and the roster's write counter never change it.
216
217### 3.1 What compares what
218
219| Comparison | Left | Right |
220|---|---|---|
221| wake, inventory write, launch authorize, serving handover and park, handover recovery, recovery rebind, operation cold handover successor, startup replay admission | stored stamp (inventory, fence target, launch record, predecessor, successor, pending intent) | fresh `actor.Binding` |
222| agent install, census, retirement, stop, generations | stored stamp | the agent's pinned stamp or another stored stamp (record to record) |
223| regional engine, moves, leases, `VerifyRegional`, evidence wiring, reboot preparation and commit | fresh actor | pinned or persisted actor, `SameBinding` |
224| receipt vs request or target (box) | `receipt.Binding` | stamp |
225| receipt (console, unchanged) | `receipt.DirectoryRevision` numeral, from the fence's `RosterRevision` | `0 < n <= roster.Revision` |
226
227### 3.2 Root's identities and startup (R2, N3)
228
229| Function | Identity | Used by | After quarantine, no restart | After readmission, no restart |
230|---|---|---|---|---|
231| `ClaimRootHTTP.InventoryActor` | fresh `assembly.Identity.Verify` in every mode (claimassembly.go:382, rootinventory_startup.go:47) | `commitRootInventoryPending` (rootinventory.go:61), `validateServingHandover` (claimservinghandover.go:25), `rebindRecoveryInventory` (rootrecovery_rebind.go:63), the operation cold handover check (N5), reboot preparation (claimservingreboot_prepare_linux.go:93) | refuses (`actorMatches`): no new inventory, handover, rebind or preparation while quarantined | passes with B1: a request stamped B0 is refused, one stamped B1 is accepted |
232| launch authorize `h.Actor` | already fresh (claimlaunch_production.go:70) | claimlaunch_authority.go:82 | refuses | B0 launch records refused |
233| `cleanupEnrollment.actor` | startup read, `Eligible false`, `Quarantined true`, with `Binding` computed from the same read | `configureSigner` (claim_evidence_wiring.go:60-64), `actorForConstruction` (claimconstruction.go:187, 229; claimjournal_migration.go:98): the stop coordinator's identity and the producers' fence stamps | stops, retirements and settlements of historical installations keep working: record to record, never a binding against a fresh actor | same; anything it newly stamps is refused by the fresh authority until root restarts (fail closed) |
234| startup binding (N3) | `BoxBinding` of the row in the startup roster read that `actorForConstruction` already makes (in instance-control mode the cleanup read, so it is available while quarantined) | `recoverRootInventory` obsolescence decision | the pending intents' B0 differs: obsolete | B0 differs from B1: obsolete |
235| agent pins (`Reconcile.DirectoryRevision`, census) | startup read | installs, local census | n/a (agent) | new installs refuse at root until the agent restarts; installs made before the quarantine are refused for good |
236
237**Startup replay, per intent, never an assembly abort (N3).** In `recoverRootInventory` (rootinventory.go:363), before replaying a pending intent, root reads its stamped binding (`q.Inventory.DirectoryRevision` and `q.Target.DirectoryRevision` for an inventory write; `recovery.Recovery.Next.DirectoryRevision` for a rebind) and compares it with the startup binding:
238
239- equal: replay as today (`withRootInventoryHistory` or `rebindRecoveryInventory`), with the existing `ErrChanged` / `ErrJournalUncertain` abandonment;
240- different: the intent is OBSOLETE. It is settled as abandoned through the existing `Guard.AbandonRootInventory` (hostguard/root.go:301-324: same host lock, same `pendingInventory` byte match, same `quarantined` mark) with a `superseded` predicate that returns true for the binding difference, and a log line `instance-control: <host>: obsolete pending <inventory|rebind> intent: stamped <b0> current <b1>`. The installation it belonged to stays refused for new work: its stamp is B0 and every fresh comparison needs B1 (the clean break of section 3.3).
241
242After the loop `PendingInstallations` is empty, `recoverRootInventoryStartup` returns nil (:65 never fires for an obsolete intent) and the assembly, with the stop and retirement service, comes up. A fresh `InventoryActor` is never consulted for an obsolete intent, so a quarantined box settles its own leftovers.
243
244So a readmitted box keeps nothing: every installation made before the quarantine is reinstalled, its interrupted root work is settled as obsolete at the next restart, and the agent and root restart to pick up B1.
245
246### 3.3 Recovery, rebind and handover (R3, N4, N5)
247
248| Path | file:line | Rule | Numeral or stale predecessor |
249|---|---|---|---|
250| recovery rebind | rootrecovery_rebind.go:68 (successor == fresh), :75 (add `source.DirectoryRevision != actor.Binding` to the `ErrTypedInventory` refusal before any reuse; `recoveryInventory` :35 then copies an equal value) | successor and predecessor both equal the fresh binding | refused |
251| remote recovery producer | claimrecovery_remote.go:54 | sends `actor.Binding` as the successor stamp (N4) | n/a |
252| handover recovery | claimhandover_recovery.go:217 (add `inv.DirectoryRevision != actor.Binding`); :243 copies an equal value | same | refused |
253| serving handover | claimservinghandover.go:29-32, claimservingparkroot_linux.go:131, claimservinglegacyroot_linux.go:102 | `prior == actor.Binding` and `next == actor.Binding` | refused |
254| operation cold handover, root check (N5) | claimhandover_transport.go:71: before `handover.Run`, `h.RequireNewWork(ctx)` then `actor := h.InventoryActor(ctx)`; refuse (409, before any successor state) unless `request.Next.Box == actor.Box`, `request.Next.Enrollment == actor.Enrollment` and `request.Next.DirectoryRevision == actor.Binding`. The prior keeps its historical authority: its fence receipt (`FenceReceiptFor`, `verifyRetirementReceipt` at :41-46) and record-to-record checks are untouched | the prior may carry any binding (it is parked under its own stamp, coldhandover.go:268); the successor must be fresh (:122 and :336 then copy a verified value) | a prior whose receipt lacks `Binding` fails `verifyRetirementReceipt`: refused |
255| operation cold handover, replay (N5) | coldhandover.go:92-98: a journal for the same `(prior, next)` is resumed only after the same fresh check passes on `request.Next` (which `j.Successor != next` ties to the journal). If the binding changed after the journal was written, the replay is refused with `ErrChanged` and the journal file stays as a record of an obsolete handover; it is under `JournalDir`, not a guard pending installation, so it does not block startup. Any guard pending state it left (`CompleteColdHandover` barrier) is settled by N3 as obsolete. The prior stays parked under its fence; the successor, if any inventory was written, is refused for new work | | |
256
257Every numeral-stamped predecessor is refused on every path (wake, launch, rebind, recovery, serving and cold handover, recovered receipts). Nothing converts one. Task 9 establishes that none exists before the roll; if one is found, the roll stops and Paul rules.
258
259### 3.4 Deferred
260
261The backfill and census pins on `Revision` (hostops/backfill.go:193, backfill_publish.go:169, claimactivation/backfill.go) still see holds. The dev backfill runs claims-off; a separate proposal can pin a whole-roster membership digest.
262
263## 4. Build plan
264
265| # | Task | Files | Test that pins it |
266|---|---|---|---|
267| 1 | `hostactors.BoxBinding(r Roster, ref string) (string, bool)` and `hostactors.ValidBinding(s string) bool`. Frozen v1 tuple, encoded like journalservice.go:184 (ordered `[]any` with a version tag, JSON, sha256, lower hex), same "never extend" comment | hostactors/binding.go | L0 `TestBoxBindingTuple`: golden vector; every tuple field mutated gives a new digest; every excluded field mutated (`Revision`, `Holds`, `Activations`, `EnrollmentAttempts`, `Consoles`, `Regions`, a sibling row, `Role`, `Label`, `Group`, `LossIDs`, `EnrolledAt`) gives the same digest; unknown box returns false |
268| 2 | `hostbox.Actor` gains `Binding`, `Bound()`, `SameBinding`; `actorMatches` also requires `Binding != ""`; the sanity sites of 1.2 | hostbox/identity.go, host sanity sites | L1 `TestIdentityVerifyRequiresBinding`; `TestActorBoundIgnoresOnlyRevision` (walks every field by reflection, as `TestSameVerifiedActorIgnoresOnlyRevision` does today); `TestMoveParksVerifiedIdentityAndRealEngine`: "revision" case expects acceptance, new "binding" case expects `ErrChanged` |
269| 3 | Box and root stamps and comparisons: `productionClaimRoster.Actor` and `verifyClaimCleanupEnrollment` compute `Binding`; the 22 stamp and compare sites of 1.2 plus claimrecovery_remote.go:54 (N4) use `actor.Binding`; `servingPriorRevision` becomes equality; `sameVerifiedActor` deleted, `SameBinding` at claimassembly.go:289, claimmoveparks.go:107, claim_evidence_wiring.go:61 | internal/host, internal/hostbox | `TestClaimRegionalEnrollmentSurvivesRosterRevision` kept; `TestServingPriorBindingIsEquality`; N4 |
270| 3b | Reboot preparation (N2): `SameBinding` at claimservingreboot_prepare_linux.go:95; store `actor.Bound()` at :109; sanity at :187 | claimservingreboot_prepare_linux.go, claimservingreboot_commit_linux.go | N2 `TestServingRebootPreparationSurvivesHoldChurn`: prepare, then real `Reserve` and `ReleaseFinishedHold`, then commit: passes; same with a root restart between prepare and commit: passes; quarantine and readmission between: refused with `ErrChanged` |
271| 4 | Receipt contract (1.4, N1): `FenceTarget.RosterRevision`; `StopRequest.RosterRevision`, `WritersStoppedRequest.RosterRevision`; the three target builders; the stop coordinator's non-zero check; the three receipt constructors; `StopReceipt.Binding`; box-side receipt comparisons; `legacyJournalProofMatch` | hostguard/fence.go, host/claimstop*.go, claimretire_transport.go, claimjournal_proof.go, rootmigration_evidence.go, claimwakestop.go, relocationeffects.go, claimservinglegacy.go, claimconstruction.go:327, claimroleassembly.go:58 | L7a `TestStopReceiptCarriesNumeralAndBinding` (normal and no-process constructors); N1 `TestRecoveredReceiptKeepsRosterNumeral`: `BeginFence` with `RosterRevision`, crash before `CompleteFence`, restart, `RecoverPending` -> `RecoverStopped`: the recovered receipt's `directory_revision` is the persisted numeral and `binding` the installation's stamp, and it passes the branch's unchanged `verifyEvidence` and `WakeResetTransport.VerifyReceipt`; a fence with zero `RosterRevision` is refused |
272| 5 | Root identities (3.2): `InventoryActor` = `assembly.Identity.Verify` at claimassembly.go:382 and rootinventory_startup.go:47; cleanup actor gains `Binding` | host/claimassembly.go, rootinventory_startup.go, claimcleanup_identity.go | L4 |
273| 5b | Startup replay (N3): obsolescence check and settlement in `recoverRootInventory`; the startup binding passed in from `recoverRootInventoryStartup` | host/rootinventory.go, rootinventory_startup.go | N3 `TestRootStartupSettlesObsoleteIntents`, through the real instance-control startup (`OpenProductionClaimAssembly`): leave an interrupted inventory-write intent and an interrupted rebind intent at B0; quarantine; restart: both settled as abandoned and logged, the assembly builds, the stop service answers a stop of the B0 installation; readmit; restart: still up, the B0 installation refused at wake; control case: a same-binding interrupted intent replays and completes |
274| 6 | Recovery (3.3): predecessor checks at rootrecovery_rebind.go:75 and claimhandover_recovery.go:217 | internal/host | L6; N4 `TestRemoteRecoveryRebindsSameBinding`: through `installRecoveryRemote` and `ClaimRootHTTP.serveRecoveryRebind` with `Reserve` and `ReleaseFinishedHold` between the predecessor's install and the recovery, and once more as a startup replay of the pending rebind intent: success, the rebound inventory carries the binding; then only the predecessor inventory's stamp is mutated: refused at :75's check, not at :68 |
275| 6b | Operation cold handover (N5): the fresh successor check before `handover.Run` at claimhandover_transport.go:71; replay refusal | host/claimhandover_transport.go | N5 `TestColdHandoverVerifiesSuccessorAtRoot`, through `serveColdHandover`: a B0 prior with a valid receipt and a B1 successor after readmission: parked and started; a wrong successor binding: 409 with no journal file, no successor inventory row and the prior not parked; a quarantined actor: 409; a journaled handover whose successor binding no longer matches: `ErrChanged`, not resumed, startup unaffected |
276| 7 | Witness tests L2 to L7 (next table) | internal/host | |
277| 8 | Guard: no console or hostops verifier change. Record the empty `git diff v0.28.4..HEAD --stat` for hostops/evidence.go, wake_reset_transport.go, record.go | | L7b, L7c |
278| 9 | Pre-roll check for leo (R7), read-only, result pasted in the fix report. All of these must be empty or absent, not merely "no running instances": root inventory rows and pending inventory requests in `/var/lib/carlos-claims/journals.db` (the `StopInventoryDB` tables, `SnapshotRequest` state at inventory_expected.go:59, the launch journal of claimjournal_migration.go:121); pending installations and fence records under `/var/lib/carlos-claims/authority/fences` (`PendingInstallations`, `PendingStopFences`); serving parks and persisted reboot preparations under `/var/lib/carlos-claims/authority/serving-parks`; retirement and handover journals under `/var/lib/carlos-claims/retire/{serving,cold,retired}`; unit bindings and materializations under `/var/lib/carlos-claims/unit-bindings` and `unit-materializations`; restart requests under `/var/lib/carlos-release/private/restarts`; the migration record under `/var/lib/carlos-claims/authority/migration` listing zero items; in the bucket, no `control/hosts/<host>.json` naming leo, no `control/hosts/*/evidence/*/leo/` or `serving-evidence/leo/` objects, and a roster with no holds or activations. Any non-empty item stops the roll | runbook | the G10 harness re-run after the roll |
279| 10 | Census. Plan 14: overlay `internal/provision/testdata/plan14-root-reads-roster-binding.tsv`, `rev3=PROPOSED-roster-binding`, loaded last; re-pin every root-read unit tasks 3 to 6b touch (classes unchanged: the binding is computed from reads the assembly already makes; the startup binding comes from the existing startup read; N3's settlement uses the existing abandonment write) with `@coverage` for internal/host, internal/hostbox, internal/hostguard. 9B8: no record write changes; `TestPlan9B8RecordWriteCensus` expected unchanged. Ruling 6: `BoxBinding` is reached only from `productionClaimRoster.Actor`, `verifyClaimCleanupEnrollment` and `recoverRootInventoryStartup`; extend the module-wide scan in claimstep8_ruling6_test.go to pin those callers and `SameBinding`'s six; drop the `sameVerifiedActor` pin. A claims-off or no-census box never builds a claim assembly, computes no binding, writes no fence and no receipt. Proposal section "Roster binding" after "Ack refresh" in census-proposal-claim-step8-readiness.md | provision testdata, host test, notes | `TestPlan14RootReadCensus`, `TestPlan9B8RecordWriteCensus`, `TestStep8CensusChangesReachOnlyClaimMode` |
280| 11 | Roll: leo (box) only; dev console stays v0.28.4; harness G10; then `journalctl` on leo shows no `ErrTypedInventory` and no `selected authority changed` after the create's hold; the first install's inventory carries a 64-hex stamp and its receipt (if any) a numeral plus `binding` | runbook | |
281
282Witness tests (task 7), all linux, in internal/host, each layer asserting only its own thing. Fixtures: `release.NewDirStore`, root signer, `hostfloor.Publish` with the local `FloorDir` policy written to match (claim_regional_revision_test.go:66-77), a one-box roster written through `Directory.Replace`, `productionClaimRoster`, the real `ClaimAssembly.Build` and `ClaimRootHTTP` wiring from `OpenProductionClaimAssembly` (the ack-refresh test's production wiring), the real `JournalService` inventory in a temp dir. Where a step needs uid 0 it runs on the dev root runner under the `rootrunner` tag with the same assertions.
283
284| Layer | Test | Asserts |
285|---|---|---|
286| L2 no restart, holds | `TestClaimBindingSurvivesRosterWritesWithoutRestart` | install at B0 through the remote path (`ReconcileAdapter.installRemote` -> root `commitRootInventoryPending`); then real `Reserve`, `ReleaseFinishedHold`, `Reserve(activation=true)`, `CancelActivation`, `BeginEnrollmentAttempt`, `SetBoxRole`: `Revision` moves by six; `EmptyInstallationCensus.CheckLocal`, a second inventory write, `prepareStateRemote`, `ClaimLaunchRoot.authorize`, `validateServingHandover` and the regional `RequestStore.Check` all pass; `Verify` returns an actor `SameBinding` to the pinned one |
287| L3 restart | same, after rebuilding the assembly | every comparison still passes |
288| L4 readmission, instance-control (R2) | `TestClaimBindingReadmissionWithoutRestart` through the real instance-control assembly | after `Directory.Quarantine`: `InventoryActor` refuses, a stop of the B0 installation completes, `configureSigner` still works; after readmission by `Replace`: `InventoryActor` returns B1, a B0 inventory write is refused, a B1 one accepted, `prepareStateRemote` and `authorize` refuse the B0 installation, the old assembly's new install is refused at root; after restart: a fresh install passes, the B0 installation is still refused |
289| L5 floor and home | `TestClaimBindingFloorAndHomeChange` | rewrite the floor locally and remotely and update `FloorSHA256` by a direct signed write, so `productionClaimRoster.Actor` passes its floor check and only the binding differs: installed-state comparisons refuse. Same with `HomeRegion`, same-reference re-enrolment and census-key rotation, each by direct signed write and named as such |
290| L6 recovery (R3) | `TestClaimBindingRecoveryRefusesPredecessor` | rebind and handover recovery refuse a predecessor at B0 after readmission and a numeral predecessor; cold handover parks a B0 predecessor with a matching receipt and starts an empty successor at B1; a numeral predecessor fails `verifyRetirementReceipt` |
291| L7a receipt | task 4 | |
292| L7b purge, mixed version (R1) | `TestPurgeAcceptsC1ReceiptOnUnchangedVerifier` | a task 4 receipt, signed with the box's evidence key, under the op's `evidence/` key; the branch's unchanged `StopBeforeTeardown` and `verifyEvidence` accept it; the same receipt with the binding in `directory_revision` is refused |
293| L7c wake reset, mixed version (R1) | `TestWakeResetAcceptsC1ReceiptOnUnchangedVerifier` | same through `WakeResetTransport.VerifyReceipt` under `serving-evidence/`, including that a quarantined or fenced actor is still refused before the receipt is read |
294| N1 to N5 | named in tasks 3b, 4, 5b, 6, 6b | |
295
296Mutation checks recorded in the fix report: add `Holds` to the tuple and L2 fails; drop `QuarantineRevision` and L4 passes wrongly; restore `<=` in `servingPriorRevision` and L4's handover step passes wrongly; drop the predecessor check and N4's mutation case passes wrongly; write the binding into `directory_revision` and L7b fails; take the receipt numeral from the request instead of the fence and N1 fails; store the unbound actor in the preparation and N2's hold case fails; skip the obsolescence check and N3 returns `errRootMigrationBusy`; skip the successor check and N5's wrong-binding case creates a journal.
297
298Local tests: changed packages only (hostactors, hostbox, hostguard `-run Fence`, host `-run 'Binding|Regional|MoveParks|Serving|Handover|Identity|Receipt|Readmission|Recovery|Obsolete|Reboot'`, hostops `-run 'C1'`, provision census tests); CI runs the full suite. Go cache at its default.
299
300## 5. Open questions for Paul
301
3021. Readmission runbook. Readmission is a clean break: every installation made before the quarantine is refused for good, its interrupted root work is settled as obsolete at the next restart, and the agent and root restart to pick up the new binding. Recommended answer: yes; reinstall after readmission, restart both services.
3032. Pre-roll check outcome. If task 9 finds any retained inventory, fence, launch, recovery, preparation or evidence record on leo, the roll stops and nothing converts it. Recommended answer: stop and rule case by case; expected result is empty.
3043. The receipt numeral's bound at root. Root persists the agent-supplied `RosterRevision` as non-zero and does not bound it against its own roster read (in instance-control mode root's read is the startup one and may be older than the agent's). The console bounds it against the current roster as today. Recommended answer: non-zero at root, bounded at the console.
305
306## 6. Invariant checklist
307
308Every site that compares a binding or an actor, its rule and its test. Fresh: stored value against the fresh actor. Historical: record to record, no fresh actor. Format: well-formed only. Bound: `SameBinding`.
309
310| Site | file:line | Rule | Test |
311|---|---|---|---|
312| inventory write | host/rootinventory.go:67 | fresh | L2, L4 |
313| wake, remote | host/rootinventory_state_linux.go:38 | fresh | L2, L4 |
314| wake, local | host/claimroleassembly_linux.go:373 | fresh | L2 |
315| target vs inventory at wake | host/claimroleassembly_linux.go:47, 128 | historical | L2 |
316| launch authorize | host/claimlaunch_authority.go:82; claimlaunch_root.go:180, 216 | fresh | L2, L4 |
317| serving handover | host/claimservinghandover.go:29-32 (prior and next) | fresh (equality) | L2, L4 |
318| serving park root, source, legacy | host/claimservingparkroot_linux.go:131; claimservingparksource_linux.go:41; claimservinglegacyroot_linux.go:44, 102 | fresh (equality) | L2 |
319| serving park sanity | host/claimservingparksource_linux.go:33; claimservingparkroot_linux.go:75; claimservingpark_transport.go:158; claimservingreboot_prepare_linux.go:187 | format (`Binding != ""`) | L1 |
320| reboot preparation reread | host/claimservingreboot_prepare_linux.go:95 | bound | N2 |
321| reboot preparation persisted, commit | host/claimservingreboot_prepare_linux.go:109, 136; claimservingreboot_commit_linux.go:30 | bound (stored `Bound()`) | N2 |
322| recovery rebind successor | host/rootrecovery_rebind.go:68 | fresh | N4 |
323| recovery rebind predecessor | host/rootrecovery_rebind.go:75 | fresh | N4, L6 |
324| handover recovery predecessor and current | host/claimhandover_recovery.go:217, 224 | fresh | L6 |
325| remote recovery producer | host/claimrecovery_remote.go:54 | produces fresh | N4 |
326| operation cold handover successor | host/claimhandover_transport.go:71 | fresh | N5 |
327| operation cold handover replay | hostbox/coldhandover.go:92-98 | fresh (refused when changed) | N5 |
328| cold handover prior | hostbox/coldhandover.go:141; receipt via `verifyRetirementReceipt` | historical | L6, N5 |
329| startup replay admission | host/rootinventory.go:363 (obsolescence) | fresh startup binding | N3 |
330| regional enrollment | host/claimassembly.go:289 | bound | `TestClaimRegionalEnrollmentSurvivesRosterRevision` |
331| bound identity | host/claimmoveparks.go:107 | bound | `TestMoveParksVerifiedIdentityAndRealEngine` |
332| evidence wiring | host/claim_evidence_wiring.go:61 | bound | L4 |
333| agent install, route register | hostbox/reconcile_adapter.go:139; inventory_registration.go:140 | historical (pinned stamp) | L2, L3, L4 |
334| agent census | hostbox/empty_census.go:135 | historical (pinned stamp) | L2 |
335| retirement inventory | hostbox/reconcile_adapter.go:301; host/claimretire_transport.go:145 | historical | L6 |
336| stop request vs inventory | host/claimstop.go:256, 412; claimstop_writers.go:119; claimstop_recovery.go:64 | historical | L7a, N1 |
337| receipt vs target or request | host/claimstop_transport.go:207; claimretire_transport.go:180; claimjournal_proof.go:106, 114; rootmigration_evidence.go:40 | historical (`Binding`) | L7a, N1 |
338| receipt numeral | host/claimstop.go:335, 433; claimstop_recovery.go:82 | from the fence's `RosterRevision` | L7a, N1 |
339| generations | host/rootgeneration_parked.go:48; rootgeneration_handover.go:101; hostbox/generationhandover.go:19; servinghandover.go:50 | historical | existing generation tests |
340| identity sanity | hostbox/identity.go:86; host/claimfilesseed_select.go:38 | format | L1 |
341| journal and fence validation | hostguard/fence.go:42; hostbox/stopinventory_db.go:42; generationpayload.go:56; hostexec/rootbroker.go:254 | format (non-empty) | existing tests |
342| console stop evidence | hostops/evidence.go:124 | format (numeral bound, unchanged) | L7b |
343| console wake-reset evidence | hostops/wake_reset_transport.go:286-300, 319 | actor active and unfenced, then format (unchanged) | L7c |
344
345## 7. Build rulings after Astra round 3 (platform-1c, 2026-10-20)
346
347Paul chose to build from rev 3 with these rulings and to review the code rather than another design draft. Astra's round 3 is at `/home/paulca/.cache/tmp/claude-1001/own/after236/roster-binding-review3.md`.
348
349| Ruling | Finding | Mechanism | Task | Test |
350|---|---|---|---|---|
351| B1 | HIGH: startup cold-handover recovery skips the fresh successor check (claimretire_transport.go:56 builds a `ColdHandover`; `Recover` calls `Run` at hostbox/coldhandover.go:652) | The same fresh successor admission as 6b (`RequireNewWork` and successor binding == fresh `InventoryActor` binding) runs before any startup `Recover` resumes a journaled handover. A journaled successor whose binding is obsolete is not resumed; it goes to B3 | 6b | N5 extended: through `ClaimRootHTTP.RecoverPending`, interrupt a handover, then quarantine and readmit (binding change), then start up. It must not resume, and B3 settles it |
352| B2 | MEDIUM: `RosterRevision` enters `FenceTarget.sameAuthority` (hostguard/fence.go:48) | `FenceTarget.RosterRevision` stays, but `sameAuthority` clears it alongside the three bookkeeping fields, so retry-authority matching ignores it. `BeginFence` and `ExistingFence` return the ORIGINAL persisted target and numeral. Exact stored-target comparisons (fence.go:104-230, recoveryfence.go) stay exact | 4 | Extend `TestBeginFenceResumesAfterUnrelatedClaimCAS`: changed numeral, same binding, for both `BeginFence` and `ExistingFence`; the returned target carries the original numeral |
353| B3 | MEDIUM: an obsolete cold-handover barrier has no settlement (`PendingInstallations` lists only `pendingInstallation`; `AbandonColdHandover` needs a session advance) | A fenced root-startup settlement for a cold-handover barrier whose journaled successor binding differs from the fresh binding. It clears the barrier and the successor's pending state, records the settlement, and keeps the predecessor's historical cleanup authority (its stop and retirement still complete). The successor is never reused. No claim session advance is needed: the trigger is binding obsolescence, decided by root at startup from a fresh roster read | 5b/6b | Interrupt after `BeginColdHandover`, then quarantine and readmit, startup, the barrier settled, the predecessor stop completing, and a clean reinstall at B1 succeeding through the real handler |
354| B4 | Checklist audit | Every row in Astra round 3's three "missing" tables, plus the correction (claimlaunch_root.go:180 historical, :216 fresh), is added to section 6 with its rule. The build implements each row's rule or confirms it unchanged, and names the test that covers it | all | Section 6 complete; the fix report maps each row to code and test |
355