carlos / platform Public
Design: roster revision vs hold churn (host claims, G10)
Status: PROPOSED, rev 3 (2026-10-20). Branch claim-empty-cohort-done at 500741f4. Static reading only; nothing built or run.
Rev 3
Astra'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.
| Astra r2 | Ruling | Mechanism | Resolution | |---|---|---|---| | 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 | | 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 | | 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 | | 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 | | 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 | | Convergence | | section 6, the invariant checklist | |
Rev 2
Astra's review of rev 1 (212022a8) returned DESIGN: revise.
| Astra r1 | Ruling | Resolution | |---|---|---| | 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 | | 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 | | 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 | | 4 MEDIUM: the witness did not establish the invariant | R4 | section 4 tests L0 to L7 | | 5 LOW: missing readers; numeric Revision must stay in several places | R5 | section 1.2, section 1.3 | | Role excluded; historical stop evidence format-only; wake-reset keeps its actor checks | R6 | section 2 tuple, section 1.4 | | Pre-roll check for leo | R7 | task 9 |
The gap
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.
Fixes 1 and 2 (79db17be, 7b805720) cover the ack and the regional check. This note is the design for the rest.
1. Survey
1.1 Writers
Every 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.
| Writer | file:line | What changes | Bumps Revision | Membership fact | |---|---|---|---|---| | Initialize | hostactors/roster.go:343 | fresh roster, Revision: 1 | n/a | yes | | Enroll, EnrollAWS, EnrollAutomatic | hostactors/enroll.go:28-80, enrollmentconfirmation.go:34 | Boxes[+ref]; consumes the attempt | yes | yes (new row) | | BeginEnrollmentAttempt (region HA auto-enrol) | hostactors/enrollmentconfirmation.go:103-124 | EnrollmentAttempts[ref] | yes | no | | Quarantine | hostactors/roster.go:293-306; caller hostresolve/service.go:290 | row State, QuarantineRevision++, LossIDs | yes, exactly one (hostops/movelossgrant.go:312 admits before+1) | yes | | Readmission | hostresolve/readmit.go:131-136 | row State: active | yes | yes | | 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 | | SetBoxRole, SetGroupRole | hostactors/roles.go:76-91, 114-138; console/apiboxroles.go:147,187 | row Role, Regions[r].Role | yes | placement only | | 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 | | BindCensusKey | hostactors/censuskey.go:25-95 (next.Revision++ at :78) | row CensusPublicKey (write-once) | yes | yes | | Hold reserve (ReserveLifecycle -> Reserve) | hostactors/lifecycle.go:135-155, census.go:246-294 (:293); caller hostops/productionheldstart.go:32 | Holds[+id] | yes | no | | Hold release (ReleaseFinishedHold, CancelHold) | hostactors/lifecycle.go:157-180, reservations.go:69-110; callers console/claimholdrecovery.go:40,46, productionheldstart.go:35,51 | Holds[-id] | yes | no | | Activation reserve (Reserve(activation=true)) | census.go:246-294 (:281); console/hostactivation.go:193 | Activations[+id] | yes | no | | Activation release, cancel | hostactors/activationcomplete.go:13-57, activationpublication.go:22-50; console/hostreports.go:91, hostactivation.go:330 | Activations[-id] | yes | no | | 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 |
Not roster writes: Invite (invitation.go:30) and InviteConsole (console_invitation.go:15) write their own objects.
So 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.
1.2 Readers of Revision and DirectoryRevision
"Treatment" is the design's answer. Binding is the per-box digest of section 2; "numeral" is strconv.FormatUint(actor.Revision, 10) as today.
Box agent (pinned once at assembly):
| Reader | file:line | Compares | Protects | Treatment | |---|---|---|---|---| | 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) | | 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) | | VerifyRegional | host/claimassembly.go:281-301 | sameVerifiedActor since 7b805720 | same | hostbox.SameBinding replaces sameVerifiedActor | | install, route register | hostbox/reconcileadapter.go:139; inventoryregistration.go:80, 140, 249 | stored inventory == pinned stamp | an inventory this process did not write is refused | opaque string, unchanged code | | local census | hostbox/emptycensus.go:99-135, 370 (EmptyInstallationCensus.CheckLocal, Initialize) | same, against the startup pin | same | unchanged code; the pin is a binding | | retirement | hostbox/reconcileadapter.go:301 | inventory == fence target | the retirement parks only what root's inventory names | record to record, unchanged | | sanity | hostbox/identity.go:86 (Revision > 0); host/claimfilesseedselect.go:38; claimservingparktransport.go:158; claimservingparkrootlinux.go:75; claimservingparksourcelinux.go:33 (actor check, beside its inventory check at :41); claimservingrebootpreparelinux.go:187 | non-zero | a roster row was read | Binding != ""; at claimservingrebootpreparelinux.go:187 the Revision == 0 clause is dropped because the stored actor is bound (section 1.6) | | 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 | | stop request producers | host/claimwakestop.go:308-313 (fallback fmt.Sprint(actor.Revision) when no launch record), claimstopwriters.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) | | remote recovery producer (rev 3, N4) | host/claimrecoveryremote.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 | | stop, retire, receipts | host/claimstop.go:223, 256, 269, 335, 399, 412, 433; claimstopwriters.go:39, 86, 109, 119; claimstoprecovery.go:64, 82; claimstoptransport.go:207-211; claimretiretransport.go:145, 180; claimjournalproof.go:106, 114; rootmigrationevidence.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) | | execution authority | host/rootexecutionauthority.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 | | journal digests and validation | hostbox/journalservice.go:184, 208 (v1 tuple, frozen); journalmigrationlinux.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) |
Root (fresh actor against the stored stamp):
| Reader | file:line | Compares | Protects | Treatment | |---|---|---|---|---| | inventory write | host/rootinventory.go:61-67; actor from InventoryActor = actorForRoot (claimcleanupidentity.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) | | wake | host/rootinventorystatelinux.go:38 (remote), claimroleassemblylinux.go:373 (local), :47, :128 (target == inventory) | same | the instance wakes only under the roster it was installed under | == actor.Binding | | launch authorize | host/claimlaunchauthority.go:82 (actor is a.Identity.Verify, claimlaunchproduction.go:70), claimlaunchroot.go:180, 216 | fresh == FenceTarget | a launch runs only for the enrollment that reserved it | == actor.Binding | | 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) | | serving park, legacy | host/claimservingparkrootlinux.go:131, claimservingparksourcelinux.go:41, claimservinglegacyrootlinux.go:44, 65, 102 | prior <= fresh, inventory == fresh | same | equality with actor.Binding | | serving park reboot preparation (rev 3, N2) | host/claimservingrebootpreparelinux.go:95 (nextActor != actor), :109 (stores Actor: actor), :136 (reflect.DeepEqual(prior, first) over the persisted preparation, which holds Active.Actor), :187 (sanity); claimservingrebootcommitlinux.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 DeepEquals compare bound actors (section 1.6) | | recovery rebind, handover recovery | host/rootrecoveryrebind.go:68, 75, 136 (re-stamp at :35); claimhandoverrecovery.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) | | startup replay (rev 3, N3) | host/rootinventorystartup.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) | | cold handover, serving branch | claimhandovertransport.go:56-70 (Validate -> validateServingHandover, fresh) | fresh successor | | unchanged | | cold handover, operation branch (rev 3, N5) | claimhandovertransport.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) | | generations | host/rootgenerationinventory.go:227 (non-empty), rootgenerationparked.go:48, rootgenerationhandover.go:101, rootexecutionsettle.go:96 | inventory == target | record to record | opaque, unchanged | | startup census | host/rootinventorystartup.go:45, :47 | pinned to the startup actor | same as the agent's census | stamp actor.Binding; InventoryActor fresh | | 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 |
Console and hostops:
| Reader | file:line | Compares | Protects | Treatment | |---|---|---|---|---| | backfill census expectedRevision | hostops/backfill.go:187-193; callers backfillpublish.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) | | census reports | hostops/backfill.go:242, backfillpublish.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 | | 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 productioncreate.go:351-353) and verifyEvidence | sanity bound plus signature, enrollment, op, host, incarnation, fence | never an authority | unchanged code; receipts keep a numeral there | | wake-reset evidence | hostops/wakeresettransport.go:286-300 (active, unfenced actor), :312 (decode), :318-319 (bound); wired by console/hostwakeresetproduction.go:16 | same | same | unchanged code; the actor check stays | | move loss grant | hostops/movelossgrant.go:312-317 | the quarantine write is exactly before.Revision+1 | the loss grant's roster write is scoped | numeric, unchanged | | 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 | | moves fence binding | hostfence/claimfence.go:32 -> hostclaim/fence.go:75 -> relocating.go:136, 306 | audit metadata | forensic | numeric, unchanged | | roles | console/apiboxroles.go:153, 193 | next != o | logging | unchanged | | activation CLI | claimactivation/runner.go:132, 143, 322; host/activationcommandlinux.go:117, 237; activationconsolereportlinux.go:240 | operator-supplied expected revision | offline CAS; claims off | numeric, unchanged | | region HA | writers only | | | |
1.3 Numeric Revision stays
Per 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.
1.4 Artifacts a console verifies, their decoders, and the receipt numeral (R1, N1)
The 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/, claimretiretransport.go:201 -retirement.json). Relocation's Release and `probestop` 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.
| Decoder | file:line | Strict | Reads the receipt | Gets the binding field | |---|---|---|---|---| | hostops.decode | hostops/record.go:32-40 | no (trailing data only) | yes: evidence.go:118, wakeresettransport.go:312 | yes: a v0.28.4 console ignores it | | decodeClaim | hostops/lifecyclegrant.go:115-120 | yes | no: the claim object references receipts by key | n/a | | decodeMoveLossIntent | hostops/movelossowner.go:115-117 | yes | no | n/a | | box-side receipt decoders | host/claimstoptransport.go:163, claimretiretransport.go:114, relocationreleases.go:42, verifyRetirementReceipt (claimretiretransport.go:174) | yes | yes | yes: agent and root are one binary on a box | | root's fence record | hostguard/record.go:98 (strict) | yes | holds the FenceTarget | gets RosterRevision; same binary |
The contract:
- One persisted numeral source (N1).
hostguard.FenceTargetgainsRosterRevision uint64 json:"roster_revision,omitempty". The agent supplies it inStopRequest.RosterRevisionandWritersStoppedRequest.RosterRevision(box-internal, strict decoders at claimstoptransport.go:89 and claimstopwriters.go:143) fromactor.Revisionof 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) andBeginFence(hostguard/fence.go:82) persists it in the fence record before any stop effect. The stop coordinator refuses a zeroRosterRevisionon 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. - Every receipt constructor reads the fence. Normal (claimstop.go:335), recovered (claimstoprecovery.go:82, from the
FenceTargetthatPendingStopFencesandRecoverStoppedalready pass throughResumeFence) and no-process (claimstop.go:433):DirectoryRevision: strconv.FormatUint(target.RosterRevision, 10)andBinding: target.DirectoryRevision. The retirement copy (claimretiretransport.go:201) re-uploads the fence's receipt bytes and needs nothing. A legacy fence with noRosterRevisioncannot produce a receipt and is refused byRecoverStopped; task 9 shows none exists. StopReceipt.Binding string json:"binding,omitempty"carries the installation's binding. The box-internal receipt comparisons move to it: claimstoptransport.go:207, claimretiretransport.go:180, claimstoprecovery.go (the receipt it builds), rootmigrationevidence.go:40, and claimjournal_proof.go:106 (wanted.Binding = inventory.DirectoryRevision) withlegacyJournalProofMatch(:114) digestingBindingin place ofDirectoryRevision. The receipt's signature covers the raw bytes (hostevidence.Verify, evidence.go:115), so the added field is signed.- Since
Revisiononly grows, v0.28.4's0 < recorded <= roster.Revisionholds for every later roster, for request-driven and crash-recovered receipts alike. hostops.StopReceipt(evidence.go:26-54),evidence.go,wake_reset_transport.goandrecord.goare not changed in this branch. The fix report recordsgit diff v0.28.4..HEAD --statfor those three files as empty.- Historical stop evidence is format-only on the stamp (R6). Wake-reset still requires an active, unfenced actor (wakeresettransport.go:286-300).
1.5 Findings
- The roster is strict-decoded (
release.VerifyRootEnvelope->strictCanonicalJSON, release/rootenvelope.go:72-90). A new roster field, evenomitempty, makes every older binary refuseDirectory.Readonce first written; the I2 pattern (censuskey.go:49-52) gates such a write on a published floor. C1 adds no roster field. TheRosterstruct is byte-identical between v0.28.4 and this branch. DirectoryRevisionis a string everywhere it is stored and is hashed as a string in the frozen journal v1 tuple. Inside the box onlyservingPriorRevisionparses it as a number; outside the box only the two stop-evidence bounds do, and they keep receiving a numeral.- Handover recovery and recovery rebind re-stamp a predecessor without checking its stamp (rootrecoveryrebind.go:75, :136; claimhandoverrecovery.go:217, :243). Rev 2 makes recovery require equality; rev 3 adds the operation cold handover's successor check.
- In instance-control mode root's
InventoryActoris 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. hostbox.Actoris compared whole in six places (section 1.6), and one of them is persisted. Any whole-actor comparison carriesRevisionimplicitly.
1.6 Whole-actor comparisons and the one helper (N2)
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):
| Site | file:line | Today | Rev 3 | |---|---|---|---| | regional enrollment | host/claimassembly.go:289 (sameVerifiedActor) | ignores Revision | SameBinding | | bound identity (regional store, leases, move fence, move parks) | host/claimmoveparks.go:107 (current != expected) | whole actor | SameBinding | | evidence wiring | host/claimevidencewiring.go:61 (cleanupEnrollment.actor != actor) | whole actor | SameBinding | | reboot preparation, immediate reread | host/claimservingrebootpreparelinux.go:95 (nextActor != actor) | whole actor | SameBinding | | reboot preparation, persisted | claimservingrebootpreparelinux.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 == "" | | reboot commit | host/claimservingrebootcommitlinux.go:30 (reflect.DeepEqual(prepared, fresh)) | persisted whole actor | same, through the stored bound actor |
Structs 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), claimfilesseedstage.go:26 (.Box, .Region), claimcleanupidentity.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.
2. Options
Common 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.
A. Holds stop bumping Revision; the S3 ETag tracks hold changes
| Aspect | A | |---|---| | Code | console side only: Replace, CensusBoxRelocation, hostactors tests that assert +1 | | 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 | | Migration | none | | Security | membership writes for other boxes, console enrolments and role changes still strand every installed instance; region HA replacements would do so chronically |
Rejected under R1.
B. Holds move to their own object
| Aspect | B | |---|---| | 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 | | v0.28.4 console | writes holds into the roster for the whole dev trial; the overlap rule cannot be atomic across two objects | | Security | fixes only holds, like A, at ten times the cost |
Rejected.
C. Boxes bind to a membership binding computed from the roster
C1, 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.
C2, 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.
| Aspect | C1 | |---|---| | Code, box and root | hostbox.Actor keeps Revision (numeral) and gains Binding, Bound() and SameBinding; productionClaimRoster.Actor (claimproduction.go:396) and verifyClaimCleanupEnrollment (claimcleanupidentity.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 claimrecoveryremote.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 | | Code, console and hostops | none (R1) | | 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 | | v0.28.4 box | stays broken by any console's holds until it takes the release | | 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 | | Signing | unchanged | | 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 | | Security, must not invalidate | holds, activations, enrollment attempts, console rows, regions, other boxes, this box's role or label | | Ordering checks | inside the box, servingPriorRevision becomes equality. Outside the box the two bounds are untouched and keep seeing numerals |
3. Recommendation
C1, the per-box binding digest, no roster change, with the R1 compatibility contract.
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.
3.1 What compares what
| Comparison | Left | Right | |---|---|---| | 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 | | agent install, census, retirement, stop, generations | stored stamp | the agent's pinned stamp or another stored stamp (record to record) | | regional engine, moves, leases, VerifyRegional, evidence wiring, reboot preparation and commit | fresh actor | pinned or persisted actor, SameBinding | | receipt vs request or target (box) | receipt.Binding | stamp | | receipt (console, unchanged) | receipt.DirectoryRevision numeral, from the fence's RosterRevision | 0 < n <= roster.Revision |
3.2 Root's identities and startup (R2, N3)
| Function | Identity | Used by | After quarantine, no restart | After readmission, no restart | |---|---|---|---|---| | ClaimRootHTTP.InventoryActor | fresh assembly.Identity.Verify in every mode (claimassembly.go:382, rootinventorystartup.go:47) | commitRootInventoryPending (rootinventory.go:61), validateServingHandover (claimservinghandover.go:25), rebindRecoveryInventory (rootrecoveryrebind.go:63), the operation cold handover check (N5), reboot preparation (claimservingrebootpreparelinux.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 | | launch authorize h.Actor | already fresh (claimlaunchproduction.go:70) | claimlaunchauthority.go:82 | refuses | B0 launch records refused | | cleanupEnrollment.actor | startup read, Eligible false, Quarantined true, with Binding computed from the same read | configureSigner (claimevidencewiring.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) | | 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 | | 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 |
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:
- equal: replay as today (
withRootInventoryHistoryorrebindRecoveryInventory), with the existingErrChanged/ErrJournalUncertainabandonment; - different: the intent is OBSOLETE. It is settled as abandoned through the existing
Guard.AbandonRootInventory(hostguard/root.go:301-324: same host lock, samependingInventorybyte match, samequarantinedmark) with asupersededpredicate that returns true for the binding difference, and a log lineinstance-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).
After 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.
So 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.
3.3 Recovery, rebind and handover (R3, N4, N5)
| Path | file:line | Rule | Numeral or stale predecessor | |---|---|---|---| | recovery rebind | rootrecoveryrebind.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 | | remote recovery producer | claimrecoveryremote.go:54 | sends actor.Binding as the successor stamp (N4) | n/a | | handover recovery | claimhandoverrecovery.go:217 (add inv.DirectoryRevision != actor.Binding); :243 copies an equal value | same | refused | | serving handover | claimservinghandover.go:29-32, claimservingparkrootlinux.go:131, claimservinglegacyrootlinux.go:102 | prior == actor.Binding and next == actor.Binding | refused | | operation cold handover, root check (N5) | claimhandovertransport.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 | | 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 | | |
Every 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.
3.4 Deferred
The 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.
4. Build plan
| # | Task | Files | Test that pins it | |---|---|---|---| | 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 | | 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 | | 3 | Box and root stamps and comparisons: productionClaimRoster.Actor and verifyClaimCleanupEnrollment compute Binding; the 22 stamp and compare sites of 1.2 plus claimrecoveryremote.go:54 (N4) use actor.Binding; servingPriorRevision becomes equality; sameVerifiedActor deleted, SameBinding at claimassembly.go:289, claimmoveparks.go:107, claimevidencewiring.go:61 | internal/host, internal/hostbox | TestClaimRegionalEnrollmentSurvivesRosterRevision kept; TestServingPriorBindingIsEquality; N4 | | 3b | Reboot preparation (N2): SameBinding at claimservingrebootpreparelinux.go:95; store actor.Bound() at :109; sanity at :187 | claimservingrebootpreparelinux.go, claimservingrebootcommitlinux.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 | | 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, claimretiretransport.go, claimjournalproof.go, rootmigrationevidence.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 | | 5 | Root identities (3.2): InventoryActor = assembly.Identity.Verify at claimassembly.go:382 and rootinventorystartup.go:47; cleanup actor gains Binding | host/claimassembly.go, rootinventorystartup.go, claimcleanupidentity.go | L4 | | 5b | Startup replay (N3): obsolescence check and settlement in recoverRootInventory; the startup binding passed in from recoverRootInventoryStartup | host/rootinventory.go, rootinventorystartup.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 | | 6 | Recovery (3.3): predecessor checks at rootrecoveryrebind.go:75 and claimhandoverrecovery.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 | | 6b | Operation cold handover (N5): the fresh successor check before handover.Run at claimhandovertransport.go:71; replay refusal | host/claimhandovertransport.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 | | 7 | Witness tests L2 to L7 (next table) | internal/host | | | 8 | Guard: no console or hostops verifier change. Record the empty git diff v0.28.4..HEAD --stat for hostops/evidence.go, wakeresettransport.go, record.go | | L7b, L7c | | 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 inventoryexpected.go:59, the launch journal of claimjournalmigration.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 | | 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 claimstep8ruling6test.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 | | 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 | |
Witness 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 (claimregionalrevision_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.
| Layer | Test | Asserts | |---|---|---| | 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 | | L3 restart | same, after rebuilding the assembly | every comparison still passes | | 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 | | 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 | | 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 | | L7a receipt | task 4 | | | 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 | | 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 | | N1 to N5 | named in tasks 3b, 4, 5b, 6, 6b | |
Mutation 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.
Local 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.
5. Open questions for Paul
- 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.
- 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.
- The receipt numeral's bound at root. Root persists the agent-supplied
RosterRevisionas 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.
6. Invariant checklist
Every 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.
| Site | file:line | Rule | Test | |---|---|---|---| | inventory write | host/rootinventory.go:67 | fresh | L2, L4 | | wake, remote | host/rootinventorystatelinux.go:38 | fresh | L2, L4 | | wake, local | host/claimroleassemblylinux.go:373 | fresh | L2 | | target vs inventory at wake | host/claimroleassemblylinux.go:47, 128 | historical | L2 | | launch authorize | host/claimlaunchauthority.go:82; claimlaunchroot.go:180, 216 | fresh | L2, L4 | | serving handover | host/claimservinghandover.go:29-32 (prior and next) | fresh (equality) | L2, L4 | | serving park root, source, legacy | host/claimservingparkrootlinux.go:131; claimservingparksourcelinux.go:41; claimservinglegacyrootlinux.go:44, 102 | fresh (equality) | L2 | | serving park sanity | host/claimservingparksourcelinux.go:33; claimservingparkrootlinux.go:75; claimservingparktransport.go:158; claimservingrebootpreparelinux.go:187 | format (Binding != "") | L1 | | reboot preparation reread | host/claimservingrebootpreparelinux.go:95 | bound | N2 | | reboot preparation persisted, commit | host/claimservingrebootpreparelinux.go:109, 136; claimservingrebootcommitlinux.go:30 | bound (stored Bound()) | N2 | | recovery rebind successor | host/rootrecoveryrebind.go:68 | fresh | N4 | | recovery rebind predecessor | host/rootrecoveryrebind.go:75 | fresh | N4, L6 | | handover recovery predecessor and current | host/claimhandoverrecovery.go:217, 224 | fresh | L6 | | remote recovery producer | host/claimrecoveryremote.go:54 | produces fresh | N4 | | operation cold handover successor | host/claimhandovertransport.go:71 | fresh | N5 | | operation cold handover replay | hostbox/coldhandover.go:92-98 | fresh (refused when changed) | N5 | | cold handover prior | hostbox/coldhandover.go:141; receipt via verifyRetirementReceipt | historical | L6, N5 | | startup replay admission | host/rootinventory.go:363 (obsolescence) | fresh startup binding | N3 | | regional enrollment | host/claimassembly.go:289 | bound | TestClaimRegionalEnrollmentSurvivesRosterRevision | | bound identity | host/claimmoveparks.go:107 | bound | TestMoveParksVerifiedIdentityAndRealEngine | | evidence wiring | host/claimevidencewiring.go:61 | bound | L4 | | agent install, route register | hostbox/reconcileadapter.go:139; inventoryregistration.go:140 | historical (pinned stamp) | L2, L3, L4 | | agent census | hostbox/emptycensus.go:135 | historical (pinned stamp) | L2 | | retirement inventory | hostbox/reconcileadapter.go:301; host/claimretiretransport.go:145 | historical | L6 | | stop request vs inventory | host/claimstop.go:256, 412; claimstopwriters.go:119; claimstoprecovery.go:64 | historical | L7a, N1 | | receipt vs target or request | host/claimstoptransport.go:207; claimretiretransport.go:180; claimjournalproof.go:106, 114; rootmigrationevidence.go:40 | historical (Binding) | L7a, N1 | | receipt numeral | host/claimstop.go:335, 433; claimstoprecovery.go:82 | from the fence's RosterRevision | L7a, N1 | | generations | host/rootgenerationparked.go:48; rootgenerationhandover.go:101; hostbox/generationhandover.go:19; servinghandover.go:50 | historical | existing generation tests | | identity sanity | hostbox/identity.go:86; host/claimfilesseedselect.go:38 | format | L1 | | journal and fence validation | hostguard/fence.go:42; hostbox/stopinventorydb.go:42; generationpayload.go:56; hostexec/rootbroker.go:254 | format (non-empty) | existing tests | | console stop evidence | hostops/evidence.go:124 | format (numeral bound, unchanged) | L7b | | console wake-reset evidence | hostops/wakereset_transport.go:286-300, 319 | actor active and unfenced, then format (unchanged) | L7c |
7. Build rulings after Astra round 3 (platform-1c, 2026-10-20)
Paul 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.
| Ruling | Finding | Mechanism | Task | Test | |---|---|---|---|---| | B1 | HIGH: startup cold-handover recovery skips the fresh successor check (claimretiretransport.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 | | 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 | | 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 | | B4 | Checklist audit | Every row in Astra round 3's three "missing" tables, plus the correction (claimlaunchroot.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 |