docs: region HA plan 1b-next rev 12 keeps file origins and P's opening past the admission trim
Astra round 11 P2, accepted. The files pin's origins now include every generation whose objects the pinned manifest references, within this incarnation's location in this region's bucket (claim §6.2a's files live set), not only the eight retained admissions. A test runs ten real admit-and-publish cycles, keeps an unchanged g1 file, and still pins and copies the manifest; a foreign reference refuses. A check of every other use of the trimmed list found one more: P's opening is now journaled (prepared_opened), so P is never reopened. Dry-run in task order on 3407d114; 4 guards removed in turn, each failing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+222
−15
docs/superpowers/plans/2026-10-07-region-ha-1b-next-relocation.md+222 −15
diff --git a/docs/superpowers/plans/2026-10-07-region-ha-1b-next-relocation.md b/docs/superpowers/plans/2026-10-07-region-ha-1b-next-relocation.md| index d7f253e74..1ed12dd19 100644 |
| --- a/docs/superpowers/plans/2026-10-07-region-ha-1b-next-relocation.md |
| +++ b/docs/superpowers/plans/2026-10-07-region-ha-1b-next-relocation.md |
| @@ -1,15 +1,30 @@ |
| -# Region HA 1b-next: Relocation, Row Cases, Create Choice and Fences Implementation Plan (rev 11) |
| +# Region HA 1b-next: Relocation, Row Cases, Create Choice and Fences Implementation Plan (rev 12) |
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| **Goal:** Build what region HA spec 1 had to leave until the host lifecycle claim was on main: the `relocating` operation within one region, released by the source's stop evidence (drain) or by its termination fence (failover); reconcile's row cases for it; create's once-only choice of executor box with `boxchoice.Choose`; and every claim CAS that consumes a termination fence. All of it dormant behind `CARLOS_REGION_HA`, and none of it mergeable until the joint two-box simulation round passes (L18). |
| -**Architecture:** Two stages (ruling 24). **Stage A, build now:** Tasks 1 to 7 and 9a, buildable on main today and dry-run on every revision: the claim library's relocation transitions and `released` coverage (`internal/hostclaim`), create's executor selection (`internal/hostclaim`, `internal/hostops`), the row and wake decisions (`internal/hostbox`), the writers-stopped observation (`internal/stopevidence`), the regional worker and the participant driver (new `internal/hostrelocate`), and the box-side release adapter with its receipt verifier (`internal/host`). **Stage B, gated:** Tasks 8a, 8b, 8c, 9 and 10 bind to claim Plan 6b and moves Plans 1 and 2, none merged. They keep their design and contracts here; their code is rewritten against the real 6b code after it merges and re-reviewed then (the "Stage B re-plan" step before Task 8a lists every obligation rounds 1 to 10 placed on them). What those need and no plan declares is a numbered request, never an assumption. The last task is the merge gate. The worker only reads termination fences: `hostfence.TerminationWorker` writes them, and `hostactors.Directory` is the only roster reader. |
| +**Architecture:** Two stages (ruling 24). **Stage A, build now:** Tasks 1 to 7 and 9a, buildable on main today and dry-run on every revision: the claim library's relocation transitions and `released` coverage (`internal/hostclaim`), create's executor selection (`internal/hostclaim`, `internal/hostops`), the row and wake decisions (`internal/hostbox`), the writers-stopped observation (`internal/stopevidence`), the regional worker and the participant driver (new `internal/hostrelocate`), and the box-side release adapter with its receipt verifier (`internal/host`). **Stage B, gated:** Tasks 8a, 8b, 8c, 9 and 10 bind to claim Plan 6b and moves Plans 1 and 2, none merged. They keep their design and contracts here; their code is rewritten against the real 6b code after it merges and re-reviewed then (the "Stage B re-plan" step before Task 8a lists every obligation rounds 1 to 11 placed on them). What those need and no plan declares is a numbered request, never an assumption. The last task is the merge gate. The worker only reads termination fences: `hostfence.TerminationWorker` writes them, and `hostactors.Directory` is the only roster reader. |
| **Tech Stack:** Go 1.26, module `github.com/carlosframework/platform`; `release.Store` (create-only and ETag compare-and-swap puts); the claim library (`hostclaim`), roster (`hostactors`), fences (`hostfence`, `termfence`), box choice (`boxchoice`), files (`hostfiles`), generation selection (`genselect`), stop evidence (`stopevidence`, `internal/host` stop receipts), execution controller (`hostexec`). |
| **Spec:** `docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md`, rev 5.4 with §24 (rulings H1 to H19 final). Read §0, §2, §5.2, §5.3, §5.8, §5.9, §5.10, §9 (L1 to L20, accepted) and §24 first. Then the claim spec rev 6.30 on `origin/design-replica-ownership` (`docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`): §1.1's activation gate, §4 ("Same-region relocation (L1)" and the relocation exception to CC11), §5.1 and P5-D2/P5-D6 (the create intent), §6.1 ("Release before full stop (L10)"), §6.4a item 2 (`released`), §7.3 D2 ("Automatic termination (L2, L4)", "The fence's journal reference") and D4 ("Automatic loss persistence"). Then moves rev 7.22 on `origin/design-moves-any-region` §7.6 ("One install routine, two sources", L16 and L17) and §7.10 (the pre-flip conversion). The contracts note `docs/superpowers/notes/2026-10-07-region-ha-contracts.md` gives `hostfiles` v2, `CopyManifest`, `Publish` and `boxchoice`. Plan 1b (`docs/superpowers/plans/2026-10-06-region-ha-1b-claim-readers.md`) lists in "Waits for the claim" what this plan now builds. |
| +## Rev 12: round 11 dispositions |
| + |
| +Astra's round 11 (`CODEX-PLAN-REGION-HA-1BN-R11.md`) resolved the chooser-test finding and raised one P2. It is accepted; none is declined. |
| + |
| +The coordinator also asked for a check of every stage A use of `holder.admissions` as authority. That check found one more case, S1. Both changes were dry-run on a clean 3407d114 with stage A applied in task order (1, 2, 3, 4, 5, 6, 7, 9a), building and running each task's tests after it. Vet for darwin and windows and the cross-builds are clean. Four new guards were each removed in turn, and each removal failed its test (listed in the Self-review). |
| + |
| +| # | Stage | Evidence checked | Disposition | What changed | Where | |
| +|---|---|---|---|---|---| |
| +| 1 (P2) | A | `AdmitWriter` drops a covered, loss-archived head once more than eight admissions are kept (generation.go:154). Rev 11's `filesPin` built its origins only from `holder.generation` and the retained admissions. A file published at g1 and never changed is still `files/g1/objects/...` at g10, because `CopyManifest` carries the entry and `Stage` writes nothing for unchanged content. The pin accepted that manifest, but `CopyPinnedManifest` refused it with `ErrOrigin`. Claim §6.2a ("Files in the live set (L13)") makes the live set every object the live manifests reference, computed independently of the database baseline. | accepted | `filesPin` adds `referencedOrigins` to the retained chain: the files prefix of every generation whose objects the pinned manifest's entries and platform items reference. Each referenced object must lie under this holder's location, which binds it to the incarnation, in this region's data bucket (the region binding), at `files/<generation>/objects/`. Any other reference refuses the pin (`ErrRefused`, `hostfiles.ErrOrigin`), so the pin never admits what the copy would refuse. `CopyPinnedManifest`'s check is unchanged. | Task 5 (`TestPinKeepsAFileOriginTheAdmissionListTrimmed`: ten real `AdmitWriter` and `PublishGeneration` cycles with real `CopyManifest` and publication. The admission list is trimmed to eight and no longer names g1, yet g10's manifest still references g1's object for the unchanged file. The pin and the copy succeed, and a reference to another incarnation refuses.) | |
| +| S1 | A | From the check. `preparedOpened`, the boundary that stops P being opened twice or its local install remade (B1-1, B2-5), scanned only the retained admissions. The same goes for `coverageFor`'s "P not opened" test. A covered P admission that is trimmed would let a later probe open P again. | fixed | The admission that opens P journals `prepared_opened` in the same CAS (`relocationAdmission`). `preparedOpened`, now exported as `PreparedOpened` for the participant, reads that key before the admission list. | Task 1 (`TestRelocationPOpenedOutlivesTheAdmissionTrim`, with P's admission removed as the trim would remove it: P's reopening is refused), Task 6 (`coverageFor`) | |
| + |
| +The check found no other authority taken from the trimmed list. `relocationAdmission` and `coverageFor` compare against the last admission, which the trim never removes: it drops only the head, and only past eight entries. `retainAutomaticLoss` records evidence, not authority. An admission the trim dropped was covered and loss-archived (`admissionLossArchived`), so nothing it could vouch for is lost; it falls back to the holder's live writer session. |
| + |
| +Astra's readings of rulings 1 to 24 stand. |
| + |
| ## Rev 11: round 10 dispositions |
| Astra's round 10 (`CODEX-PLAN-REGION-HA-1BN-R10.md`) resolved both findings from round 9 and raised one P2: a test that would still pass with its production wiring disconnected. It is accepted; none is declined. |
| @@ -251,7 +266,7 @@ Left out, with where it goes: |
| - **Go environment.** `GOFLAGS=-buildvcs=false`. Leave `GOCACHE` at its default; if the sandbox refuses it, `GOCACHE=$TMPDIR/gocache` for that command. Socket tests need `TMPDIR=/tmp/k` (`mkdir -p /tmp/k`). Run tests niced (`nice -n 19`), targeted with `-run`; never the full `-race` suite, and no simulation sweeps. Scratch goes under `$TMPDIR`. |
| - **Gate before each commit:** `go build ./... && go vet ./<pkgs>/ && gofmt -l <dirs>` (empty), the task's targeted tests, then `GOOS=darwin go build ./... && GOOS=windows go build ./...`. (`GOOS=windows go vet ./internal/...` fails on main's own test files, `release/versions_test.go`, `appconfig/syncexec_test.go` and `activator/activator_test.go`; vet the task's packages instead.) |
| - Commit messages: lowercase `area:` prefix and a sentence stating the behaviour, then a blank line and `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Work on a branch cut from `origin/main` (suggested `region-ha-1b-next`). Merging is Paul's call, through `amadan branch merge`, and only after Task 11. |
| -- **Dry run.** Tasks 1 to 7 were applied in order to `origin/main` at 3407d114, built, vetted, cross-built for darwin, windows and linux/arm64, and their tests run; nineteen guards were each removed in turn and their test watched to fail (rev 1), then rev 2's twenty-five, rev 3's eleven, rev 4's eight, rev 5's seven, rev 6's eleven, rev 7's three, rev 8's two, rev 9's two, rev 10's one and rev 11's four (listed in the Self-review); from rev 10 on, stage A is also applied one task at a time in plan order, building and testing after each. Then the code was removed; only this document is committed. |
| +- **Dry run.** Tasks 1 to 7 were applied in order to `origin/main` at 3407d114, built, vetted, cross-built for darwin, windows and linux/arm64, and their tests run; nineteen guards were each removed in turn and their test watched to fail (rev 1), then rev 2's twenty-five, rev 3's eleven, rev 4's eight, rev 5's seven, rev 6's eleven, rev 7's three, rev 8's two, rev 9's two, rev 10's one, rev 11's four and rev 12's four (listed in the Self-review); from rev 10 on, stage A is also applied one task at a time in plan order, building and testing after each. Then the code was removed; only this document is committed. |
| ## Review Focus |
| @@ -295,8 +310,8 @@ Spec §5.8 and claim §4. Every transition is a `func(*Claim) error` for `hostcl |
| **Interfaces:** |
| - Consumes (main): `Update`, `RequireOp`, `VerifyFence`, `FenceRef`, `FenceAuthority`, `AcknowledgeExecution`, `AdmitWriter`, `validBoxReference`, `isHex64`, `KindRelocating`, `validateRelocating`. |
| -- Produces: constants `ReasonDrain|ReasonRebalance|ReasonFailover`, `PhaseStarting … PhaseProbeFailed`, `MaxTargetReplacements = 2`, `FailoverPinDelay = 30s`; errors `ErrRelocation`, `ErrReplacementsExceeded`, `ErrAlreadyRecorded`; types `RelocationStart`, `LocalDischarge`, `Release`, `ReleaseAuthority` (`VerifyRelease(ctx, Claim, Release) error`), `RecoveryPin` (with `Empty`), `PreparedAuthority` (with `FilesSeq`, `FilesManifestSHA256`), `LocalInstall`, `Probe`, `Target{Box, Enrollment}`, `AutomaticLoss` (with `Incarnation`, `Stamp`, `LastAdmission`, `Reassigned`) and `Claim.EvidenceRefs`; `OwesLocal(c, box) bool`, `LocalChoreBoxes(c) []string`; `MaxProbeAttempts = 3`, `ErrProbeAttemptsExhausted`; functions `RecoveryPinKey(host, op string, gen uint64) string`, `BeginProbeAttempt(opID, box string)`, `FenceExhaustedTarget(ctx, s, observed, opID, FenceRef, *LocalDischarge, FenceAuthority) (Observed, error)`, `RelocationStartGuard(c, region, source, target string, sourceFenced bool) error`, `StartRelocation(ctx, s, observed, RelocationStart, FenceAuthority) (Observed, error)`, `FenceSource(ctx, s, observed, opID, FenceRef, *LocalDischarge, FenceAuthority) (Observed, error)`, `ToReleasing(opID, now time.Time)`, `JournalRelease(opID, Release)`, `PinAfterRelease(ctx, s, observed, opID, ReleaseAuthority, *Target) (Observed, error)`, `PinFailover(opID)`, `JournalPin(opID, RecoveryPin)`, `NameTarget(opID, Target)`, `ToInstalling(opID, Target, prepared string)`, `ToInstalled(opID, PreparedAuthority)`, `RecordLocalInstall(opID, LocalInstall)`, `ToProbing(opID, now time.Time)`, `RecordProbe(opID, Probe)`, `ToProbeFailed(opID, now time.Time)`, `CompleteRelocation(opID, newID func() string)`, `ReplaceTarget(ctx, s, observed, opID, FenceRef, *LocalDischarge, next Target, prepared string, FenceAuthority) (Observed, error)`, `MarkStuck(opID, reason)`. |
| -- Journal keys later tasks read: `exec_ack.<box>`, `fence.<box>`, `fence.<box>.observed_at`, `discharge.<box>`, `reason`, `releasing_since`, `release`, `release.phase`, `released_by_target`, `pin`, `prepared`, `install_stamp`, `installed_boxes`, `prepared_authority`, `local_install`, `probing_since`, `probe_attempts.<box>`, `probe`, `probe_failed_since`, `replacements`, `excluded_targets`, `stuck`. Claim fields: `evidence_refs`, `loss_refs`. |
| +- Produces: constants `ReasonDrain|ReasonRebalance|ReasonFailover`, `PhaseStarting … PhaseProbeFailed`, `MaxTargetReplacements = 2`, `FailoverPinDelay = 30s`; errors `ErrRelocation`, `ErrReplacementsExceeded`, `ErrAlreadyRecorded`; types `RelocationStart`, `LocalDischarge`, `Release`, `ReleaseAuthority` (`VerifyRelease(ctx, Claim, Release) error`), `RecoveryPin` (with `Empty`), `PreparedAuthority` (with `FilesSeq`, `FilesManifestSHA256`), `LocalInstall`, `Probe`, `Target{Box, Enrollment}`, `AutomaticLoss` (with `Incarnation`, `Stamp`, `LastAdmission`, `Reassigned`) and `Claim.EvidenceRefs`; `OwesLocal(c, box) bool`, `LocalChoreBoxes(c) []string`, `PreparedOpened(c) bool`; `MaxProbeAttempts = 3`, `ErrProbeAttemptsExhausted`; functions `RecoveryPinKey(host, op string, gen uint64) string`, `BeginProbeAttempt(opID, box string)`, `FenceExhaustedTarget(ctx, s, observed, opID, FenceRef, *LocalDischarge, FenceAuthority) (Observed, error)`, `RelocationStartGuard(c, region, source, target string, sourceFenced bool) error`, `StartRelocation(ctx, s, observed, RelocationStart, FenceAuthority) (Observed, error)`, `FenceSource(ctx, s, observed, opID, FenceRef, *LocalDischarge, FenceAuthority) (Observed, error)`, `ToReleasing(opID, now time.Time)`, `JournalRelease(opID, Release)`, `PinAfterRelease(ctx, s, observed, opID, ReleaseAuthority, *Target) (Observed, error)`, `PinFailover(opID)`, `JournalPin(opID, RecoveryPin)`, `NameTarget(opID, Target)`, `ToInstalling(opID, Target, prepared string)`, `ToInstalled(opID, PreparedAuthority)`, `RecordLocalInstall(opID, LocalInstall)`, `ToProbing(opID, now time.Time)`, `RecordProbe(opID, Probe)`, `ToProbeFailed(opID, now time.Time)`, `CompleteRelocation(opID, newID func() string)`, `ReplaceTarget(ctx, s, observed, opID, FenceRef, *LocalDischarge, next Target, prepared string, FenceAuthority) (Observed, error)`, `MarkStuck(opID, reason)`. |
| +- Journal keys later tasks read: `exec_ack.<box>`, `fence.<box>`, `fence.<box>.observed_at`, `discharge.<box>`, `reason`, `releasing_since`, `release`, `release.phase`, `released_by_target`, `pin`, `prepared`, `install_stamp`, `installed_boxes`, `prepared_authority`, `local_install`, `probing_since`, `probe_attempts.<box>`, `probe`, `probe_failed_since`, `replacements`, `excluded_targets`, `stuck`, `prepared_opened` (rev 12). Claim fields: `evidence_refs`, `loss_refs`. |
| - [ ] **Step 1: Write the failing tests** |
| @@ -1267,6 +1282,35 @@ func TestRelocationFenceReassignsTheDeadBoxsRegionalWork(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// Round 11's sweep: P's opening is journaled with the admission that made |
| +// it, so it survives AdmitWriter's trim of holder.admissions to eight. The |
| +// removal below stands in for that trim (generation.go drops a covered, |
| +// loss-archived head once more than eight are kept); with P's admission |
| +// gone from the list, P is still opened: never reopened, and the local |
| +// install is never remade. |
| +func TestRelocationPOpenedOutlivesTheAdmissionTrim(t *testing.T) { |
| + ctx := context.Background() |
| + s, o := relAtPhase(t, "probing+P") |
| + if o.Claim.Op.Journal["prepared_opened"] != "P1" { |
| + t.Fatalf("the opening was not journaled: %+v", o.Claim.Op.Journal) |
| + } |
| + o = step(t, s, o, func(c *Claim) error { |
| + c.Holder.Admissions = slices.DeleteFunc(c.Holder.Admissions, func(a AdmissionRecord) bool { return a.Generation == "P1" }) |
| + return nil |
| + }) |
| + if !PreparedOpened(o.Claim) { |
| + t.Fatal("P forgotten with its admission") |
| + } |
| + again := Admission{ObservedOpID: "rel-1", ObservedPhase: PhaseProbing, ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation, |
| + Box: relTarget, Mode: "cold", NewGeneration: "P1", KeepStamp: true, Region: "ie", PredecessorRegion: "ie"} |
| + if _, err := Update(ctx, s, o, AdmitWriter(again)); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("P reopened after the trim: %v", err) |
| + } |
| + if _, err := Update(ctx, s, o, RecordLocalInstall("rel-1", localInstall(o.Claim))); err == nil { |
| + t.Fatal("the local install was remade after P was opened") |
| + } |
| +} |
| ``` |
| - [ ] **Step 2: Run them to see them fail** |
| @@ -2081,12 +2125,20 @@ func ToInstalled(opID string, a PreparedAuthority) func(*Claim) error { |
| } |
| } |
| -// preparedOpened reports whether any admission has named P. |
| +// PreparedOpened reports whether P has been opened: journaled as |
| +// prepared_opened by the admission that opened it (rev 12: AdmitWriter |
| +// trims holder.admissions to eight, so the admission list alone could |
| +// forget it), or named by a retained admission. |
| +func PreparedOpened(c *Claim) bool { return preparedOpened(c) } |
| + |
| func preparedOpened(c *Claim) bool { |
| p := c.Op.Journal["prepared"] |
| if p == "" || c.Holder == nil { |
| return false |
| } |
| + if c.Op.Journal["prepared_opened"] == p { |
| + return true |
| + } |
| for _, a := range c.Holder.Admissions { |
| if a.Generation == p { |
| return true |
| @@ -2472,7 +2524,7 @@ Apply to `internal/hostclaim/generation.go` (`git apply`): |
| ```diff |
| diff --git a/internal/hostclaim/generation.go b/internal/hostclaim/generation.go |
| -index ffd7e058..fdf8f960 100644 |
| +index ffd7e058..74947b56 100644 |
| --- a/internal/hostclaim/generation.go |
| +++ b/internal/hostclaim/generation.go |
| @@ -43,6 +43,10 @@ func AdmitWriter(in Admission) func(*Claim) error { |
| @@ -2497,7 +2549,7 @@ index ffd7e058..fdf8f960 100644 |
| default: |
| return ErrWrongState |
| } |
| -@@ -261,3 +269,54 @@ func CoverParkedPredecessors(observedOpID, observedPhase string, region string, |
| +@@ -261,3 +269,60 @@ func CoverParkedPredecessors(observedOpID, observedPhase string, region string, |
| return nil |
| } |
| } |
| @@ -2533,6 +2585,12 @@ index ffd7e058..fdf8f960 100644 |
| + if p == "" || (!opened && in.NewGeneration != p) || (opened && in.NewGeneration == p) { |
| + return fmt.Errorf("%w: the first probe opens P, and only it", ErrWrongState) |
| + } |
| ++ if !opened { |
| ++ // The opening is journaled with the admission, so it outlives the |
| ++ // admission list's trim (rev 12): P is never opened twice. |
| ++ ensureJournal(c) |
| ++ c.Op.Journal["prepared_opened"] = p |
| ++ } |
| + if in.Coverage != "released" { |
| + return nil |
| + } |
| @@ -5555,6 +5613,7 @@ import ( |
| "encoding/hex" |
| "encoding/json" |
| "errors" |
| + "fmt" |
| "strings" |
| "testing" |
| "time" |
| @@ -5845,6 +5904,107 @@ func TestCopyPinnedManifestEnforcesThePin(t *testing.T) { |
| } |
| }) |
| } |
| + |
| +// Round 11: holder.admissions keeps eight entries, but an unchanged file |
| +// published at g1 is still referenced by every later generation's |
| +// manifest (each copies the last, and unchanged content is never |
| +// rewritten). Ten real admissions and publications later, with g1 trimmed |
| +// from the admission list, the pin still admits g1's object and the copy |
| +// succeeds; a reference outside this incarnation's files refuses. |
| +func TestPinKeepsAFileOriginTheAdmissionListTrimmed(t *testing.T) { |
| + ctx := context.Background() |
| + claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + created := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC) |
| + nh := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: created} |
| + body := []byte(`{"host":"` + host + `","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`) |
| + step := func(o hostclaim.Observed, tr func(*hostclaim.Claim) error) hostclaim.Observed { |
| + t.Helper() |
| + next, err := hostclaim.Update(ctx, claims, o, tr) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return next |
| + } |
| + o, err := hostclaim.Read(ctx, claims, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o = step(o, hostclaim.BeginCreate(nh, body, "c", "console", created)) |
| + o = step(o, hostclaim.CompleteCreate("c", "src")) |
| + loc := o.Claim.Holder.Location |
| + n := 0 |
| + newID := func() string { n++; return fmt.Sprintf("id%d", n) } |
| + var gens []string |
| + prev, prevSeq := "", int64(0) |
| + for i := 1; i <= 10; i++ { |
| + gen := fmt.Sprintf("g%d", i) |
| + o = step(o, hostclaim.AdmitWriter(hostclaim.Admission{Region: "ie", PredecessorRegion: "ie", ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation, |
| + Box: "src", Mode: "cold", NewGeneration: gen, PredecessorCertified: true})) |
| + prefix := loc + "/files/" + gen + "/" |
| + gens = append(gens, prefix) |
| + if prev != "" { |
| + if _, err := hostfiles.CopyManifest(ctx, data, prev, prefix, prevSeq, gens); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + p, err := hostfiles.Open(ctx, data, prefix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // kept.txt never changes after g1; changed.txt changes every time. |
| + if err := p.Stage(ctx, "kept.txt", []byte("written once"), 0o644, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.Stage(ctx, "changed.txt", []byte(gen), 0o644, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if prevSeq, err = p.Commit(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + prev = prefix |
| + o = step(o, hostclaim.PublishGeneration("", "", o.Claim.Holder.Session, gen, "ie", newID)) |
| + } |
| + h := o.Claim.Holder |
| + if len(h.Admissions) != 8 { |
| + t.Fatalf("admissions not trimmed to eight: %d", len(h.Admissions)) |
| + } |
| + for _, a := range h.Admissions { |
| + if a.Generation == "g1" || a.Predecessor == "g1" { |
| + t.Fatalf("g1 is still in the admission list: %+v", h.Admissions) |
| + } |
| + } |
| + m, _, err := hostfiles.Load(ctx, data, loc+"/files/g10/") |
| + if err != nil || !strings.HasPrefix(m.Entries["kept.txt"].Object, loc+"/files/g1/objects/") { |
| + t.Fatalf("the unchanged file is not g1's object: %+v %v", m.Entries["kept.txt"], err) |
| + } |
| + obs := o |
| + obs.Claim.Op.ID = "rel-1" |
| + p := StorePinner{Claims: claims, Data: data, Bucket: "data", Region: "ie", SelectDB: selectAt(41)} |
| + pin, err := p.Pin(ctx, obs, time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obj, err := ReadPin(ctx, claims, pin) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := CopyPinnedManifest(ctx, claims, data, obj.Files, loc+"/files/P1/") |
| + if err != nil || got.Entries["kept.txt"] != m.Entries["kept.txt"] { |
| + t.Fatalf("copy with g1 trimmed from the admissions: %+v %v", got.Entries, err) |
| + } |
| + // A reference outside this incarnation's files is never admitted. |
| + foreign := m |
| + foreign.Entries = map[string]hostfiles.Entry{"x.txt": {Object: "instances/" + host + "/other-inc/files/g1/objects/" + strings.Repeat("a", 64), Seq: 1, SHA256: strings.Repeat("a", 64)}} |
| + raw, _ := json.Marshal(foreign) |
| + _, etag, _ := data.Get(ctx, hostfiles.ManifestKey(loc+"/files/g10/")) |
| + if _, err := data.Put(ctx, hostfiles.ManifestKey(loc+"/files/g10/"), raw, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + obs.Claim.Gen++ |
| + if _, err := p.Pin(ctx, obs, time.Date(2026, 10, 7, 9, 1, 0, 0, time.UTC)); !errors.Is(err, hostfiles.ErrOrigin) { |
| + t.Fatalf("a foreign reference was pinned: %v", err) |
| + } |
| +} |
| ``` |
| - [ ] **Step 2: Run them to see them fail** |
| @@ -6409,6 +6569,8 @@ import ( |
| "errors" |
| "fmt" |
| "os" |
| + "sort" |
| + "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/genselect" |
| @@ -6572,8 +6734,14 @@ func pinRef(key string, raw []byte, obj PinObject) hostclaim.RecoveryPin { |
| // holder.admissions that has one. It is chosen independently of the |
| // database baseline (B2-4). A read error refuses; no manifest anywhere in |
| // the chain is a host that never published files, pinned at sequence 0. |
| -// Origins is the whole chain, so a manifest built by earlier copies (whose |
| -// entries name earlier generations' objects, L13) restores. |
| +// Origins is the retained chain plus every generation of this incarnation |
| +// whose objects the pinned manifest references (rev 12, round 11): the |
| +// claim's §6.2a files live set is the objects the live manifests |
| +// reference, independent of holder.admissions, which AdmitWriter trims to |
| +// eight. An unchanged file published at g1 and carried by every later copy |
| +// stays restorable at g10. Every referenced object must lie under this |
| +// holder's location (its incarnation) in this region's data bucket, at |
| +// files/<generation>/objects/; any other reference refuses the pin. |
| func (p StorePinner) filesPin(ctx context.Context, h *hostclaim.Holder) (FilesPin, []byte, error) { |
| chain := []string{h.Generation} |
| for i := len(h.Admissions) - 1; i >= 0; i-- { |
| @@ -6600,6 +6768,9 @@ func (p StorePinner) filesPin(ctx context.Context, h *hostclaim.Holder) (FilesPi |
| if err := json.Unmarshal(raw, &m); err != nil || m.V != hostfiles.ManifestVersion { |
| return FilesPin{}, nil, errors.Join(fmt.Errorf("%w: files manifest %s", ErrRefused, prefix), err) |
| } |
| + if origins, err = referencedOrigins(h.Location, origins, m); err != nil { |
| + return FilesPin{}, nil, err |
| + } |
| sum := sha256.Sum256(raw) |
| return FilesPin{Prefix: prefix, Seq: m.Seq, SHA256: hex.EncodeToString(sum[:]), Origins: origins}, raw, nil |
| } |
| @@ -6611,6 +6782,44 @@ func (p StorePinner) filesPin(ctx context.Context, h *hostclaim.Holder) (FilesPi |
| return FilesPin{Prefix: prefix, Origins: []string{prefix}}, nil, nil |
| } |
| +// referencedOrigins adds to origins the files prefix of every generation |
| +// whose objects m references, each of which must be an object this |
| +// incarnation's location published: <location>/files/<generation>/objects/. |
| +func referencedOrigins(location string, origins []string, m hostfiles.Manifest) ([]string, error) { |
| + base := location + "/files/" |
| + have := make(map[string]bool, len(origins)) |
| + for _, o := range origins { |
| + have[o] = true |
| + } |
| + var extra []string |
| + check := func(name string, e hostfiles.Entry) error { |
| + if e.Object == "" { |
| + return nil // a tombstone or a skipped entry names no object |
| + } |
| + gen, rest, ok := strings.Cut(strings.TrimPrefix(e.Object, base), "/") |
| + if !strings.HasPrefix(e.Object, base) || !ok || gen == "" || gen == "." || gen == ".." || !strings.HasPrefix(rest, "objects/") { |
| + return fmt.Errorf("%w: %s references %s, outside %s: %w", ErrRefused, name, e.Object, base, hostfiles.ErrOrigin) |
| + } |
| + if o := base + gen + "/"; !have[o] { |
| + have[o] = true |
| + extra = append(extra, o) |
| + } |
| + return nil |
| + } |
| + for name, e := range m.Entries { |
| + if err := check(name, e); err != nil { |
| + return nil, err |
| + } |
| + } |
| + for name, e := range m.Platform { |
| + if err := check(name, e); err != nil { |
| + return nil, err |
| + } |
| + } |
| + sort.Strings(extra) |
| + return append(origins, extra...), nil |
| +} |
| + |
| // ReadPin reads and verifies the pin object a journaled RecoveryPin names: |
| // the exact bytes hash to the journaled digest (ruling 5). Every reader of |
| // a pin goes through it. |
| @@ -7200,11 +7409,8 @@ func coverageFor(c *hostclaim.Claim) string { |
| if json.Unmarshal([]byte(c.Op.Journal["release"]), &r) != nil || r.ReleasedBy == "" { |
| return "" |
| } |
| - p := c.Op.Journal["prepared"] |
| - for _, a := range c.Holder.Admissions { |
| - if a.Generation == p { |
| - return "" |
| - } |
| + if hostclaim.PreparedOpened(c) { |
| + return "" |
| } |
| pred := hostclaim.AdmissionRecord{Session: c.Holder.Session, Box: c.Holder.WriterBox, Generation: c.Holder.Generation} |
| if n := len(c.Holder.Admissions); n > 0 { |
| @@ -11223,3 +11429,4 @@ Expected: exit 0. Exit 2 is pending: wait and read again. Exit 1: read the log a |
| - **Rev 9 guards**, each removed in turn and its test watched to fail: `FenceExhaustedTarget` keeping a live source's `op.exec` and phase (the `starting` and `releasing` cases revoke it); `Release` keeping the recorded directory revision whenever positions are recorded (the same-controller retry asks at the new revision). Two in all. |
| - **Rev 10 guard**, removed and its test watched to fail: `Attempt` creating with the selection it made (`TestCreateIsTodaysWithoutASelection`: the completion substitutes box-a). The in-order dry run is the check for finding 1. One in all. |
| - **Rev 11 guards**, each removed in turn and its test watched to fail: the console chooser's live-edges reader (an empty snapshot fails on the box identities); its claim-loads reader (an empty map fails on the counts); `ProductionAuthority.SelectExecutor` consulting its chooser (a fixed box-a fails); `regionHAEnabled` honouring the parsed value (any parseable value as on fails on `0`). Four in all. |
| +- **Rev 12 guards**, each removed in turn and its test watched to fail: the pin's referenced origins (the copy refuses g1's object with `ErrOrigin`); the refusal of a reference outside the incarnation's files (a foreign reference is pinned); `preparedOpened` reading `prepared_opened` (P is forgotten with its admission); the opening admission journaling it. Four in all. |