plan: claim Plan 12 rev 2 (Astra r1 and moves review: TargetReleased session evidence, ReleaseFencedTarget with record_seq and a box fence verifier, loss before first admission, one-time loss consumption, recovery source retirement (M21), old-region and qualified retired-location labels, free-host lane proof; tests compiled and run in a scratch export)
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+2166
−408
docs/superpowers/plans/2026-10-10-host-lifecycle-claim-12-moves-integration.md+2166 −408
CI passed — run details
diff --git a/docs/superpowers/plans/2026-10-10-host-lifecycle-claim-12-moves-integration.md b/docs/superpowers/plans/2026-10-10-host-lifecycle-claim-12-moves-integration.md| index fb8e1ce8a..24ec05838 100644 |
| --- a/docs/superpowers/plans/2026-10-10-host-lifecycle-claim-12-moves-integration.md |
| +++ b/docs/superpowers/plans/2026-10-10-host-lifecycle-claim-12-moves-integration.md |
| @@ -1,38 +1,45 @@ |
| # Host lifecycle claim, Plan 12: moves integration completion |
| -Implementation plan, 2026-10-10, rev 1. This supplies the claim side of the controller's accepted moves delta answers M2, M3, M5, M6, M7, M9 and M12. Apply Tasks A–H in order. The amended claim spec is rev 6.32. This plan authorizes no production changes. |
| +Implementation plan, 2026-10-10, rev 2. This supplies the claim side of the controller's accepted moves delta answers M2, M3, M5, M6, M7, M9, M12 and M21. Apply Tasks A–H in order. The amended claim spec is rev 6.32. This plan authorizes no production changes. |
| Requirements: `/home/paulca/.cache/tmp/claude-1001/own/after236/plan12-brief.md`, and sections 2 (M1–M21) and 5 of `/home/paulca/amadan.net/carlos/platform-moves-p5/.superpowers/sdd/2026-10-06-moves-phase1-5-cleanup-and-parks/delta-after-plan10.md`. The controller's accepted answers override the delta's suggested rulings. In particular, the claim owns the instance-residue census; moves cannot be asked to invent that history. |
| +## Revision log |
| + |
| +- Rev 2, 2026-10-10: applies every controller ruling in `plan12-rev2.md`, the Astra round-one findings, and the moves review's seven ordered asks. Replaces the moves stop adapter with the target's own release journal and an optional enrolled root receipt. Adds fenced-target release under the recovery grant, first-consumption loss routing, claim-owned return retirement, independent census refusals and a retirement-history write gate. Replays against `44c1b6d7` and runs the Go checks in the shared-cache scratch export. The per-finding disposition and actual verification results are in `/home/paulca/.cache/tmp/claude-1001/own/after236/plan12-rev2-report.md`. |
| +- Rev 1, `7facdb61`: initial A–H patches; static review identified the issues corrected here. |
| + |
| +Binding inputs: `/home/paulca/.cache/tmp/claude-1001/own/after236/plan12-rev2.md`, `/home/paulca/.cache/tmp/claude-1001/own/findings-plan12-r1.md`, and `/home/paulca/amadan.net/carlos/platform-moves-p5/.superpowers/sdd/2026-10-06-moves-phase1-5-cleanup-and-parks/plan12-moves-review.md`. Rev 2's controller rulings supersede rev 1's signed-stop proposal and responsibility split. |
| + |
| ## Global Constraints |
| - Every code hunk is anchored to `44c1b6d77ed16f92e46e892b8254835edbd567d8`, the required `origin/main` base carrying claim Plans 1–11, then the earlier tasks in this document. Never replace that input with the moving remote-tracking ref. It advanced during authoring; none of its subsequent changes are included. |
| -- Everything remains behind `CARLOS_HOST_CLAIM`. Group D's existing moves gate remains in place. No new initializer, registration, flag, service or production activation is added. With the flag off, existing bytes and behavior stay identical to the pinned base. |
| +- Everything remains behind `CARLOS_HOST_CLAIM`. Group D's existing moves gate remains in place. No new initializer, service or production activation is added. The retired-history write gate only restricts claim-on writes. With the flag off, existing bytes and behavior stay identical to the pinned base. |
| - Keep #267: no by-name root file effect, no root SQLite open, and all AWS configuration loads through `rootaws`. This plan adds no AWS client and no privileged file effect. `Guard.Activate` and `Guard.With` retain their descriptor-based implementation. A source never manufactures a root receipt for a remote target session. |
| - Read and preserve the underlying admission, stop, publication, park and loss checks. A cross-box predecessor proof is not a coverage certificate. A lease does not replace authorization. The new local authorizer does not weaken the regional one. |
| -- Existing wire data still parses. The new `retired_locations` map is optional. Because old claim readers are strict, deploy the new reader to every claim consumer before enabling writes of the new field; the release floor must reflect that requirement. Missing historical mappings remain a typed refusal. |
| -- Change census rows only with the classifications in Task G. No console route, record writer or moves copy sink is added here. Moves' later callers add their own classified rows when they land. |
| +- Existing wire data still parses. The new `retired_locations` map is optional. Because old claim readers are strict, leave `CARLOS_HOST_CLAIM_RETIRED_LOCATIONS` unset until every claim consumer has the new reader. Set it to `1` only after that rollout and its release-floor check; `retire` otherwise writes no new field. Missing historical mappings remain a typed refusal. |
| +- Task G classifies the recovery-grant record writer, operator reason and changed identity/transport units. No console route is added. |
| - Author without git writes, commits, subagents or production activity. Export the pinned commit under `$TMPDIR`. Never create a private Go cache, never put one under `/tmp`, and remove only scratch paths this session created. |
| -- The two new diagnostic strings are inventory drafts, not approved product copy. The implementation owner must obtain copy approval before shipping them. This document and the report are implementation instructions for the controller. |
| +- The inventoried diagnostic and rollback strings are inventory drafts, not approved product copy. The implementation owner must obtain copy approval before shipping them. This document and the report are implementation instructions for the controller. |
| ## Review Focus |
| -1. M2 reaches the real `ClaimWakeAssembly.Build` writer through `ClaimAssembly.Build`. The root public key comes from the verified production authority, while moves supplies only its loss policy and baseline validator. Omitting `Admission.LossID` must not admit clean-return coverage for an operation that journals a loss. |
| -2. J2(b) uses the actual predecessor admission when present. Clean return may already have changed `holder.generation` back to `src_gen`; the target's session and its admission generation remain the predecessor. Require the target stop's authenticated session, a matching committed loss source, or a freshly verified target fence. Never treat `StoppedReport.ClaimGen` as its session. |
| -3. `RunHeld` borrows an acquisition-bound exact bucket/location lease through the configured `AggregateLeases`. It must retain the caller's lease after success, pending, authorization failure and partial acquisition failure. All other constituents retain their normal lifecycle and cancellation. |
| -4. M6 uses data-bucket lock names only. The staging signature horizon remains the original persisted `evidence_at`. Instance scope and replica lease names currently coincide, so deduplication yields one lock. |
| -5. M7 distinguishes identity from evidence. The 32 retained IDs do not identify which one occupied the bare legacy location. Retain the location at future retirement, refuse unprovable old residue, and keep the legacy label separate from the moves proof's label. |
| -6. J5 writes a verified reference by CAS and has no other effect. Consuming transitions must queue their chores in their own CAS. A fence declaration cannot be passed off as a completed local or regional park, or as a signed stopped report. |
| -7. Preserve the unwrapped owner for `park.Local`; keep `NewClaimRegionalWorker`'s stronger wrapper on the returned regional engine. Recheck enrollment when the local callback runs, not just when the capability is obtained. |
| -8. Main's current unsigned `moves.StoppedReport` cannot satisfy the signed target-stop contract. Moves must supply that verifier and session-bearing evidence before enabling normal recovery. Test all three evidence forms here, then run moves' acceptance test with its real registered owners before rollout. |
| +1. First loss consumption accepts frozen target, source-cut and empty source sets. The marker is written with admission and survives trimming. A restarted probe and a later source session do not consume the same declaration again; all still refuse clean-return or sealed coverage. |
| +2. Remote recovery selects only restoring with a nonempty target writer. Reverting, never-admitted holders and subsequent source writers retain their local path. The existing string-valued `claimRecoveryKind` and new observation helper have distinct names. |
| +3. `TargetReleased` captures the target's session and generation from the holder in its own exec-to-none CAS. Recovery checks that journal plus the ordinary complete report, or an enrolled root receipt, or the operation's signed loss, or a verified target fence. No moves adapter or release-key signature fixture is introduced. |
| +4. `ReleaseFencedTarget` revokes execution, removes participation, reaches `released` and queues every owed park atomically. The console grant verifies record ETag and sequence again after fence verification. The source waits for the claim phase and accepts the consumed-fence marker. |
| +5. The claim's restore hook retires the source database and marker for restoring and reverting under the exact source stamp, generation, inventory and stop evidence. The Linux acceptance test continues through `Prepare` and `Restore`. |
| +6. `RunHeld` keeps the same authorizer and census and borrows the exact caller-held location lease. The claim owns `ReplicaParkLeaseSet`; moves no longer registers a resolver. The caller keeps its lease through `published`. |
| +7. The holder's bare location gets its incarnation label outside the live region. Qualified paths use their own identity regardless of the 32-entry cap. Ambiguous bare history gets a typed refusal for that location. The claim proves free-host lanes from its retained identity and quiet bound; one namespace's refusal does not stop the other's sweep. |
| +8. Preserve #267, the unwrapped local park authorizer, flag-off behavior and the C8 files-provider gate. Spec §6.6 P6-D3 already requires destination-local objects after cross-region materialization. |
| ## Published interfaces |
| -The patches below contain the complete definitions. Moves integrates with: |
| +The patches supply these interfaces: |
| ```go |
| // internal/hostbox |
| -// The record passed to Sources has already passed root-signature verification. |
| +// Sources receives the root-verified record and validates its frozen baselines. |
| type LossAdmissionSources func(context.Context, hostclaim.Observed, |
| hostclaim.LossRecord, hostclaim.Admission, *genselect.Plan) ([]hostclaim.ValidatedWriteSource, error) |
| type LossAdmission struct { |
| @@ -43,17 +50,12 @@ type LossAdmission struct { |
| func ReplicaParkLeaseSet(context.Context, string, string) ([]string, error) |
| func (AggregateLeases) AcquireHeld(context.Context, string, string, park.HeldLease) (park.HeldLease, error) |
| -// internal/host: new fields on the existing ClaimMovesOwners. |
| -// LossRecovery hostclaim.LossRecoveryOwner |
| -// LossSources hostbox.LossAdmissionSources |
| -// TargetStop ClaimTargetStopVerifier |
| -type ClaimTargetStop struct { |
| - Host, OpID, Box, Incarnation, Generation string |
| - Session uint64 |
| -} |
| -type ClaimTargetStopVerifier func(context.Context, hostclaim.Observed, string) (ClaimTargetStop, error) |
| +// internal/host |
| +// ClaimMovesOwners gains LossRecovery and LossSources. It gains no TargetStop. |
| type ClaimLocalParkAuthorizer func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error |
| func (ClaimProviders) MoveParks(context.Context) (park.Engine, hostbox.LeaseSet, ClaimLocalParkAuthorizer, error) |
| +func (ClaimProviders) VerifyMoveTargetFence(context.Context, hostclaim.Observed) (hostclaim.ReturnFence, error) |
| +func RegisterProductionClaimMoveParks(func(context.Context, hostclaim.Observed) (park.View, error), hostbox.RegionalEvidence) error |
| func ClaimRetirementCensus(hostclaim.Observed, []lease.ObjectVersion) (map[string]string, error) |
| // internal/park |
| @@ -61,13 +63,26 @@ func (Engine) RunHeld(context.Context, Work, HeldLease) error |
| // internal/hostclaim |
| func OperationLossID(*Claim) (string, error) |
| -func DeclareMoveTargetFence(context.Context, release.Store, Observed, |
| - string, FenceRef, FenceAuthority) (Observed, error) |
| +func OperationLossConsumed(*Claim, string) bool |
| +func VerifyMoveTargetFence(context.Context, release.Store, Observed, FenceRef, FenceAuthority) (ReturnFence, error) |
| +func ReleaseFencedTarget(FencedTargetRelease) func(*Claim) error |
| + |
| +// internal/hostops |
| +func MoveLossRecoveryOwner() hostclaim.LossRecoveryOwner |
| +func ParseMoveLossBaseline(string) (kind, region, identity string, err error) |
| +func (*MoveRecoveryGrant) ReleaseFencedTarget(context.Context, hostclaim.FenceRef) error |
| ``` |
| -`TargetStop` is a trusted evidence adapter, just as `LossRecoveryOwner` is a trusted policy adapter. It must verify the evidence's signature against the target's enrolled signer, immutable object identity, complete stop checks and tuple before returning `ClaimTargetStop`. The claim checks the returned tuple again. A JSON decoder alone is not an implementation. The claim does not supply a signer or accept a caller's `Verified: true` field. |
| +The grant performs one mutation per invocation: record the fence rollback |
| +using `RollbackTargetFenced`, `LossRef` and a fresh `LostAt` bound; release the |
| +claim with its parks; then complete the record with the fence as evidence. |
| +Moves calls the same method on successive recovery passes. A dirty fenced |
| +target follows declared loss. Sources validates the frozen record once; |
| +the writer derives admission maxima before calling `AdmissionEvidence`. |
| -The existing J4 `hostresolve.OperationOwner` and `AdmitWriterWithLoss` signatures remain unchanged. `MoveParks` gains a third value before its error; its only current callers on this base are the tests patched in Task F. Moves Task 9a must consume all four results. |
| +The existing J4 owner and `AdmitWriterWithLoss` signatures remain compatible. |
| +`MoveParks` gains the local authorizer before its error. Move-park registration |
| +now takes only view and evidence; production uses the claim's lease resolver. |
| ## Replay without git writes |
| @@ -84,8 +99,10 @@ git -C "$CLAIM12_REPO" archive "$CLAIM12_BASE" | tar -x -C "$CLAIM12_SCRATCH/tre |
| mkdir -p "$CLAIM12_SCRATCH/tree/docs/superpowers/specs" |
| cp "$CLAIM12_REPO/docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md" "$CLAIM12_SCRATCH/tree/docs/superpowers/specs/" |
| cd "$CLAIM12_SCRATCH/tree" |
| -# Use the default shared Go cache. No per-task cache or downloads. |
| -export GOFLAGS=-mod=mod GOPROXY=off |
| +# The controller-designated existing shared cache; never remove it. |
| +export GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache |
| +# The export has no git metadata; disable VCS stamping, not compilation checks. |
| +export GOFLAGS="-mod=mod -buildvcs=false" GOPROXY=off |
| ``` |
| Save the following as `$CLAIM12_SCRATCH/apply.py`. It rejects absent stages and any fuzz, offset or already-applied result. It only touches the exported files, never the git directory. |
| @@ -119,15 +136,15 @@ python3 "$CLAIM12_SCRATCH/apply.py" "$CLAIM12_PLAN" A implementation |
| go test ./internal/hostbox ./internal/hostclaim -run 'TestPlan12|TestAdmitWriterWithLoss' -count=1 |
| ``` |
| -## Task A: Route declared recovery through the loss admission primitive (M2) |
| +## Task A: Admit first loss consumption and later source sessions (M2) |
| -The current Group D writer always calls the pure `AdmitWriter` transition. Add the typed loss configuration to the writer, pass it through production construction, and freeze the choice from the operation's journal. The primitive itself also rejects a loss-bearing operation's attempt to bypass verification. Stale CAS is returned once; evidence must be selected again by the caller. |
| +Route the first current-operation loss through the signed declaration primitive, including a source-cut predecessor or no frozen sources. Record `loss_admitted.<id>` in the admission CAS. Later source admissions keep the original losses and use normal admission; a repeated matching input LossID is cleared only after durable consumption. Coverage remains uncovered throughout the loss-bearing operation. |
| -Tests cover a signed committed declaration, missing owner and source adapters, wrong source identity, wrong operation, wrong signing key, stale observation, both invalid coverage forms and direct primitive bypass. Existing loss-library tests continue to cover every frozen additional source and duplicate identities. |
| +Implement `ValidateLossRecovery` on the existing hostops loss owner and export its pure policy adapter and baseline decoder. `LossAdmissionSources` is the one source of both primary and additional maxima; the writer fills them before invoking admission evidence. Every frozen tuple still passes the primitive's exact validation. Missing adapters refuse first consumption. Stale observations return one CAS conflict. |
| -Focused command: `go test ./internal/hostbox ./internal/hostclaim -run 'TestPlan12|TestAdmitWriterWithLoss' -count=1`. |
| +Focused command: `go test ./internal/hostbox ./internal/hostclaim ./internal/hostops -run 'TestPlan12Loss|TestLossBeforeTargetAdmissionRecovers|TestAdmitWriterWithLoss' -count=1`. |
| -Expected first failure: the writer has no `Loss` field or `LossAdmission` type. After implementation, the successful admission carries `LossID`, uncovered cold coverage and the target's exact write loss. Moves' callbacks remain unset until its recovery owners register them; there is no clean-return fallback. |
| +Expected first failure: `LossAdmission`, `OperationLossConsumed` or the hostops policy API is undefined. Tests include a second cold admission using a reconstructed writer, loss before target admission, direct bypass, invalid coverage, bad source identity, wrong operation, bad signature and stale CAS. |
| ### Failing tests |
| @@ -135,7 +152,7 @@ Expected first failure: the writer has no `Loss` field or `LossAdmission` type. |
| ```diff |
| --- /dev/null |
| +++ b/internal/hostbox/plan12_loss_test.go |
| -@@ -0,0 +1,114 @@ |
| +@@ -0,0 +1,154 @@ |
| +//go:build unix |
| + |
| +package hostbox |
| @@ -162,13 +179,25 @@ Expected first failure: the writer has no `Loss` field or `LossAdmission` type. |
| + return nil |
| +} |
| +func TestPlan12LossAdmission(t *testing.T) { |
| -+ for _, mode := range []string{"ok", "clean-return", "sealed", "missing-owner", "missing-source", "wrong-source", "wrong-op", "wrong-signature", "stale", "bypass"} { |
| ++ plan12LossAdmissionCases(t, []string{"ok", "source-cut", "no-source", "clean-return", "sealed", "missing-owner", "missing-source", "wrong-source", "wrong-op", "wrong-signature", "stale", "bypass"}) |
| ++} |
| ++func TestLossBeforeTargetAdmissionRecovers(t *testing.T) { |
| ++ plan12LossAdmissionCases(t, []string{"source-cut", "no-source"}) |
| ++} |
| ++func plan12LossAdmissionCases(t *testing.T, modes []string) { |
| ++ for _, mode := range modes { |
| + t.Run(mode, func(t *testing.T) { |
| + _, store, o := workFixture(t) |
| + var err error |
| + o, err = hostclaim.Update(t.Context(), store, o, func(c *hostclaim.Claim) error { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindRestoring, ID: "op", Phase: hostclaim.PhaseProbing, Exec: "box", Participants: []string{"box"}, Journal: map[string]string{"loss.loss_1": "{}"}} |
| + c.Holder.Session, c.Holder.Generation, c.Holder.WriterBox = 2, "target-gen", "target" |
| ++ if mode == "source-cut" || mode == "no-source" { |
| ++ c.Holder.WriterBox = "box" |
| ++ } |
| ++ if mode == "no-source" { |
| ++ c.Holder.Session, c.Holder.Generation, c.Holder.WriterBox = 0, "", "" |
| ++ } |
| + c.LossRefs = []string{"loss_1"} |
| + return nil |
| + }) |
| @@ -183,6 +212,13 @@ Expected first failure: the writer has no `Loss` field or `LossAdmission` type. |
| + r := hostclaim.LossRecord{V: 1, ID: "loss_1", State: "prepared", Actor: "operator", AcceptedAt: now, Host: o.Host, HolderIncarnation: o.Claim.Holder.Incarnation, HolderStamp: o.Claim.Holder.Stamp, HolderSession: 2, ExpectedClaimGen: o.Claim.Gen, ExpectedClaimETag: o.ETag, OpID: "op", OpKind: hostclaim.KindLanding, OpPhase: "probing", Box: "target", Enrollment: "enroll", QuarantineRevision: 1, |
| + CloudObservation: hostclaim.LossCloudObservation{ObservedAt: now, Account: "123", Region: "eu-west-1", InstanceID: "i-1", State: "stopped", RequestID: "request"}, OperatorAttestation: hostclaim.LossOperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true}, FencedAt: now, |
| + ConfirmedConsequences: hostclaim.LossConsequences{RevokeExecution: true, RemoveParticipation: true, RecoveryIntent: json.RawMessage(`{"kind":"move-target-lost"}`)}, Timers: hostclaim.TimerLoss{Knowledge: hostclaim.TimersUnknown}, WriteSources: []hostclaim.WriteSource{{Reason: "target", Box: "target", Session: 2, Generation: "target-gen", Region: "uk", Baseline: "target-snapshot"}}} |
| ++ if mode == "source-cut" { |
| ++ r.WriteSources[0].Reason, r.WriteSources[0].Box = "source-cut", "box" |
| ++ } |
| ++ if mode == "no-source" { |
| ++ r.WriteSources = nil |
| ++ r.HolderSession = 0 |
| ++ } |
| + if mode == "wrong-op" { |
| + r.OpID = "different" |
| + } |
| @@ -197,10 +233,11 @@ Expected first failure: the writer has no `Loss` field or `LossAdmission` type. |
| + owner := &plan12LossOwner{} |
| + w := Writer{Claims: store, Box: "box", Region: "ie", AdmissionEvidence: func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error { return nil }, Loss: &LossAdmission{PublicKey: pub, Owner: owner}} |
| + w.Loss.Sources = func(_ context.Context, _ hostclaim.Observed, r hostclaim.LossRecord, _ hostclaim.Admission, _ *genselect.Plan) ([]hostclaim.ValidatedWriteSource, error) { |
| -+ if len(r.WriteSources) != 1 || r.WriteSources[0].Baseline != "target-snapshot" { |
| -+ t.Fatal("not frozen sources") |
| ++ if mode == "no-source" { |
| ++ return nil, nil |
| + } |
| -+ v := hostclaim.ValidatedWriteSource{Reason: "target", Box: "target", Session: 2, Generation: "target-gen", Region: "uk", Baseline: "target-snapshot", RemoteMaxTXID: 7} |
| ++ f := r.WriteSources[0] |
| ++ v := hostclaim.ValidatedWriteSource{Reason: f.Reason, Box: f.Box, Session: f.Session, Generation: f.Generation, Region: f.Region, Baseline: f.Baseline, RemoteMaxTXID: 7} |
| + if mode == "wrong-source" { |
| + v.Session++ |
| + } |
| @@ -215,7 +252,10 @@ Expected first failure: the writer has no `Loss` field or `LossAdmission` type. |
| + if mode == "wrong-signature" { |
| + w.Loss.PublicKey, _, _ = ed25519.GenerateKey(nil) |
| + } |
| -+ in := hostclaim.Admission{ObservedOpID: "op", ObservedPhase: hostclaim.PhaseProbing, ObservedSession: 2, ObservedGeneration: "target-gen", Box: "box", Mode: "cold", NewGeneration: "returned", Region: "ie", PredecessorRegion: "uk", RemoteMaxTXID: 7} |
| ++ in := hostclaim.Admission{ObservedOpID: "op", ObservedPhase: hostclaim.PhaseProbing, ObservedSession: 2, ObservedGeneration: "target-gen", Box: "box", Mode: "cold", NewGeneration: "returned", Region: "ie", PredecessorRegion: "uk", RemoteMaxTXID: 999} |
| ++ if mode == "no-source" { |
| ++ in.ObservedSession, in.ObservedGeneration = 0, "" |
| ++ } |
| + if mode == "clean-return" || mode == "sealed" { |
| + in.Coverage = mode |
| + } |
| @@ -231,7 +271,7 @@ Expected first failure: the writer has no `Loss` field or `LossAdmission` type. |
| + } else { |
| + admitted, err = w.Admit(t.Context(), o, in, nil) |
| + } |
| -+ if mode != "ok" { |
| ++ if mode != "ok" && mode != "source-cut" && mode != "no-source" { |
| + if err == nil { |
| + t.Fatal("accepted", mode) |
| + } |
| @@ -244,12 +284,74 @@ Expected first failure: the writer has no `Loss` field or `LossAdmission` type. |
| + t.Fatal(err) |
| + } |
| + a := admitted.Claim.Holder.Admissions[0] |
| -+ if a.LossID != "loss_1" || a.Coverage != "" || a.WritesLost == nil || a.WritesLost.Session != 2 || owner.calls != 1 { |
| ++ if a.LossID != "loss_1" || a.Coverage != "" || (mode != "no-source" && (a.WritesLost == nil || a.WritesLost.Session != 2 || a.WritesLost.RemoteMaxTXID != 7)) || owner.calls != 1 { |
| + t.Fatalf("%+v owner=%d", a, owner.calls) |
| + } |
| ++ // Restart loses the writer object, but not the claim's consumption marker. |
| ++ restarted := Writer{Claims: store, Box: "box", Region: "ie", AdmissionEvidence: w.AdmissionEvidence} |
| ++ fresh, err := hostclaim.Read(t.Context(), store, o.Host) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ in.ObservedSession, in.ObservedGeneration, in.NewGeneration = fresh.Claim.Holder.Session, fresh.Claim.Holder.Generation, "second-cold" |
| ++ in.PredecessorRegion = "ie" |
| ++ in.LossID = "loss_1" |
| ++ again, err := restarted.Admit(t.Context(), fresh, in, nil) |
| ++ if err != nil { |
| ++ t.Fatal("second cold admission after restart", err) |
| ++ } |
| ++ if again.Claim.Holder.Admissions[0].LossID != "loss_1" || again.Claim.Holder.Admissions[len(again.Claim.Holder.Admissions)-1].LossID != "" { |
| ++ t.Fatal("loss consumed again") |
| ++ } |
| ++ |
| + }) |
| + } |
| +} |
| +--- /dev/null |
| ++++ b/internal/hostops/plan12_loss_test.go |
| +@@ -0,0 +1,42 @@ |
| ++package hostops |
| ++ |
| ++import ( |
| ++ "github.com/carlosframework/platform/internal/hostclaim" |
| ++ "testing" |
| ++) |
| ++ |
| ++func TestPlan12LossOwnerPolicy(t *testing.T) { |
| ++ for _, bad := range []string{"", "stamp", "executor", "rollback", "coverage", "intent"} { |
| ++ in := moveLossIntent{Kind: moveLossIntentKind, Move: "move", Incarnation: "inc", Rollback: "rb_abcdefghijklmnopqrstuvwxyz", Target: "target", Source: "source", TargetRegion: "uk", SourceRegion: "ie", RecordKey: "record", RecordETag: "etag", RecordState: moveLossRecordLanding} |
| ++ raw, _ := in.encode() |
| ++ r := hostclaim.LossRecord{ID: "loss_1", OpID: "move", Box: "target", HolderIncarnation: "inc", ConfirmedConsequences: hostclaim.LossConsequences{RecoveryIntent: raw}} |
| ++ c := hostclaim.Claim{Holder: &hostclaim.Holder{Incarnation: "inc", Key: "record", Stamp: 7}, Op: hostclaim.Op{Kind: hostclaim.KindRestoring, ID: "move", Phase: hostclaim.PhaseProbing, Exec: "source", Intent: map[string]string{"source": "source", "target": "target", "to": "uk", "rollback": in.Rollback}, Journal: map[string]string{hostclaim.JournalSourceStamp: "7", "lost_target_released": "loss_1"}}} |
| ++ a := hostclaim.Admission{Box: "source", Region: "ie", Mode: "cold"} |
| ++ switch bad { |
| ++ case "stamp": |
| ++ c.Holder.Stamp++ |
| ++ case "executor": |
| ++ c.Op.Exec = "target" |
| ++ case "rollback": |
| ++ c.Op.Intent["rollback"] = "other" |
| ++ case "coverage": |
| ++ a.Coverage = "clean-return" |
| ++ case "intent": |
| ++ r.ConfirmedConsequences.RecoveryIntent = []byte(`{}`) |
| ++ } |
| ++ err := MoveLossRecoveryOwner().ValidateLossRecovery(c, r, a) |
| ++ if (err == nil) != (bad == "") { |
| ++ t.Fatal(bad, err) |
| ++ } |
| ++ } |
| ++ for _, s := range []string{"replica:ie/gen", "seal:move"} { |
| ++ if _, _, _, err := ParseMoveLossBaseline(s); err != nil { |
| ++ t.Fatal(s, err) |
| ++ } |
| ++ } |
| ++ for _, s := range []string{"replica:ie/", "seal:", "replica:ie/gen/other"} { |
| ++ if _, _, _, err := ParseMoveLossBaseline(s); err == nil { |
| ++ t.Fatal(s) |
| ++ } |
| ++ } |
| ++} |
| ``` |
| ### Implementation |
| @@ -258,7 +360,7 @@ Expected first failure: the writer has no `Loss` field or `LossAdmission` type. |
| ```diff |
| --- /dev/null |
| +++ b/internal/hostbox/writer_loss.go |
| -@@ -0,0 +1,40 @@ |
| +@@ -0,0 +1,58 @@ |
| +package hostbox |
| + |
| +import ( |
| @@ -297,14 +399,35 @@ Expected first failure: the writer has no `Loss` field or `LossAdmission` type. |
| + if err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| ++ p := hostclaim.AdmissionRecord{Box: o.Claim.Holder.WriterBox, Session: o.Claim.Holder.Session, Generation: o.Claim.Holder.Generation} |
| ++ if a := o.Claim.Holder.Admissions; len(a) > 0 { |
| ++ p = a[len(a)-1] |
| ++ } |
| ++ in.RemoteMaxTXID, in.AdditionalWritesLost = 0, nil |
| ++ for _, s := range sources { |
| ++ if s.Box == p.Box && s.Session == p.Session && s.Generation == p.Generation { |
| ++ in.RemoteMaxTXID = s.RemoteMaxTXID |
| ++ } else { |
| ++ in.AdditionalWritesLost = append(in.AdditionalWritesLost, hostclaim.WritesLost{Box: s.Box, Session: s.Session, Generation: s.Generation, RemoteMaxTXID: s.RemoteMaxTXID}) |
| ++ } |
| ++ } |
| ++ if baseline != nil && baseline.MaxTXID != in.RemoteMaxTXID { |
| ++ return hostclaim.Observed{}, ErrChanged |
| ++ } |
| ++ if err := w.AdmissionEvidence(ctx, o, in, baseline); err != nil { |
| ++ return hostclaim.Observed{}, err |
| ++ } |
| + return hostclaim.AdmitWriterWithLoss(ctx, w.Claims, w.Loss.PublicKey, o, in, id, sources, w.Loss.Owner) |
| +} |
| --- /dev/null |
| +++ b/internal/hostclaim/operation_loss.go |
| -@@ -0,0 +1,23 @@ |
| +@@ -0,0 +1,36 @@ |
| +package hostclaim |
| + |
| -+import "strings" |
| ++import ( |
| ++ "strconv" |
| ++ "strings" |
| ++) |
| + |
| +// OperationLossID selects only a declaration journalled under this operation. |
| +// Old claim-level LossRefs alone do not turn a later move into a loss recovery. |
| @@ -325,6 +448,16 @@ Expected first failure: the writer has no `Loss` field or `LossAdmission` type. |
| + } |
| + return id, nil |
| +} |
| ++ |
| ++// OperationLossConsumed survives admission-history trimming and process restart. |
| ++// Only the verified admission CAS records this operation-local marker. |
| ++func OperationLossConsumed(c *Claim, id string) bool { |
| ++ if c == nil || c.Holder == nil || id == "" { |
| ++ return false |
| ++ } |
| ++ n, err := strconv.ParseUint(c.Op.Journal["loss_admitted."+id], 10, 64) |
| ++ return err == nil && n > 0 && n <= c.Holder.Session |
| ++} |
| --- a/internal/hostbox/writer.go |
| +++ b/internal/hostbox/writer.go |
| @@ -12,6 +12,7 @@ |
| @@ -335,26 +468,37 @@ Expected first failure: the writer has no `Loss` field or `LossAdmission` type. |
| Claims release.Store |
| Data genselect.Reader |
| Bucket, Region, Box string |
| -@@ -40,6 +41,16 @@ |
| - if err := w.AdmissionEvidence(ctx, observed, in, baseline); err != nil { |
| - return hostclaim.Observed{}, err |
| +@@ -33,6 +34,19 @@ |
| } |
| + if w.Claims == nil || w.AdmissionEvidence == nil || w.Box == "" || w.Region == "" || observed.Absent || observed.Claim == nil || observed.Claim.Holder == nil || in.Box != w.Box || in.Region != w.Region { |
| + return hostclaim.Observed{}, ErrUnsupported |
| ++ } |
| + lossID, err := hostclaim.OperationLossID(observed.Claim) |
| + if err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| -+ if lossID != "" { |
| ++ if lossID != "" && !hostclaim.OperationLossConsumed(observed.Claim, lossID) { |
| + return w.admitLoss(ctx, observed, in, baseline, lossID) |
| + } |
| + if in.LossID != "" { |
| -+ return hostclaim.Observed{}, hostclaim.ErrLossRecord |
| -+ } |
| - // Exactly one attempt. A conflict requires new evidence, not an internal retry. |
| - return hostclaim.Update(ctx, w.Claims, observed, hostclaim.AdmitWriter(in)) |
| - } |
| ++ if lossID == "" || in.LossID != lossID || !hostclaim.OperationLossConsumed(observed.Claim, lossID) { |
| ++ return hostclaim.Observed{}, hostclaim.ErrLossRecord |
| ++ } |
| ++ in.LossID = "" |
| + } |
| + if baseline != nil && baseline.MaxTXID != in.RemoteMaxTXID { |
| + return hostclaim.Observed{}, ErrChanged |
| --- a/internal/hostclaim/generation.go |
| +++ b/internal/hostclaim/generation.go |
| -@@ -48,6 +48,13 @@ |
| +@@ -3,6 +3,7 @@ |
| + import ( |
| + "fmt" |
| + "reflect" |
| ++ "strconv" |
| + ) |
| + |
| + const RootGeneration = "@root" |
| +@@ -48,6 +49,17 @@ |
| return err |
| } |
| } |
| @@ -362,12 +506,34 @@ Expected first failure: the writer has no `Loss` field or `LossAdmission` type. |
| + if err != nil { |
| + return err |
| + } |
| -+ if id != "" && (!in.lossVerified || in.LossID != id || in.Coverage != "") { |
| ++ if id != "" && (in.Coverage != "" || (!OperationLossConsumed(c, id) && (!in.lossVerified || in.LossID != id))) { |
| + return ErrLossRecord |
| ++ } |
| ++ |
| ++ if c.Op.Kind == KindRestoring && c.Op.Journal["fence."+c.Op.Intent["target"]] != "" && in.Coverage != "" { |
| ++ return ErrFence |
| + } |
| h := c.Holder |
| if in.LossID != "" && !in.lossVerified { |
| return fmt.Errorf("%w: declaration-bound admission requires verified durable declaration", ErrWrongState) |
| +@@ -151,6 +163,9 @@ |
| + h.Admissions = h.Admissions[1:] |
| + } |
| + h.Session, h.Generation, h.WriterBox = entry.Session, entry.Generation, in.Box |
| ++ if in.lossVerified && id != "" { |
| ++ setJournal(c, "loss_admitted."+id, strconv.FormatUint(entry.Session, 10)) |
| ++ } |
| + return nil |
| + } |
| + } |
| +@@ -171,6 +186,7 @@ |
| + return ErrWrongState |
| + } |
| + } |
| ++ |
| + h := c.Holder |
| + admitted := false |
| + for _, record := range h.Admissions { |
| --- a/internal/host/claimowners.go |
| +++ b/internal/host/claimowners.go |
| @@ -39,13 +39,15 @@ |
| @@ -418,17 +584,115 @@ Expected first failure: the writer has no `Loss` field or `LossAdmission` type. |
| var closeMu sync.Mutex |
| closed := false |
| assembly.Close = func(ctx context.Context) error { |
| +--- a/internal/hostclaim/lost.go |
| ++++ b/internal/hostclaim/lost.go |
| +@@ -152,14 +152,17 @@ |
| + want[key] = source |
| + } |
| + targetKey := fmt.Sprintf("%s\x00%s\x00%d", predecessor.Box, predecessor.Generation, predecessor.Session) |
| +- if want[targetKey].Reason != "target" { |
| ++ if len(want) != 0 && want[targetKey].Reason != "target" && want[targetKey].Reason != "source-cut" { |
| + return Observed{}, fmt.Errorf("%w: predecessor is not the declared lost target", ErrLossRecord) |
| + } |
| + primary := WritesLost{Box: predecessor.Box, Generation: predecessor.Generation, Session: predecessor.Session, RemoteMaxTXID: in.RemoteMaxTXID} |
| +- if !validWriteLosses(&primary, in.AdditionalWritesLost) { |
| ++ if len(want) != 0 && !validWriteLosses(&primary, in.AdditionalWritesLost) { |
| + return Observed{}, ErrLossRecord |
| + } |
| +- declared := map[string]WritesLost{targetKey: primary} |
| ++ declared := map[string]WritesLost{} |
| ++ if len(want) != 0 { |
| ++ declared[targetKey] = primary |
| ++ } |
| + for _, additional := range in.AdditionalWritesLost { |
| + key := fmt.Sprintf("%s\x00%s\x00%d", additional.Box, additional.Generation, additional.Session) |
| + if _, ok := want[key]; !ok { |
| +--- /dev/null |
| ++++ b/internal/hostops/move_loss_admission.go |
| +@@ -0,0 +1,49 @@ |
| ++package hostops |
| ++ |
| ++import ( |
| ++ "github.com/carlosframework/platform/internal/hostclaim" |
| ++ "strconv" |
| ++ "strings" |
| ++) |
| ++ |
| ++const MoveLossReplicaBaseline = "replica:" |
| ++const MoveLossSealBaseline = "seal:" |
| ++ |
| ++// ParseMoveLossBaseline is shared by the frozen-source writer and validators. |
| ++func ParseMoveLossBaseline(value string) (kind, region, identity string, err error) { |
| ++ if strings.HasPrefix(value, MoveLossReplicaBaseline) { |
| ++ r, g, ok := strings.Cut(strings.TrimPrefix(value, MoveLossReplicaBaseline), "/") |
| ++ if ok && r != "" && g != "" && !strings.Contains(g, "/") { |
| ++ return "replica", r, g, nil |
| ++ } |
| ++ } |
| ++ if strings.HasPrefix(value, MoveLossSealBaseline) { |
| ++ id := strings.TrimPrefix(value, MoveLossSealBaseline) |
| ++ if id != "" && !strings.Contains(id, "/") { |
| ++ return "seal", "", id, nil |
| ++ } |
| ++ } |
| ++ return "", "", "", hostclaim.ErrLossRecord |
| ++} |
| ++ |
| ++// MoveLossRecoveryOwner shares the resolve owner's signed-intent policy with boxes. |
| ++// This policy is pure; it exposes neither the console store nor mutation grants. |
| ++func MoveLossRecoveryOwner() hostclaim.LossRecoveryOwner { return &moveLossOwner{} } |
| ++ |
| ++func (m *moveLossOwner) ValidateLossRecovery(c hostclaim.Claim, loss hostclaim.LossRecord, a hostclaim.Admission) error { |
| ++ in, _, err := lossIntentOf(loss) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ stamp, err := strconv.ParseUint(c.Op.Journal[hostclaim.JournalSourceStamp], 10, 64) |
| ++ if err != nil || stamp == 0 || c.Holder == nil || c.Holder.Stamp != stamp || |
| ++ c.Holder.Incarnation != in.Incarnation || c.Holder.Key != in.RecordKey || |
| ++ c.Op.ID != in.Move || c.Op.Kind != hostclaim.KindRestoring || c.Op.Phase != hostclaim.PhaseProbing || |
| ++ c.Op.Intent["rollback"] != in.Rollback || c.Op.Exec != in.Source || c.Op.Intent["source"] != in.Source || |
| ++ c.Op.Intent["target"] != in.Target || c.Op.Intent["to"] != in.TargetRegion || |
| ++ !hostclaim.LostTargetReleased(&c, loss.ID) || a.Box != in.Source || a.Region != in.SourceRegion || |
| ++ a.Mode != "cold" || a.Coverage != "" || a.KeepStamp || a.PredecessorCertified { |
| ++ return hostclaim.ErrLossRecord |
| ++ } |
| ++ return nil |
| ++} |
| +--- a/internal/hostops/move_loss_owner.go |
| ++++ b/internal/hostops/move_loss_owner.go |
| +@@ -546,17 +546,17 @@ |
| + if a.Box != in.Target || a.Session <= in.CutSession { |
| + continue |
| + } |
| +- if err := add(hostclaim.WriteSource{Reason: "target", Box: a.Box, Generation: a.Generation, Session: a.Session, Region: a.Region, Baseline: "replica:" + a.Region + "/" + a.Generation}); err != nil { |
| ++ if err := add(hostclaim.WriteSource{Reason: "target", Box: a.Box, Generation: a.Generation, Session: a.Session, Region: a.Region, Baseline: MoveLossReplicaBaseline + a.Region + "/" + a.Generation}); err != nil { |
| + return nil, err |
| + } |
| + } |
| + if h.WriterBox == in.Target && h.Session > in.CutSession { |
| +- if err := add(hostclaim.WriteSource{Reason: "target", Box: h.WriterBox, Generation: h.Generation, Session: h.Session, Region: in.TargetRegion, Baseline: "replica:" + in.TargetRegion + "/" + h.Generation}); err != nil { |
| ++ if err := add(hostclaim.WriteSource{Reason: "target", Box: h.WriterBox, Generation: h.Generation, Session: h.Session, Region: in.TargetRegion, Baseline: MoveLossReplicaBaseline + in.TargetRegion + "/" + h.Generation}); err != nil { |
| + return nil, err |
| + } |
| + } |
| + if !in.SrcCovered { |
| +- if err := add(hostclaim.WriteSource{Reason: "source-cut", Box: in.Source, Generation: in.SrcGen, Session: in.CutSession, Region: in.SourceRegion, Baseline: "seal:" + in.Move}); err != nil { |
| ++ if err := add(hostclaim.WriteSource{Reason: "source-cut", Box: in.Source, Generation: in.SrcGen, Session: in.CutSession, Region: in.SourceRegion, Baseline: MoveLossSealBaseline + in.Move}); err != nil { |
| + return nil, err |
| + } |
| + } |
| ``` |
| -## Task B: Declare a target fence without inventing release effects (M12, J5) |
| +## Task B: Declare and release a fenced target atomically (M12, J5) |
| -Add a typed evidence declaration for the post-flip target. It binds the observed operation, target enrollment and region to `VerifyFence`, then records the reference in one CAS. Identical replay re-verifies evidence and the live ETag without another write. A differing reference refuses. |
| +`VerifyMoveTargetFence` verifies the immutable fence against the roster, target enrollment, region and current operation. `ReleaseFencedTarget` consumes its typed result and queues the target location, staging and optional partial return lane in one CAS. It shares the park-queue implementation with `ReleaseLostTarget`, removes the target from participants and journals the consumed fence. `ReturnToSource` accepts that marker. |
| -This function is deliberately an evidence declaration. It changes no record, phase, executor, holder, participant or chore. Therefore it introduces no unfinished effect requiring a new obligation kind. The moves recovery owner must persist its record decision and queue every park in the consuming transition; it must not use this call as a release or park acknowledgment. This preserves the same evidence-recording shape as `ConvertMovingToRelocating` without borrowing that function's pre-flip authority transfer. |
| +The console action lives on `MoveRecoveryGrant`: only a live `RunRecovery` grant bound to the current record can invoke it. Its successive passes write the fence rollback decision, release the claim, and finish the target release record, with one mutation per callback. It checks ETag and `record_seq` again after fence reads and immediately before the claim CAS. The persisted `LostAt` from the fence decision bounds the parks, even when an earlier rollback already existed. Moves' `Recovery.Moves` hook calls this method on each pass. The source gates on the claim's released phase. Dirty target records refuse this action and require J4 loss. The box-side capability verifies a journalled fence with the same roster authority and an enrollment-bound identity check. |
| -Focused command: `go test ./internal/hostclaim -run 'TestPlan12TargetFenceDeclaration|TestC7' -count=1`. |
| +Focused command: `go test ./internal/hostclaim ./internal/hostops -run 'TestPlan12.*Fence|TestReleaseLostTarget|TestMoveRecovery' -count=1`. |
| -Expected first failure: `DeclareMoveTargetFence` is undefined. Test both permitted kinds, wrong operation/box/enrollment/region, missing verification, a committed move, stale CAS, replay, and preservation of holder and obligations. |
| +Expected first failure: the verifier, release transition and grant method are undefined. Regressions cover both kinds, wrong tuple, absent proof, committed landing, stale CAS, a sequence bump during fence verification, an escaped grant, participant removal, parks, partial-return scope, record-first landing and restoring recovery, and replay. No release marker acknowledges a park. |
| ### Failing tests |
| @@ -436,26 +700,37 @@ Expected first failure: `DeclareMoveTargetFence` is undefined. Test both permitt |
| ```diff |
| --- /dev/null |
| +++ b/internal/hostclaim/plan12_fence_test.go |
| -@@ -0,0 +1,72 @@ |
| +@@ -0,0 +1,113 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "errors" |
| + "reflect" |
| ++ "strconv" |
| + "testing" |
| ++ "time" |
| +) |
| + |
| -+func TestPlan12TargetFenceDeclaration(t *testing.T) { |
| ++func TestPlan12TargetFenceRelease(t *testing.T) { |
| + for _, kind := range []string{KindLanding, KindRestoring} { |
| -+ for _, mode := range []string{"ok", "wrong-op", "wrong-target", "wrong-enrollment", "wrong-region", "unverified", "stale", "committed"} { |
| ++ for _, mode := range []string{"ok", "wrong-op", "wrong-target", "wrong-enrollment", "wrong-region", "unverified", "zero-proof", "stale", "committed", "partial-return"} { |
| + t.Run(kind+"/"+mode, func(t *testing.T) { |
| + s, o, ref, a := c7Fixture(t) |
| + var err error |
| + o, err = Update(t.Context(), s, o, func(c *Claim) error { |
| -+ c.Op.Kind, c.Op.Phase = kind, PhaseProbing |
| ++ c.Obligations = nil |
| ++ c.Op.Kind, c.Op.Phase, c.Op.Exec = kind, PhaseProbing, "adi-knitting" |
| ++ c.Op.Participants = []string{"bri-knitting", "adi-knitting"} |
| + c.Op.Intent["target"], c.Op.Intent["source"], c.Op.Intent["to"], c.Op.Intent["target_enrollment"] = "adi-knitting", "bri-knitting", "ie", ref.Enrollment |
| ++ setJournal(c, JournalSourceStamp, "1") |
| ++ setJournal(c, JournalSrcGen, "src") |
| ++ if kind == KindRestoring { |
| ++ c.Op.Phase = PhaseReleasing |
| ++ c.Op.Intent["rollback"] = lostRB |
| ++ setJournal(c, JournalTargetStamp, strconv.FormatUint(c.Holder.Stamp, 10)) |
| ++ } |
| + if mode == "wrong-target" { |
| -+ c.Op.Intent["target"] = "elsewhere" |
| ++ c.Op.Intent["target"] = "other" |
| + } |
| + if mode == "wrong-enrollment" { |
| + c.Op.Intent["target_enrollment"] = "bad" |
| @@ -471,22 +746,36 @@ Expected first failure: `DeclareMoveTargetFence` is undefined. Test both permitt |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| -+ op := o.Claim.Op.ID |
| -+ if mode == "wrong-op" { |
| -+ op = "different" |
| -+ } |
| + if mode == "unverified" { |
| + a.err = ErrFence |
| + } |
| ++ f, err := VerifyMoveTargetFence(t.Context(), s, o, ref, a) |
| ++ n := 0 |
| ++ scope := func(loc string) ParkScope { |
| ++ return ParkScope{Location: loc, Incarnation: o.Claim.Holder.Incarnation, EvidenceAt: time.Now().UTC(), RequestLimit: 2 * time.Minute} |
| ++ } |
| ++ r := FencedTargetRelease{OpID: o.Claim.Op.ID, RollbackID: lostRB, Target: "adi-knitting", TargetRegion: "ie", SourceRegion: "uk", Location: scope(o.Claim.Holder.Location), Lane: scope("moves/" + o.Host + "/" + o.Claim.Op.ID), Fence: f, NewID: func() string { n++; return "fence-" + strconv.Itoa(n) }} |
| ++ if mode == "wrong-op" { |
| ++ r.OpID = "other" |
| ++ } |
| ++ if mode == "zero-proof" { |
| ++ r.Fence = ReturnFence{} |
| ++ } |
| ++ if mode == "partial-return" { |
| ++ lane := scope("moves/" + o.Host + "/" + lostRB) |
| ++ r.ReturnLane = &lane |
| ++ } |
| + if mode == "stale" { |
| -+ _, err = Update(t.Context(), s, o, func(*Claim) error { return nil }) |
| -+ if err != nil { |
| -+ t.Fatal(err) |
| ++ if _, e := Update(t.Context(), s, o, func(*Claim) error { return nil }); e != nil { |
| ++ t.Fatal(e) |
| + } |
| + } |
| + s.puts = 0 |
| -+ got, err := DeclareMoveTargetFence(t.Context(), s, o, op, ref, a) |
| -+ if mode != "ok" { |
| ++ var got Observed |
| ++ if err == nil { |
| ++ got, err = Update(t.Context(), s, o, ReleaseFencedTarget(r)) |
| ++ } |
| ++ if mode != "ok" && mode != "partial-return" { |
| + if err == nil { |
| + t.Fatal("accepted", mode) |
| + } |
| @@ -498,17 +787,165 @@ Expected first failure: `DeclareMoveTargetFence` is undefined. Test both permitt |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| -+ if s.puts != 1 || got.Claim.Op.Journal["fence.adi-knitting"] == "" || !reflect.DeepEqual(o.Claim.Holder, got.Claim.Holder) || !reflect.DeepEqual(o.Claim.Obligations, got.Claim.Obligations) || got.Claim.Op.Exec != o.Claim.Op.Exec || got.Claim.Op.Phase != o.Claim.Op.Phase { |
| -+ t.Fatal("unexpected effects") |
| ++ if s.puts != 1 || got.Claim.Op.Phase != PhaseReleased || got.Claim.Op.Exec != "none" || stringPresent(got.Claim.Op.Participants, r.Target) || len(got.Claim.Obligations) < 2 { |
| ++ t.Fatal(got.Claim) |
| + } |
| -+ again, err := DeclareMoveTargetFence(t.Context(), s, got, op, ref, a) |
| -+ if err != nil || again.ETag != got.ETag || s.puts != 1 { |
| ++ clone, _ := cloneClaim(got.Claim) |
| ++ if err := ReleaseFencedTarget(r)(clone); err != nil || !reflect.DeepEqual(clone, got.Claim) { |
| + t.Fatal("replay", err) |
| + } |
| ++ if err := ReturnToSource(r.OpID, "bri-knitting", false, ReturnFence{})(clone); err != nil { |
| ++ t.Fatal("source needs fence authority", err) |
| ++ } |
| + }) |
| + } |
| + } |
| +} |
| ++ |
| ++func TestPlan12FenceRefusesCleanCoverage(t *testing.T) { |
| ++ c := lostTargetClaim(t, KindRestoring, PhaseProbing) |
| ++ c.LossRefs = nil |
| ++ delete(c.Op.Journal, "loss."+lostID) |
| ++ c.Op.Exec = "ie-box" |
| ++ c.Op.Journal["fence.uk-box"] = "verified-in-release" |
| ++ h := c.Holder |
| ++ in := Admission{ObservedOpID: c.Op.ID, ObservedPhase: c.Op.Phase, ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: "ie-box", Mode: "cold", NewGeneration: "return", Region: "ie", PredecessorRegion: "uk", Coverage: "clean-return"} |
| ++ if err := AdmitWriter(in)(c); !errors.Is(err, ErrFence) { |
| ++ t.Fatal(err) |
| ++ } |
| ++} |
| +--- /dev/null |
| ++++ b/internal/hostops/plan12_fence_test.go |
| +@@ -0,0 +1,129 @@ |
| ++package hostops |
| ++ |
| ++import ( |
| ++ "context" |
| ++ "encoding/json" |
| ++ "errors" |
| ++ "github.com/carlosframework/platform/internal/hostclaim" |
| ++ "github.com/carlosframework/platform/internal/provision" |
| ++ "github.com/carlosframework/platform/internal/release" |
| ++ "github.com/carlosframework/platform/internal/termfence" |
| ++ "strings" |
| ++ "testing" |
| ++) |
| ++ |
| ++type plan12BumpFence struct { |
| ++ c7ReplayAuthority |
| ++ bump func() |
| ++} |
| ++ |
| ++func (a *plan12BumpFence) ValidateFence(ctx context.Context, f termfence.Record) (hostclaim.FenceBinding, error) { |
| ++ if a.bump != nil { |
| ++ fn := a.bump |
| ++ a.bump = nil |
| ++ fn() |
| ++ } |
| ++ return a.c7ReplayAuthority.ValidateFence(ctx, f) |
| ++} |
| ++func TestPlan12RecoveryFenceGrant(t *testing.T) { |
| ++ for _, mode := range []string{"ok", "from-landing", "from-restoring", "changed-sequence", "dirty", "escaped", "wrong-ref"} { |
| ++ t.Run(mode, func(t *testing.T) { |
| ++ w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { |
| ++ c.Op.Kind, c.Op.Phase, c.Op.Exec = hostclaim.KindLanding, hostclaim.PhaseProbing, "source" |
| ++ c.Op.Intent["source"], c.Op.Intent["target"], c.Op.Intent["to"], c.Op.Intent["target_enrollment"] = "target", "source", "ie", strings.Repeat("a", 32) |
| ++ c.Op.Participants = []string{"target", "source"} |
| ++ c.Obligations = nil |
| ++ c.Op.Journal = map[string]string{hostclaim.JournalSourceStamp: "1", hostclaim.JournalSrcGen: "src"} |
| ++ r.Region = "uk" |
| ++ r.Move.From, r.Move.To, r.Move.State = "uk", "ie", provision.MoveRollingBack |
| ++ at := r.CreatedAt |
| ++ r.Move.Rollback = &provision.MoveRollback{ID: "rb_abcdefghijklmnopqrstuvwxyz", Reason: provision.RollbackTargetFenced, Evidence: "control/box-fences/" + strings.Repeat("a", 32) + ".json", RequestedAt: &at, TargetPhase: provision.RbTargetLost, LossRef: "control/box-fences/" + strings.Repeat("a", 32) + ".json", LostAt: &at, SourcePhase: provision.RbSourceWaiting, Dirty: mode == "dirty"} |
| ++ if mode == "from-restoring" { |
| ++ c.Op.Kind, c.Op.Phase = hostclaim.KindRestoring, hostclaim.PhaseReleasing |
| ++ c.Op.Intent["rollback"] = r.Move.Rollback.ID |
| ++ c.Op.Journal[hostclaim.JournalTargetStamp] = "7" |
| ++ r.Move.Rollback.TargetPhase = provision.RbTargetReleasing |
| ++ r.Move.Rollback.LossRef = "" |
| ++ r.Move.Rollback.LostAt = nil |
| ++ r.Move.Rollback.Reason = provision.RollbackLandingDeadline |
| ++ } |
| ++ if mode == "from-landing" { |
| ++ r.Region = "ie" |
| ++ r.Move.State = provision.MoveLanding |
| ++ r.Move.Rollback = nil |
| ++ } |
| ++ }) |
| ++ authority := &plan12BumpFence{c7ReplayAuthority: w.authority} |
| ++ if mode == "changed-sequence" { |
| ++ authority.bump = func() { |
| ++ r, e := ReadRecord(t.Context(), w.raw, w.key) |
| ++ if e != nil { |
| ++ t.Fatal(e) |
| ++ } |
| ++ if _, _, e = provision.WriteRecord(provision.WithRecordSequence(t.Context()), w.raw, w.key, r.Body, r.Body, r.ETag); e != nil { |
| ++ t.Fatal(e) |
| ++ } |
| ++ } |
| ++ } |
| ++ ref := w.ref |
| ++ if mode == "wrong-ref" { |
| ++ ref.SHA256 = strings.Repeat("e", 64) |
| ++ } |
| ++ var escaped *MoveRecoveryGrant |
| ++ run := func() error { |
| ++ return w.control.RunRecovery(t.Context(), func(ctx context.Context) error { |
| ++ return runMoveRecovery(ctx, w.claim(t), func(ctx context.Context, g *MoveRecoveryGrant) error { |
| ++ if mode == "escaped" { |
| ++ escaped = g |
| ++ return nil |
| ++ } |
| ++ return g.ReleaseFencedTarget(ctx, ref) |
| ++ }, authority) |
| ++ }) |
| ++ } |
| ++ err := run() |
| ++ if mode == "escaped" { |
| ++ err = escaped.ReleaseFencedTarget(t.Context(), ref) |
| ++ } |
| ++ if mode != "ok" && mode != "from-landing" && mode != "from-restoring" { |
| ++ if err == nil { |
| ++ t.Fatal("accepted", mode) |
| ++ } |
| ++ if mode == "changed-sequence" && !errors.Is(err, release.ErrCASConflict) { |
| ++ t.Fatal(err) |
| ++ } |
| ++ return |
| ++ } |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if mode == "from-landing" || mode == "from-restoring" { |
| ++ if mode == "from-landing" && w.claim(t).Claim.Op.Kind != hostclaim.KindLanding { |
| ++ t.Fatal("record-first pass changed claim") |
| ++ } |
| ++ if err := run(); err != nil { |
| ++ t.Fatal("claim pass", err) |
| ++ } |
| ++ } |
| ++ o := w.claim(t) |
| ++ if o.Claim.Op.Phase != hostclaim.PhaseReleased || len(o.Claim.Op.Participants) != 1 || len(o.Claim.Obligations) != 2 { |
| ++ t.Fatal(o.Claim) |
| ++ } |
| ++ before, _ := json.Marshal(o.Claim.Obligations) |
| ++ if err := run(); err != nil { |
| ++ t.Fatal("replay", err) |
| ++ } |
| ++ after, _ := json.Marshal(w.claim(t).Claim.Obligations) |
| ++ if string(before) != string(after) { |
| ++ t.Fatal("requeued parks") |
| ++ } |
| ++ rec, err := provision.GetRecord(t.Context(), w.raw, "bab", "app", w.host) |
| ++ if err != nil || rec.Move.Rollback.TargetPhase != provision.RbTargetReleased || rec.Move.Rollback.Evidence != ref.Key { |
| ++ t.Fatal(rec, err) |
| ++ } |
| ++ if err := run(); err != nil { |
| ++ t.Fatal("completed replay", err) |
| ++ } |
| ++ }) |
| ++ } |
| ++} |
| ``` |
| ### Implementation |
| @@ -517,7 +954,7 @@ Expected first failure: `DeclareMoveTargetFence` is undefined. Test both permitt |
| ```diff |
| --- /dev/null |
| +++ b/internal/hostclaim/move_target_fence.go |
| -@@ -0,0 +1,47 @@ |
| +@@ -0,0 +1,81 @@ |
| +package hostclaim |
| + |
| +import ( |
| @@ -526,56 +963,504 @@ Expected first failure: `DeclareMoveTargetFence` is undefined. Test both permitt |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| -+// DeclareMoveTargetFence records evidence only. It does not acknowledge a |
| -+// park, transfer execution or advance the move record. ReturnToSource and |
| -+// the recovery owner still have to queue their retirement work atomically. |
| -+func DeclareMoveTargetFence(ctx context.Context, s release.Store, o Observed, opID string, ref FenceRef, authority FenceAuthority) (Observed, error) { |
| -+ if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.ETag == "" || opID == "" || o.Claim.Op.ID != opID { |
| -+ return Observed{}, ErrStaleOp |
| ++const journalFencedTargetReleased = "fenced_target_released" |
| ++ |
| ++// VerifyMoveTargetFence supplies a target-bound capability without writing the claim. |
| ++func VerifyMoveTargetFence(ctx context.Context, s release.Store, o Observed, ref FenceRef, authority FenceAuthority) (ReturnFence, error) { |
| ++ if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.ETag == "" { |
| ++ return ReturnFence{}, ErrStaleOp |
| + } |
| + c := o.Claim |
| -+ if (c.Op.Kind != KindLanding && c.Op.Kind != KindRestoring) || c.Op.Phase == PhaseCommitted || c.Op.Intent["target"] == "" || c.Op.Intent["target"] == c.Op.Intent["source"] || c.Op.Intent["target_enrollment"] != ref.Enrollment { |
| -+ return Observed{}, ErrWrongState |
| ++ if (c.Op.Kind != KindLanding && c.Op.Kind != KindRestoring) || c.Op.Phase == PhaseCommitted || c.Op.ID == "" || c.Op.Intent["target"] == c.Op.Intent["source"] || c.Op.Intent["target_enrollment"] != ref.Enrollment { |
| ++ return ReturnFence{}, ErrWrongState |
| + } |
| -+ fence, err := VerifyFence(ctx, s, authority, ref) |
| ++ f, err := VerifyFence(ctx, s, authority, ref) |
| + if err != nil { |
| -+ return Observed{}, err |
| ++ return ReturnFence{}, err |
| + } |
| -+ if fence.Box() != c.Op.Intent["target"] || fence.Region() != c.Op.Intent["to"] { |
| -+ return Observed{}, ErrFence |
| ++ if f.Box() != c.Op.Intent["target"] || f.Region() != c.Op.Intent["to"] { |
| ++ return ReturnFence{}, ErrFence |
| + } |
| + raw, err := json.Marshal(ref) |
| + if err != nil { |
| -+ return Observed{}, err |
| ++ return ReturnFence{}, err |
| ++ } |
| ++ return ReturnFence{op: c.Op.ID, box: f.Box(), ref: string(raw)}, nil |
| ++} |
| ++ |
| ++// FencedTargetRelease has the same scopes as loss release, with verified fence authority. |
| ++type FencedTargetRelease struct { |
| ++ OpID, RollbackID, Target, TargetRegion, SourceRegion string |
| ++ Location, Lane ParkScope |
| ++ ReturnLane *ParkScope |
| ++ NewID func() string |
| ++ Fence ReturnFence |
| ++} |
| ++ |
| ++// ReleaseFencedTarget declares and consumes the fence in one CAS, including all parks. |
| ++func ReleaseFencedTarget(r FencedTargetRelease) func(*Claim) error { |
| ++ return func(c *Claim) error { |
| ++ if err := RequireOp(r.OpID)(c); err != nil { |
| ++ return err |
| ++ } |
| ++ if c.Holder == nil || !rollbackIDShape.MatchString(r.RollbackID) || r.Target == "" || r.NewID == nil || r.SourceRegion == "" || r.SourceRegion == r.TargetRegion { |
| ++ return ErrMalformed |
| ++ } |
| ++ f := r.Fence |
| ++ if f.op != r.OpID || f.box != r.Target || f.ref == "" || c.Op.Intent["target"] != r.Target || c.Op.Intent["source"] == r.Target || c.Op.Intent["to"] != r.TargetRegion { |
| ++ return ErrFence |
| ++ } |
| ++ var ref FenceRef |
| ++ if json.Unmarshal([]byte(f.ref), &ref) != nil || c.Op.Intent["target_enrollment"] != ref.Enrollment { |
| ++ return ErrFence |
| ++ } |
| ++ if old := c.Op.Journal["fence."+r.Target]; old != "" && old != f.ref { |
| ++ return ErrFence |
| ++ } |
| ++ if c.Op.Kind == KindRestoring && c.Op.Intent["rollback"] == r.RollbackID && c.Op.Journal[journalFencedTargetReleased] == f.ref { |
| ++ return nil |
| ++ } |
| ++ if c.Op.Exec != r.Target && c.Op.Exec != "" && c.Op.Exec != "none" { |
| ++ return ErrWrongState |
| ++ } |
| ++ return inOneWrite(c, func(c *Claim) error { |
| ++ setJournal(c, "fence."+r.Target, f.ref) |
| ++ c.Op.Exec = "none" |
| ++ c.Op.Participants = removeString(c.Op.Participants, r.Target) |
| ++ return releaseTargetParks(c, LostTargetRelease{OpID: r.OpID, RollbackID: r.RollbackID, Target: r.Target, TargetRegion: r.TargetRegion, SourceRegion: r.SourceRegion, Location: r.Location, Lane: r.Lane, ReturnLane: r.ReturnLane, NewID: r.NewID}) |
| ++ }, func(c *Claim) error { setJournal(c, journalFencedTargetReleased, f.ref); return nil }) |
| ++ } |
| ++} |
| ++ |
| ++// FencedTargetReleased is the durable marker, checked without creating another CAS. |
| ++func FencedTargetReleased(c *Claim, fence ReturnFence) bool { |
| ++ return c != nil && c.Op.Kind == KindRestoring && c.Op.ID == fence.op && c.Op.Intent["target"] == fence.box && fence.ref != "" && c.Op.Journal["fence."+fence.box] == fence.ref && c.Op.Journal[journalFencedTargetReleased] == fence.ref |
| ++} |
| +--- /dev/null |
| ++++ b/internal/hostops/move_fence_release.go |
| +@@ -0,0 +1,112 @@ |
| ++package hostops |
| ++ |
| ++import ( |
| ++ "context" |
| ++ "crypto/rand" |
| ++ "github.com/carlosframework/platform/internal/hostclaim" |
| ++ "github.com/carlosframework/platform/internal/moves" |
| ++ "github.com/carlosframework/platform/internal/provision" |
| ++ "github.com/carlosframework/platform/internal/release" |
| ++) |
| ++ |
| ++// ReleaseFencedTarget advances one record-first recovery step per grant: |
| ++// record decision, claim release with parks, then record release. Each pass |
| ++// verifies the fence again; the source continues to gate on the claim phase. |
| ++func (g *MoveRecoveryGrant) ReleaseFencedTarget(ctx context.Context, ref hostclaim.FenceRef) error { |
| ++ if g == nil || g.state == nil { |
| ++ return ErrPending |
| ++ } |
| ++ g.state.mu.Lock() |
| ++ defer g.state.mu.Unlock() |
| ++ o, record, rec, err := g.current(ctx) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ |
| ++ m := rec.Move |
| ++ if m == nil || m.ID != g.o.OpID || m.Incarnation != g.o.Incarnation { |
| ++ return ErrIdentity |
| ++ } |
| ++ fence, err := hostclaim.VerifyMoveTargetFence(ctx, g.w.raw, o, ref, g.authority) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ guard := provision.MoveGuard{ID: m.ID, Incarnation: m.Incarnation} |
| ++ writeRecord := func() error { |
| ++ body, err := g.prepareRecord(ctx, rec) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ _, _, err = provision.WriteRecord(provision.WithRecordSequence(ctx), g.w.raw, record.Holder.Key, record.Body, body, record.ETag) |
| ++ return err |
| ++ } |
| ++ if m.State == provision.MoveLanding { |
| ++ if rec.Region != m.To { |
| ++ return ErrIdentity |
| ++ } |
| ++ id, err := moves.NewRollbackID(rand.Reader) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ if err := provision.MoveRollBack(guard, id, provision.RollbackTargetFenced, g.w.now())(&rec); err != nil { |
| ++ return err |
| ++ } |
| ++ } else if m.State != provision.MoveRollingBack || m.Rollback == nil || rec.Region != m.From { |
| ++ return ErrIdentity |
| ++ } |
| ++ rb := m.Rollback |
| ++ if rb.Dirty { |
| ++ return ErrPending |
| ++ } // J4: a dead dirty target cannot build a return lane. |
| ++ if hostclaim.FencedTargetReleased(o.Claim, fence) { |
| ++ if rb.LossRef != ref.Key { |
| ++ return ErrIdentity |
| ++ } |
| ++ if rb.TargetPhase == provision.RbTargetReleased && rb.Evidence == ref.Key { |
| ++ return nil |
| ++ } |
| ++ if err := provision.MoveRollbackLossCommitted(guard)(&rec); err != nil { |
| ++ return err |
| ++ } |
| ++ return writeRecord() |
| ++ } |
| ++ if rb.TargetPhase != provision.RbTargetLost || rb.LossRef != ref.Key { |
| ++ if err := provision.MoveRollbackTargetLost(guard, ref.Key, g.w.now())(&rec); err != nil { |
| ++ return err |
| ++ } |
| ++ rb.Reason = provision.RollbackTargetFenced |
| ++ return writeRecord() |
| ++ } |
| ++ if rb.Reason != provision.RollbackTargetFenced || rb.LostAt == nil || rb.LostAt.IsZero() { |
| ++ return ErrPending |
| ++ } |
| ++ h := o.Claim.Holder |
| ++ r := hostclaim.FencedTargetRelease{OpID: m.ID, RollbackID: rb.ID, Target: o.Claim.Op.Intent["target"], TargetRegion: m.To, SourceRegion: m.From, |
| ++ Location: moves.TargetLocationScope(*h, *rb.LostAt), Lane: moves.StagingScope(o.Host, m.ID, h.Incarnation, *rb.LostAt), NewID: moveLossNewID(ref.SHA256), Fence: fence} |
| ++ if err := CheckCreateFreeze(ctx, g.w.raw, g.w.pub); err != nil { |
| ++ return err |
| ++ } |
| ++ // Fence and roster reads may have raced a new record. Bind both ETag and sequence again. |
| ++ if _, _, _, err := g.current(ctx); err != nil { |
| ++ return err |
| ++ } |
| ++ g.state.active = false |
| ++ _, err = hostclaim.Update(ctx, g.w.raw, o, func(c *hostclaim.Claim) error { |
| ++ record, err := ReadRecord(ctx, g.w.raw, h.Key) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ _, seq, err := recordSequence(record.Body) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ if record.ETag != g.o.RecordETag || seq != g.o.RecordSeq { |
| ++ return release.ErrCASConflict |
| ++ } |
| ++ if err := moveAdmission(ctx, g.w.raw, o.Host, c); err != nil { |
| ++ return err |
| ++ } |
| ++ return hostclaim.ReleaseFencedTarget(r)(c) |
| ++ }) |
| ++ return err |
| ++} |
| +--- a/internal/provision/provision.go |
| ++++ b/internal/provision/provision.go |
| +@@ -1817,6 +1817,8 @@ |
| + // records (§7.10): the target box was declared lost before the move |
| + // committed. A recorded sentence, so it names no box; the box is in the |
| + // loss record LossRef names. |
| ++const RollbackTargetFenced = "target fenced" |
| ++ |
| + const RollbackDeclaredLost = "the target box was declared lost" |
| + |
| + // The reasons a move may record when it stops for an operator (§7.8 stuck, |
| +@@ -1940,6 +1942,8 @@ |
| + rb.Reason = RollbackCommitDeadline |
| + case RollbackProbeUnpriced: |
| + rb.Reason = RollbackProbeUnpriced |
| ++ case RollbackTargetFenced: |
| ++ rb.Reason = RollbackTargetFenced |
| + case RollbackDeclaredLost: |
| + rb.Reason = RollbackDeclaredLost |
| + default: |
| +@@ -1959,7 +1963,7 @@ |
| + func recordedRollbackReason(r string) bool { |
| + switch r { |
| + case RollbackConfigChanged, RollbackLandingDeadline, RollbackClaimWired, RollbackLandingLayout, RollbackInstallMismatch, |
| +- RollbackFilesOccupied, RollbackProbeAttempts, RollbackCommitDeadline, RollbackDeclaredLost, RollbackProbeUnpriced: |
| ++ RollbackFilesOccupied, RollbackProbeAttempts, RollbackCommitDeadline, RollbackDeclaredLost, RollbackTargetFenced, RollbackProbeUnpriced: |
| + return true |
| + } |
| + return false |
| +--- /dev/null |
| ++++ b/internal/host/claimmovefence.go |
| +@@ -0,0 +1,41 @@ |
| ++package host |
| ++ |
| ++import ( |
| ++ "context" |
| ++ "encoding/json" |
| ++ "github.com/carlosframework/platform/internal/hostbox" |
| ++ "github.com/carlosframework/platform/internal/hostclaim" |
| ++ "github.com/carlosframework/platform/internal/hostfence" |
| ++ "github.com/carlosframework/platform/internal/hostops" |
| ++ "github.com/carlosframework/platform/internal/release" |
| ++ "time" |
| ++) |
| ++ |
| ++func (p ClaimProviders) VerifyMoveTargetFence(ctx context.Context, o hostclaim.Observed) (hostclaim.ReturnFence, error) { |
| ++ if p.moveTargetFence == nil { |
| ++ return hostclaim.ReturnFence{}, hostclaim.ErrFence |
| + } |
| -+ key := "fence." + fence.Box() |
| -+ if old, ok := c.Op.Journal[key]; ok { |
| -+ if old != string(raw) { |
| -+ return Observed{}, ErrFence |
| ++ return p.moveTargetFence(ctx, o) |
| ++} |
| ++func claimBoxFenceVerifier(check func(context.Context) error, store release.Store, authority *hostops.ProductionAuthority) func(context.Context, hostclaim.Observed) (hostclaim.ReturnFence, error) { |
| ++ return func(ctx context.Context, o hostclaim.Observed) (hostclaim.ReturnFence, error) { |
| ++ if check == nil || store == nil || authority == nil || o.Claim == nil { |
| ++ return hostclaim.ReturnFence{}, hostclaim.ErrFence |
| ++ } |
| ++ if err := check(ctx); err != nil { |
| ++ return hostclaim.ReturnFence{}, err |
| + } |
| -+ // Replay still re-reads the signed roster, immutable fence and live claim. |
| -+ fresh, err := Read(ctx, s, o.Host) |
| ++ fresh, err := hostclaim.Read(ctx, store, o.Host) |
| + if err != nil { |
| -+ return Observed{}, err |
| ++ return hostclaim.ReturnFence{}, err |
| + } |
| + if fresh.ETag != o.ETag { |
| -+ return Observed{}, ErrConflict |
| ++ return hostclaim.ReturnFence{}, hostbox.ErrChanged |
| + } |
| -+ return fresh, nil |
| ++ var ref hostclaim.FenceRef |
| ++ if json.Unmarshal([]byte(o.Claim.Op.Journal["fence."+o.Claim.Op.Intent["target"]]), &ref) != nil { |
| ++ return hostclaim.ReturnFence{}, hostclaim.ErrFence |
| ++ } |
| ++ return hostclaim.VerifyMoveTargetFence(ctx, store, o, ref, hostfence.ClaimFenceAuthority{Directory: authority.Directory, Now: time.Now}) |
| + } |
| -+ return Update(ctx, s, o, func(next *Claim) error { setJournal(next, key, string(raw)); return nil }) |
| +} |
| +--- a/internal/host/claimwiring.go |
| ++++ b/internal/host/claimwiring.go |
| +@@ -7,6 +7,7 @@ |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| ++ "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/schedule" |
| +@@ -19,14 +20,15 @@ |
| + } |
| + |
| + type ClaimProviders struct { |
| +- moveParks *claimMoveParks |
| +- Delivery schedule.DeliveryAdmission |
| +- Selector hostwrite.Selector |
| +- Wake *activator.ClaimRuntime |
| +- Regional RegionalLoop |
| +- Stop *ClaimStopCoordinator |
| +- Provision func(*provision.ReconcileConfig) |
| +- Close func(context.Context) error |
| ++ moveTargetFence func(context.Context, hostclaim.Observed) (hostclaim.ReturnFence, error) |
| ++ moveParks *claimMoveParks |
| ++ Delivery schedule.DeliveryAdmission |
| ++ Selector hostwrite.Selector |
| ++ Wake *activator.ClaimRuntime |
| ++ Regional RegionalLoop |
| ++ Stop *ClaimStopCoordinator |
| ++ Provision func(*provision.ReconcileConfig) |
| ++ Close func(context.Context) error |
| + } |
| + type ClaimFactory func(context.Context) (ClaimProviders, error) |
| + |
| +--- a/internal/hostclaim/move_loss.go |
| ++++ b/internal/hostclaim/move_loss.go |
| +@@ -68,88 +68,7 @@ |
| + stringPresent(c.Op.Participants, r.Target) || c.Op.Exec == r.Target { |
| + return fmt.Errorf("%w: %q is not this move's declared-lost target", ErrWrongState, r.Target) |
| + } |
| +- location := r.Location |
| +- if err := location.Validate(c.Host); err != nil { |
| +- return err |
| +- } |
| +- lane, err := parkQueueGuard(c, r.OpID, r.TargetRegion, r.Lane, r.NewID) |
| +- if err != nil { |
| +- return err |
| +- } |
| +- // The location park's scope is checked against holder.location by |
| +- // QueueLocationPark; the lane is this move's own. |
| +- if err := requireHolderLocation(c, r.Location); err != nil { |
| +- return err |
| +- } |
| +- if lane.Location != "moves/"+c.Host+"/"+r.OpID { |
| +- return fmt.Errorf("%w: a lost target's lane park is the move's own lane", ErrMalformed) |
| +- } |
| +- var returnLane *ParkScope |
| +- if r.ReturnLane != nil { |
| +- s, err := parkQueueGuard(c, r.OpID, r.SourceRegion, *r.ReturnLane, r.NewID) |
| +- if err != nil { |
| +- return err |
| +- } |
| +- returnLane = &s |
| +- if s.Location != "moves/"+c.Host+"/"+r.RollbackID { |
| +- return fmt.Errorf("%w: the return lane is this rollback's own lane", ErrMalformed) |
| +- } |
| +- } |
| +- steps := []func(*Claim) error{} |
| +- switch { |
| +- case c.Op.Kind == KindLanding && c.Op.Phase != PhaseCommitted: |
| +- steps = append(steps, RollBack(r.OpID, r.RollbackID)) |
| +- case c.Op.Kind == KindRestoring && c.Op.Intent["rollback"] == r.RollbackID: |
| +- default: |
| +- return fmt.Errorf("%w: op is %s/%q, a lost target is released from a landing before its commit or from its own rollback", ErrWrongState, c.Op.Kind, c.Op.Phase) |
| +- } |
| +- steps = append(steps, func(c *Claim) error { |
| +- switch c.Op.Phase { |
| +- case PhaseReleasing: |
| +- if c.Op.Exec != "" && c.Op.Exec != "none" { |
| +- return fmt.Errorf("%w: exec is %q", ErrWrongState, c.Op.Exec) |
| +- } |
| +- c.Op.Phase = PhaseReleased |
| +- parkBox := "" |
| +- if stamp, err := strconv.ParseUint(c.Op.Journal[JournalTargetStamp], 10, 64); err == nil && InventoryHasStamp(c, r.Target, stamp) { |
| +- parkBox = r.Target |
| +- } |
| +- if err := QueueLocationPark(r.OpID, r.TargetRegion, parkBox, r.Location, r.NewID)(c); err != nil { |
| +- return err |
| +- } |
| +- return CoverRegionPredecessors(r.OpID, PhaseReleased, r.TargetRegion)(c) |
| +- case PhaseReleased: |
| +- // The target released itself before it was lost: its release |
| +- // queued the location park and covered the region. |
| +- if c.Op.Journal[JournalEvidenceTarget] == "" { |
| +- return fmt.Errorf("%w: released without the target's evidence", ErrWrongState) |
| +- } |
| +- if returnLane == nil { |
| +- return fmt.Errorf("%w: a dirty release that died before its return owes the return lane's park", ErrMalformed) |
| +- } |
| +- return nil |
| +- } |
| +- return fmt.Errorf("%w: op is restoring/%q, the lost target's release precedes the source's claim", ErrWrongState, c.Op.Phase) |
| +- }, func(c *Claim) error { |
| +- if owesStagingPark(c, r.OpID, r.TargetRegion) { |
| +- return nil |
| +- } |
| +- _, err := queueReplicaPark(c, r.OpID, r.TargetRegion, lane, r.NewID) |
| +- return err |
| +- }, func(c *Claim) error { |
| +- if returnLane == nil { |
| +- return nil |
| +- } |
| +- if _, err := queueReplicaPark(c, r.OpID, r.SourceRegion, *returnLane, r.NewID); err != nil { |
| +- return err |
| +- } |
| +- setJournal(c, journalReturnLane, returnLane.Location) |
| +- return nil |
| +- }, func(c *Claim) error { |
| +- setJournal(c, journalLostTargetReleased, r.LossID) |
| +- return nil |
| +- }) |
| +- return inOneWrite(c, steps...) |
| ++ return inOneWrite(c, func(c *Claim) error { return releaseTargetParks(c, r) }, func(c *Claim) error { setJournal(c, journalLostTargetReleased, r.LossID); return nil }) |
| + } |
| + } |
| + |
| +@@ -171,3 +90,85 @@ |
| + func LostTargetReleased(c *Claim, lossID string) bool { |
| + return c != nil && lossID != "" && c.Op.Kind == KindRestoring && c.Op.Journal[journalLostTargetReleased] == lossID |
| + } |
| ++ |
| ++func releaseTargetParks(c *Claim, r LostTargetRelease) error { |
| ++ location := r.Location |
| ++ if err := location.Validate(c.Host); err != nil { |
| ++ return err |
| ++ } |
| ++ lane, err := parkQueueGuard(c, r.OpID, r.TargetRegion, r.Lane, r.NewID) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ // The location park's scope is checked against holder.location by |
| ++ // QueueLocationPark; the lane is this move's own. |
| ++ if err := requireHolderLocation(c, r.Location); err != nil { |
| ++ return err |
| ++ } |
| ++ if lane.Location != "moves/"+c.Host+"/"+r.OpID { |
| ++ return fmt.Errorf("%w: a lost target's lane park is the move's own lane", ErrMalformed) |
| ++ } |
| ++ var returnLane *ParkScope |
| ++ if r.ReturnLane != nil { |
| ++ s, err := parkQueueGuard(c, r.OpID, r.SourceRegion, *r.ReturnLane, r.NewID) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ returnLane = &s |
| ++ if s.Location != "moves/"+c.Host+"/"+r.RollbackID { |
| ++ return fmt.Errorf("%w: the return lane is this rollback's own lane", ErrMalformed) |
| ++ } |
| ++ } |
| ++ steps := []func(*Claim) error{} |
| ++ switch { |
| ++ case c.Op.Kind == KindLanding && c.Op.Phase != PhaseCommitted: |
| ++ steps = append(steps, RollBack(r.OpID, r.RollbackID)) |
| ++ case c.Op.Kind == KindRestoring && c.Op.Intent["rollback"] == r.RollbackID: |
| ++ default: |
| ++ return fmt.Errorf("%w: op is %s/%q, a lost target is released from a landing before its commit or from its own rollback", ErrWrongState, c.Op.Kind, c.Op.Phase) |
| ++ } |
| ++ steps = append(steps, func(c *Claim) error { |
| ++ switch c.Op.Phase { |
| ++ case PhaseReleasing: |
| ++ if c.Op.Exec != "" && c.Op.Exec != "none" { |
| ++ return fmt.Errorf("%w: exec is %q", ErrWrongState, c.Op.Exec) |
| ++ } |
| ++ c.Op.Phase = PhaseReleased |
| ++ parkBox := "" |
| ++ if stamp, err := strconv.ParseUint(c.Op.Journal[JournalTargetStamp], 10, 64); err == nil && InventoryHasStamp(c, r.Target, stamp) { |
| ++ parkBox = r.Target |
| ++ } |
| ++ if err := QueueLocationPark(r.OpID, r.TargetRegion, parkBox, r.Location, r.NewID)(c); err != nil { |
| ++ return err |
| ++ } |
| ++ return CoverRegionPredecessors(r.OpID, PhaseReleased, r.TargetRegion)(c) |
| ++ case PhaseReleased: |
| ++ // The target released itself before it was lost: its release |
| ++ // queued the location park and covered the region. |
| ++ if c.Op.Journal[JournalEvidenceTarget] == "" { |
| ++ return fmt.Errorf("%w: released without the target's evidence", ErrWrongState) |
| ++ } |
| ++ if returnLane == nil { |
| ++ return fmt.Errorf("%w: a dirty release that died before its return owes the return lane's park", ErrMalformed) |
| ++ } |
| ++ return nil |
| ++ } |
| ++ return fmt.Errorf("%w: op is restoring/%q, the lost target's release precedes the source's claim", ErrWrongState, c.Op.Phase) |
| ++ }, func(c *Claim) error { |
| ++ if owesStagingPark(c, r.OpID, r.TargetRegion) { |
| ++ return nil |
| ++ } |
| ++ _, err := queueReplicaPark(c, r.OpID, r.TargetRegion, lane, r.NewID) |
| ++ return err |
| ++ }, func(c *Claim) error { |
| ++ if returnLane == nil { |
| ++ return nil |
| ++ } |
| ++ if _, err := queueReplicaPark(c, r.OpID, r.SourceRegion, *returnLane, r.NewID); err != nil { |
| ++ return err |
| ++ } |
| ++ setJournal(c, journalReturnLane, returnLane.Location) |
| ++ return nil |
| ++ }) |
| ++ return inOneWrite(c, steps...) |
| ++} |
| +--- a/internal/hostclaim/move.go |
| ++++ b/internal/hostclaim/move.go |
| +@@ -959,6 +959,9 @@ |
| + return true |
| + } |
| + if !stringPresent(c.Op.Participants, target) { |
| ++ if ref := c.Op.Journal["fence."+target]; ref != "" && c.Op.Journal[journalFencedTargetReleased] == ref { |
| ++ return true |
| ++ } |
| + for _, id := range c.LossRefs { |
| + if _, ok := c.Op.Journal["loss."+id]; ok { |
| + return true |
| ``` |
| -## Task C: Hand a remote target predecessor back to recovery (M3, J2(b)) |
| +## Task C: Hand recovery over and restore the source database (M3, M21) |
| -Replace the Plan 10 landing-only regression. Recovery kinds dispatch to cross-box evidence before lease acquisition and again after admission. The normal stop form is an explicit moves-supplied signature verifier because the current `StoppedReport` has neither a signature nor a writer-session field. Loss and fence forms are read and verified by the claim code itself. |
| +Use `claimRecoveryObservation` alongside the existing string-valued `claimRecoveryKind`. Remote dispatch accepts only restoring whose predecessor is the nonempty target writer. Every revert and never-admitted holder takes the local path. |
| -The real wake-assembly admission regression exercises `stopPredecessor`, `wireClaimAdmission` and `runtime.Admit`. It stands in for the S3 lease with the existing fixture, while using the real guard and launch journal. It does not prove moves' owner implementation, C8 files, privileged stops or production activation; those remain moves acceptance gates. |
| +`TargetReleased` journals `target_session` and `target_generation` from the holder in its own authority-releasing CAS; it invents neither for a never-admitted target. Recovery checks those fields and the complete ordinary report using `moves.StoppedKey`. Signed root receipts remain an alternative with exact tuple, digest and enrolled signing-key checks. The operation's signed loss and freshly verified fence remain alternatives. |
| -Focused command: `go test ./internal/host -run 'TestPlan12Recovery|TestPlan9D2CrossBox|TestPlan10J2|TestPlan11.*Relocation' -count=1`. |
| +Extend the claim's guarded artifact retirement to restoring and reverting. The first source return uses `JournalSourceStamp`, `src_gen`, cut session and complete source report. Revert journals its source stamp and generation before returning authority. Later source sessions use completed root writer-stop receipts; add their stop authority alongside landing and relocation. Inventory and descriptor guards still select the exact database and marker. |
| -Expected first failure: the new target-stop verifier type is absent; after its API exists, the old routing and fresh-stamp check would still refuse recovery. Evidence cases include signed stop, signed loss and verified fence, plus wrong session, wrong operation and unverified fence. Keep the separate landing artifact-retirement test: J2(d)'s S-tagged local database is moves' responsibility and is not silently retired here. |
| +Focused command: `go test ./internal/host ./internal/hostclaim ./internal/hostguard -run 'TestPlan12Recovery|TestPlan12TargetReleased|TestPlan9D2CrossBox|TestPlan9D4|TestPlan10J2|TestPlan11.*Relocation' -count=1`. |
| + |
| +Expected first failure: the target journal constants and recovery APIs are absent. Tests accept target journal, enrolled root receipt, signed loss and verified fence and refuse mismatched sessions, stamps, operations, enrollments, signatures and unverified fences. The Linux acceptance test runs the real wake assembly through admission, Prepare and Restore over source-stamped database files, for both recovery kinds, including clean-return coverage with the holder generation rewound to src_gen and a target predecessor admission. Its transport, lease and process kernel are fixtures; the revert case starts after an already completed source cut and stubs that local stop. It is not a live moves or privileged-kernel test. |
| ### Failing tests |
| @@ -583,10 +1468,26 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| ```diff |
| --- a/internal/host/plan10_j2_test.go |
| +++ b/internal/host/plan10_j2_test.go |
| -@@ -320,36 +320,6 @@ |
| - } |
| - } |
| - |
| +@@ -303,7 +303,7 @@ |
| + // The recovery's local artifact retirement stays fail-closed: the landing's |
| + // retire-artifacts is bound to the landing writer (hostguard.LandingWriter), |
| + // and a recovery op is not one. Recover has nothing to re-drive for it. |
| +-func TestPlan10J2ArtifactRetirementStaysLandingOnly(t *testing.T) { |
| ++func TestPlan10J2ArtifactRetirementRefusesUnadmittedRecovery(t *testing.T) { |
| + f := newRecoveryFixture(t) |
| + dataDir := f.guard.DataDir |
| + r := claimArtifactRetirement{Local: park.Local{Claims: f.claims, Box: "box-1", DataDir: dataDir}, Continue: hostclaim.SessionContinues} |
| +@@ -314,38 +314,8 @@ |
| + if err := r.Retire(t.Context(), o, in, "m1"); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("%s retire: %v", kind, err) |
| + } |
| +- if err := r.Recover(t.Context(), o, in); err != nil { |
| ++ if err := r.Recover(t.Context(), o, in); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("%s recover: %v", kind, err) |
| +- } |
| +- } |
| +-} |
| +- |
| -// Cross-box handover and predecessor evidence stay landing-only: a recovery |
| -// op whose previous writer ran on another box goes to the local path, which |
| -// needs this box's own completed fence receipt. |
| @@ -613,16 +1514,12 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| - w.BeforeAdmission = claimPredecessorStop(w, "box-1", func(context.Context, hostclaim.Observed) error { remote++; return nil }) |
| - if err := w.stopPredecessor(t.Context(), prior); err != nil || localStops != 1 || remote != 0 { |
| - t.Fatalf("%s predecessor stop: local=%d remote=%d %v", kind, localStops, remote, err) |
| -- } |
| -- } |
| --} |
| -- |
| - // A clean return records its current inputs at probing before the held |
| - // render and launch. No configured phase is needed. The adapter must call |
| - // recoveryProbeRequest.Record first; movesrecoveryprobe.go supplies the adapter. |
| + } |
| + } |
| + } |
| --- /dev/null |
| +++ b/internal/host/plan12_handover_test.go |
| -@@ -0,0 +1,160 @@ |
| +@@ -0,0 +1,173 @@ |
| +package host |
| + |
| +import ( |
| @@ -633,6 +1530,7 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| + "encoding/json" |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| ++ "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/termfence" |
| @@ -654,8 +1552,11 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| + t.Helper() |
| + f := newRecoveryFixture(t) |
| + o := f.read() |
| -+ key := "moves/bab/jam/" + o.Host + "/m1/stopped-target.json" |
| -+ journal := map[string]string{hostclaim.JournalEvidenceTarget: key} |
| ++ key := moves.StoppedKey(moves.ReportPrefix("bab", "jam", o.Host, "m1"), "target") |
| ++ journal := map[string]string{hostclaim.JournalEvidenceTarget: key, hostclaim.JournalTargetSession: "2", hostclaim.JournalTargetGeneration: "target-gen"} |
| ++ if mode == "wrong-session" { |
| ++ journal[hostclaim.JournalTargetSession] = "3" |
| ++ } |
| + pub := f.signer.Public().(ed25519.PublicKey) |
| + h := claimCrossBoxHandover{Guard: f.guard, Evidence: f.claims, Box: "box-1", PublicKey: pub, FenceAuthority: plan12FenceAuthority{reject: mode == "unverified-fence"}} |
| + if mode == "fence" || mode == "unverified-fence" { |
| @@ -692,29 +1593,18 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| + if err = f.guard.Activate(t.Context(), o.Host, "box-1", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| -+ proof := ClaimTargetStop{Host: o.Host, OpID: "m1", Box: "box-2", Incarnation: o.Claim.Holder.Incarnation, Generation: "target-gen", Session: 2} |
| -+ if mode == "wrong-session" { |
| -+ proof.Session++ |
| -+ } |
| ++ report := plan12Stopped(o, "target", "box-2") |
| + if mode == "wrong-op" { |
| -+ proof.OpID = "another" |
| ++ report.OpID = "another" |
| + } |
| -+ raw, err := release.SignRootEnvelope(release.RootSigner(f.signer), proof) |
| ++ raw, err := json.Marshal(report) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = f.claims.Put(t.Context(), key, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| -+ h.TargetStop = func(ctx context.Context, _ hostclaim.Observed, key string) (ClaimTargetStop, error) { |
| -+ raw, _, err := f.claims.Get(ctx, key) |
| -+ if err != nil { |
| -+ return ClaimTargetStop{}, err |
| -+ } |
| -+ var p ClaimTargetStop |
| -+ err = release.VerifyRootEnvelope(pub, raw, &p) |
| -+ return p, err |
| -+ } |
| ++ |
| + if mode == "loss" || mode == "wrong-loss-op" { |
| + now := time.Now().UTC() |
| + r := hostclaim.LossRecord{V: 1, ID: "loss_1", State: "prepared", Actor: "operator", AcceptedAt: now, Host: o.Host, HolderIncarnation: o.Claim.Holder.Incarnation, HolderStamp: o.Claim.Holder.Stamp, HolderSession: 2, ExpectedClaimGen: o.Claim.Gen, ExpectedClaimETag: o.ETag, OpID: "m1", OpKind: hostclaim.KindLanding, OpPhase: "probing", Box: "box-2", Enrollment: "enroll", QuarantineRevision: 1, |
| @@ -735,7 +1625,7 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| + return f, o, h |
| +} |
| +func TestPlan12RecoveryPredecessorEvidence(t *testing.T) { |
| -+ for _, kind := range []string{hostclaim.KindRestoring, hostclaim.KindReverting} { |
| ++ for _, kind := range []string{hostclaim.KindRestoring} { |
| + for _, mode := range []string{"stop", "loss", "fence", "wrong-session", "wrong-op", "wrong-loss-op", "unverified-fence"} { |
| + t.Run(kind+"/"+mode, func(t *testing.T) { |
| + _, o, h := plan12Handover(t, kind, mode) |
| @@ -752,7 +1642,7 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| + } |
| +} |
| +func TestPlan12RecoveryAdmissionAfterTargetSession(t *testing.T) { |
| -+ for _, kind := range []string{hostclaim.KindRestoring, hostclaim.KindReverting} { |
| ++ for _, kind := range []string{hostclaim.KindRestoring} { |
| + t.Run(kind, func(t *testing.T) { |
| + f, o, h := plan12Handover(t, kind, "stop") |
| + c := f.controller("boot-1", "controller-1", 4101, "start-1") |
| @@ -766,19 +1656,278 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| + if err := c.wake.stopPredecessor(t.Context(), o); err != nil { |
| + t.Fatal(err) |
| + } |
| -+ live, cancel := context.WithCancel(t.Context()) |
| -+ defer cancel() |
| -+ done := make(chan struct{}) |
| -+ go func() { <-live.Done(); close(done) }() |
| -+ c.wake.mu.Lock() |
| -+ c.wake.pending[o.Host] = &claimWakeAttempt{observed: o, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done} |
| -+ c.wake.mu.Unlock() |
| -+ got, err := c.runtime.Admit(t.Context(), registry.Route{Host: o.Host}, o, activator.StartPolicy{Probe: true}) |
| -+ if err != nil { |
| ++ live, cancel := context.WithCancel(t.Context()) |
| ++ defer cancel() |
| ++ done := make(chan struct{}) |
| ++ go func() { <-live.Done(); close(done) }() |
| ++ c.wake.mu.Lock() |
| ++ c.wake.pending[o.Host] = &claimWakeAttempt{observed: o, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done} |
| ++ c.wake.mu.Unlock() |
| ++ got, err := c.runtime.Admit(t.Context(), registry.Route{Host: o.Host}, o, activator.StartPolicy{Probe: true}) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if got.Claim.Holder.Session <= 2 || got.Claim.Holder.Stamp != got.Claim.Holder.Session || got.Claim.Holder.WriterBox != "box-1" { |
| ++ t.Fatal(got) |
| ++ } |
| ++ }) |
| ++ } |
| ++} |
| ++ |
| ++func plan12Stopped(o hostclaim.Observed, role, box string) moves.StoppedReport { |
| ++ return moves.StoppedReport{V: 1, Role: role, Box: box, Host: o.Host, OpID: o.Claim.Op.ID, Incarnation: o.Claim.Holder.Incarnation, ClaimGen: o.Claim.Gen, CheckedAt: time.Now().UTC(), Steps: map[string]moves.Check{moves.StepTree: {OK: true}, moves.StepStray: {OK: true}, moves.StepFDs: {OK: true}, moves.StepLease: {OK: true}, moves.StepReplica: {OK: true}}} |
| ++} |
| ++func TestPlan12RecoveryLocalRouting(t *testing.T) { |
| ++ for _, kind := range []string{hostclaim.KindRestoring, hostclaim.KindReverting} { |
| ++ for _, box := range []string{"", "box-1"} { |
| ++ o := plan10J2Observed(kind, hostclaim.PhaseProbing, "box-1") |
| ++ o.Claim.Holder = &hostclaim.Holder{WriterBox: box} |
| ++ if claimRemotePredecessor(o, "box-1") { |
| ++ t.Fatal(kind, box) |
| ++ } |
| ++ called := false |
| ++ hook := claimAdmissionHandover("box-1", func(context.Context, hostclaim.Observed, hostclaim.Observed) error { called = true; return nil }, func(context.Context, hostclaim.Observed, hostclaim.Observed) error { t.Fatal("remote"); return nil }) |
| ++ if err := hook(t.Context(), o, o); err != nil || !called { |
| ++ t.Fatal(err) |
| ++ } |
| ++ } |
| ++ } |
| ++} |
| +--- /dev/null |
| ++++ b/internal/hostclaim/plan12_target_release_test.go |
| +@@ -0,0 +1,30 @@ |
| ++package hostclaim |
| ++ |
| ++import ( |
| ++ "strconv" |
| ++ "testing" |
| ++) |
| ++ |
| ++func TestPlan12TargetReleasedCapturesWriter(t *testing.T) { |
| ++ for _, admitted := range []bool{false, true} { |
| ++ c := lostTargetClaim(t, KindRestoring, PhaseReleasing) |
| ++ c.Lost, c.LossRefs, c.LostRevisions = nil, nil, nil |
| ++ delete(c.Op.Journal, "loss."+lostID) |
| ++ c.Op.Participants = []string{"ie-box", "uk-box"} |
| ++ c.Op.Exec = "uk-box" |
| ++ if !admitted { |
| ++ c.Holder.WriterBox = "ie-box" |
| ++ } |
| ++ r := lostRelease(c, false) |
| ++ if err := TargetReleased(lostOp, "uk-box", "uk", "evidence", r.Location, r.NewID)(c); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if admitted { |
| ++ if c.Op.Journal[JournalTargetSession] != strconv.FormatUint(c.Holder.Session, 10) || c.Op.Journal[JournalTargetGeneration] != c.Holder.Generation { |
| ++ t.Fatal(c.Op.Journal) |
| ++ } |
| ++ } else if _, ok := c.Op.Journal[JournalTargetSession]; ok { |
| ++ t.Fatal("invented target session") |
| ++ } |
| ++ } |
| ++} |
| +--- /dev/null |
| ++++ b/internal/host/plan12_restore_linux_test.go |
| +@@ -0,0 +1,133 @@ |
| ++//go:build linux |
| ++ |
| ++package host |
| ++ |
| ++import ( |
| ++ "context" |
| ++ "encoding/json" |
| ++ "github.com/carlosframework/platform/internal/activator" |
| ++ "github.com/carlosframework/platform/internal/hostbox" |
| ++ "github.com/carlosframework/platform/internal/hostclaim" |
| ++ "github.com/carlosframework/platform/internal/hostguard" |
| ++ "github.com/carlosframework/platform/internal/moves" |
| ++ "github.com/carlosframework/platform/internal/park" |
| ++ "github.com/carlosframework/platform/internal/registry" |
| ++ "os" |
| ++ "os/exec" |
| ++ "path/filepath" |
| ++ "strconv" |
| ++ "testing" |
| ++) |
| ++ |
| ++func TestPlan12RecoveryRestoreSourceDatabase(t *testing.T) { |
| ++ for _, name := range []string{hostclaim.KindRestoring, hostclaim.KindReverting, "clean-return"} { |
| ++ t.Run(name, func(t *testing.T) { |
| ++ kind, clean := name, name == "clean-return" |
| ++ if clean { |
| ++ kind = hostclaim.KindRestoring |
| ++ } |
| ++ f, o, h := plan12Handover(t, hostclaim.KindRestoring, "stop") |
| ++ stamp := o.Claim.Holder.Stamp |
| ++ db := filepath.Join(f.guard.DataDir, "state", "data.db") |
| ++ if err := os.MkdirAll(filepath.Dir(db), 0700); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if err := os.Mkdir(filepath.Join(f.guard.DataDir, ".parked"), 0770); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if err := os.WriteFile(db, []byte("source database"), 0600); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if err := hostbox.WriteGenerationMarker(db+".gen", "src"); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ key := moves.StoppedKey(moves.ReportPrefix("bab", "jam", o.Host, "m1"), "source") |
| ++ report := plan12Stopped(o, "source", "box-1") |
| ++ raw, _ := json.Marshal(report) |
| ++ if _, err := f.claims.Put(t.Context(), key, raw, ""); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ var err error |
| ++ o, err = hostclaim.Update(t.Context(), f.claims, o, func(c *hostclaim.Claim) error { |
| ++ c.Op.Kind = kind |
| ++ c.Op.Journal[hostclaim.JournalSourceStamp] = strconv.FormatUint(stamp, 10) |
| ++ c.Op.Journal[hostclaim.JournalSrcGen] = "src" |
| ++ c.Op.Journal[hostclaim.JournalCutSession] = "1" |
| ++ c.Op.Journal[hostclaim.JournalEvidenceSource] = key |
| ++ if kind == hostclaim.KindRestoring { |
| ++ c.Holder.Admissions = []hostclaim.AdmissionRecord{{Session: 2, Box: "box-2", Generation: "target-gen", Mode: "cold", Region: "uk", Predecessor: "src", PredecessorRegion: "ie"}} |
| ++ if clean { |
| ++ c.Holder.Generation = "src" |
| ++ } |
| ++ } |
| ++ if kind == hostclaim.KindReverting { |
| ++ c.Holder.WriterBox = "box-1" |
| ++ c.Holder.Session = 2 |
| ++ c.Holder.Generation = "src" |
| ++ c.Holder.Admissions = []hostclaim.AdmissionRecord{{Session: 2, Box: "box-1", Generation: "src", Mode: "cold", Region: "ie"}} |
| ++ } |
| ++ return nil |
| ++ }) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ local := park.Local{Guard: f.guard, Claims: f.claims, DataDir: f.guard.DataDir, Box: "box-1"} |
| ++ if err = local.RecordInventory(t.Context(), o.Host, hostguard.Captured{Incarnation: o.Claim.Holder.Incarnation, Stamp: stamp}, "flat", []string{"state/data.db", "state/data.db.gen"}, func(*hostclaim.Claim) error { return nil }); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ o = f.read() |
| ++ c := f.controller("boot-1", "controller-1", 4101, "start-1") |
| ++ c.wake.Inputs = plan12ReturnInputs{plan9D2Inputs: plan9D2Inputs{recoveryInputs{f}}, clean: clean} |
| ++ c.wake.Continue = hostclaim.SessionContinues |
| ++ c.wake.Operations = &plan9D2Attempts{} |
| ++ c.wake.ProbeReady = func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil } |
| ++ retirement := claimArtifactRetirement{Local: local, Continue: hostclaim.SessionContinues} |
| ++ c.wake.RetireArtifacts, c.wake.RecoverArtifacts = retirement.Retire, retirement.Recover |
| ++ // The cut's complete report is already durable. This fixture has no source launch journal. |
| ++ if kind == hostclaim.KindReverting { |
| ++ c.wake.Stop = func(context.Context, hostclaim.Observed) error { return nil } |
| ++ } |
| ++ wireClaimAdmission(c.wake, "box-1", c.wake.AfterAdmission, h, nil) |
| ++ if err := c.wake.stopPredecessor(t.Context(), o); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ live, cancel := context.WithCancel(t.Context()) |
| ++ defer cancel() |
| ++ done := make(chan struct{}) |
| ++ go func() { <-live.Done(); close(done) }() |
| ++ c.wake.mu.Lock() |
| ++ c.wake.pending[o.Host] = &claimWakeAttempt{observed: o, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done} |
| ++ c.wake.mu.Unlock() |
| ++ admitted, err := c.runtime.Admit(t.Context(), registry.Route{Host: o.Host}, o, activator.StartPolicy{Probe: true}) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ launch, err := c.runtime.Prepare(t.Context(), registry.Route{Host: o.Host}, admitted, activator.StartPolicy{Probe: true}) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if err := launch.Restore(t.Context()); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if got, err := os.ReadFile(db + ".gen"); err != nil || string(got) == "src" { |
| ++ t.Fatal(string(got), err) |
| ++ } |
| ++ if tag := f.read().Claim.Inventory["box-1"].Paths["state/data.db"]; tag != admitted.Claim.Holder.Stamp { |
| ++ t.Fatal("old ownership", tag) |
| ++ } |
| ++ }) |
| ++ } |
| ++} |
| ++ |
| ++type plan12ReturnInputs struct { |
| ++ plan9D2Inputs |
| ++ clean bool |
| ++} |
| ++ |
| ++func (in plan12ReturnInputs) Select(ctx context.Context, rt registry.Route, o hostclaim.Observed, p activator.StartPolicy) (ClaimLaunchInputs, error) { |
| ++ selected, err := in.plan9D2Inputs.Select(ctx, rt, o, p) |
| ++ if in.clean { |
| ++ selected.Admission.Coverage = "clean-return" |
| ++ } |
| ++ return selected, err |
| ++} |
| +--- /dev/null |
| ++++ b/internal/host/plan12_receipt_test.go |
| +@@ -0,0 +1,67 @@ |
| ++package host |
| ++ |
| ++import ( |
| ++ "crypto/ed25519" |
| ++ "crypto/sha256" |
| ++ "encoding/hex" |
| ++ "encoding/json" |
| ++ "github.com/carlosframework/platform/internal/hostactors" |
| ++ "github.com/carlosframework/platform/internal/hostclaim" |
| ++ "github.com/carlosframework/platform/internal/hostevidence" |
| ++ "github.com/carlosframework/platform/internal/release" |
| ++ "strings" |
| ++ "testing" |
| ++ "time" |
| ++) |
| ++ |
| ++func TestPlan12RecoveryRootReceiptAlternative(t *testing.T) { |
| ++ for _, bad := range []string{"", "session", "stamp", "enrollment", "signature", "operation", "region", "digest"} { |
| ++ t.Run(bad, func(t *testing.T) { |
| ++ f, o, h := plan12Handover(t, hostclaim.KindRestoring, "stop") |
| ++ key := ed25519.NewKeyFromSeed([]byte(strings.Repeat("b", 32))) |
| ++ en := strings.Repeat("a", 32) |
| ++ d := hostactors.Directory{Store: f.claims, PublicKey: f.signer.Public().(ed25519.PublicKey), Bucket: "bucket"} |
| ++ roster := hostactors.Roster{V: 1, Revision: 1, DeploymentBucket: "bucket", Boxes: map[string]hostactors.Box{"box-2": {Enrollment: en, BoxID: "box-2", Label: "box-2", Namespace: "platform", Region: "uk", Pool: "default", State: "active", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-2", InstanceID: "i-target"}, EvidenceSHA256: strings.Repeat("d", 64), CensusPublicKey: key.Public().(ed25519.PublicKey)}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| ++ wire, _ := release.SignRootEnvelope(release.RootSigner(f.signer), roster) |
| ++ if _, err := f.claims.Put(t.Context(), hostactors.Key, wire, ""); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ r := StopReceipt{V: 1, ID: "stop", Kind: "stopped", Host: o.Host, Incarnation: o.Claim.Holder.Incarnation, InstallationStamp: 2, WriterSession: 2, OperationID: "m1", Phase: "releasing", ObligationID: "writer-session-2", Box: "box-2", Region: "uk", Enrollment: en, EvidenceAt: time.Now(), RequestLimitNS: int64(2 * time.Minute), ClaimGen: 1, FenceID: "fence", InventorySHA256: strings.Repeat("e", 64), LocalJournalSHA256: strings.Repeat("f", 64), ProducerDrainDigest: strings.Repeat("a", 64), CgroupIdentities: []string{"root-role"}} |
| ++ switch bad { |
| ++ case "session": |
| ++ r.WriterSession++ |
| ++ case "stamp": |
| ++ r.InstallationStamp++ |
| ++ case "enrollment": |
| ++ r.Enrollment = strings.Repeat("e", 32) |
| ++ case "operation": |
| ++ r.OperationID = "other" |
| ++ case "region": |
| ++ r.Region = "ie" |
| ++ } |
| ++ body, _ := json.Marshal(r) |
| ++ receiptKey := stopEvidenceKey(o.Host, "m1", "box-2", "stop") |
| ++ sum := sha256.Sum256(body) |
| ++ signer := key |
| ++ if bad == "signature" { |
| ++ signer = f.signer |
| ++ } |
| ++ if err := hostevidence.Publish(t.Context(), f.claims, signer, receiptKey, body); err != nil { |
| + t.Fatal(err) |
| + } |
| -+ if got.Claim.Holder.Session <= 2 || got.Claim.Holder.Stamp != got.Claim.Holder.Session || got.Claim.Holder.WriterBox != "box-1" { |
| -+ t.Fatal(got) |
| ++ h.Directory = d |
| ++ o.Claim.Op.Journal["target_stop_receipt"] = receiptKey |
| ++ o.Claim.Op.Journal["target_stop_sha256"] = hex.EncodeToString(sum[:]) |
| ++ o.Claim.Op.Journal[hostclaim.JournalTargetStamp] = "2" |
| ++ // Remove the unsigned report path: the receipt alone must suffice. |
| ++ delete(o.Claim.Op.Journal, hostclaim.JournalEvidenceTarget) |
| ++ if bad == "digest" { |
| ++ o.Claim.Op.Journal["target_stop_sha256"] = strings.Repeat("0", 64) |
| ++ } |
| ++ err := h.recoveryEvidence(t.Context(), o) |
| ++ if (err == nil) != (bad == "") { |
| ++ t.Fatal(bad, err) |
| + } |
| + }) |
| + } |
| @@ -789,36 +1938,20 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| <!-- apply: C implementation --> |
| ```diff |
| ---- a/internal/host/claimowners.go |
| -+++ b/internal/host/claimowners.go |
| -@@ -39,6 +39,7 @@ |
| - // with CARLOS_MOVES_PHASE1 on. ProbeReady replaces the claim's probe |
| - // readiness check when set. |
| - type ClaimMovesOwners struct { |
| -+ TargetStop ClaimTargetStopVerifier |
| - LossRecovery hostclaim.LossRecoveryOwner |
| - LossSources hostbox.LossAdmissionSources |
| - ClaimKindOwners |
| -@@ -47,7 +48,7 @@ |
| - } |
| - |
| - func (m ClaimMovesOwners) zero() bool { |
| -- return m.LossRecovery == nil && m.LossSources == nil && m.Inputs == nil && m.AdmissionEvidence == nil && m.PublicationEvidence == nil && m.Operations == nil && m.ProbeReady == nil |
| -+ return m.TargetStop == nil && m.LossRecovery == nil && m.LossSources == nil && m.Inputs == nil && m.AdmissionEvidence == nil && m.PublicationEvidence == nil && m.Operations == nil && m.ProbeReady == nil |
| - } |
| - |
| - // ClaimRelocationOwners is region HA's owner set for the relocating kind |
| --- a/internal/host/claimhandover_crossbox.go |
| +++ b/internal/host/claimhandover_crossbox.go |
| -@@ -2,6 +2,7 @@ |
| +@@ -2,8 +2,10 @@ |
| import ( |
| "context" |
| + "crypto/ed25519" |
| "strconv" |
| ++ "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/hostbox" |
| -@@ -39,12 +40,18 @@ |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| +@@ -39,12 +41,18 @@ |
| // installation stamp (item 4), so the local record this box initialized for |
| // the landing installation is the one the session runs under. |
| type claimCrossBoxHandover struct { |
| @@ -827,35 +1960,35 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| - Box string |
| + PublicKey ed25519.PublicKey |
| + FenceAuthority hostclaim.FenceAuthority |
| -+ TargetStop ClaimTargetStopVerifier |
| ++ Directory hostactors.Directory |
| + Guard hostguard.Guard |
| + Evidence release.Store |
| + Box string |
| } |
| func (h claimCrossBoxHandover) After(ctx context.Context, prior, admitted hostclaim.Observed) error { |
| -+ if claimRecoveryKind(prior) { |
| ++ if claimRecoveryObservation(prior) { |
| + return h.afterRecovery(ctx, prior, admitted) |
| + } |
| if h.Evidence == nil || h.Box == "" || prior.Claim == nil || prior.Claim.Holder == nil || admitted.Claim == nil || admitted.Claim.Holder == nil || prior.Host != admitted.Host || prior.Absent || admitted.Absent { |
| return hostbox.ErrChanged |
| } |
| -@@ -115,6 +122,9 @@ |
| +@@ -115,6 +123,9 @@ |
| } |
| func (h claimCrossBoxHandover) Before(ctx context.Context, prior hostclaim.Observed) error { |
| -+ if claimRecoveryKind(prior) { |
| ++ if claimRecoveryObservation(prior) { |
| + return h.beforeRecovery(ctx, prior) |
| + } |
| if _, err := h.sourceEvidence(ctx, prior); err != nil { |
| return err |
| } |
| -@@ -171,5 +181,5 @@ |
| +@@ -171,5 +182,5 @@ |
| if prior.Claim == nil || prior.Claim.Holder == nil || prior.Claim.Holder.WriterBox == box { |
| return false |
| } |
| - return prior.Claim.Op.Kind == hostclaim.KindRelocating || (prior.Claim.Op.Kind == hostclaim.KindLanding && prior.Claim.Holder.WriterBox != "") |
| -+ return claimRecoveryKind(prior) || prior.Claim.Op.Kind == hostclaim.KindRelocating || (prior.Claim.Op.Kind == hostclaim.KindLanding && prior.Claim.Holder.WriterBox != "") |
| ++ return (prior.Claim.Op.Kind == hostclaim.KindRestoring && prior.Claim.Holder.WriterBox != "" && prior.Claim.Holder.WriterBox == prior.Claim.Op.Intent["target"]) || prior.Claim.Op.Kind == hostclaim.KindRelocating || (prior.Claim.Op.Kind == hostclaim.KindLanding && prior.Claim.Holder.WriterBox != "") |
| } |
| --- a/internal/host/claimconstruction.go |
| +++ b/internal/host/claimconstruction.go |
| @@ -867,14 +2000,13 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| -@@ -282,7 +283,11 @@ |
| - wake.AfterAdmission = coldAdmissionHook(guard, owners.Identity, controlsock.Path) |
| +@@ -283,6 +284,11 @@ |
| wake.RetireArtifacts = func(context.Context, hostclaim.Observed, ClaimLaunchInputs, string) error { return hostbox.ErrChanged } |
| if movesC1 { |
| -- handover := claimCrossBoxHandover{Guard: guard, Evidence: store, Box: actor.Box} |
| -+ handover := claimCrossBoxHandover{Guard: guard, Evidence: store, Box: actor.Box, TargetStop: owners.Moves.TargetStop} |
| + handover := claimCrossBoxHandover{Guard: guard, Evidence: store, Box: actor.Box} |
| + if owners.RetirementAuthority != nil { |
| + handover.PublicKey = owners.RetirementAuthority.Directory.PublicKey |
| ++ handover.Directory = owners.RetirementAuthority.Directory |
| + handover.FenceAuthority = hostfence.ClaimFenceAuthority{Directory: owners.RetirementAuthority.Directory, Now: time.Now} |
| + } |
| var relocation *ClaimRelocationOwners |
| @@ -882,7 +2014,7 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| relocation = &owners.Relocation |
| --- /dev/null |
| +++ b/internal/host/claimhandover_recovery.go |
| -@@ -0,0 +1,145 @@ |
| +@@ -0,0 +1,143 @@ |
| +package host |
| + |
| +import ( |
| @@ -891,18 +2023,12 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| ++ "github.com/carlosframework/platform/internal/moves" |
| ++ "strconv" |
| +) |
| + |
| -+// ClaimTargetStop is the identity authenticated by moves' signed stop reader. |
| -+// Session is a writer session, never the report's claim generation. |
| -+type ClaimTargetStop struct { |
| -+ Host, OpID, Box, Incarnation, Generation string |
| -+ Session uint64 |
| -+} |
| -+type ClaimTargetStopVerifier func(context.Context, hostclaim.Observed, string) (ClaimTargetStop, error) |
| -+ |
| -+func claimRecoveryKind(o hostclaim.Observed) bool { |
| -+ return o.Claim != nil && (o.Claim.Op.Kind == hostclaim.KindRestoring || o.Claim.Op.Kind == hostclaim.KindReverting) |
| ++func claimRecoveryObservation(o hostclaim.Observed) bool { |
| ++ return o.Claim != nil && claimRecoveryKind(o.Claim.Op.Kind) |
| +} |
| +func recoveryPredecessor(o hostclaim.Observed) hostclaim.AdmissionRecord { |
| + h := o.Claim.Holder |
| @@ -913,7 +2039,7 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| + return p |
| +} |
| +func (h claimCrossBoxHandover) recoveryEvidence(ctx context.Context, o hostclaim.Observed) error { |
| -+ if !claimRecoveryKind(o) || o.Absent || o.ETag == "" || o.Claim.Holder == nil || h.Evidence == nil || h.Box == "" { |
| ++ if !claimRecoveryObservation(o) || o.Absent || o.ETag == "" || o.Claim.Holder == nil || h.Evidence == nil || h.Box == "" { |
| + return hostbox.ErrChanged |
| + } |
| + c := o.Claim |
| @@ -956,16 +2082,20 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| + } |
| + return nil |
| + } |
| ++ if key := op.Journal["target_stop_receipt"]; key != "" { |
| ++ return h.recoveryStopReceipt(ctx, o, p, key, op.Journal["target_stop_sha256"]) |
| ++ } |
| + key := op.Journal[hostclaim.JournalEvidenceTarget] |
| -+ expected := "moves/" + c.Holder.Account + "/" + c.Holder.App + "/" + o.Host + "/" + op.ID + "/stopped-target.json" |
| -+ if key != expected || h.TargetStop == nil { |
| ++ expected := moves.StoppedKey(moves.ReportPrefix(c.Holder.Account, c.Holder.App, o.Host, op.ID), "target") |
| ++ session, err := strconv.ParseUint(op.Journal[hostclaim.JournalTargetSession], 10, 64) |
| ++ if err != nil || session != p.Session || op.Journal[hostclaim.JournalTargetGeneration] != p.Generation || key != expected { |
| + return hostbox.ErrChanged |
| + } |
| -+ proof, err := h.TargetStop(ctx, o, key) |
| ++ report, err := readStoppedReport(ctx, h.Evidence, key) |
| + if err != nil { |
| + return err |
| + } |
| -+ if proof != (ClaimTargetStop{Host: o.Host, OpID: op.ID, Box: p.Box, Incarnation: c.Holder.Incarnation, Generation: p.Generation, Session: p.Session}) { |
| ++ if report.Role != "target" || report.Box != p.Box || report.Host != o.Host || report.OpID != op.ID || report.Incarnation != c.Holder.Incarnation { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| @@ -1017,7 +2147,7 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| + return hostbox.ErrChanged |
| + } |
| + // Activate retires the prior local stamp through the descriptor-based guard. |
| -+ // It neither clears a fence nor retires files; moves owns S-tagged artifacts. |
| ++ // The restore hook subsequently retires the source database and marker. |
| + if err = h.Guard.Activate(ctx, admitted.Host, h.Box, nil); err != nil { |
| + return err |
| + } |
| @@ -1028,6 +2158,298 @@ Expected first failure: the new target-stop verifier type is absent; after its A |
| + return nil |
| + }, func() error { return nil }) |
| +} |
| +--- /dev/null |
| ++++ b/internal/host/claimrecoveryreceipt.go |
| +@@ -0,0 +1,56 @@ |
| ++package host |
| ++ |
| ++import ( |
| ++ "bytes" |
| ++ "context" |
| ++ "crypto/sha256" |
| ++ "encoding/hex" |
| ++ "encoding/json" |
| ++ "github.com/carlosframework/platform/internal/hostbox" |
| ++ "github.com/carlosframework/platform/internal/hostclaim" |
| ++ "github.com/carlosframework/platform/internal/hostevidence" |
| ++ "strconv" |
| ++) |
| ++ |
| ++// A root-signed stop receipt is an alternative to the target's own release journal. |
| ++func (h claimCrossBoxHandover) recoveryStopReceipt(ctx context.Context, o hostclaim.Observed, p hostclaim.AdmissionRecord, key, digest string) error { |
| ++ raw, _, err := h.Evidence.Get(ctx, key) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ sum := sha256.Sum256(raw) |
| ++ if hex.EncodeToString(sum[:]) != digest { |
| ++ return ErrClaimStopReceipt |
| ++ } |
| ++ var r StopReceipt |
| ++ if json.Unmarshal(raw, &r) != nil { |
| ++ return ErrClaimStopReceipt |
| ++ } |
| ++ canonical, err := json.Marshal(r) |
| ++ if err != nil || !bytes.Equal(raw, canonical) { |
| ++ return ErrClaimStopReceipt |
| ++ } |
| ++ c := o.Claim |
| ++ stamp, err := strconv.ParseUint(c.Op.Journal[hostclaim.JournalTargetStamp], 10, 64) |
| ++ if err != nil || stamp == 0 || r.V != 1 || r.Kind != "stopped" || r.Host != o.Host || r.Box != p.Box || r.OperationID != c.Op.ID || |
| ++ r.Incarnation != c.Holder.Incarnation || r.WriterSession != p.Session || r.InstallationStamp != stamp || |
| ++ r.Region != c.Op.Intent["to"] || r.Enrollment != c.Op.Intent["target_enrollment"] || r.Phase != hostclaim.PhaseReleasing || |
| ++ key != stopEvidenceKey(o.Host, c.Op.ID, p.Box, r.ID) || r.FenceID == "" || r.ObligationID == "" || r.ClaimGen == 0 || |
| ++ r.EvidenceAt.IsZero() || r.RequestLimitNS != int64(hostbox.RequestLimit) || len(r.CgroupIdentities) == 0 || |
| ++ !stopDigest(r.InventorySHA256) || !stopDigest(r.LocalJournalSHA256) || !stopDigest(r.ProducerDrainDigest) { |
| ++ return ErrClaimStopReceipt |
| ++ } |
| ++ roster, err := h.Directory.Read(ctx) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ box, ok := roster.Roster.Boxes[p.Box] |
| ++ if !ok || box.Enrollment != r.Enrollment || box.Region != r.Region { |
| ++ return ErrClaimStopIdentity |
| ++ } |
| ++ sig, _, err := h.Evidence.Get(ctx, key+".sig") |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ return hostevidence.Verify(box.CensusPublicKey, key, raw, sig) |
| ++} |
| +--- a/internal/host/claimretire_local.go |
| ++++ b/internal/host/claimretire_local.go |
| +@@ -4,8 +4,10 @@ |
| + "context" |
| + "errors" |
| + "fmt" |
| ++ "github.com/carlosframework/platform/internal/moves" |
| + "os" |
| + "path/filepath" |
| ++ "strconv" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| +@@ -13,21 +15,12 @@ |
| + "github.com/carlosframework/platform/internal/park" |
| + ) |
| + |
| +-// claimArtifactRetirement is the production ColdRestore retirement hook for a |
| +-// landing writer (claim Plan 9 D4, moves ruling H15). ColdRestore calls it |
| +-// when the local .gen names another generation: a restarted probe over the |
| +-// landing's prepared database or an earlier probe's, or a cold wake after the |
| +-// open over the probe's generation. It is retire-artifacts(S) of claim §6.4 |
| +-// and needs no chore (AdmitWriter queues none, and none is invented): it |
| +-// parks only the database and its marker, both tagged with the stamp S of the |
| +-// superseded local installation, through park.Local under the admitted |
| +-// installation's guard, and moves no stamp. The files directory and |
| +-// <host>.env stay where the landing placed them. Root's completed full stop |
| +-// of the one writer session that ran S on this box must already be journalled |
| +-// (the writer-session receipt the wake's Stop collected before admission). |
| +-// Serving and every other kind keep main's refusal: their predecessor |
| +-// installation is retired by the cold handover before restore. Continue is |
| +-// bound only with the moves C1 integration; nil refuses. |
| ++// claimArtifactRetirement parks a superseded database and marker under the |
| ++// admitted installation's descriptor guard. Landing probes, restoring and |
| ++// reverting share the same exact inventory checks. Initial source recovery |
| ++// uses the source cut's journal and report; later sessions require root's |
| ++// completed writer-stop receipt. Files and environment paths are unaffected. |
| ++// Continue is bound only by the moves C1 integration; nil refuses. |
| + type claimArtifactRetirement struct { |
| + Local park.Local |
| + Continue func(kind, from, to string) bool |
| +@@ -35,7 +28,7 @@ |
| + |
| + func (r claimArtifactRetirement) Retire(ctx context.Context, admitted hostclaim.Observed, in ClaimLaunchInputs, generation string) error { |
| + box := r.Local.Box |
| +- if r.Continue == nil || r.Local.Claims == nil || box == "" || !filepath.IsAbs(r.Local.DataDir) || admitted.Claim == nil || admitted.Claim.Holder == nil || admitted.Claim.Op.Kind != hostclaim.KindLanding { |
| ++ if r.Continue == nil || r.Local.Claims == nil || box == "" || !filepath.IsAbs(r.Local.DataDir) || admitted.Claim == nil || admitted.Claim.Holder == nil || !claimArtifactKind(admitted.Claim.Op.Kind) { |
| + return hostbox.ErrChanged |
| + } |
| + h := admitted.Claim.Holder |
| +@@ -59,13 +52,16 @@ |
| + return hostbox.ErrChanged |
| + } |
| + c := fresh.Claim |
| +- if _, ok := hostguard.LandingWriter(c, box); !ok { |
| ++ if _, ok := claimArtifactWriter(c, box); !ok { |
| + return hostbox.ErrChanged |
| + } |
| + // The superseded database is this op's: its prepared generation, named by |
| + // the op id, or a generation an earlier session on this box was admitted |
| + // into under it. |
| +- known := generation == c.Op.ID |
| ++ known := c.Op.Kind == hostclaim.KindLanding && generation == c.Op.ID |
| ++ if claimRecoveryKind(c.Op.Kind) && generation == c.Op.Journal[hostclaim.JournalSrcGen] { |
| ++ known = true |
| ++ } |
| + for _, a := range c.Holder.Admissions { |
| + if a.Generation == generation && a.Box == box && a.Session < c.Holder.Session { |
| + known = true |
| +@@ -79,14 +75,14 @@ |
| + if stamp == 0 || stamp >= c.Holder.Stamp || paths[database] != stamp { |
| + return hostbox.ErrChanged |
| + } |
| +- if err := r.stoppedAt(ctx, admitted.Host, c, box, stamp); err != nil { |
| ++ if err := r.artifactsStopped(ctx, admitted.Host, c, box, stamp, generation); err != nil { |
| + return err |
| + } |
| + check := func(now *hostclaim.Claim) error { |
| + if !claimSameWriter(fresh, hostclaim.Observed{Host: admitted.Host, Claim: now}, r.Continue) { |
| + return hostbox.ErrChanged |
| + } |
| +- if _, ok := hostguard.LandingWriter(now, box); !ok { |
| ++ if _, ok := claimArtifactWriter(now, box); !ok { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| +@@ -132,7 +128,7 @@ |
| + if r.Continue == nil || r.Local.Claims == nil || admitted.Claim == nil || admitted.Claim.Holder == nil || !filepath.IsAbs(in.DBPath) { |
| + return hostbox.ErrChanged |
| + } |
| +- if admitted.Claim.Op.Kind != hostclaim.KindLanding { |
| ++ if !claimArtifactKind(admitted.Claim.Op.Kind) { |
| + return nil |
| + } |
| + if _, err := os.Lstat(in.DBPath + ".gen"); err == nil { |
| +@@ -148,7 +144,7 @@ |
| + return hostbox.ErrChanged |
| + } |
| + c, box := fresh.Claim, r.Local.Box |
| +- if _, ok := hostguard.LandingWriter(c, box); !ok { |
| ++ if _, ok := claimArtifactWriter(c, box); !ok { |
| + return hostbox.ErrChanged |
| + } |
| + db, err := filepath.Rel(r.Local.DataDir, in.DBPath) |
| +@@ -166,6 +162,12 @@ |
| + } |
| + if stamp > c.Holder.Stamp { |
| + return hostbox.ErrChanged |
| ++ } |
| ++ if claimRecoveryKind(c.Op.Kind) { |
| ++ sourceStamp, e := strconv.ParseUint(c.Op.Journal[hostclaim.JournalSourceStamp], 10, 64) |
| ++ if e == nil && stamp == sourceStamp { |
| ++ return r.Retire(ctx, admitted, in, c.Op.Journal[hostclaim.JournalSrcGen]) |
| ++ } |
| + } |
| + receipts, err := r.Local.Guard.CompletedWriterStops(ctx, admitted.Host, hostguard.Captured{Incarnation: c.Holder.Incarnation, Stamp: stamp}, c.Op.ID, box) |
| + if err != nil { |
| +@@ -184,3 +186,38 @@ |
| + } |
| + return hostbox.ErrChanged |
| + } |
| ++ |
| ++func claimArtifactKind(kind string) bool { |
| ++ return kind == hostclaim.KindLanding || claimRecoveryKind(kind) |
| ++} |
| ++func claimArtifactWriter(c *hostclaim.Claim, box string) (hostclaim.AdmissionRecord, bool) { |
| ++ if c != nil && claimRecoveryKind(c.Op.Kind) { |
| ++ return hostguard.RecoveryWriter(c, box) |
| ++ } |
| ++ return hostguard.LandingWriter(c, box) |
| ++} |
| ++func (r claimArtifactRetirement) artifactsStopped(ctx context.Context, host string, c *hostclaim.Claim, box string, stamp uint64, generation string) error { |
| ++ sourceStamp, err := strconv.ParseUint(c.Op.Journal[hostclaim.JournalSourceStamp], 10, 64) |
| ++ if !claimRecoveryKind(c.Op.Kind) || err != nil || stamp != sourceStamp { |
| ++ return r.stoppedAt(ctx, host, c, box, stamp) |
| ++ } |
| ++ if c.Op.Intent["source"] != box || generation != c.Op.Journal[hostclaim.JournalSrcGen] { |
| ++ return hostbox.ErrChanged |
| ++ } |
| ++ session, err := strconv.ParseUint(c.Op.Journal[hostclaim.JournalCutSession], 10, 64) |
| ++ if err != nil || session >= c.Holder.Session { |
| ++ return hostbox.ErrChanged |
| ++ } |
| ++ key := moves.StoppedKey(moves.ReportPrefix(c.Holder.Account, c.Holder.App, host, c.Op.ID), "source") |
| ++ if c.Op.Journal[hostclaim.JournalEvidenceSource] != key { |
| ++ return hostbox.ErrChanged |
| ++ } |
| ++ report, err := readStoppedReport(ctx, r.Local.Claims, key) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ if report.Box != box || report.Role != "source" || report.Host != host || report.OpID != c.Op.ID || report.Incarnation != c.Holder.Incarnation { |
| ++ return hostbox.ErrChanged |
| ++ } |
| ++ return nil |
| ++} |
| +--- a/internal/hostguard/writerstop.go |
| ++++ b/internal/hostguard/writerstop.go |
| +@@ -110,3 +110,16 @@ |
| + }) |
| + return out, err |
| + } |
| ++ |
| ++func RecoveryWriter(c *hostclaim.Claim, box string) (hostclaim.AdmissionRecord, bool) { |
| ++ if c == nil || (c.Op.Kind != hostclaim.KindRestoring && c.Op.Kind != hostclaim.KindReverting) || c.Op.Intent["source"] != box { |
| ++ return hostclaim.AdmissionRecord{}, false |
| ++ } |
| ++ return operationWriter(c, box, c.Op.Kind) |
| ++} |
| ++func RecoveryWriterStop(c *hostclaim.Claim, box, region string) (string, bool) { |
| ++ if _, ok := RecoveryWriter(c, box); !ok { |
| ++ return "", false |
| ++ } |
| ++ return operationWriterStop(c, box, region, c.Op.Kind) |
| ++} |
| +--- a/internal/hostguard/fence.go |
| ++++ b/internal/hostguard/fence.go |
| +@@ -268,6 +268,9 @@ |
| + // landing's own current writer on this box (claim Plan 9 D4). |
| + if writerStop(t.ObligationID) { |
| + id, ok := LandingWriterStop(c, t.Box, t.Region) |
| ++ if c.Op.Kind == hostclaim.KindRestoring || c.Op.Kind == hostclaim.KindReverting { |
| ++ id, ok = RecoveryWriterStop(c, t.Box, t.Region) |
| ++ } |
| + if c.Op.Kind == hostclaim.KindRelocating { |
| + id, ok = RelocationWriterStop(c, t.Box, t.Region) |
| + } |
| +--- a/internal/host/claimwakestop.go |
| ++++ b/internal/host/claimwakestop.go |
| +@@ -365,5 +365,8 @@ |
| + if c != nil && c.Op.Kind == hostclaim.KindRelocating { |
| + return hostguard.RelocationWriterStop(c, actor.Box, actor.Region) |
| + } |
| ++ if c != nil && claimRecoveryKind(c.Op.Kind) { |
| ++ return hostguard.RecoveryWriterStop(c, actor.Box, actor.Region) |
| ++ } |
| + return hostguard.LandingWriterStop(c, actor.Box, actor.Region) |
| + } |
| +--- a/internal/hostclaim/move.go |
| ++++ b/internal/hostclaim/move.go |
| +@@ -416,6 +416,8 @@ |
| + if stagingScope.Location != "moves/"+c.Host+"/"+opID || stagingScope.Incarnation != c.Holder.Incarnation { |
| + return fmt.Errorf("%w: the revert's park must be this move's own lane and this holder's incarnation", ErrMalformed) |
| + } |
| ++ setJournal(c, JournalSourceStamp, strconv.FormatUint(c.Holder.Stamp, 10)) |
| ++ setJournal(c, JournalSrcGen, c.Holder.Generation) |
| + c.Op.Kind, c.Op.Exec = KindReverting, sourceBox |
| + if c.Op.Journal[JournalRevPhase] == "" { |
| + setJournal(c, JournalRevPhase, RevPhaseEvidence) |
| +@@ -890,6 +892,9 @@ |
| + // A replay that finds the release written with the same evidence (released |
| + // or any later phase) is a no-op and queues nothing, even after a chore has |
| + // completed. A refused release leaves the claim as found. |
| ++const JournalTargetSession = "target_session" |
| ++const JournalTargetGeneration = "target_generation" |
| ++ |
| + func TargetReleased(opID, targetBox, targetRegion, evidenceKey string, scope ParkScope, newID func() string) func(*Claim) error { |
| + return func(c *Claim) error { |
| + if err := RequireOp(opID)(c); err != nil { |
| +@@ -921,6 +926,13 @@ |
| + parkBox = targetBox |
| + } |
| + return inOneWrite(c, |
| ++ func(c *Claim) error { |
| ++ if c.Holder != nil && c.Holder.WriterBox == targetBox { |
| ++ setJournal(c, JournalTargetSession, strconv.FormatUint(c.Holder.Session, 10)) |
| ++ setJournal(c, JournalTargetGeneration, c.Holder.Generation) |
| ++ } |
| ++ return nil |
| ++ }, |
| + ExecToNone(opID, targetBox, evidenceKey, JournalEvidenceTarget), |
| + func(c *Claim) error { c.Op.Phase = PhaseReleased; return nil }, |
| + QueueLocationPark(opID, targetRegion, parkBox, scope, newID), |
| ``` |
| ## Task D: Run parks under the caller’s location lease (M5, M6) |
| @@ -1402,17 +2824,17 @@ Expected first failure: `RunHeld` and `ReplicaParkLeaseSet` are undefined. The t |
| +} |
| ``` |
| -## Task E: Provide the archived-instance census (M7) |
| +## Task E: Label residue independently and prove free-host lanes (M7) |
| -Main retains `RetiredIncarnations` (32 IDs), `QuietAfter` by region, and loss references. It retains no general retired holder/location archive. `retire` currently throws away the mapping when a holder is later removed. A claim object's historical S3 versions are not exposed by `release.Store`; do not assume that a version-history reader or retention policy exists. |
| +Retain the optional `retired_locations` mapping only with `CARLOS_HOST_CLAIM_RETIRED_LOCATIONS=1`; leave that gate off until every strict reader supports it. The map follows the existing bounded retired-ID history. Missing, saturated or ambiguous bare-location history receives a typed refusal. The current holder's bare location can be labelled in other regions when history excludes another occupant; authoritative-region live generations remain protected. |
| -Add an optional incarnation-to-location map in the same retirement CAS and trim it with the existing IDs. `ClaimRetirementCensus` proves each non-live instance location from retained history. Legacy objects require a unique explicit mapping; a path-qualified incarnation requires its retired ID. Missing or ambiguous history returns `ClaimDependencyError`. This intentionally cannot recover already discarded legacy history. |
| +A qualified incarnation path identifies itself, including beyond the 32-entry history cap. Retirement authority still comes from the existing regional worker and evidence checks. `View.RefusedLocations` isolates ambiguous instance residue. A refused moves proof is dropped per lane by `VerifyMovesSweep`, leaving proven instance candidates eligible. |
| -The production regional adapter composes that census with moves' provider. When moves residue exists, moves receives only its namespace; the full listing still reaches its `VerifyMovesSweep` precondition. `View.ArchivedIncarnations` keeps the legacy label independent from the move's proof label. Without moves residue, the claim can answer from its own proven instance history. It establishes a conservative nonzero observation-time bound; the queued scope persists it and the engine waits the full horizon. It never replaces an already persisted bound on retry. |
| +For a free claim the claim side proves lane retirement using the newest retained incarnation and the maximum nonzero QuietAfter bound, strictly before listing. Without that bound the lane refuses. Moves receives only its own namespace for occupied hosts and no longer has to prove free claims. The claim supplies the production `ReplicaParkLeaseSet` resolver. |
| -Focused command: `go test ./internal/host ./internal/hostclaim ./internal/park -run 'TestPlan12Archived|TestPlan12Legacy|Test.*Retir|Test.*Sweep|Test.*Park' -count=1`. |
| +Focused command: `go test ./internal/host ./internal/hostclaim ./internal/park -run 'TestPlan12.*(Census|Legacy|HolderResidue|Qualified|RetiredLocation)|Test.*Sweep' -count=1`. |
| -Expected first failure: the census and retained mapping fields are undefined. Tests drive the real retirement transitions and cover legacy and old-incarnation residue, absent history, unknown incarnation, ambiguous history, mixed namespace labels and a nonzero evidence bound. The existing wire round-trip and retirement tests remain required. |
| +Expected first failure: the census, view labels and write-gated field are absent. Regressions cover the old source region, live-region protection, qualified identities outside retained history, absent/ambiguous bare history, separate namespace refusals, free lanes, listing-time ordering and the write gate. |
| ### Failing tests |
| @@ -1420,7 +2842,7 @@ Expected first failure: the census and retained mapping fields are undefined. Te |
| ```diff |
| --- /dev/null |
| +++ b/internal/host/plan12_census_test.go |
| -@@ -0,0 +1,98 @@ |
| +@@ -0,0 +1,164 @@ |
| +package host |
| + |
| +import ( |
| @@ -1435,6 +2857,7 @@ Expected first failure: the census and retained mapping fields are undefined. Te |
| + |
| +func plan12Retired(t *testing.T) hostclaim.Observed { |
| + t.Helper() |
| ++ t.Setenv("CARLOS_HOST_CLAIM_RETIRED_LOCATIONS", "1") |
| + f := newRecoveryFixture(t) |
| + o := f.read() |
| + var err error |
| @@ -1457,7 +2880,7 @@ Expected first failure: the census and retained mapping fields are undefined. Te |
| + return o |
| +} |
| +func TestPlan12ArchivedIncarnationCensus(t *testing.T) { |
| -+ for _, mode := range []string{"legacy", "old-incarnation", "missing-history", "unknown-incarnation", "ambiguous-legacy"} { |
| ++ for _, mode := range []string{"legacy", "old-incarnation", "missing-history", "qualified-beyond-history", "ambiguous-legacy"} { |
| + t.Run(mode, func(t *testing.T) { |
| + o := plan12Retired(t) |
| + inc := o.Claim.RetiredIncarnations[0] |
| @@ -1468,7 +2891,7 @@ Expected first failure: the census and retained mapping fields are undefined. Te |
| + if mode == "missing-history" { |
| + o.Claim.RetiredLocations = nil |
| + } |
| -+ if mode == "unknown-incarnation" { |
| ++ if mode == "qualified-beyond-history" { |
| + loc += "/" + strings.Repeat("b", 64) |
| + } |
| + if mode == "ambiguous-legacy" { |
| @@ -1478,7 +2901,10 @@ Expected first failure: the census and retained mapping fields are undefined. Te |
| + } |
| + versions := []lease.ObjectVersion{{Key: loc + "/0000/db", VersionID: "v1"}} |
| + labels, err := ClaimRetirementCensus(o, versions) |
| -+ if mode == "legacy" || mode == "old-incarnation" { |
| ++ if mode == "qualified-beyond-history" { |
| ++ inc = strings.Repeat("b", 64) |
| ++ } |
| ++ if mode == "legacy" || mode == "old-incarnation" || mode == "qualified-beyond-history" { |
| + if err != nil || labels[loc] != inc { |
| + t.Fatal(labels, err) |
| + } |
| @@ -1519,6 +2945,89 @@ Expected first failure: the census and retained mapping fields are undefined. Te |
| + t.Fatal(label, at, err) |
| + } |
| +} |
| ++ |
| ++func TestPlan12HolderResidueInOtherRegion(t *testing.T) { |
| ++ f := newRecoveryFixture(t) |
| ++ o := f.read() |
| ++ c := o.Claim |
| ++ c.Holder.Location = "instances/" + o.Host |
| ++ c.Holder.Generation = "live" |
| ++ loc := c.Holder.Location |
| ++ versions := []lease.ObjectVersion{{Key: loc + "/live/db", VersionID: "v1"}} |
| ++ labels, err := ClaimRetirementCensus(o, versions) |
| ++ if err != nil || labels[loc] != c.Holder.Incarnation { |
| ++ t.Fatal(labels, err) |
| ++ } |
| ++ view := park.View{ClaimETag: o.ETag, Region: "uk", AuthoritativeRegion: "ie", ArchivedIncarnations: labels} |
| ++ d, err := park.Decide(o, view, versions, "") |
| ++ if err != nil || len(d.Candidates) != 1 || d.Candidates[0].Incarnation != c.Holder.Incarnation { |
| ++ t.Fatal(d, err) |
| ++ } |
| ++ view.Region = "ie" |
| ++ view.MarkerKnown = true |
| ++ view.MarkerGeneration = "live" |
| ++ view.GenerationOf = func(park.Ref) (string, bool) { return "live", true } |
| ++ d, err = park.Decide(o, view, versions, "") |
| ++ if err != nil || len(d.Candidates) != 0 { |
| ++ t.Fatal("live generation swept", d, err) |
| ++ } |
| ++} |
| ++func TestPlan12CensusNamespacesAndFreeLanes(t *testing.T) { |
| ++ o := plan12Retired(t) |
| ++ o.Claim.QuietAfter = map[string]time.Time{"ie": time.Unix(10, 0), "uk": time.Unix(20, 0)} |
| ++ proof, err := claimFreeMoveProof(o) |
| ++ if err != nil || proof.Check(o, o.Claim.RetiredIncarnations[0], time.Unix(21, 0)) != nil { |
| ++ t.Fatal(proof, err) |
| ++ } |
| ++ if proof.Check(o, proof.Incarnation, time.Unix(20, 0)) == nil { |
| ++ t.Fatal("accepted listing before retirement") |
| ++ } |
| ++ delete(o.Claim.RetiredLocations, o.Claim.RetiredIncarnations[0]) |
| ++ v := []lease.ObjectVersion{{Key: "instances/" + o.Host + "/0000/db", VersionID: "one"}, {Key: "moves/" + o.Host + "/move/db", VersionID: "two"}} |
| ++ p := productionClaimRegional{Now: func() time.Time { return time.Unix(30, 0) }} |
| ++ label, _, err := p.retirement(t.Context(), o, v) |
| ++ if err != nil || label != proof.Incarnation { |
| ++ t.Fatal("legacy refusal blocked lanes", err) |
| ++ } |
| ++ view := park.View{ClaimETag: o.ETag, Region: "ie", RefusedLocations: map[string]error{"instances/" + o.Host: claimRetirementDependency()}} |
| ++ d, err := park.Decide(o, view, v, label) |
| ++ if err != nil || len(d.Candidates) != 1 || d.Candidates[0].Location != "moves/"+o.Host { |
| ++ t.Fatal(d, err) |
| ++ } |
| ++ o.Claim.QuietAfter = nil |
| ++ if _, err := claimFreeMoveProof(o); err == nil { |
| ++ t.Fatal("invented retirement bound") |
| ++ } |
| ++} |
| ++func TestPlan12QualifiedIdentityBeyondHistoryCap(t *testing.T) { |
| ++ o := plan12Retired(t) |
| ++ loc := "instances/" + o.Host + "/" + strings.Repeat("e", 64) |
| ++ labels, err := ClaimRetirementCensus(o, []lease.ObjectVersion{{Key: loc + "/g/db", VersionID: "v"}}) |
| ++ if err != nil || labels[loc] != strings.Repeat("e", 64) { |
| ++ t.Fatal(labels, err) |
| ++ } |
| ++} |
| +--- /dev/null |
| ++++ b/internal/hostclaim/plan12_history_test.go |
| +@@ -0,0 +1,18 @@ |
| ++package hostclaim |
| ++ |
| ++import ( |
| ++ "strings" |
| ++ "testing" |
| ++) |
| ++ |
| ++func TestPlan12RetiredLocationWriteGate(t *testing.T) { |
| ++ for _, enabled := range []string{"", "1"} { |
| ++ t.Setenv("CARLOS_HOST_CLAIM_RETIRED_LOCATIONS", enabled) |
| ++ inc := strings.Repeat("a", 64) |
| ++ c := &Claim{Host: "shop.example", Holder: &Holder{Incarnation: inc, Location: "instances/shop.example"}} |
| ++ retire(c, inc) |
| ++ if (c.RetiredLocations[inc] != "") != (enabled == "1") { |
| ++ t.Fatal(enabled, c.RetiredLocations) |
| ++ } |
| ++ } |
| ++} |
| ``` |
| ### Implementation |
| @@ -1549,11 +3058,19 @@ Expected first failure: the census and retained mapping fields are undefined. Te |
| } |
| --- a/internal/hostclaim/create.go |
| +++ b/internal/hostclaim/create.go |
| -@@ -42,9 +42,20 @@ |
| +@@ -4,6 +4,7 @@ |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| ++ "os" |
| + "time" |
| + ) |
| + |
| +@@ -42,9 +43,20 @@ |
| } |
| func retire(c *Claim, inc string) { |
| -+ if c.Holder != nil && c.Holder.Incarnation == inc && isHex64(inc) { |
| ++ if os.Getenv("CARLOS_HOST_CLAIM_RETIRED_LOCATIONS") == "1" && c.Holder != nil && c.Holder.Incarnation == inc && isHex64(inc) { |
| + if c.RetiredLocations == nil { |
| + c.RetiredLocations = map[string]string{} |
| + } |
| @@ -1572,17 +3089,27 @@ Expected first failure: the census and retained mapping fields are undefined. Te |
| --- a/internal/park/sweep.go |
| +++ b/internal/park/sweep.go |
| -@@ -18,6 +18,9 @@ |
| +@@ -18,6 +18,10 @@ |
| // Pinned includes the exact noncurrent versions needed by the installed root. |
| // MarkerKnown means its read succeeded; an absent marker may denote @root. |
| type View struct { |
| + // ArchivedIncarnations labels proven retired locations independently of |
| + // the moves-sweep proof's incarnation. Missing entries never invent labels. |
| + ArchivedIncarnations map[string]string |
| ++ RefusedLocations map[string]error |
| ClaimETag, Region, AuthoritativeRegion string |
| MarkerKnown bool |
| MarkerGeneration string |
| -@@ -147,6 +150,9 @@ |
| +@@ -142,11 +146,18 @@ |
| + if isLock(c.Host, loc, x.Key) { |
| + continue |
| + } |
| ++ if refusal := v.RefusedLocations[loc]; refusal != nil && !(c.Holder != nil && loc == c.Holder.Location && v.Region == v.AuthoritativeRegion) { |
| ++ d.Alerts = append(d.Alerts, Alert{loc, refusal.Error()}) |
| ++ continue |
| ++ } |
| + candidate := Candidate{Location: loc} |
| + if tail := strings.TrimPrefix(loc, "instances/"+c.Host+"/"); tail != loc { |
| candidate.Incarnation = tail |
| } else { |
| candidate.Incarnation = legacyIncarnation |
| @@ -1592,7 +3119,7 @@ Expected first failure: the census and retained mapping fields are undefined. Te |
| } |
| if c.Holder != nil && loc == c.Holder.Location && v.Region == v.AuthoritativeRegion { |
| if liveErr != nil { |
| -@@ -403,3 +409,23 @@ |
| +@@ -403,3 +414,23 @@ |
| } |
| return d, nil |
| } |
| @@ -1618,11 +3145,12 @@ Expected first failure: the census and retained mapping fields are undefined. Te |
| +} |
| --- /dev/null |
| +++ b/internal/host/claimretirementcensus.go |
| -@@ -0,0 +1,124 @@ |
| +@@ -0,0 +1,142 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| ++ "errors" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/lease" |
| @@ -1631,15 +3159,13 @@ Expected first failure: the census and retained mapping fields are undefined. Te |
| + "time" |
| +) |
| + |
| -+// ClaimRetirementCensus proves labels from retained claim history. Bare legacy |
| -+// objects contain no incarnation identity. A retired ID alone cannot label |
| -+// them: the retirement must have retained its holder location as well. |
| ++// ClaimRetirementCensus supplies identity; the regional engine retains retirement |
| ++// authority and region-specific live-set checks. Bare paths need complete history. |
| +func ClaimRetirementCensus(o hostclaim.Observed, versions []lease.ObjectVersion) (map[string]string, error) { |
| + if o.Absent || o.Claim == nil || o.ETag == "" { |
| + return nil, hostbox.ErrChanged |
| + } |
| -+ c := o.Claim |
| -+ if err := c.Validate(); err != nil { |
| ++ if err := o.Claim.Validate(); err != nil { |
| + return nil, err |
| + } |
| + locations, err := park.ResidueLocations(o.Host, versions) |
| @@ -1647,59 +3173,85 @@ Expected first failure: the census and retained mapping fields are undefined. Te |
| + return nil, err |
| + } |
| + labels := map[string]string{} |
| -+ retired := map[string]bool{} |
| -+ for _, inc := range c.RetiredIncarnations { |
| -+ retired[inc] = true |
| -+ } |
| ++ var dependency error |
| + for _, loc := range locations { |
| -+ if loc == "moves/"+o.Host || c.Holder != nil && loc == c.Holder.Location { |
| ++ if loc == "moves/"+o.Host { |
| + continue |
| + } |
| -+ var inc string |
| -+ if loc == "instances/"+o.Host { |
| -+ for candidate, where := range c.RetiredLocations { |
| -+ if where != loc { |
| -+ continue |
| -+ } |
| -+ if inc != "" && inc != candidate { |
| -+ return nil, claimRetirementDependency() |
| -+ } |
| -+ inc = candidate |
| -+ } |
| -+ } else { |
| -+ inc = strings.TrimPrefix(loc, "instances/"+o.Host+"/") |
| ++ if loc != "instances/"+o.Host { |
| ++ // Identity is encoded in this validated qualified path, even beyond the history cap. |
| ++ labels[loc] = strings.TrimPrefix(loc, "instances/"+o.Host+"/") |
| ++ continue |
| + } |
| -+ if inc == "" || !retired[inc] { |
| -+ return nil, claimRetirementDependency() |
| ++ inc, err := claimLegacyIncarnation(o.Claim) |
| ++ if err != nil { |
| ++ dependency = err |
| ++ continue |
| + } |
| + labels[loc] = inc |
| + } |
| -+ return labels, nil |
| ++ return labels, dependency |
| +} |
| +func claimRetirementDependency() error { |
| + return &ClaimDependencyError{Contracts: []string{"claim archived incarnation and location evidence"}} |
| +} |
| ++ |
| ++func claimLegacyIncarnation(c *hostclaim.Claim) (string, error) { |
| ++ if c == nil || len(c.RetiredIncarnations) >= 32 { |
| ++ return "", claimRetirementDependency() |
| ++ } |
| ++ loc, inc := "instances/"+c.Host, "" |
| ++ if c.Holder != nil && c.Holder.Location == loc { |
| ++ inc = c.Holder.Incarnation |
| ++ } |
| ++ for _, retired := range c.RetiredIncarnations { |
| ++ where := c.RetiredLocations[retired] |
| ++ if where == "" { |
| ++ return "", claimRetirementDependency() |
| ++ } |
| ++ if where == loc { |
| ++ if inc != "" && inc != retired { |
| ++ return "", claimRetirementDependency() |
| ++ } |
| ++ inc = retired |
| ++ } |
| ++ } |
| ++ if inc == "" { |
| ++ return "", claimRetirementDependency() |
| ++ } |
| ++ return inc, nil |
| ++} |
| +func claimArchivedLocations(c *hostclaim.Claim) map[string]string { |
| + labels := map[string]string{} |
| -+ ambiguous := map[string]bool{} |
| + if c == nil { |
| + return labels |
| + } |
| -+ for inc, loc := range c.RetiredLocations { |
| -+ if prior := labels[loc]; prior != "" && prior != inc { |
| -+ ambiguous[loc] = true |
| ++ if inc, err := claimLegacyIncarnation(c); err == nil { |
| ++ labels["instances/"+c.Host] = inc |
| ++ } |
| ++ return labels |
| ++} |
| ++ |
| ++func claimFreeMoveProof(o hostclaim.Observed) (park.MoveSweepProof, error) { |
| ++ if o.Absent || o.Claim == nil || o.ETag == "" || !o.Claim.Free() || len(o.Claim.RetiredIncarnations) == 0 { |
| ++ return park.MoveSweepProof{}, claimRetirementDependency() |
| ++ } |
| ++ var at time.Time |
| ++ for _, bound := range o.Claim.QuietAfter { |
| ++ if bound.After(at) { |
| ++ at = bound |
| + } |
| -+ labels[loc] = inc |
| + } |
| -+ for loc := range ambiguous { |
| -+ delete(labels, loc) |
| ++ if at.IsZero() { |
| ++ return park.MoveSweepProof{}, claimRetirementDependency() |
| + } |
| -+ return labels |
| ++ return park.MoveSweepProof{Host: o.Host, ClaimETag: o.ETag, MoveID: "claim-free", Incarnation: o.Claim.RetiredIncarnations[0], RetiredAt: at}, nil |
| +} |
| + |
| +func (p productionClaimRegional) retirement(ctx context.Context, o hostclaim.Observed, v []lease.ObjectVersion) (string, time.Time, error) { |
| + labels, err := ClaimRetirementCensus(o, v) |
| -+ if err != nil { |
| ++ var dependency *ClaimDependencyError |
| ++ if err != nil && !errors.As(err, &dependency) { |
| + return "", time.Time{}, err |
| + } |
| + now := time.Now().UTC() |
| @@ -1709,43 +3261,53 @@ Expected first failure: the census and retained mapping fields are undefined. Te |
| + if now.IsZero() { |
| + return "", time.Time{}, hostbox.ErrChanged |
| + } |
| -+ locations, err := park.ResidueLocations(o.Host, v) |
| -+ if err != nil { |
| -+ return "", time.Time{}, err |
| -+ } |
| -+ hasMoves := false |
| -+ for _, loc := range locations { |
| -+ if loc == "moves/"+o.Host { |
| -+ hasMoves = true |
| -+ } |
| -+ } |
| -+ if !hasMoves { |
| -+ return labels["instances/"+o.Host], now, nil |
| -+ } |
| -+ if p.Parks == nil || p.Parks.Evidence == nil { |
| -+ return "", time.Time{}, &ClaimDependencyError{Contracts: []string{"moves Plan 5 newest retired move fence or release evidence"}} |
| -+ } |
| -+ // Moves proves its own namespace. It cannot be asked to infer legacy labels. |
| -+ filtered := make([]lease.ObjectVersion, 0, len(v)) |
| ++ var filtered []lease.ObjectVersion |
| + for _, x := range v { |
| + if strings.HasPrefix(x.Key, "moves/"+o.Host+"/") { |
| + filtered = append(filtered, x) |
| + } |
| + } |
| -+ inc, at, err := p.Parks.Evidence.VerifyRetirement(ctx, o, filtered) |
| -+ if err != nil { |
| -+ return "", time.Time{}, err |
| ++ if len(filtered) == 0 { |
| ++ return labels["instances/"+o.Host], now, nil |
| + } |
| -+ if at.IsZero() || at.After(now) { |
| -+ return "", time.Time{}, hostbox.ErrChanged |
| ++ if proof, err := claimFreeMoveProof(o); err == nil { |
| ++ return proof.Incarnation, now, nil |
| ++ } |
| ++ label := labels["instances/"+o.Host] |
| ++ if o.Claim.Holder != nil { |
| ++ label = o.Claim.Holder.Incarnation |
| ++ } else if len(o.Claim.RetiredIncarnations) > 0 { |
| ++ label = o.Claim.RetiredIncarnations[0] |
| ++ } |
| ++ if p.Parks != nil && p.Parks.Evidence != nil { |
| ++ inc, at, err := p.Parks.Evidence.VerifyRetirement(ctx, o, filtered) |
| ++ if err == nil && !at.IsZero() && !at.After(now) { |
| ++ return inc, now, nil |
| ++ } |
| + } |
| -+ // A listing-time bound is conservative for already retired instance data; |
| -+ // moves has its own earlier retirement proof, checked again by Sweep. |
| -+ return inc, now, nil |
| ++ // A refused lane is dropped by VerifyMovesSweep; it cannot block proven instance residue. |
| ++ return label, now, nil |
| +} |
| --- a/internal/host/claimproduction.go |
| +++ b/internal/host/claimproduction.go |
| -@@ -297,6 +297,7 @@ |
| +@@ -36,7 +36,6 @@ |
| + Open(context.Context, release.Store, lease.VersionedStore, hostbox.Actor, ClaimMoveReadAccess) (ClaimMovesOwners, ClaimFilesManifest, error) |
| + View(context.Context, hostclaim.Observed) (park.View, error) |
| + VerifyRetirement(context.Context, hostclaim.Observed, []lease.ObjectVersion) (string, time.Time, error) |
| +- LeaseSet(context.Context, string, string) ([]string, error) |
| + Close(context.Context) error |
| + } |
| + |
| +@@ -53,7 +52,7 @@ |
| + return hostwrite.ErrUnavailable |
| + } |
| + if claimMoveSources.Parks == nil { |
| +- claimMoveSources.Parks = &claimMoveParkSources{View: s.View, Evidence: s, Leases: s.LeaseSet} |
| ++ claimMoveSources.Parks = &claimMoveParkSources{View: s.View, Evidence: s} |
| + } |
| + claimMoveSources.Source = s |
| + return nil |
| +@@ -297,6 +296,7 @@ |
| } |
| type productionClaimRegional struct { |
| @@ -1753,7 +3315,7 @@ Expected first failure: the census and retained mapping fields are undefined. Te |
| Identity hostbox.IdentityProvider |
| Parks *claimMoveParkSources |
| Local *productionClaimLocal |
| -@@ -309,7 +310,12 @@ |
| +@@ -309,12 +309,21 @@ |
| if _, err := p.Identity.Verify(ctx); err != nil { |
| return park.View{}, err |
| } |
| @@ -1763,12 +3325,28 @@ Expected first failure: the census and retained mapping fields are undefined. Te |
| + return park.View{}, err |
| + } |
| + v.ArchivedIncarnations = claimArchivedLocations(o.Claim) |
| ++ v.RefusedLocations = map[string]error{} |
| ++ if _, err := claimLegacyIncarnation(o.Claim); err != nil { |
| ++ v.RefusedLocations["instances/"+o.Host] = err |
| ++ } |
| ++ if o.Claim.Holder == nil && len(o.Claim.RetiredIncarnations) == 0 { |
| ++ v.RefusedLocations["moves/"+o.Host] = claimRetirementDependency() |
| ++ } |
| + return v, nil |
| } |
| func (p productionClaimRegional) LeaseSet(ctx context.Context, bucket, scope string) ([]string, error) { |
| - if p.Parks == nil || p.Parks.Leases == nil { |
| -@@ -325,13 +331,10 @@ |
| - return p.Parks.Leases(ctx, bucket, scope) |
| +- if p.Parks == nil || p.Parks.Leases == nil { |
| +- return nil, &ClaimDependencyError{Contracts: []string{"moves Plan 5 complete replica and staging lease inventory"}} |
| +- } |
| + a, err := p.Identity.Verify(ctx) |
| + if err != nil { |
| + return nil, err |
| +@@ -322,16 +331,13 @@ |
| + if a.Bucket != bucket { |
| + return nil, hostbox.ErrChanged |
| + } |
| +- return p.Parks.Leases(ctx, bucket, scope) |
| ++ return hostbox.ReplicaParkLeaseSet(ctx, bucket, scope) |
| } |
| func (p productionClaimRegional) VerifyRetirement(ctx context.Context, o hostclaim.Observed, v []lease.ObjectVersion) (string, time.Time, error) { |
| - if p.Parks == nil || p.Parks.Evidence == nil { |
| @@ -1800,6 +3378,33 @@ Expected first failure: the new four-value accessor does not exist. Also verify |
| ```diff |
| --- a/internal/host/claimmoveparks_test.go |
| +++ b/internal/host/claimmoveparks_test.go |
| +@@ -56,8 +56,8 @@ |
| + claimMoveSources.Parks = oldParks |
| + claimMoveSources.Unlock() |
| + }) |
| +- err := RegisterProductionClaimMoveParks(nil, nil, nil) |
| +- for _, name := range []string{"view", "regional evidence", "lease inventory"} { |
| ++ err := RegisterProductionClaimMoveParks(nil, nil) |
| ++ for _, name := range []string{"view", "regional evidence"} { |
| + if err == nil || !strings.Contains(err.Error(), name) { |
| + t.Fatalf("missing %s: %v", name, err) |
| + } |
| +@@ -66,13 +66,13 @@ |
| + t.Fatal("partial registration") |
| + } |
| + source := &parkRegistrationWake{} |
| +- if err := RegisterProductionClaimMoveParks(source.View, source, source.LeaseSet); err != nil { |
| ++ if err := RegisterProductionClaimMoveParks(source.View, source); err != nil { |
| + t.Fatal(err) |
| + } |
| + if source.opens != 0 || claimMoveSources.Source != nil { |
| + t.Fatal("parks opened or registered wake") |
| + } |
| +- if err := RegisterProductionClaimMoveParks(source.View, source, source.LeaseSet); !errors.Is(err, hostwrite.ErrUnavailable) { |
| ++ if err := RegisterProductionClaimMoveParks(source.View, source); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatalf("replacement: %v", err) |
| + } |
| + parks := claimMoveSources.Parks |
| @@ -115,8 +115,8 @@ |
| sentinel := errors.New("real view") |
| calls := 0 |
| @@ -1882,16 +3487,40 @@ Expected first failure: the new four-value accessor does not exist. Also verify |
| ```diff |
| --- a/internal/host/claimmoveparks.go |
| +++ b/internal/host/claimmoveparks.go |
| -@@ -27,7 +27,7 @@ |
| +@@ -16,21 +16,17 @@ |
| + type claimMoveParkSources struct { |
| + View func(context.Context, hostclaim.Observed) (park.View, error) |
| + Evidence hostbox.RegionalEvidence |
| +- Leases hostbox.LeaseSet |
| + } |
| + |
| + // RegisterProductionClaimMoveParks installs the regional contracts independently |
| + // of the wake owners and C8 files manifest. Registration is atomic and one-shot. |
| +-func RegisterProductionClaimMoveParks(view func(context.Context, hostclaim.Observed) (park.View, error), retirementEvidence hostbox.RegionalEvidence, leases hostbox.LeaseSet) error { |
| ++func RegisterProductionClaimMoveParks(view func(context.Context, hostclaim.Observed) (park.View, error), retirementEvidence hostbox.RegionalEvidence) error { |
| + var missing []string |
| + if view == nil { |
| missing = append(missing, "moves Plan 5 exact-version generation and files-manifest view") |
| } |
| if retirementEvidence == nil { |
| - missing = append(missing, "moves Plan 5 archived-incarnation regional evidence") |
| +- } |
| +- if leases == nil { |
| +- missing = append(missing, "moves Plan 5 complete replica and staging lease inventory") |
| + missing = append(missing, "moves Plan 5 retired-move regional evidence") |
| } |
| - if leases == nil { |
| - missing = append(missing, "moves Plan 5 complete replica and staging lease inventory") |
| -@@ -87,22 +87,29 @@ |
| + if len(missing) != 0 { |
| + return &ClaimDependencyError{Contracts: missing} |
| +@@ -40,7 +36,7 @@ |
| + if claimMoveSources.Parks != nil { |
| + return hostwrite.ErrUnavailable |
| + } |
| +- claimMoveSources.Parks = &claimMoveParkSources{View: view, Evidence: retirementEvidence, Leases: leases} |
| ++ claimMoveSources.Parks = &claimMoveParkSources{View: view, Evidence: retirementEvidence} |
| + return nil |
| + } |
| + |
| +@@ -87,25 +83,38 @@ |
| } |
| } |
| @@ -1926,6 +3555,15 @@ Expected first failure: the new four-value accessor does not exist. Also verify |
| } |
| func (p productionClaimRegional) VerifyMovesSweep(ctx context.Context, o hostclaim.Observed, versions []lease.ObjectVersion) (park.MoveSweepProof, error) { |
| ++ if _, err := p.Identity.Verify(ctx); err != nil { |
| ++ return park.MoveSweepProof{}, err |
| ++ } |
| ++ if o.Claim != nil && o.Claim.Free() { |
| ++ return claimFreeMoveProof(o) |
| ++ } |
| + if p.Parks == nil { |
| + return park.MoveSweepProof{}, &ClaimDependencyError{Contracts: []string{"moves Plan 5 newest retired move fence or release evidence"}} |
| + } |
| --- a/internal/host/claimassembly.go |
| +++ b/internal/host/claimassembly.go |
| @@ -251,7 +251,7 @@ |
| @@ -1933,7 +3571,7 @@ Expected first failure: the new four-value accessor does not exist. Also verify |
| return ClaimProviders{}, err |
| } |
| - return ClaimProviders{moveParks: &claimMoveParks{engine: regional.Regional.Engine, leases: leases, check: check}, Delivery: ClaimTimerDelivery(a.Producers, a.NewID), Selector: source, Wake: wake, Regional: regional, Stop: a.Stop, Provision: a.Provision, Close: a.Close}, nil |
| -+ return ClaimProviders{moveParks: &claimMoveParks{local: claimMoveLocalAuthorizer(check, a.Authorize), engine: regional.Regional.Engine, leases: leases, check: check}, Delivery: ClaimTimerDelivery(a.Producers, a.NewID), Selector: source, Wake: wake, Regional: regional, Stop: a.Stop, Provision: a.Provision, Close: a.Close}, nil |
| ++ return ClaimProviders{moveTargetFence: claimBoxFenceVerifier(check, a.Claims, a.RetirementAuthority), moveParks: &claimMoveParks{local: claimMoveLocalAuthorizer(check, a.Authorize), engine: regional.Regional.Engine, leases: leases, check: check}, Delivery: ClaimTimerDelivery(a.Producers, a.NewID), Selector: source, Wake: wake, Regional: regional, Stop: a.Stop, Provision: a.Provision, Close: a.Close}, nil |
| } |
| type claimRegionalEnrollment struct { |
| @@ -1956,22 +3594,24 @@ Expected first failure: the new four-value accessor does not exist. Also verify |
| +} |
| ``` |
| -## Task G: Classify the changed census units |
| +## Task G: Classify every changed census against the pinned base |
| -Run the existing record-write census before applying this patch: `go test ./internal/provision -run 'TestPlan9B8' -count=1`. Its expected defect is stale hashes and unlisted units, not an infrastructure failure. Review these exact classifications before applying the inventory patch: |
| +Run `go test ./internal/provision -run TestPlan9B8 -count=1` before and after this patch. The initial failure must identify the changed units below. Every hunk is generated against `44c1b6d7` plus Tasks A–F, including the then-current B8 inventory. Do not apply this census to `70f8119f` or a concurrent moves branch without reconciling its own changes. |
| -| Unit | Classification | Reason | |
| -|---|---|---| |
| -| `hostclaim.Claim.Validate` | `key-site`, `lit:instances` | Validates retained location identity; writes no record. | |
| -| `park.Engine.run` (formerly `Engine.Run`) | `versioned-transport`, `sel:CopyVersion,sel:PutIfAbsent` | Existing exact-version archive effects move into the common engine body; no new storage authority. | |
| -| `park.Decide` | `key-site`, `lit:instances` | Chooses an independently proven legacy archive label. | |
| -| `hostbox.ReplicaParkLeaseSet` | `key-site`, `lit:instances` | Parses relative data-bucket location names. | |
| -| `host.ClaimRetirementCensus` | `key-site`, `lit:instances` | Classifies instance residue from claim history. | |
| -| `host.productionClaimRegional.retirement` | `key-site`, `lit:instances` | Separates instance evidence from the moves namespace. | |
| +| Unit | Classification and reason | |
| +|---|---| |
| +| `hostclaim.Claim.Validate` | `key-site`, `lit:instances`; validates optional retained paths. | |
| +| `park.Engine.run` (was `Engine.Run`) | `versioned-transport`, `sel:CopyVersion,sel:PutIfAbsent`; existing exact-version effects moved to the shared body. | |
| +| `park.Decide` | `key-site`, `lit:instances`; isolates refusal and chooses labels. | |
| +| `hostbox.ReplicaParkLeaseSet` | `key-site`, `lit:instances`; parses data-bucket scopes. | |
| +| `host.ClaimRetirementCensus`, `claimLegacyIncarnation`, `claimArchivedLocations`, `productionClaimRegional.retirement` | `key-site`, `lit:instances`; identity and namespace classification, no record write. | |
| +| `host.productionClaimRegional.View` | `key-site`, `lit:instances`; attaches independent labels and refusals. | |
| +| `hostops.moveLossOwner.ValidateLossRecovery` | `key-site`, `key:RecordKey`; compares the signed intent's record identity. | |
| +| `hostops.MoveRecoveryGrant.ReleaseFencedTarget` | `record-writer`, `key:WriteRecord,key:writeRecord`; one sequenced record effect or claim effect per expiring grant. | |
| -No production top-level declaration touched by this task writes an instance record. The console authority census scans console, hostops, mail and cmd/console; none of those production files changes. The console route census has no new route. The moves copy scan has no new moves sink. Leave those inventories unchanged, and run them after this patch. Later moves callers of the fence declaration and the four-value capability belong to that integration's classified changes. |
| +The console authority scanner also gains the names `VerifyMoveTargetFence` and `ReleaseFencedTarget`. Its fixture adds exactly the two hostclaim calls in `hostops/move_fence_release.go`; both are under the expiring, record-bound recovery grant. Preserve repeated fixture entries: they represent separate source calls. The route inventory is unchanged. The moves scan adds the new grant file and pending row MV-N319 for the controller's exact `target fenced` reason, plus its pinned copy ID. No sink exemption is added; serial integration must reconcile any competing use of that ID. |
| -After applying: `go test ./internal/provision -run 'TestPlan9B8' -count=1`, `go test ./internal/console -run 'TestClaim.*(Census|Route)' -count=1`, and `go test . -run 'TestMoves(StringsAreRecorded|SinksAreSentences)' -count=1`. |
| +After applying, run `go test ./internal/provision -run TestPlan9B8 -count=1`, `go test ./internal/console -run 'TestClaimAuthoritySourceCensus|TestClaim.*Routes' -count=1`, and `go test . -run 'TestMoves(StringsAreRecorded|SinksAreSentences)' -count=1`. Socket-dependent route tests require an environment that permits their test listeners. |
| ### Implementation |
| @@ -1979,17 +3619,22 @@ After applying: `go test ./internal/provision -run 'TestPlan9B8' -count=1`, `go |
| ```diff |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| -@@ -472,6 +472,9 @@ |
| +@@ -472,6 +472,14 @@ |
| // BEGIN REVIEWED INVENTORY |
| var plan9B8CensusInventory = []censusEntry{ |
| -+ {"internal/host/claimretirementcensus.go", "productionClaimRegional.retirement", "key-site", "lit:instances", "af5e4800aca07da1f414063b5f38325713bb1463b5be1887beb6fc060b7ab2a5"}, |
| -+ {"internal/host/claimretirementcensus.go", "ClaimRetirementCensus", "key-site", "lit:instances", "217ae8f21e4e682571f30b0921a854a3f51b38a43b11d4124bb1e0eb2c24a55e"}, |
| ++ {"internal/hostops/move_fence_release.go", "MoveRecoveryGrant.ReleaseFencedTarget", "record-writer", "key:WriteRecord,key:writeRecord", "69e582fe41487e31ba16b2cb56bd7d8d197152209fb6ca3cb2fd9862afe4c12d"}, |
| ++ {"internal/host/claimretirementcensus.go", "claimLegacyIncarnation", "key-site", "lit:instances", "31a14ea105c67e00a24f06b9500a221ee5bfc47402b9c33cecfde807e51db530"}, |
| ++ {"internal/host/claimretirementcensus.go", "claimArchivedLocations", "key-site", "lit:instances", "9ba8ea4ecb5ad63621c1322467b9a80f8f5703896ba1fc3f7cb1addd56418950"}, |
| ++ {"internal/hostops/move_loss_admission.go", "moveLossOwner.ValidateLossRecovery", "key-site", "key:RecordKey", "ea2aa35a069ffdc846ecb208c33940245fc865795188818e86ea7a93135dd6a9"}, |
| ++ {"internal/host/claimproduction.go", "productionClaimRegional.View", "key-site", "lit:instances", "61ff9ff47054b6a96cb24467c7aaf41c112b7c5129987af5ce27e0b7f862348e"}, |
| ++ {"internal/host/claimretirementcensus.go", "productionClaimRegional.retirement", "key-site", "lit:instances", "19306389daacc704a94cfb434b7d557ced96abe09958a01be38a0707b3ea0a6f"}, |
| ++ {"internal/host/claimretirementcensus.go", "ClaimRetirementCensus", "key-site", "lit:instances", "abc0bd8c5f5d328abfac8821f42f8f23954723ed6506747f33b0197824e2611f"}, |
| + {"internal/hostbox/parkleases.go", "ReplicaParkLeaseSet", "key-site", "lit:instances", "0ca8d9b743402fda9d15cc215464423b35e21c1c2b47f91294d76188b7d43fb6"}, |
| {"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Convert", "record-writer", "key:WriteRecord", "794963a24a0a50d980505f83ac912973a10267b21f74bd41c56584ab9b0b25a1"}, |
| {"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Record", "record-writer", "key:WriteRecord", "6b8bd4131842d8f22a6ab55aaae736329df40e34abb348d3d68cbd0a713503ad"}, |
| // Plan 10: reviewed typed console move and loss writers. |
| -@@ -686,7 +689,7 @@ |
| +@@ -686,7 +694,7 @@ |
| {"internal/hostclaim/adopt.go", "Adopt", "key-site", "lit:instances", "24c06b99cce6c638acb19826985cfadd3c736fd0ace7289bcf7ac3dfe784f05a"}, |
| {"internal/hostclaim/claim.go", "ParkScope.Validate", "key-site", "lit:instances", "80b798b0cb679d8c533ff562c0c4804c06f53e7dba126250a3c8182f7ff0c08c"}, |
| {"internal/hostclaim/claim.go", "holderKeyMatches", "key-site", "lit:instances,lit:sidecars", "d15a6841ea6f8cbfa9c8407fd3af020ddeacdfa0743b9e17de412f113faf7979"}, |
| @@ -1998,22 +3643,75 @@ After applying: `go test ./internal/provision -run 'TestPlan9B8' -count=1`, `go |
| {"internal/hostclaim/create.go", "BeginCreate", "key-site", "lit:instances", "45ee1e2b1fc9fa8ffdef4f27cf983dccfa3acf7218dccd06f0ee6d12572f4a44"}, |
| {"internal/hostclaim/lossrecord.go", "LossRecordKey", "key-site", "key:LossRecordKey", "1b109b49a8fcdb09f01dbf908a173394072f01afb0c9e324023fb9b3f71f98b3"}, |
| {"internal/hostclaim/lossrecord.go", "ReadLossRecord", "key-site", "key:LossRecordKey", "1237dd3fa3e79a81e6c35f371bf545c9a04841a6297c078d96852daf25b7b483"}, |
| -@@ -808,8 +811,8 @@ |
| +@@ -808,8 +816,8 @@ |
| {"internal/objstore/sidecar.go", "claimRawPutSidecar", "nonrecord", "sel:Put", "233e3d808dd462ff83aecdac5176b180556b948c552aa26f6e0b778df91a24c7"}, |
| {"internal/ops/litestream.go", "FleetDBsWithSkipped", "key-site", "lit:instances", "f62291df0685db15529c4946dca358a29aba66fc31e81aab299de579e37f60f1"}, |
| {"internal/park/remote.go", "locationOf", "key-site", "lit:instances", "83b3efca31b3faadcd5997dc181d81b7bb8fc866cc4a448683bf8423b1bea866"}, |
| - {"internal/park/remote.go", "Engine.Run", "versioned-transport", "sel:CopyVersion,sel:PutIfAbsent", "dd4b60ef9b3f5e186b9866cee818f2684ed93bd4e1c934cd66d1af9ef376d81e"}, |
| - {"internal/park/sweep.go", "Decide", "key-site", "lit:instances", "c4ec278026ad3b99ed088471bf32a2b2787b19b948587b34f784e97a31a29f1d"}, |
| + {"internal/park/remote.go", "Engine.run", "versioned-transport", "sel:CopyVersion,sel:PutIfAbsent", "3de5b215e4b7aba67768243891b3f6d8ff8c77a465aefe5168c789cb012b1ea9"}, |
| -+ {"internal/park/sweep.go", "Decide", "key-site", "lit:instances", "6f8660313ec724b5a390a8c00aadb3412579849bda89e68ef4b55cc52d05dc39"}, |
| ++ {"internal/park/sweep.go", "Decide", "key-site", "lit:instances", "334144f96a8b19448790578370923c290b1ef50142e8ffc7a7f5933281f7e057"}, |
| {"internal/park/sweep.go", "Engine.Discover", "key-site", "lit:instances", "69b83e6e859c1c9809ba76d46cb0192888df1ecdcd161b4f2b6435733eecdfa3"}, |
| {"internal/provision/claim_app_writes.go", "UpdateRecord", "record-writer", "key:UpdateRecord,key:claimRawUpdateRecord", "caf714cb78e1280d489c7ad5b3cefb1420e812d541108b1fe13d733abed97229"}, |
| {"internal/provision/provision.go", "ManifestKey", "key-site", "lit:instances", "b8ee0e77bdfade19af79a4012c411c1eb0b6ddca16037e5536fa8597f7cde270"}, |
| +--- a/internal/console/claim_authority_census_test.go |
| ++++ b/internal/console/claim_authority_census_test.go |
| +@@ -25,7 +25,7 @@ |
| + "NewClamAVScanner": true, "NewRoute53Writer": true, "BeginExclusive": true, |
| + "ResumeExclusive": true, "RegisterStart": true, "FinishWakeRetryReset": true, "ReservePurge": true, |
| + "ConsoleMove": true, "InspectMoveLoss": true, "DeclareMoveLoss": true, |
| +- "RunRecovery": true, "runMoveRecovery": true, "captureMoveStartBase": true, "resolveUncertain": true, "ConvertMovingToRelocating": true, |
| ++ "RunRecovery": true, "runMoveRecovery": true, "captureMoveStartBase": true, "resolveUncertain": true, "ConvertMovingToRelocating": true, "ReleaseFencedTarget": true, "VerifyMoveTargetFence": true, |
| + "BeginPurge": true, "SavePurgeJournal": true, "RemovePurgeJournal": true, |
| + "AcquireMailGate": true, "ReleaseMailGate": true, "EnrollNewApp": true, |
| + "EnrollBackfillApp": true, "OpenGated": true, "WithAppMutation": true, |
| +--- a/internal/console/testdata/claim-authority-sites.txt |
| ++++ b/internal/console/testdata/claim-authority-sites.txt |
| +@@ -12,6 +12,8 @@ |
| + ../hostops/lifecycle_grant.go|RecoveryPass|w.RunRecovery(ctx, r.Pass) |
| + ../hostops/move_enrollment.go|RegisterMove|a.Control.RegisterStart(ctx, h.Account, h.App, appID, id, reg, a.Now()) |
| + ../hostops/move_enrollment.go|StartClaimMoveRecord|h.captureMoveStartBase(ctx, seq) |
| ++../hostops/move_fence_release.go|ReleaseFencedTarget|hostclaim.ReleaseFencedTarget(r) |
| ++../hostops/move_fence_release.go|ReleaseFencedTarget|hostclaim.VerifyMoveTargetFence(ctx, g.w.raw, o, ref, g.authority) |
| + ../hostops/move_loss_authority.go|DeclareMoveLoss|a.Control.DeclareMoveLoss(ctx, a.Signer, a.Directory.Bucket, actor, account, app, host, box, in) |
| + ../hostops/move_loss_authority.go|InspectMoveLoss|a.Control.InspectMoveLoss(ctx, a.Signer, a.Directory.Bucket, actor, account, app, host, box) |
| + ../hostops/move_recovery_bridge.go|RecoverTerminatedMovingHost|hostclaim.ConvertMovingToRelocating(mh.Lifecycle(ctx), s, fresh, c.Op.ID, ref, authority, guard) |
| +--- a/approved_moves_copy_test.go |
| ++++ b/approved_moves_copy_test.go |
| +@@ -74,7 +74,7 @@ |
| + "MV-N300", "MV-N301", "MV-N302", "MV-N303", "MV-N304", "MV-N305", "MV-N306", |
| + "MV-N307", "MV-N308", "MV-N309", "MV-N310", "MV-N311", "MV-N312", |
| + "MV-N313", "MV-N314", |
| +- "MV-N315", "MV-N316", "MV-N317", "MV-N318", |
| ++ "MV-N315", "MV-N316", "MV-N317", "MV-N318", "MV-N319", |
| + } |
| + |
| + // copyRow is one row of the record. The File cell is the file the sentence is |
| +@@ -543,6 +543,7 @@ |
| + // copy" task appends the files it touched; TestMovesScanFilesCoverTheDiff |
| + // fails when the list falls behind. |
| + var movesScanFiles = []string{ |
| ++ "internal/hostops/move_fence_release.go", |
| + "internal/hostops/move_recovery_grant.go", |
| + "internal/hostops/move_loss_owner.go", |
| + "internal/host/movesrecoveryprobe.go", |
| +--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| ++++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +@@ -308,3 +308,9 @@ |
| + - MV-N146: the row's text carries a note, `(MV-N68 with a new <which>)`. The code says `no fresh rate measurement for the disk's per-file sync` through MV-N68's `NoFreshRate`, which is correct; the note is not a part of any sentence the code could say. |
| + |
| + The 16 rows added after that review (MV-N184 to MV-N186, MV-N300 to MV-N312) were approved by Paul on 2026-10-09 with no edits. |
| ++ |
| ++## Claim Plan 12 rev 2 |
| ++ |
| ++| id | file | text | status | |
| ++|---|---|---|---| |
| ++| MV-N319 | internal/provision/provision.go | target fenced | pending | |
| ``` |
| ## Task H: Amend the claim spec to rev 6.32 |
| -Use the worktree spec identified in Replay. Before applying this patch, the assertion below fails on rev 6.31. Run it again afterward. The amendment adds the accepted J2(b) evidence forms, J5 declaration semantics, loss-bound admission, `RunHeld`, the exact M6 lease set, M7 history/refusals and M9 local authorization. It also replaces P6-D5's older all-bucket staging-lease wording, so there are not two conflicting instructions. |
| +Use the worktree spec identified in Replay. Before applying this patch, the assertion below fails on rev 6.31. Run it again afterward. The amendment adds the accepted J2(b) evidence forms, J5 release and grant semantics, loss-bound admission, `RunHeld`, the exact M6 lease set, M7 history/refusals, M21 restore retirement and M9 local authorization. It also replaces P6-D5's older all-bucket staging-lease wording, so there are not two conflicting instructions. |
| ```bash |
| python3 - <<'CHECK' |
| @@ -2022,8 +3720,8 @@ import hashlib |
| p = Path('docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md') |
| s = p.read_text() |
| assert 'Design, 2026-10-10, **rev 6.32**.' in s |
| -for term in ('RunHeld', 'retired_locations', 'DeclareMoveTargetFence', |
| - 'ClaimMovesOwners.TargetStop', 'LossAdmissionSources', |
| +for term in ('RunHeld', 'retired_locations', 'ReleaseFencedTarget', |
| + 'target_session', 'LossAdmissionSources', |
| 'Deployment-bucket staging leases are never', 'MoveParks'): |
| assert term in s, term |
| assert 'Acquire all required replica-location and staging-work leases' not in s |
| @@ -2042,7 +3740,7 @@ The code archive has no copy of this spec, so copying the checked document input |
| @@ -1,8 +1,10 @@ |
| # The host lifecycle claim |
| -+**Rev 6.32 (2026-10-10):** completes the claim interfaces for moves M2, M3, M5, M6, M7, M9 and M12. Claim Plan 12 supplies loss-bound Group D admission, recovery predecessor evidence, typed target fence declaration, held-lease parks, local park authority and retained retirement-location evidence. |
| ++**Rev 6.32 (2026-10-10):** completes the claim interfaces for moves M2, M3, M5, M6, M7, M9, M12 and M21. Claim Plan 12 supplies loss-bound Group D admission, recovery predecessor evidence, atomic fenced-target release and restore retirement, held-lease parks, local park authority and retained retirement-location evidence. |
| + |
| **Rev 6.31 (2026-10-09):** records the accepted region HA contracts in amadan #236: `AutomaticLoss` in claim-level `evidence_refs`, the stop-only `relocation-release` obligation, and immutable signed `enrolled_at`. Section 6.2a binds moves' push, landing-install and rollback-return parks by name and region; R6 refuses automatic empty failover after any writer session. Claim Plan 11 supplies the integration. |
| @@ -2051,7 +3749,7 @@ The code archive has no copy of this spec, so copying the checked document input |
| 6.5 added the R6-4 rule. Rev 6.6 added writer epochs (R7-1, amadan #225). |
| Rev 6.7 answered the moves session's notes on §6.4a. Rev 6.8 rewrote §6.4a |
| around writer sessions, for joint round 8. Rev 6.10 answers joint round 10 |
| -@@ -1605,6 +1607,55 @@ |
| +@@ -1605,6 +1607,60 @@ |
| `s3:DeleteObjectVersion` on its own data bucket. Today it has only |
| `Get`, `Put`, `Delete` and `List` (region-host main.tf:166, :174). |
| @@ -2082,83 +3780,109 @@ The code archive has no copy of this spec, so copying the checked document input |
| +incarnation occupied `instances/<host>`. Neither its ordering nor a move's |
| +`LastMove` is sufficient to infer that mapping. |
| + |
| -+Retirement now retains `retired_locations`, a map from incarnation to the |
| -+holder's location, in the same claim CAS that appends the retired ID. Its |
| -+entries are retained only while the corresponding retired ID remains. |
| -+The map is optional; older claims remain readable without it. There is no |
| -+backfill by guessing. A legacy census requires a unique retained mapping; |
| -+missing, trimmed or ambiguous history returns a typed dependency error. |
| -+An old incarnation-qualified location additionally requires that incarnation |
| -+in `retired_incarnations`. The current holder's live location remains under |
| -+the ordinary live-set rules. |
| -+ |
| -+The claim census labels instance residue; moves proves its own staging |
| -+namespace from its retired-move evidence. `View.ArchivedIncarnations` |
| -+keeps these labels separate when both namespaces occur in one listing. |
| -+The claim's census supplies a nonzero observation-time bound after proving |
| -+retirement, so the park still waits the complete late-completion horizon. |
| -+Every retry uses the scope's persisted bound. A missing instance proof |
| -+refuses the sweep; it never falls back to the moves proof's incarnation. |
| -+ |
| -+The field is additive, but older strict claim readers reject unknown fields. |
| -+Upgrade every claim reader before enabling writers of `retired_locations`; |
| -+include that compatibility requirement in the release floor. |
| ++Retirement retains `retired_locations` in its existing CAS only while |
| ++`CARLOS_HOST_CLAIM_RETIRED_LOCATIONS=1`. Leave this write gate off until |
| ++every strict claim reader has the rev 6.32 reader. Entries follow the |
| ++32-entry history cap. Old claims remain readable; missing, trimmed or |
| ++ambiguous bare-location history produces a typed dependency refusal. |
| ++The holder's own bare location is labelled with its incarnation in other |
| ++regions when the history excludes another occupant. Region-specific live-set |
| ++protection still applies in the authoritative region. |
| ++ |
| ++Qualified `instances/<host>/<incarnation>` paths identify themselves. Their |
| ++cleanup uses the ordinary retirement authority and live-set checks; it never |
| ++requires membership of the bounded `retired_incarnations` list. |
| ++ |
| ++The claim census labels instance residue. Moves proves its own staging |
| ++namespace. `View.ArchivedIncarnations` separates their labels, and |
| ++`View.RefusedLocations` excludes only the ambiguous location. A refused |
| ++moves proof drops that lane through `VerifyMovesSweep`, leaving proven |
| ++instance candidates eligible. A bare-location refusal cannot relabel it |
| ++with the move's incarnation or block a separately proved lane. |
| ++ |
| ++For a free claim, no move remains live. The claim supplies the lanes' proof |
| ++using its newest retained incarnation and the maximum nonzero `QuietAfter` |
| ++bound; the bound must precede the version listing. Without that evidence, |
| ++lanes remain refused while independently proven instance residue can proceed. |
| ++The census establishes a conservative initial observation-time bound only |
| ++after proving retirement. Every retry retains the scope's original bound. |
| + |
| ### 6.3 Local retirement (J-I-7, 199-13, 199-14) |
| Retirement covers both layouts. |
| -@@ -2295,6 +2346,60 @@ |
| +@@ -2295,6 +2351,81 @@ |
| the old session certified that every one of its requests had completed. |
| Dead generations are parked by the at-rest sweep (§6.2a). |
| +### 6.4b Moves recovery admission completion (J2(b), J5) |
| + |
| -+When a `restoring/probing` operation journals `loss.<id>`, Group D's |
| -+`hostbox.Writer.Admit` uses `AdmitWriterWithLoss`. The loss must be the |
| -+operation's committed signed record. Moves supplies `LossRecoveryOwner` |
| -+and `LossAdmissionSources`; the latter validates every remote baseline |
| -+named in the record's frozen `WriteSources`. The claim library checks exact |
| -+source membership, unique box/session identities and the remote maxima. |
| -+The owner checks the legal recovery kind, executor, stamp and input policy. |
| -+A loss-bearing operation cannot use `clean-return` or sealed coverage and |
| -+cannot bypass the route by omitting `Admission.LossID`. The accepted |
| -+admission is cold, uncovered, and records the signed declaration ID. |
| -+Old claim-level loss references without this operation's journal do not |
| -+turn a later operation into a loss recovery. Multiple current declarations |
| -+are refused until an explicit combination protocol is defined. |
| -+ |
| -+A `restoring` or `reverting` source whose predecessor writer ran on the |
| -+move's target uses cross-box handover at `probing`. It needs one of: |
| -+ |
| -+- Authenticated target stop evidence at the operation's exact |
| -+ `evidence_target` / `stopped-target.json` key, naming the host, |
| -+ incarnation, operation, target box, generation and holder's writer |
| -+ session. `claim_gen` is not a session. Moves supplies the signature |
| -+ verifier through `ClaimMovesOwners.TargetStop`; unsigned legacy reports |
| -+ do not qualify. |
| -+- This operation's `loss.<id>`, re-read from the committed signed loss |
| -+ record and naming that exact target predecessor in its frozen sources. |
| -+- `fence.<target>`, re-verified against the immutable termination evidence |
| -+ and roster, and bound to the target enrollment and region in the move. |
| -+ |
| -+The claim checks the source executor, operation and predecessor before |
| -+admission and the resulting cold session and fresh stamp afterward. It |
| -+never asks the source root to manufacture a local stop receipt for the |
| -+target's session. Existing local fences still refuse access; this evidence |
| -+does not clear them or grant file effects. Moves remains responsible for |
| -+retiring the source's S-tagged database under the existing guarded local |
| -+retirement protocol before using the recovered installation (J2(d)). |
| -+ |
| -+`DeclareMoveTargetFence` records a verified `fence.<target>` through one |
| -+ETag-bound claim CAS for an uncommitted `landing` or `restoring` operation. |
| -+It binds the operation, target box, enrollment and region, rejects a |
| -+conflicting reference, and verifies the fence again on retry. This is an |
| -+evidence declaration: it changes no holder, executor, phase, participant, |
| -+record or obligation. It creates no unjournalled follow-up obligation. |
| -+A recovery owner that consumes the fence must atomically queue any parks |
| -+or other chores its transition owes, before acknowledging release; the |
| -+reference itself never discharges one. Moves must retain record-first |
| -+close ordering and verify a fresh `ReturnFence` when returning execution. |
| ++The first `restoring/probing` admission under `loss.<id>` uses |
| ++`AdmitWriterWithLoss`, including a loss before target admission. The frozen |
| ++sources may name the source-cut predecessor or contain no target source. |
| ++`hostops.MoveLossRecoveryOwner` supplies the existing loss owner's signed-intent |
| ++policy. `LossAdmissionSources` validates each frozen baseline; the writer |
| ++fills both primary and additional remote maxima from that one result. |
| ++`ParseMoveLossBaseline` is the shared decoder of replica and seal baselines. |
| ++ |
| ++The admission CAS records `loss_admitted.<id>` with the admitted session. |
| ++This operation-local marker survives admission-history trimming. A restarted |
| ++probe and later source admissions use ordinary admission after consumption, |
| ++retaining the original loss evidence. Every admission under this operation |
| ++still refuses sealed or clean-return coverage. Omitting `Admission.LossID` |
| ++cannot bypass first consumption. Claim-level historical references alone do |
| ++not turn a later operation into loss recovery. Multiple current declarations |
| ++remain refused pending a combination protocol. |
| ++ |
| ++Only a `restoring` source whose nonempty predecessor writer is the move's |
| ++target uses cross-box recovery handover. Reverts and never-admitted holders |
| ++retain the local path. Recovery accepts any of these evidence forms: |
| ++ |
| ++- The target's own `TargetReleased` exec-to-none CAS records |
| ++ `target_session` and `target_generation` from the holder when the target |
| ++ admitted. Recovery compares those journal values with the predecessor and |
| ++ checks the complete unsigned `stopped-target.json` report's role, host, |
| ++ operation, incarnation and box. This is the same claim authority as the |
| ++ source cut's `cut_session`; `claim_gen` is never a session. Keys come from |
| ++ `moves.StoppedKey(moves.ReportPrefix(...), "target")`. No moves stop adapter |
| ++ or new signature scheme is required. |
| ++- A root-signed `StopReceipt`, where available, bound through |
| ++ `target_stop_receipt` and `target_stop_sha256`; verify its exact digest, |
| ++ enrolled box signature, installation stamp, session, operation, phase, |
| ++ region, enrollment and completed stop evidence. |
| ++- This operation's committed, signed `loss.<id>` naming the exact target |
| ++ predecessor in its frozen sources. |
| ++- A freshly verified `fence.<target>` naming the operation's target |
| ++ enrollment and region. |
| ++ |
| ++Before admission the claim checks the source executor and predecessor; |
| ++afterward it checks the cold session and fresh stamp. The source never |
| ++manufactures a local receipt for the target. Restore retirement is claim-owned |
| ++for both restoring and reverting: the guarded local hook retires the source's |
| ++old database and marker at `JournalSourceStamp`, using `src_gen` and the |
| ++source cut evidence. Later source sessions retain the root writer-stop receipt |
| ++requirement. The descriptor-based guard and exact inventory checks remain. |
| ++ |
| ++`ReleaseFencedTarget` both declares and consumes a verified `ReturnFence` in |
| ++one CAS. It rolls back an uncommitted landing if needed, removes the target |
| ++from execution and participants, reaches `released`, covers target-region |
| ++predecessors and queues the target location, staging lane and any partial |
| ++return lane as obligations. It records a release marker that `ReturnToSource` |
| ++accepts without giving the source console authority. The reference never |
| ++acknowledges those parks. Replays preserve scopes and do not requeue completed |
| ++work. A fenced recovery cannot claim clean-return coverage. |
| ++ |
| ++Moves consumes `MoveRecoveryGrant.ReleaseFencedTarget` from the console's |
| ++`Recovery.Moves` hook. The grant first records the rollback decision with reason `target fenced`, |
| ++the immutable fence key in `LossRef` and a new `LostAt` bound, then releases |
| ++the claim, then records the target release with the fence as evidence. Each |
| ++callback performs at most one mutation and verifies the fence again; moves |
| ++calls the same method on later passes. The grant rechecks both record ETag |
| ++and `record_seq` before its claim CAS. The persisted fence-decision time, |
| ++not an older rollback request time, bounds the parks. The source continues to wait |
| ++for the claim's `released` phase, so record-first ordering remains safe. |
| ++A dirty fenced target requires J4 declared loss; it cannot create a return lane. |
| ++`ClaimProviders.VerifyMoveTargetFence` exposes the box-side roster-backed |
| ++verifier for callers that need a typed fence, without granting a write. |
| + |
| +`MoveParks` also returns the enrollment-bound local authorizer used by the |
| +claim's own `park.Local`. Moves uses it for local retirement, with its |
| @@ -2168,7 +3892,7 @@ The code archive has no copy of this spec, so copying the checked document input |
| ### 6.5 Door reads pinned to versions (J-N10) |
| The pull door's listing: |
| -@@ -2595,7 +2700,7 @@ |
| +@@ -2595,7 +2726,7 @@ |
| 1. Validate its enrollment, effective region, bucket and retirement authority. |
| 2. Resolve immutable evidence references covering every producer that could write the scope. |
| @@ -2177,7 +3901,7 @@ The code archive has no copy of this spec, so copying the checked document input |
| 4. Re-read the claim and validate the exact obligation, original operation, phase and immutable scope. |
| 5. Obtain P6-D3’s view under that observation. |
| -@@ -2618,8 +2723,8 @@ |
| +@@ -2618,8 +2749,8 @@ |
| Rejected alternatives: |
| @@ -2192,54 +3916,88 @@ The code archive has no copy of this spec, so copying the checked document input |
| ## Integration and rollout |
| -Nothing here arms moves or raises a production floor. Implement the consumer work in the delta's order, with these interfaces: |
| - |
| -| Delta / moves task | What moves must build | |
| +| Delta / moves task | Consumer work and gate | |
| |---|---| |
| -| M2; Plan 4 Task 10 | A declared return kind in `waiting`/`claimed`; uncovered cold probe with the loss ID; `recoveryProbeMatches` refusal of clean-return whenever `LossRef` exists. Supply `ClaimMovesOwners.LossRecovery` and `LossSources` from the signed record's exact frozen `WriteSources`, including every target session and uncovered source-cut session once. Validate each baseline and its maximum; do not synthesize zeroes. Run `TestDeclaredLossNeverAdmitsCleanReturn`, `TestLostTargetRecoveryRunsAgainstARealDeclaration` and `TestLostTargetReturnDeclaresEverySessionOnce`. | |
| -| M3; Task 9b / Plan 4 Task 9 | Implement and register `TargetStop`, including authenticated writer session and generation. The current unsigned stopped-report reader does not qualify. Keep the dirty/clean return input policies and fresh stamp. Run `TestRecoveryAdmissionAfterTargetSession` against the real registered `ClaimWakeAssembly` for stop, loss and fence evidence, not just the fixture in this plan. | |
| -| M5; Task 9a | Keep the existing own-location handle from `checking` through `published`; call the returned engine's `RunHeld` for that exact location. Use `Run` for other scopes. Acquire through the production `RenewingLeases` adapter so the handle retains its identity; no release/reacquire shortcut or fake production lease resolver. Run moves' `TestLandingResidueParkUnderItsOwnLease` with real `AggregateLeases`. | |
| -| M6; Task 9a | Use `ReplicaParkLeaseSet` or an equivalent exact resolver. Bind staging parks to the seal/abort/revert/return-seal `evidence_at`. Never put the deployment bucket's `control/staging-leases` name into the data bucket. Run `TestMovesLeaseSetCoversTheScope`. | |
| -| M7/M8; Task 5 | Keep `VerifyRetirement` for the moves namespace and implement `VerifyMovesSweep` from no live move, matching `LastMove` / `released_move` (or a free claim), and every lane's operation or exact-version modification time before retirement. Claim production composes its instance census with this provider. Verify mixed labels, free-host sweeps, unknown lanes, and refusals for history the claim cannot prove. `LastMove.At` remains the moves proof's retirement time. | |
| -| M9; Task 9a | Consume `(engine, leases, localAuthorize, err)` from `MoveParks`. Bind `park.Local.Authorize` to `localAuthorize`, retaining each arm's additional move checks. Keep the regional engine intact. Refuse a bucket/region mismatch before arming. | |
| -| M12; Plan 4 Task 10 fence form | Call `DeclareMoveTargetFence` from typed, claim-enabled recovery authority after observing a real terminated target. Keep the exact operation and target enrollment. The declaration only records evidence. The consuming record/claim recovery transition must queue the target-location, staging and any return-lane parks before release, retain inventory-bound local work, and use a freshly verified `ReturnFence`. It cannot use an arbitrary `SetJournal` as verification or acknowledge parks on the strength of the fence. | |
| -| M1/M10 | Resolve and implement C8's origin-qualified files manifest with the files owner. Keep source-region versions pinned until commit. Confirm whether a target may still reference a source-region object after commit; if so, implement the required copy/pin policy before source retirement. This plan does not invent a files producer. | |
| -| M4/M11/M13–M21 | Moves retains record-first box closes plus its recovery hook, console alerting and durable rate limits, recorded revert-input checks, lost-box gate/quarantine, install ownership fixes, release-time stuck reason, wiring and censuses. Keep J4's existing declare-lost sequence. Prove `TestCleanReturnAdmissionRetiresTheSTaggedDatabase` before recovery activation. | |
| - |
| -Land the claim implementation through a reviewed amadan branch; do not commit or land from this authoring session. Integrate moves providers on their reviewed branches. Run the complete gate on the combined tip and require CI green on that exact tip before merge. Check main's CI after merge. No production flag change is included in this task. |
| - |
| -Reader rollout precedes the first new `retired_locations` write. Existing claims without the optional field remain valid. Old legacy residue with missing history continues to refuse; a clock, newest move ID or first retained incarnation does not repair that absence. Any future evidence-import/backfill needs its own reviewed design. Production moves remains unarmed until C8, recovery owners, the signed stop adapter, S-tagged retirement and the named integration tests pass. |
| +| M2 / Plan 4 Task 10 | Register `hostops.MoveLossRecoveryOwner()` and a baseline validator using `ParseMoveLossBaseline`. Select the declared return kind and refuse clean-return whenever loss evidence exists. The writer fills maxima from the validated frozen sources. Run moves' real-declaration and every-session-once acceptance tests, including a restarted probe and later cold source admission. | |
| +| M3 / Task 9b | Use the existing unsigned target stop report and `TargetReleased`; no `TargetStop` adapter. Preserve recovery input policy and fresh stamp. Run `TestRecoveryAdmissionAfterTargetSession` with real registered owners for target journal, loss and fence, then continue through restore. | |
| +| M5/M6 / Task 9a | Retain the own-location `RenewingLeases` handle through `published` and call `RunHeld`. Use the claim-returned canonical resolver. Bind staging parks to persisted evidence time and pin the prepared generation while parking. Test with real `AggregateLeases`. | |
| +| M7/M8 / Task 5 | Provide `VerifyRetirement` and `VerifyMovesSweep` for occupied hosts' moves namespace only, using no live move, matching retained release/LastMove and complete lane evidence. Drop the obsolete instance-residue and bare-live-location refusals. The claim supplies free-host proof and instance labels. | |
| +| M9 / Task 9a | Consume all four `MoveParks` results and use its unwrapped local authorizer. Keep each move arm's exact-scope checks and the regional engine's stronger wrapper. Check bucket/region before arming. | |
| +| M12 / Task 7 recovery hook | In `Recovery.Moves`, call the record-bound `MoveRecoveryGrant.ReleaseFencedTarget` on successive passes. It records the decision, releases the claim, then records completion; no raw record or claim handle is exposed. The source waits for claim `released`. An admitted fenced target returns cold and uncovered. A dirty fenced target requires J4 loss. The box-side verifier is available through `ClaimProviders`. | |
| +| M21 / Task 9b | Claim-owned retirement now handles restoring and reverting. Moves supplies the selected restore inputs and runs `TestCleanReturnAdmissionRetiresTheSTaggedDatabase` against the integrated provider through restore. | |
| +| M1/M10 | Implement C8's files provider. Spec §6.6 P6-D3 already requires cross-region landing to materialize destination-local objects and reference those. There is no open decision allowing retained source-region object references after commit. | |
| +| M4/M11/M13–M20 | Moves retains record-first closes, recorded revert inputs, its single recovery/alerting hook, durable rate limits, lost-box gate, carries/install fixes, stuck reason, provider arming and its own censuses. J4's declare-lost sequence stays. | |
| + |
| +This authoring session makes no commit, merge, activation or production change. |
| +Land the implementation through a reviewed amadan branch and verify CI on its |
| +exact tip, then main. Integrate moves' Task 5 and Task 7 census changes serially. |
| +Keep the retired-location write gate off through reader rollout. Missing legacy |
| +history remains a typed refusal; any evidence backfill needs its own design. |
| +Production moves stays unarmed until C8 and the integrated recovery/restore tests |
| +pass. No additional claim-side open question remains from these reviews. |
| ## Copy review inventory |
| -No UI, HTTP response, CLI help, alert subject, page or move phase sentence is introduced here. Reused typed errors retain their existing text. The only new diagnostics are assembled by the existing `ClaimDependencyError.Error` formatter: |
| +These are implementation-plan drafts, not shipped product copy. Existing typed |
| +errors are reused. No CLI, HTTP or page text changes. |
| -| Draft ID | Full sentence | Site and classification | |
| +| ID | Exact text | Site | |
| |---|---|---| |
| -| HC12-D1 | `host claims: missing provider: claim archived incarnation and location evidence` | `claimRetirementDependency`; internal scheduler refusal for missing, trimmed or ambiguous incarnation evidence. It may reach operator logs. | |
| -| HC12-D2 | `host claims: missing provider: moves Plan 5 retired-move regional evidence` | `RegisterProductionClaimMoveParks`; internal startup/registration refusal. Replaces the inaccurate request for moves-owned archived-instance evidence. | |
| +| HC12-D1 | `host claims: missing provider: claim archived incarnation and location evidence` | `claimRetirementDependency`; typed census refusal. | |
| +| HC12-D2 | `host claims: missing provider: moves Plan 5 retired-move regional evidence` | Move-park registration dependency. | |
| +| HC12-D3 / MV-N319 | `target fenced` | `provision.RollbackTargetFenced`; controller-specified recorded reason, with a pending moves-copy row. | |
| -These drafts are inventoried for approval before implementation shipment. They are not added to `approved_moves_copy_test.go`, whose operator sinks are unchanged. Test-only failure strings are assertions, not shipped copy. Future moves pages, timers, declared-return messages and the release-time stuck sentence belong in moves' approved MV-N inventory with their actual sinks. |
| +The implementation owner must complete applicable copy approval before shipment. |
| +Test assertion strings are not shipped. The report is the controller's per-finding |
| +implementation handoff. |
| ## Verification and authoring results |
| -Authoring used one source export from the required main commit. A second fresh export pinned to `44c1b6d77ed16f92e46e892b8254835edbd567d8` accepted every A–H patch in tests-first order with `patch --fuzz=0`, no offsets, and no rejects. Its Go files matched the authored tree byte for byte. `gofmt -l .` was clean over that replay. The spec assertion above passed after its patch and failed before it. |
| - |
| -The Go tests were attempted with the required default cache at `/home/paulca/.cache/go-build`; the sandbox refused cache writes before compilation. Consequently no Go compilation, red/green regression, census execution, full gate or cross-build is claimed by this authoring run. The inventory hashes were computed from the exact formatted declaration slices after reviewing the classifications in Task G. They still require the real census test. |
| - |
| -Before landing, observe the failing tests first, then the passing focused tests for each task, followed by: |
| +Verified in a fresh export of the pinned base with the controller-designated |
| +shared cache. All 14 patch stages applied with `--fuzz=0`, without offsets; |
| +the 54 resulting files matched the authored implementation byte for byte. |
| +Tasks A–G each demonstrated a failing pre-implementation check and a passing |
| +post-implementation check. The final B, C and G revisions repeated that |
| +sequence; unchanged A, D, E and F retained their earlier results. |
| + |
| +On the complete final replay, the selected recovery/loss/retirement/park tests |
| +passed in all six affected packages. The B8 record-write census, console |
| +authority census and moves copy-inventory tests passed. `go build ./...`, |
| +`go vet ./...`, Darwin amd64 and Windows amd64 builds passed; `gofmt -l .` |
| +returned no files. The restore acceptance fixture covers restoring, reverting |
| +and clean-return through restore, with its fixture limits recorded in the report. |
| + |
| +Broader checks encountered sandbox listener refusals: the persisted-launch |
| +retirement test could not open its Unix socket, and console tests could not |
| +open loopback TCP listeners (`operation not permitted`). Those are not passes; |
| +full listener-dependent CI remains required before landing. Markdown whitespace |
| +checking found only the required space prefixes on unified-diff context lines; |
| +no prose whitespace defect was found. |
| + |
| +Replay and validate the final plan as follows, using the shared-cache exports |
| +from the bootstrap section: |
| ```bash |
| +go test ./internal/host ./internal/hostclaim ./internal/hostbox ./internal/hostops ./internal/hostguard ./internal/park -run 'TestPlan12|TestLossBeforeTargetAdmissionRecovers|TestAdmitWriterWithLoss|TestAggregateLeases|TestRegionalWorker|Test.*Sweep|TestReleaseLostTarget|TestPlan9D2CrossBox|TestPlan9D4|TestPlan10J2' -count=1 |
| +go test ./internal/provision -run TestPlan9B8 -count=1 |
| +go test ./internal/console -run TestClaimAuthoritySourceCensus -count=1 |
| +go test . -run 'TestMoves(StringsAreRecorded|SinksAreSentences)' -count=1 |
| go build ./... |
| +go vet ./... |
| +CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build ./... |
| +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build ./... |
| +gofmt -l . |
| +# Before landing in an environment that permits the test listeners: |
| make test |
| -# Match all targets from .amadan/ci.d/20-cross@5 once on the final tip. |
| ``` |
| -Also retain the repository's flag-off tests, #267 authority checks and claim identity/retirement tests. There is no exemption for a known failure. A sandbox bind or cache error is reported separately from a failing assertion and must not be called a passing gate. No production test or AWS operation is required to author or locally replay this plan. |
| +No cache, permission, dependency or socket failure counts as a successful test. |
| +Retain flag-off, identity, retirement and #267 checks. Full CI on the integrated |
| +tip remains required before landing. |
| ## Cleanup |
| -After all replay commands and processes finish, leave the scratch directory and remove only the path returned by this task's `mktemp`. The default shared Go cache is not task scratch and must remain untouched. |
| +After all replay commands and processes finish, leave the scratch directory and remove only the path returned by this task's `mktemp`. The controller-designated shared Go cache is not task scratch and must remain untouched. |
| ```bash |
| cd "$CLAIM12_REPO" |