plan: claim Plan 15 rev 1, durable admitted-generation provenance (content-addressed node chain in the deployment store, head committed in the admission CAS, AdmittedOrigin read API, seeded from retained admissions; spec rev 6.35)
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+1619
−0
docs/superpowers/plans/2026-10-12-host-lifecycle-claim-15-admitted-provenance.md+1619 −0
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 151.297s
ok github.com/carlosframework/platform/cmd/console 2.214s
ok github.com/carlosframework/platform/internal/activator 55.683s
ok github.com/carlosframework/platform/internal/agentsock 0.014s
ok github.com/carlosframework/platform/internal/analytics 0.016s
ok github.com/carlosframework/platform/internal/appconfig 1.008s
ok github.com/carlosframework/platform/internal/apperr 0.069s
ok github.com/carlosframework/platform/internal/applog 0.129s
ok github.com/carlosframework/platform/internal/awsinv 0.003s
ok github.com/carlosframework/platform/internal/caps 0.012s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.037s
ok github.com/carlosframework/platform/internal/clamd 0.054s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 50.883s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 1.389s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.011s
ok github.com/carlosframework/platform/internal/dbverify 0.005s
ok github.com/carlosframework/platform/internal/dnsrec 0.005s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 1.053s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.005s
ok github.com/carlosframework/platform/internal/edge 22.848s
ok github.com/carlosframework/platform/internal/edgesfile 0.006s
ok github.com/carlosframework/platform/internal/fleetapi 0.094s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/host 6.657s
ok github.com/carlosframework/platform/internal/infraview 0.005s
ok github.com/carlosframework/platform/internal/instancectl 0.414s
ok github.com/carlosframework/platform/internal/instanceexec 0.010s
ok github.com/carlosframework/platform/internal/instancefs 0.005s
ok github.com/carlosframework/platform/internal/lease 5.072s
ok github.com/carlosframework/platform/internal/ledger 0.024s
ok github.com/carlosframework/platform/internal/mail 0.058s
ok github.com/carlosframework/platform/internal/maintenance 0.088s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.460s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.063s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/objstore 0.083s
ok github.com/carlosframework/platform/internal/ops 0.098s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 0.848s
ok github.com/carlosframework/platform/internal/publicrelease 0.011s
ok github.com/carlosframework/platform/internal/registry 1.259s
ok github.com/carlosframework/platform/internal/relay 7.221s
ok github.com/carlosframework/platform/internal/release 3.607s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/schedule 1.390s
ok github.com/carlosframework/platform/internal/status 0.073s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/svcrole 0.005s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.005s
ok github.com/carlosframework/platform/internal/usage 0.007s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.571s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.022s
minio contract: PASS
diff --git a/docs/superpowers/plans/2026-10-12-host-lifecycle-claim-15-admitted-provenance.md b/docs/superpowers/plans/2026-10-12-host-lifecycle-claim-15-admitted-provenance.md| new file mode 100644 |
| index 000000000..015a9f6c7 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-12-host-lifecycle-claim-15-admitted-provenance.md |
| @@ -0,0 +1,1619 @@ |
| +# Host lifecycle claim, Plan 15: durable admitted-generation provenance |
| + |
| +Implementation plan, 2026-10-12, rev 1. Based on `origin/main` at |
| +`8916558d34f68698f488ee7a0aa7f4a55808c380`. Apply Tasks A–E in order. |
| +Task M is a separately pinned moves integration replay. The spec amendment |
| +is rev 6.35. This plan authorizes no commits, production changes or activation. |
| + |
| +Requirements: `/home/paulca/.cache/tmp/claude-1001/own/after236/plan15-brief.md`. |
| +The originating review is |
| +`/home/paulca/.cache/tmp/claude-1001/own/after236/review-moves-task6c.md`. |
| +This plan and its write report are implementation instructions for the |
| +controller. They introduce no product copy. |
| + |
| +## Global Constraints |
| + |
| +- The code base is the full SHA above, followed only by earlier tasks here. |
| + Do not export the design branch as the implementation base. The worktree's |
| + git directory is read-only. No subagents or Claude/Anthropic tools. |
| +- Preserve all admission, stop, loss, publication and retirement checks. |
| + Provenance answers whether a prefix was admitted. It grants no current |
| + write, launch, coverage, object-existence or object-content authority. |
| +- Keep flag-off behavior identical to the base: no new field or storage |
| + calls while either recording switch is off on an unmigrated claim. |
| + No server, initializer, credential, IAM change or runtime registration |
| + is installed by these patches. Strict readers must precede new writers. |
| +- Keep #267 and Plan 14's boundaries. This code adds no root filesystem |
| + effect, SQLite open, AWS configuration loader or signing key. |
| +- Use the designated existing shared cache |
| + `/home/paulca/.cache/tmp/claude-1001/gocache`; never remove it. Scratch |
| + goes under `$TMPDIR`; delete only the exact directory created here. |
| +- Preserve the design worktree's existing changes to `.gitignore` and |
| + Plan 14. The spec amendment is replayed on a separate scratch input. |
| +- The independent claim-writer prerequisite below is mandatory. The |
| + library and a hash chain alone do not satisfy the stolen-box-credential |
| + requirement on the pinned deployment policy. |
| + |
| +## Decision and code evidence |
| + |
| +Choose option (b): a content-addressed, digest-linked chain of claim-scoped |
| +objects, with its head and count committed in the admission claim CAS |
| +(compare-and-swap). The exact origin tuple is host, incarnation, location, |
| +region and generation. The actual files prefix is |
| +`<location>/files/g-<generation>/`; callers pass the generation token, |
| +without adding the layout's `g-` prefix. |
| + |
| +The pinned code rules out sequential-range compression. In |
| +`internal/host/claimconstruction.go`, `newID` generates 16 random bytes and |
| +hex-encodes them. `movesprobeowners.go` also uses operation IDs as prepared |
| +generation names and `m.NewID()` for subsequent attempts. `AdmitWriter` |
| +accepts a caller-supplied nonempty token rather than allocating a consecutive |
| +per-incarnation number. `Claim.Gen` and `Holder.Session` are claim revisions, |
| +not a gap-free generation-name sequence. |
| + |
| +Option (a) would couple retention of authority evidence to a distributed |
| +live-set and park proof. That is unnecessary here, and an absence inferred |
| +from one manifest cannot justify deleting provenance. Option (b) keeps |
| +claim CAS bodies small without deleting historical permissions or widening |
| +the eight-entry admission window. |
| + |
| +`Claim.Origins` serializes as optional `admitted_origins: {digest,count}`. |
| +It stays at claim level through holder removal, new incarnations and the |
| +32-entry retirement cap. Each node under |
| +`control/host-origins/<host>/<digest>.json` contains one tuple, its predecessor |
| +digest, version and count. SHA-256 is domain-separated with |
| +`carlos-admitted-origin-v1` and a NUL byte. Nodes are strictly decoded and |
| +limited to 4096 bytes. The chain is capped at 1,048,576 nodes, at most 4 GiB |
| +of committed node bodies per host across all incarnations. One distinct |
| +origin per hour reaches that limit after about 120 years; one per day takes |
| +about 2,870 years. Adjacent repeated tuples, including ordinary warm sessions, |
| +reuse the head. Nonadjacent duplicates are harmless and count toward the cap. |
| +Exhaustion refuses the next growing admission; it does not truncate history. |
| + |
| +This bounds the added claim field, each object, the total committed record |
| +and decoder memory. A query streams one node at a time, with worst-case |
| +O(N) reads and O(1) traversal memory. Caller cancellation returns an error. |
| +Do not silently limit the walk to eight entries or treat a deadline as |
| +absence. Batch proof or an authenticated search index is a future performance |
| +improvement; it must retain this authority contract. Before activation, |
| +measure proof latency at the deployment's expected retained history size. |
| + |
| +Uncommitted candidates from failed CAS attempts are outside the committed |
| +record. This plan adds no candidate garbage collector. Retries with the same |
| +inputs reuse identical objects; a different candidate can leave an orphan. |
| +Storage growth from abandoned/adversarial candidates needs separate operational |
| +limits. It cannot confer provenance. Never sweep committed nodes as generation, |
| +replica, instance-residue or move-staging garbage. |
| + |
| +## Independent claim-writer prerequisite |
| + |
| +The brief's compromised-box requirement exposes an existing boundary gap. |
| +`infra/modules/region-host/main.tf` grants the host role `s3:PutObject` and |
| +`s3:DeleteObject` over both `${var.deployment_bucket_arn}/*` and the data |
| +bucket. A stolen host credential can therefore replace |
| +`control/hosts/<host>.json`, not only manifests. No hash stored in that same |
| +mutable claim can authenticate itself against that attacker. |
| + |
| +The controller must keep Plan 15 inactive until a separately trusted claim |
| +transition service owns writes to the anchor. The scoped infrastructure work |
| +must remove or explicitly deny all box/data-principal writes and deletes to |
| +`control/hosts/*`, including alternate policies, assumed roles and proxy |
| +paths. The service's credential must not be obtainable by the box principal. |
| +The service must handle other claim transitions too; denying this prefix |
| +without moving their writes would stop the lifecycle engine. |
| + |
| +The admission endpoint accepts structured admission inputs and existing |
| +required evidence, authenticates the enrolled actor independently, rereads the |
| +claim, validates executor, operation, phase, session, generation, region, |
| +fence/loss and stop/baseline evidence through the existing admission owners, |
| +and runs `AdmitWriter` plus `Update` against its private store. It accepts no |
| +arbitrary transition callback, caller-chosen head or ready-to-write claim |
| +body. Other endpoints preserve `Origins`. A generic authenticated Put proxy |
| +is insufficient. The private pending tuple in Task A prevents an accidental |
| +in-process bypass; it is not a security boundary against a compromised |
| +process holding the raw store. |
| + |
| +Required activation witnesses: the real box credential cannot put/delete the |
| +claim, cannot acquire the writer credential, and cannot get the service to |
| +commit an invented origin; ordinary valid admission through the service |
| +still commits the admission and head together. Local tests here prove the |
| +transition and reader behavior, not IAM enforcement. No scoped production |
| +approval has been supplied, and no infrastructure is changed by this plan. |
| + |
| +## Atomicity and crash behavior |
| + |
| +`AdmitWriter` captures its validated origin before appending and pruning the |
| +admission list. `Update` owns persistence: after transition/claim validation, |
| +it checks that the ordinary callback preserved the old head, verifies the |
| +pending tuple against the resulting holder and admission, and rereads the |
| +stored observation before seeding. It then stages create-only nodes and |
| +uses the existing single claim Put to commit the admission and head. |
| + |
| +| Failure point | Durable authority | |
| +| --- | --- | |
| +| Before any node Put | Old claim and old provenance only | |
| +| Some seed/candidate nodes written; later Put fails | Old claim only; unreferenced candidates prove nothing | |
| +| Claim CAS loses or fails before commit | Old claim only; no proof from the losing candidate | |
| +| Claim CAS commits; reply is lost | Both admission and head exist; reread the claim to resolve uncertainty | |
| +| Commit succeeds, process crashes before materialization | Both remain recorded; ordinary recovery resumes from the committed observation | |
| + |
| +The record is the graph reachable from the committed head, not every object |
| +in its directory. This distinction permits staging before the CAS without a |
| +reverse split commit. Readers never list candidates or trust upload receipts. |
| +A candidate's create conflict is accepted only after reading identical bytes. |
| +A malformed or missing referenced node refuses; it never falls back to hints. |
| + |
| +## Published interfaces and moves contract |
| + |
| +```go |
| +// internal/hostclaim. Claims is the authoritative deployment store. |
| +func AdmittedOrigin(ctx context.Context, claims release.Store, |
| + observed Observed, incarnation, location, region, generation string) (bool, error) |
| + |
| +// internal/host. Implements movesOriginProof without depending on its branch. |
| +type ClaimOriginProof struct { |
| + Claims release.Store |
| + HomeRegion, Region string |
| +} |
| +func (ClaimOriginProof) Proven(context.Context, hostclaim.Observed, |
| + hostclaim.Admission, string) (bool, error) |
| +``` |
| + |
| +The query compares the stored claim's ETag and bytes with the observation. |
| +A changed or caller-edited observation returns `ErrConflict`. Empty or invalid |
| +coordinates return `ErrMalformed`. A valid but unrecorded tuple returns |
| +`false,nil`. Missing/corrupt/unreadable reachable nodes return an error. |
| +Moves refuses on either false or error; it does not substitute the manifest's |
| +`Origins`, a retained predecessor, `Holder.Generation`, or |
| +`Admission.NewGeneration` for proof. |
| + |
| +The library compares regions exactly. `ClaimOriginProof` queries the verified |
| +home roster name and `@home` only when its configured region is that verified |
| +home placement. A foreign region remains foreign. The adapter gets HomeRegion |
| +from the verified actor, never from a manifest. An error while proving one |
| +alias does not fall through to a permissive alias. |
| + |
| +Moves' `movesFilesAllowed` must prove every candidate, every entry/platform |
| +object's origin and every `Origins` hint through this adapter before forming |
| +its allowed set. Keep its location and generation syntax checks and the |
| +subsequent `ValidateManifest`. `Origins` remains a carry-forward hint. A |
| +prepared generation is proven only from the observation after admission. |
| +Cross-region prepared materialization writes destination-local objects; |
| +source-region history does not authorize them in the destination. |
| + |
| +The proof API may establish historical membership for retired incarnations, |
| +which is useful to an independently authorized reader. A moves start still |
| +uses its exact current holder and all existing start authority checks. This |
| +API is not a cross-incarnation data-access grant. |
| + |
| +## Migration and rollout |
| + |
| +1. Ship strict readers to every claim consumer, including console decoders, |
| + box/root workers, CLI/admin readers and tests. `decodeStrict` rejects |
| + unknown fields on older binaries. Raise the release floor before the |
| + first writer emits `admitted_origins`; do not downgrade afterward. |
| +2. Complete the independent writer boundary and its credential-level witnesses. |
| + The recording toggle is not evidence that the boundary exists. |
| +3. Enable `CARLOS_HOST_CLAIM_ORIGINS=1` alongside `CARLOS_HOST_CLAIM=1` only |
| + within the approved activation work. The first successful admission seeds |
| + exact retained `AdmissionRecord.Generation`/`Region` tuples from the |
| + stored old holder, then its own new tuple, in the same claim CAS. |
| +4. While a legacy claim has no head, the read API can prove exact retained |
| + entries only. It never infers a discarded entry from a retained record's |
| + `Predecessor`, root generation, retirement list, object listing or manifest. |
| + The empty legacy root has no generation-qualified files origin. |
| +5. Audit every live files manifest before allowing establishment. A host past |
| + the old pruning window may still depend on an unchanged object from an |
| + unprovable origin. The new record cannot reconstruct lost evidence. |
| + Such a start refuses even if its bytes happen to remain in the bucket. |
| +6. #250's activation files-manifest backfill resolves that only if it actually |
| + freezes the host, verifies the authoritative local/recovery snapshot, |
| + re-copies every app file and platform item (env and wake state included) |
| + into an admitted prefix, publishes the replacement manifest and rereads |
| + and verifies all referenced bytes before releasing the hold. An unavailable |
| + or unverifiable source stays blocked. No mass trust of old prefixes, empty |
| + bootstrap, `Origins` edit or manifest-only copy is permitted. |
| + |
| +The available `discussion-250.txt` and the pinned code do not establish a |
| +completed re-copy backfill. The moves provider explicitly has no pin reader |
| +on its current branch and refuses a host with no manifest. Therefore this |
| +plan does not claim that #250 already covers pruned-origin hosts. The |
| +controller must identify and run that backfill's implementation and acceptance |
| +witness; neither this metadata migration nor Task M supplies it. |
| + |
| +New recording also refuses legacy origin tokens over 256 bytes or malformed |
| +coordinates rather than blessing unusable paths. Existing claims still parse. |
| +Once a head exists, turning recording off refuses admissions that would leave |
| +an unrecorded gap. Other transitions preserve the head. Before migration, |
| +flag-off keeps the base serialized bytes and single claim Put; no history |
| +objects, additional Gets or changed generation names are introduced. |
| + |
| +## Review Focus |
| + |
| +1. Only a validated admission queues provenance, including loss and fenced-loss |
| + wrappers that already call `AdmitWriter`. It is collected before pruning. |
| +2. The stored observation supplies migration inputs; forged in-memory history, |
| + raw head edits and general console lifecycle grants cannot introduce a head |
| + through the reviewed library/boundary paths. |
| +3. Partial writes and losing CAS attempts never make a candidate authoritative. |
| + An uncertain successful CAS retains both fields. Retries check exact bytes. |
| +4. Old valid origins survive repeated pruning, holder removal and retirement |
| + list loss. Wrong incarnation, location, host and region stay unproven. |
| +5. Real files establishment uses the durable proof after pruning. An additional |
| + never-admitted `Origins` hint refuses even on a valid current manifest. |
| +6. The cap refuses growth without pruning, and corrupt/missing nodes refuse. |
| + No list-based fallback, blanket root-prefix permission or copied hint is added. |
| +7. Keep the independent writer and frozen backfill gates visible. Passing local |
| + tests does not close either production gate. |
| + |
| +## Replay without git writes |
| + |
| +The Go input is the pinned main export. The separate spec input is rev 6.34, |
| +SHA-256 `ba992b669ffd7a9c788050ec6686846aaf8750f86462e074c051bf825739b663`. |
| +For this authoring run it was obtained by copying the design worktree's rev |
| +6.33 (SHA-256 `5432be6e12e8fc134eabcf3b19733a1a5935971e81c3ebd2f34f70e333945000`) |
| +and applying only Plan 14 rev 3 Task I's spec amendment. No Plan 14 Go code |
| +is a dependency of this replay. Verify that spec hash before Task E. |
| + |
| +```bash |
| +CLAIM15_REPO=/home/paulca/amadan.net/carlos/platform/.claude/worktrees/replica-ownership |
| +CLAIM15_PLAN="$CLAIM15_REPO/docs/superpowers/plans/2026-10-12-host-lifecycle-claim-15-admitted-provenance.md" |
| +CLAIM15_BASE=8916558d34f68698f488ee7a0aa7f4a55808c380 |
| +CLAIM15_SCRATCH=$(mktemp -d "$TMPDIR/claim15.XXXXXX") |
| +export CLAIM15_REPO CLAIM15_PLAN CLAIM15_BASE CLAIM15_SCRATCH |
| +mkdir "$CLAIM15_SCRATCH/tree" |
| +git -C "$CLAIM15_REPO" archive "$CLAIM15_BASE" | tar -x -C "$CLAIM15_SCRATCH/tree" |
| +export GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache |
| +export GOFLAGS="-mod=mod -buildvcs=false" GOPROXY=off |
| +cd "$CLAIM15_SCRATCH/tree" |
| +``` |
| + |
| +Save this helper as `$CLAIM15_SCRATCH/apply.py`. It refuses missing stages, |
| +offsets, fuzz and already-applied hunks. Apply each task's tests, witness its |
| +stated failure, then apply implementation and rerun. Dependency or permission |
| +failures are not regression witnesses. |
| + |
| +```python |
| +import pathlib, re, subprocess, sys |
| +plan, task, phase = sys.argv[1:] |
| +blocks = [body for name, stage, body in re.findall( |
| + r'<!-- apply: (\w+) (tests|implementation) -->\n```diff\n(.*?)\n```', |
| + pathlib.Path(plan).read_text(), re.S) if name == task and stage == phase] |
| +if not blocks: |
| + raise SystemExit('missing task/phase') |
| +for body in blocks: |
| + result = subprocess.run(['patch', '--batch', '--forward', '--fuzz=0', '-p1'], |
| + input=body + '\n', text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) |
| + print(result.stdout, end='') |
| + if result.returncode or re.search(r'offset|fuzz|previously applied|Reversed', result.stdout): |
| + raise SystemExit('patch did not apply exactly') |
| +``` |
| + |
| +## Task A: Add the durable record, admission hook and reader |
| + |
| +Files: `internal/hostclaim/{origins.go,claim.go,generation.go,store.go}` and |
| +`plan15_origins_test.go`. Preserve the public `AdmitWriter`, `Update` and |
| +loss-admission signatures. No caller can supply an `OriginHead` to admission. |
| +The node Put is create-only in the same deployment store as the claim. |
| + |
| +Focused command: `go test ./internal/hostclaim -run TestPlan15 -count=1`. |
| +Expected initial failure: `AdmittedOrigin`, `OriginHead` and the private node |
| +helpers are undefined. Then run the full hostclaim package, including existing |
| +warm/cold, loss, relocation, wake-retry and flag-off transition tests. |
| + |
| +The tests cover legacy migration, actual admission pruning, retention across |
| +holder removal, exact scope, unknown origins, warm reuse, caps, cancellation, |
| +malformed counts/heads, raw candidate injection, observation forgery, direct |
| +head edits, node failure, failed/conflicting claim writes, and a committed CAS |
| +whose reply is lost. They never claim to exercise real IAM permissions. |
| + |
| +<!-- apply: A tests --> |
| +```diff |
| +--- /dev/null |
| ++++ b/internal/hostclaim/plan15_origins_test.go |
| +@@ -0,0 +1,292 @@ |
| ++package hostclaim |
| ++ |
| ++import ( |
| ++ "context" |
| ++ "encoding/json" |
| ++ "errors" |
| ++ "fmt" |
| ++ "os" |
| ++ "reflect" |
| ++ "strings" |
| ++ "testing" |
| ++ |
| ++ "github.com/carlosframework/platform/internal/release" |
| ++) |
| ++ |
| ++func plan15Enable(t *testing.T) { |
| ++ t.Helper() |
| ++ t.Setenv("CARLOS_HOST_CLAIM", "1") |
| ++ t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "1") |
| ++} |
| ++func plan15Admit(o Observed, generation, region string) func(*Claim) error { |
| ++ h := o.Claim.Holder |
| ++ return AdmitWriter(Admission{ObservedOpID: o.Claim.Op.ID, ObservedPhase: o.Claim.Op.Phase, |
| ++ ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: o.Claim.Op.Exec, |
| ++ Mode: "cold", NewGeneration: generation, Region: region, PredecessorRegion: region, PredecessorCertified: true}) |
| ++} |
| ++func plan15Proof(t *testing.T, s release.Store, o Observed, generation, region string, want bool) { |
| ++ t.Helper() |
| ++ h := o.Claim.Holder |
| ++ got, err := AdmittedOrigin(t.Context(), s, o, h.Incarnation, h.Location, region, generation) |
| ++ if err != nil || got != want { |
| ++ t.Fatalf("proof %s/%s=%v,%v want %v", region, generation, got, err, want) |
| ++ } |
| ++} |
| ++func TestPlan15PruningMigrationAndScope(t *testing.T) { |
| ++ t.Setenv("CARLOS_HOST_CLAIM", "") |
| ++ t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "") |
| ++ s := release.NewDirStore(t.TempDir()) |
| ++ o := servingClaim(t, t.Context(), s) |
| ++ // A legacy claim can have more admissions than the covered retention window. |
| ++ for i := 0; i < 12; i++ { |
| ++ o = updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, fmt.Sprintf("old%d", i), "ie")) |
| ++ o = updateGenerationTest(t, t.Context(), s, o, func(c *Claim) error { |
| ++ for i := range c.Holder.Admissions { |
| ++ c.Holder.Admissions[i].Covered = true |
| ++ } |
| ++ return nil |
| ++ }) |
| ++ } |
| ++ if len(o.Claim.Holder.Admissions) != 8 { |
| ++ t.Fatal("fixture did not prune") |
| ++ } |
| ++ plan15Enable(t) |
| ++ plan15Proof(t, s, o, "old0", "ie", false) |
| ++ plan15Proof(t, s, o, "old4", "ie", true) |
| ++ o = updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, "first", "ie")) |
| ++ for i := 0; i < 24; i++ { |
| ++ o = updateGenerationTest(t, t.Context(), s, o, func(c *Claim) error { |
| ++ for i := range c.Holder.Admissions { |
| ++ c.Holder.Admissions[i].Covered = true |
| ++ } |
| ++ return nil |
| ++ }) |
| ++ o = updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, fmt.Sprintf("new%d", i), "ie")) |
| ++ } |
| ++ for _, g := range []string{"first", "old4", "old11", "new0", "new23"} { |
| ++ plan15Proof(t, s, o, g, "ie", true) |
| ++ } |
| ++ plan15Proof(t, s, o, "old0", "ie", false) |
| ++ plan15Proof(t, s, o, "never", "ie", false) |
| ++ plan15Proof(t, s, o, "first", "uk", false) |
| ++ h := o.Claim.Holder |
| ++ for _, q := range []struct{ inc, loc string }{{strings.Repeat("b", 64), "instances/" + o.Host}, {h.Incarnation, "instances/" + o.Host}} { |
| ++ got, err := AdmittedOrigin(t.Context(), s, o, q.inc, q.loc, "ie", "first") |
| ++ if got || err != nil { |
| ++ t.Fatalf("wrong scope %v %v", got, err) |
| ++ } |
| ++ } |
| ++ // The old incarnation's history survives holder removal and the retirement cap. |
| ++ old := *h |
| ++ o = updateGenerationTest(t, t.Context(), s, o, func(c *Claim) error { |
| ++ c.Holder = nil |
| ++ c.Op = Op{} |
| ++ c.Obligations = nil |
| ++ c.RetiredIncarnations = nil |
| ++ return nil |
| ++ }) |
| ++ got, err := AdmittedOrigin(t.Context(), s, o, old.Incarnation, old.Location, "ie", "first") |
| ++ if !got || err != nil { |
| ++ t.Fatalf("retired origin %v %v", got, err) |
| ++ } |
| ++} |
| ++ |
| ++type plan15Store struct { |
| ++ release.Store |
| ++ failNode, failClaim, conflict, uncertain bool |
| ++ puts int |
| ++} |
| ++ |
| ++func (s *plan15Store) Put(ctx context.Context, key string, b []byte, tag string) (string, error) { |
| ++ s.puts++ |
| ++ if strings.HasPrefix(key, "control/host-origins/") && s.failNode { |
| ++ return "", os.ErrPermission |
| ++ } |
| ++ if strings.HasPrefix(key, "control/hosts/") { |
| ++ if s.failClaim { |
| ++ return "", os.ErrPermission |
| ++ } |
| ++ if s.conflict { |
| ++ return "", release.ErrCASConflict |
| ++ } |
| ++ if s.uncertain { |
| ++ _, err := s.Store.Put(ctx, key, b, tag) |
| ++ if err != nil { |
| ++ return "", err |
| ++ } |
| ++ return "", os.ErrDeadlineExceeded |
| ++ } |
| ++ } |
| ++ return s.Store.Put(ctx, key, b, tag) |
| ++} |
| ++func TestPlan15AtomicityAndRetry(t *testing.T) { |
| ++ for _, fault := range []string{"node", "claim", "conflict", "uncertain"} { |
| ++ t.Run(fault, func(t *testing.T) { |
| ++ plan15Enable(t) |
| ++ raw := release.NewDirStore(t.TempDir()) |
| ++ o := servingClaim(t, t.Context(), raw) |
| ++ s := &plan15Store{Store: raw, failNode: fault == "node", failClaim: fault == "claim", conflict: fault == "conflict", uncertain: fault == "uncertain"} |
| ++ if _, err := Update(t.Context(), s, o, plan15Admit(o, "candidate", "ie")); err == nil { |
| ++ t.Fatal("fault ignored") |
| ++ } |
| ++ fresh, err := Read(t.Context(), raw, o.Host) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ committed := fault == "uncertain" |
| ++ if (fresh.Claim.Holder.Generation == "candidate") != committed || (fresh.Claim.Origins != nil) != committed { |
| ++ t.Fatal("split commit") |
| ++ } |
| ++ plan15Proof(t, raw, fresh, "candidate", "ie", committed) |
| ++ if !committed { |
| ++ fresh = updateGenerationTest(t, t.Context(), raw, fresh, plan15Admit(fresh, "candidate", "ie")) |
| ++ plan15Proof(t, raw, fresh, "candidate", "ie", true) |
| ++ } |
| ++ if _, err := Update(t.Context(), raw, o, plan15Admit(o, "stale", "ie")); !errors.Is(err, ErrConflict) { |
| ++ t.Fatalf("stale: %v", err) |
| ++ } |
| ++ plan15Proof(t, raw, fresh, "stale", "ie", false) |
| ++ }) |
| ++ } |
| ++} |
| ++func TestPlan15NoSelfAuthentication(t *testing.T) { |
| ++ plan15Enable(t) |
| ++ raw := release.NewDirStore(t.TempDir()) |
| ++ o := servingClaim(t, t.Context(), raw) |
| ++ o = updateGenerationTest(t, t.Context(), raw, o, plan15Admit(o, "real", "ie")) |
| ++ h := o.Claim.Holder |
| ++ fake := originNode{V: 1, Host: o.Host, Count: o.Claim.Origins.Count + 1, Previous: o.Claim.Origins.Digest, Origin: originTuple{h.Incarnation, h.Location, "ie", "forged"}} |
| ++ body, _ := json.Marshal(fake) |
| ++ digest := originDigest(body) |
| ++ if _, err := raw.Put(t.Context(), originKey(o.Host, digest), body, ""); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ plan15Proof(t, raw, o, "forged", "ie", false) |
| ++ // Even a caller who knows the ETag cannot substitute a new head in the reader. |
| ++ tampered := o |
| ++ clone := *o.Claim |
| ++ tampered.Claim = &clone |
| ++ clone.Origins = &OriginHead{digest, fake.Count} |
| ++ if ok, err := AdmittedOrigin(t.Context(), raw, tampered, h.Incarnation, h.Location, "ie", "forged"); ok || !errors.Is(err, ErrConflict) { |
| ++ t.Fatalf("edited observation: %v %v", ok, err) |
| ++ } |
| ++ for _, change := range []func(*Claim){func(c *Claim) { c.Origins = &OriginHead{digest, fake.Count} }, func(c *Claim) { c.Origins = nil }} { |
| ++ if _, err := Update(t.Context(), raw, o, func(c *Claim) error { change(c); return nil }); !errors.Is(err, ErrWrongState) { |
| ++ t.Fatalf("head bypass: %v", err) |
| ++ } |
| ++ } |
| ++ // A caller-edited migration seed is refused against the stored observation. |
| ++ fresh := servingClaim(t, t.Context(), release.NewDirStore(t.TempDir())) |
| ++ fresh.Claim.Holder.Admissions = append(fresh.Claim.Holder.Admissions, AdmissionRecord{Region: "ie", Generation: "invented", Mode: "cold"}) |
| ++ if _, err := Update(t.Context(), raw, fresh, plan15Admit(fresh, "new", "ie")); !errors.Is(err, ErrConflict) { |
| ++ t.Fatalf("forged migration: %v", err) |
| ++ } |
| ++ // Missing and corrupt reachable nodes are errors, never an affirmative answer. |
| ++ key := originKey(o.Host, o.Claim.Origins.Digest) |
| ++ b, tag, err := raw.Get(t.Context(), key) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if _, err = raw.Put(t.Context(), key, append(b, ' '), tag); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if ok, err := AdmittedOrigin(t.Context(), raw, o, h.Incarnation, h.Location, "ie", "real"); ok || err == nil { |
| ++ t.Fatal("corruption accepted") |
| ++ } |
| ++ if err = raw.Delete(t.Context(), key); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if ok, err := AdmittedOrigin(t.Context(), raw, o, h.Incarnation, h.Location, "ie", "real"); ok || err == nil { |
| ++ t.Fatal("missing node accepted") |
| ++ } |
| ++} |
| ++func TestPlan15FlagOffAndWarm(t *testing.T) { |
| ++ for _, flags := range [][2]string{{"", ""}, {"", "1"}, {"1", ""}} { |
| ++ t.Run(fmt.Sprint(flags), func(t *testing.T) { |
| ++ t.Setenv("CARLOS_HOST_CLAIM", flags[0]) |
| ++ t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", flags[1]) |
| ++ raw := release.NewDirStore(t.TempDir()) |
| ++ o := servingClaim(t, t.Context(), raw) |
| ++ s := &plan15Store{Store: raw} |
| ++ o = updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, "legacy", "ie")) |
| ++ if o.Claim.Origins != nil || s.puts != 1 { |
| ++ t.Fatal("flag-off wrote provenance") |
| ++ } |
| ++ b, _ := json.Marshal(o.Claim) |
| ++ if strings.Contains(string(b), "admitted_origins") { |
| ++ t.Fatal("flag-off wire changed") |
| ++ } |
| ++ }) |
| ++ } |
| ++ plan15Enable(t) |
| ++ raw := release.NewDirStore(t.TempDir()) |
| ++ o := servingClaim(t, t.Context(), raw) |
| ++ o = updateGenerationTest(t, t.Context(), raw, o, plan15Admit(o, "same", "ie")) |
| ++ before := *o.Claim.Origins |
| ++ o = updateGenerationTest(t, t.Context(), raw, o, AdmitWriter(Admission{ObservedSession: o.Claim.Holder.Session, ObservedGeneration: "same", Box: o.Claim.Op.Exec, Mode: "warm", Region: "ie", PredecessorCertified: true})) |
| ++ if !reflect.DeepEqual(before, *o.Claim.Origins) { |
| ++ t.Fatal("warm duplicated origin") |
| ++ } |
| ++ plan15Proof(t, raw, o, "same", "ie", true) |
| ++ t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "") |
| ++ if _, err := Update(t.Context(), raw, o, plan15Admit(o, "gap", "ie")); !errors.Is(err, ErrWrongState) { |
| ++ t.Fatalf("disabled writer made gap: %v", err) |
| ++ } |
| ++} |
| ++ |
| ++func TestPlan15LimitsAndMalformedHistory(t *testing.T) { |
| ++ plan15Enable(t) |
| ++ s := release.NewDirStore(t.TempDir()) |
| ++ o := servingClaim(t, t.Context(), s) |
| ++ for _, gen := range []string{"../escape", RootGeneration, strings.Repeat("x", 257)} { |
| ++ if _, err := Update(t.Context(), s, o, plan15Admit(o, gen, "ie")); err == nil { |
| ++ t.Fatalf("bad generation %q", gen) |
| ++ } |
| ++ } |
| ++ o = updateGenerationTest(t, t.Context(), s, o, plan15Admit(o, "first", "ie")) |
| ++ h := o.Claim.Holder |
| ++ // A synthetic near-limit history tests refusal without writing a million nodes. |
| ++ node := originNode{V: 1, Host: o.Host, Count: originCountLimit, Previous: strings.Repeat("a", 64), Origin: originTuple{h.Incarnation, h.Location, "ie", "first"}} |
| ++ b, _ := json.Marshal(node) |
| ++ head := OriginHead{originDigest(b), originCountLimit} |
| ++ if _, err := s.Put(t.Context(), originKey(o.Host, head.Digest), b, ""); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ o.Claim.Origins = &head |
| ++ key, _ := Key(o.Host) |
| ++ b, _ = json.Marshal(o.Claim) |
| ++ tag, err := s.Put(t.Context(), key, b, o.ETag) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ o.ETag = tag |
| ++ if _, err := Update(t.Context(), s, o, plan15Admit(o, "over-limit", "ie")); !errors.Is(err, ErrWrongState) { |
| ++ t.Fatalf("limit: %v", err) |
| ++ } |
| ++ ctx, cancel := context.WithCancel(t.Context()) |
| ++ cancel() |
| ++ if ok, err := AdmittedOrigin(ctx, s, o, h.Incarnation, h.Location, "ie", "absent"); ok || err == nil { |
| ++ t.Fatal("ignored cancellation") |
| ++ } |
| ++ node.Count-- |
| ++ b, _ = json.Marshal(node) |
| ++ // Same digest-shaped head with a mismatched count cannot prove membership. |
| ++ if _, err := s.Put(t.Context(), originKey(o.Host, originDigest(b)), b, ""); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ o.Claim.Origins = &OriginHead{originDigest(b), originCountLimit} |
| ++ b, _ = json.Marshal(o.Claim) |
| ++ o.ETag, err = s.Put(t.Context(), key, b, o.ETag) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if ok, err := AdmittedOrigin(t.Context(), s, o, h.Incarnation, h.Location, "ie", "first"); ok || err == nil { |
| ++ t.Fatal("count mismatch accepted") |
| ++ } |
| ++ for _, head := range []*OriginHead{{"bad", 1}, {strings.Repeat("a", 64), 0}, {strings.Repeat("a", 64), originCountLimit + 1}} { |
| ++ o.Claim.Origins = head |
| ++ if err := o.Claim.Validate(); err == nil { |
| ++ t.Fatal("bad head parsed") |
| ++ } |
| ++ } |
| ++} |
| +``` |
| + |
| +<!-- apply: A implementation --> |
| +```diff |
| +--- /dev/null |
| ++++ b/internal/hostclaim/origins.go |
| +@@ -0,0 +1,284 @@ |
| ++package hostclaim |
| ++ |
| ++import ( |
| ++ "bytes" |
| ++ "context" |
| ++ "crypto/sha256" |
| ++ "encoding/hex" |
| ++ "encoding/json" |
| ++ "errors" |
| ++ "io" |
| ++ "os" |
| ++ "strings" |
| ++ |
| ++ "github.com/carlosframework/platform/internal/release" |
| ++) |
| ++ |
| ++const originNodeLimit = 4096 |
| ++const originCountLimit = 1 << 20 |
| ++ |
| ++// OriginHead authenticates only nodes reachable from this claim's committed head. |
| ++type OriginHead struct { |
| ++ Digest string `json:"digest"` |
| ++ Count uint64 `json:"count"` |
| ++} |
| ++ |
| ++type originTuple struct { |
| ++ Incarnation string `json:"incarnation"` |
| ++ Location string `json:"location"` |
| ++ Region string `json:"region"` |
| ++ Generation string `json:"generation"` |
| ++} |
| ++ |
| ++type originNode struct { |
| ++ V int `json:"v"` |
| ++ Host string `json:"host"` |
| ++ Count uint64 `json:"count"` |
| ++ Previous string `json:"previous,omitempty"` |
| ++ Origin originTuple `json:"origin"` |
| ++} |
| ++ |
| ++func originEnabled() bool { |
| ++ return os.Getenv("CARLOS_HOST_CLAIM") == "1" && os.Getenv("CARLOS_HOST_CLAIM_ORIGINS") == "1" |
| ++} |
| ++ |
| ++func originDigest(b []byte) string { |
| ++ h := sha256.New() |
| ++ h.Write([]byte("carlos-admitted-origin-v1\x00")) |
| ++ h.Write(b) |
| ++ return hex.EncodeToString(h.Sum(nil)) |
| ++} |
| ++ |
| ++func originKey(host, digest string) string { |
| ++ return "control/host-origins/" + host + "/" + digest + ".json" |
| ++} |
| ++ |
| ++func originHeadValid(h *OriginHead) bool { |
| ++ return h == nil || h.Count > 0 && h.Count <= originCountLimit && isHex64(h.Digest) && h.Digest == strings.ToLower(h.Digest) |
| ++} |
| ++ |
| ++func (o originTuple) valid(host string) bool { |
| ++ if !isHex64(o.Incarnation) || o.Incarnation != strings.ToLower(o.Incarnation) || |
| ++ o.Region == "" || len(o.Region) > 64 || strings.ContainsAny(o.Region, "/\\\x00") || |
| ++ o.Generation == "" || len(o.Generation) > 256 || o.Generation == RootGeneration || |
| ++ o.Generation == "." || o.Generation == ".." || strings.ContainsAny(o.Generation, "/\\\x00") { |
| ++ return false |
| ++ } |
| ++ return o.Location == "instances/"+host || o.Location == "instances/"+host+"/"+o.Incarnation |
| ++} |
| ++ |
| ++// ValidateOriginObject classifies inert, content-addressed candidates. Creating a |
| ++// candidate grants nothing; only the claim CAS can make one reachable. |
| ++func ValidateOriginObject(key string, body []byte) error { |
| ++ if len(body) == 0 || len(body) > originNodeLimit { |
| ++ return ErrMalformed |
| ++ } |
| ++ d := json.NewDecoder(bytes.NewReader(body)) |
| ++ d.DisallowUnknownFields() |
| ++ var n originNode |
| ++ if err := d.Decode(&n); err != nil { |
| ++ return ErrMalformed |
| ++ } |
| ++ if _, err := d.Token(); err != io.EOF { |
| ++ return ErrMalformed |
| ++ } |
| ++ if _, err := Key(n.Host); err != nil { |
| ++ return ErrMalformed |
| ++ } |
| ++ if n.V != 1 || n.Count == 0 || n.Count > originCountLimit || !n.Origin.valid(n.Host) || |
| ++ (n.Count == 1) != (n.Previous == "") || |
| ++ n.Previous != "" && (!isHex64(n.Previous) || n.Previous != strings.ToLower(n.Previous)) || |
| ++ key != originKey(n.Host, originDigest(body)) { |
| ++ return ErrMalformed |
| ++ } |
| ++ return nil |
| ++} |
| ++ |
| ++func readOrigin(ctx context.Context, s release.Store, host string, h OriginHead) (originNode, error) { |
| ++ var n originNode |
| ++ if err := ctx.Err(); err != nil { |
| ++ return n, err |
| ++ } |
| ++ key := originKey(host, h.Digest) |
| ++ b, _, err := s.Get(ctx, key) |
| ++ if err != nil { |
| ++ return n, err |
| ++ } |
| ++ if err = ValidateOriginObject(key, b); err != nil { |
| ++ return n, err |
| ++ } |
| ++ if err = json.Unmarshal(b, &n); err != nil { |
| ++ return n, err |
| ++ } |
| ++ if n.Host != host || n.Count != h.Count { |
| ++ return n, ErrMalformed |
| ++ } |
| ++ return n, nil |
| ++} |
| ++ |
| ++func pendingOrigin(c *Claim, entry AdmissionRecord) error { |
| ++ if !originEnabled() { |
| ++ // Once written, provenance cannot silently stop collecting admissions. |
| ++ if c.Origins != nil { |
| ++ return ErrWrongState |
| ++ } |
| ++ return nil |
| ++ } |
| ++ if c.originAdmission != nil || c.Holder == nil { |
| ++ return ErrWrongState |
| ++ } |
| ++ o := originTuple{c.Holder.Incarnation, c.Holder.Location, entry.Region, entry.Generation} |
| ++ // The empty legacy root has no files/g-<generation>/ origin. |
| ++ if entry.Generation == "" { |
| ++ return nil |
| ++ } |
| ++ if !o.valid(c.Host) { |
| ++ return ErrWrongState |
| ++ } |
| ++ c.originAdmission = &o |
| ++ return nil |
| ++} |
| ++ |
| ++// commitOrigins stages immutable candidates before Update's existing claim CAS. |
| ++// A failed or uncertain CAS leaves candidates without granting them authority. |
| ++func commitOrigins(ctx context.Context, s release.Store, obs Observed, next *Claim) error { |
| ++ var old *OriginHead |
| ++ if obs.Claim != nil { |
| ++ old = obs.Claim.Origins |
| ++ } |
| ++ if !sameOriginHead(old, next.Origins) { |
| ++ return ErrWrongState |
| ++ } |
| ++ if next.originAdmission == nil { |
| ++ return nil |
| ++ } |
| ++ if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil || next.Holder == nil { |
| ++ return ErrWrongState |
| ++ } |
| ++ h := next.Holder |
| ++ want := *next.originAdmission |
| ++ if want.Incarnation != h.Incarnation || want.Location != h.Location || want.Generation != h.Generation || |
| ++ h.Session != next.Gen || len(h.Admissions) == 0 { |
| ++ return ErrWrongState |
| ++ } |
| ++ a := h.Admissions[len(h.Admissions)-1] |
| ++ if a.Session != h.Session || a.Generation != want.Generation || a.Region != want.Region { |
| ++ return ErrWrongState |
| ++ } |
| ++ // Bind migration to the stored observation, never a caller-edited admission list. |
| ++ fresh, err := Read(ctx, s, obs.Host) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ before, _ := json.Marshal(obs.Claim) |
| ++ stored, _ := json.Marshal(fresh.Claim) |
| ++ if fresh.Absent || fresh.ETag != obs.ETag || !bytes.Equal(before, stored) { |
| ++ return ErrConflict |
| ++ } |
| ++ var origins []originTuple |
| ++ if old == nil { |
| ++ for _, a := range obs.Claim.Holder.Admissions { |
| ++ if a.Generation == "" { |
| ++ continue |
| ++ } |
| ++ origins = append(origins, originTuple{obs.Claim.Holder.Incarnation, obs.Claim.Holder.Location, a.Region, a.Generation}) |
| ++ } |
| ++ } |
| ++ origins = append(origins, want) |
| ++ head := OriginHead{} |
| ++ if old != nil { |
| ++ head = *old |
| ++ } |
| ++ for _, o := range origins { |
| ++ if !o.valid(obs.Host) { |
| ++ return ErrWrongState |
| ++ } |
| ++ if head.Count != 0 { |
| ++ last, err := readOrigin(ctx, s, obs.Host, head) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ if last.Origin == o { |
| ++ continue |
| ++ } |
| ++ } |
| ++ if head.Count == originCountLimit { |
| ++ return ErrWrongState |
| ++ } |
| ++ node := originNode{V: 1, Host: obs.Host, Count: head.Count + 1, Previous: head.Digest, Origin: o} |
| ++ body, err := json.Marshal(node) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ key := originKey(obs.Host, originDigest(body)) |
| ++ if err = ValidateOriginObject(key, body); err != nil { |
| ++ return err |
| ++ } |
| ++ if _, err = s.Put(ctx, key, body, ""); errors.Is(err, release.ErrCASConflict) { |
| ++ existing, _, readErr := s.Get(ctx, key) |
| ++ if readErr != nil { |
| ++ return readErr |
| ++ } |
| ++ if !bytes.Equal(existing, body) { |
| ++ return ErrMalformed |
| ++ } |
| ++ } else if err != nil { |
| ++ return err |
| ++ } |
| ++ head = OriginHead{Digest: originDigest(body), Count: node.Count} |
| ++ } |
| ++ next.Origins = &head |
| ++ next.originAdmission = nil |
| ++ return nil |
| ++} |
| ++ |
| ++func sameOriginHead(a, b *OriginHead) bool { |
| ++ if a == nil || b == nil { |
| ++ return a == nil && b == nil |
| ++ } |
| ++ return *a == *b |
| ++} |
| ++ |
| ++// AdmittedOrigin proves one exact storage tuple. The caller uses a fresh claim |
| ++// observation from the authoritative deployment store, not the regional data |
| ++// bucket. No region alias is inferred here; pass the recorded engine name. |
| ++func AdmittedOrigin(ctx context.Context, s release.Store, obs Observed, incarnation, location, region, generation string) (bool, error) { |
| ++ want := originTuple{incarnation, location, region, generation} |
| ++ if s == nil || obs.Absent || obs.Claim == nil || obs.ETag == "" || obs.Host != obs.Claim.Host || !want.valid(obs.Host) { |
| ++ return false, ErrMalformed |
| ++ } |
| ++ fresh, err := Read(ctx, s, obs.Host) |
| ++ if err != nil { |
| ++ return false, err |
| ++ } |
| ++ before, marshalErr := json.Marshal(obs.Claim) |
| ++ stored, storedErr := json.Marshal(fresh.Claim) |
| ++ if fresh.Absent || fresh.ETag != obs.ETag || marshalErr != nil || storedErr != nil || !bytes.Equal(before, stored) { |
| ++ return false, ErrConflict |
| ++ } |
| ++ // The holder binding and head both come from the unchanged stored observation. |
| ++ c := fresh.Claim |
| ++ if c.Origins == nil { |
| ++ h := c.Holder |
| ++ if h == nil || h.Incarnation != incarnation || h.Location != location { |
| ++ return false, nil |
| ++ } |
| ++ for _, a := range h.Admissions { |
| ++ if a.Generation == generation && a.Region == region { |
| ++ return true, nil |
| ++ } |
| ++ } |
| ++ return false, nil |
| ++ } |
| ++ for head := *c.Origins; head.Count > 0; { |
| ++ node, err := readOrigin(ctx, s, obs.Host, head) |
| ++ if err != nil { |
| ++ return false, err |
| ++ } |
| ++ if node.Origin == want { |
| ++ return true, nil |
| ++ } |
| ++ head = OriginHead{Digest: node.Previous, Count: head.Count - 1} |
| ++ } |
| ++ return false, nil |
| ++} |
| +--- a/internal/hostclaim/claim.go |
| ++++ b/internal/hostclaim/claim.go |
| +@@ -155,6 +155,9 @@ |
| + } |
| + |
| + type Claim struct { |
| ++ Origins *OriginHead `json:"admitted_origins,omitempty"` |
| ++ originAdmission *originTuple |
| ++ |
| + RetiredLocations map[string]string `json:"retired_locations,omitempty"` |
| + EvidenceRefs []AutomaticLoss `json:"evidence_refs,omitempty"` |
| + V int `json:"v"` |
| +@@ -225,6 +228,9 @@ |
| + } |
| + |
| + func (c *Claim) Validate() error { |
| ++ if c != nil && !originHeadValid(c.Origins) { |
| ++ return ErrMalformed |
| ++ } |
| + if c == nil || c.V != schemaVersion || !controlname.ValidHostShape(c.Host) || c.Gen == 0 { |
| + return fmt.Errorf("%w: header", ErrMalformed) |
| + } |
| +--- a/internal/hostclaim/generation.go |
| ++++ b/internal/hostclaim/generation.go |
| +@@ -163,6 +163,9 @@ |
| + entry.WritesLost = &WritesLost{Box: predecessor.Box, Generation: predecessor.Generation, Session: predecessor.Session, RemoteMaxTXID: in.RemoteMaxTXID} |
| + } |
| + } |
| ++ if err := pendingOrigin(c, entry); err != nil { |
| ++ return err |
| ++ } |
| + h.Admissions = append(h.Admissions, entry) |
| + for len(h.Admissions) > 8 && h.Admissions[0].Covered && admissionLossArchived(h.Admissions[0]) { |
| + h.Admissions = h.Admissions[1:] |
| +--- a/internal/hostclaim/store.go |
| ++++ b/internal/hostclaim/store.go |
| +@@ -98,6 +98,9 @@ |
| + if err := next.Validate(); err != nil { |
| + return Observed{}, err |
| + } |
| ++ if err := commitOrigins(ctx, s, obs, next); err != nil { |
| ++ return Observed{}, err |
| ++ } |
| + body, err := json.Marshal(next) |
| + if err != nil { |
| + return Observed{}, err |
| +``` |
| + |
| +## Task B: Classify candidates and preserve the head through console grants |
| + |
| +The new lifecycle family accepts only create-only, bounded, digest-bound |
| +candidate objects. It has no app mutation scope because a candidate alone |
| +authorizes nothing. Deletion and overwrite refuse. This is an exact family, |
| +not a general exemption for `control/` or the host's data prefixes. |
| + |
| +General console app/recovery grants preserve the origin head. They are not |
| +admission authority. Existing typed move and loss paths continue through |
| +`hostclaim.Update`, whose old-head check also applies. The eventual trusted |
| +admission service uses the private claim store after its own evidence checks; |
| +do not weaken this boundary to admit a caller-proposed head. |
| + |
| +Focused command: `go test ./internal/hostops -run 'TestPlan15|TestPlan9C2' -count=1`. |
| +Expected first failures: the candidate family is unclassified, and an ordinary |
| +same-app lifecycle grant accepts an introduced head. Tests include a valid |
| +unchanged-head write so a blanket denial cannot satisfy them. |
| + |
| +<!-- apply: B tests --> |
| +```diff |
| +--- /dev/null |
| ++++ b/internal/hostops/plan15_origins_test.go |
| +@@ -0,0 +1,62 @@ |
| ++package hostops |
| ++ |
| ++import ( |
| ++ "crypto/sha256" |
| ++ "encoding/hex" |
| ++ "strings" |
| ++ "testing" |
| ++ |
| ++ "github.com/carlosframework/platform/internal/hostclaim" |
| ++) |
| ++ |
| ++func TestPlan15OriginCandidateBoundary(t *testing.T) { |
| ++ w, raw, _ := testWriter(t) |
| ++ body := []byte(`{"v":1,"host":"x.example","count":1,"origin":{"incarnation":"` + strings.Repeat("a", 64) + `","location":"instances/x.example","region":"ie","generation":"g1"}}`) |
| ++ sum := sha256.Sum256(append([]byte("carlos-admitted-origin-v1\x00"), body...)) |
| ++ key := "control/host-origins/x.example/" + hex.EncodeToString(sum[:]) + ".json" |
| ++ for _, tc := range []struct { |
| ++ name string |
| ++ target MutationTarget |
| ++ ok bool |
| ++ }{ |
| ++ {"candidate", MutationTarget{Method: "Put", Key: key, After: body}, true}, |
| ++ {"overwrite", MutationTarget{Method: "Put", Key: key, After: body, ETag: "v"}, false}, |
| ++ {"delete", MutationTarget{Method: "Delete", Key: key}, false}, |
| ++ {"wrong host", MutationTarget{Method: "Put", Key: strings.Replace(key, "x.example", "other.example", 1), After: body}, false}, |
| ++ {"wrong digest", MutationTarget{Method: "Put", Key: key, After: append(append([]byte(nil), body...), ' ')}, false}, |
| ++ {"unknown field", MutationTarget{Method: "Put", Key: key, After: []byte(`{"bogus":true}`)}, false}, |
| ++ {"oversize", MutationTarget{Method: "Put", Key: key, After: make([]byte, 4097)}, false}, |
| ++ } { |
| ++ t.Run(tc.name, func(t *testing.T) { |
| ++ c, err := ClassifyMutation(t.Context(), raw, w, tc.target) |
| ++ if (err == nil) != tc.ok { |
| ++ t.Fatalf("classification %+v %v", c, err) |
| ++ } |
| ++ if tc.ok && (c.Family != "lifecycle-origin-candidate" || len(c.Scopes) != 0 || c.NonApp == "") { |
| ++ t.Fatalf("bad class %+v", c) |
| ++ } |
| ++ }) |
| ++ } |
| ++} |
| ++ |
| ++func TestPlan15LifecycleGrantCannotRewriteOrigins(t *testing.T) { |
| ++ w, o, clock := wakeResetFixture(t) |
| ++ ctx := registerFor(t, w, o, "create2", "create", o.Claim.Holder.Incarnation, clock) |
| ++ step := claimStep(t, o, func(c *hostclaim.Claim) error { |
| ++ c.Origins = &hostclaim.OriginHead{Digest: strings.Repeat("a", 64), Count: 1} |
| ++ return nil |
| ++ }) |
| ++ if err := authorizeClaimWrite(ctx, w.raw, o.Host, step); err == nil { |
| ++ t.Fatal("general app grant manufactured origin authority") |
| ++ } |
| ++ if err := authorizeClaimWrite(ctx, w.raw, o.Host, claimStep(t, o, func(*hostclaim.Claim) error { return nil })); err != nil { |
| ++ t.Fatalf("unchanged head refused: %v", err) |
| ++ } |
| ++ o.Claim.Origins = &hostclaim.OriginHead{Digest: strings.Repeat("b", 64), Count: 2} |
| ++ for _, head := range []*hostclaim.OriginHead{nil, {Digest: strings.Repeat("a", 64), Count: 1}} { |
| ++ step = claimStep(t, o, func(c *hostclaim.Claim) error { c.Origins = head; return nil }) |
| ++ if err := authorizeClaimWrite(ctx, w.raw, o.Host, step); err == nil { |
| ++ t.Fatal("grant erased or rolled back origin history") |
| ++ } |
| ++ } |
| ++} |
| +``` |
| + |
| +<!-- apply: B implementation --> |
| +```diff |
| +--- a/internal/hostops/mutation_classifier.go |
| ++++ b/internal/hostops/mutation_classifier.go |
| +@@ -13,6 +13,7 @@ |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| ++ "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/release" |
| + ) |
| + |
| +@@ -70,6 +71,11 @@ |
| + // are never deleted (C3). |
| + func lifecycleFamily(ctx context.Context, raw release.Store, w *ControlWriter, t MutationTarget) (string, bool, error) { |
| + switch { |
| ++ case strings.HasPrefix(t.Key, "control/host-origins/"): |
| ++ if t.Method != "Put" || t.ETag != "" || len(t.Before) != 0 || hostclaim.ValidateOriginObject(t.Key, t.After) != nil { |
| ++ return "", true, ErrIdentity |
| ++ } |
| ++ return "lifecycle-origin-candidate", true, nil |
| + case strings.HasPrefix(t.Key, "control/hosts/") && strings.HasSuffix(t.Key, ".json") && strings.Count(t.Key, "/") == 2: |
| + host := strings.TrimSuffix(strings.TrimPrefix(t.Key, "control/hosts/"), ".json") |
| + if t.Method != "Put" || !controlname.ValidHostShape(host) { |
| +--- a/internal/hostops/lifecycle_grant.go |
| ++++ b/internal/hostops/lifecycle_grant.go |
| +@@ -9,6 +9,7 @@ |
| + "io" |
| + "math" |
| + "os" |
| ++ "reflect" |
| + "strings" |
| + "time" |
| + |
| +@@ -169,7 +170,9 @@ |
| + if err != nil || next == nil { |
| + return ErrIdentity |
| + } |
| +- if !claimTransitionShape(host, old, next) { |
| ++ if !claimTransitionShape(host, old, next) || |
| ++ (old == nil && next.Origins != nil) || |
| ++ (old != nil && !reflect.DeepEqual(old.Origins, next.Origins)) { |
| + return ErrIdentity |
| + } |
| + for _, g := range grants { |
| +``` |
| + |
| +## Task C: Supply moves' proof adapter |
| + |
| +Add the adapter as a separate file so it compiles on the pinned main even |
| +though `movesfiles.go` has not landed there. Its method set is compile-checked |
| +against the moves interface. The test creates and admits a real claim, checks |
| +exact and verified home-region reads, and refuses a free-standing next generation. |
| + |
| +Focused command: `go test ./internal/host -run TestPlan15 -count=1`. |
| +Expected first failure: `ClaimOriginProof` is undefined. Task M below binds it |
| +into the actual C8 provider on its separately pinned branch and tests real |
| +establishment. This main-based adapter alone is not a production wiring claim. |
| + |
| +<!-- apply: C tests --> |
| +```diff |
| +--- /dev/null |
| ++++ b/internal/host/plan15_originproof_test.go |
| +@@ -0,0 +1,51 @@ |
| ++package host |
| ++ |
| ++import ( |
| ++ "context" |
| ++ "testing" |
| ++ "time" |
| ++ |
| ++ "github.com/carlosframework/platform/internal/hostclaim" |
| ++ "github.com/carlosframework/platform/internal/release" |
| ++) |
| ++ |
| ++var _ interface { |
| ++ Proven(context.Context, hostclaim.Observed, hostclaim.Admission, string) (bool, error) |
| ++} = ClaimOriginProof{} |
| ++ |
| ++func TestPlan15MovesProof(t *testing.T) { |
| ++ t.Setenv("CARLOS_HOST_CLAIM", "1") |
| ++ t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "1") |
| ++ s := release.NewDirStore(t.TempDir()) |
| ++ host := "test.example" |
| ++ o, err := hostclaim.Read(t.Context(), s, host) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ n := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/app/instances/test.example.json", Account: "bab", App: "app", Host: host, Region: "ie", CreatedAt: time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC)} |
| ++ o, err = hostclaim.Update(t.Context(), s, o, hostclaim.BeginCreate(n, []byte(`{"host":"test.example","account":"bab","app":"app","created_at":"2026-10-12T00:00:00Z"}`), "create", "console", time.Now())) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ o, err = hostclaim.Update(t.Context(), s, o, hostclaim.CompleteCreate("create", "box")) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ o, err = hostclaim.Update(t.Context(), s, o, hostclaim.AdmitWriter(hostclaim.Admission{Box: "box", Mode: "cold", Region: "ie", NewGeneration: "admitted"})) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ for _, tc := range []struct { |
| ++ home, region, generation string |
| ++ want bool |
| ++ }{ |
| ++ {"", "ie", "admitted", true}, {"ie", "@home", "admitted", true}, {"", "@home", "admitted", false}, |
| ++ {"ie", "uk", "admitted", false}, {"", "ie", "prepared", false}, |
| ++ } { |
| ++ p := ClaimOriginProof{Claims: s, HomeRegion: tc.home, Region: tc.region} |
| ++ got, err := p.Proven(t.Context(), o, hostclaim.Admission{NewGeneration: tc.generation}, tc.generation) |
| ++ if got != tc.want || err != nil { |
| ++ t.Fatalf("%+v: %v %v", tc, got, err) |
| ++ } |
| ++ } |
| ++} |
| +``` |
| + |
| +<!-- apply: C implementation --> |
| +```diff |
| +--- /dev/null |
| ++++ b/internal/host/claimoriginproof.go |
| +@@ -0,0 +1,39 @@ |
| ++package host |
| ++ |
| ++import ( |
| ++ "context" |
| ++ |
| ++ "github.com/carlosframework/platform/internal/hostclaim" |
| ++ "github.com/carlosframework/platform/internal/regionname" |
| ++ "github.com/carlosframework/platform/internal/release" |
| ++) |
| ++ |
| ++// ClaimOriginProof implements moves' Proven contract. HomeRegion must come from |
| ++// the verified roster/placement binding, and Claims is the deployment store. |
| ++// Admission.NewGeneration is deliberately not evidence of admission. |
| ++type ClaimOriginProof struct { |
| ++ Claims release.Store |
| ++ HomeRegion, Region string |
| ++} |
| ++ |
| ++func (p ClaimOriginProof) Proven(ctx context.Context, o hostclaim.Observed, _ hostclaim.Admission, generation string) (bool, error) { |
| ++ if o.Absent || o.Claim == nil || o.Claim.Holder == nil || p.Region == "" { |
| ++ return false, hostclaim.ErrWrongState |
| ++ } |
| ++ h := o.Claim.Holder |
| ++ regions := []string{p.Region} |
| ++ if p.HomeRegion != "" && regionname.PlacementRegion(p.HomeRegion) == p.HomeRegion && regionname.SameRegion(p.HomeRegion, p.Region, regionname.HomeRegion) { |
| ++ if p.Region == regionname.HomeRegion { |
| ++ regions = append(regions, p.HomeRegion) |
| ++ } else { |
| ++ regions = append(regions, regionname.HomeRegion) |
| ++ } |
| ++ } |
| ++ for _, region := range regions { |
| ++ ok, err := hostclaim.AdmittedOrigin(ctx, p.Claims, o, h.Incarnation, h.Location, region, generation) |
| ++ if err != nil || ok { |
| ++ return ok, err |
| ++ } |
| ++ } |
| ++ return false, nil |
| ++} |
| +``` |
| + |
| +## Task D: Update the reviewed source census |
| + |
| +Run before changing the inventory: |
| +`go test ./internal/provision -run TestPlan9B8RecordWriteCensus -count=1`. |
| +It must report exactly three changed digests and two unlisted units. |
| + |
| +| Unit | Classification and reason | |
| +| --- | --- | |
| +| `hostclaim.Claim.Validate` | Existing `key-site`; adds only optional-head shape/cap validation | |
| +| `hostclaim.Update` | Existing `nonrecord`, `sel:Put`; stages candidate nodes, then retains the one host-claim CAS | |
| +| `hostclaim.originTuple.valid` | New `key-site`, `lit:instances`; accepts only the host's bare or incarnation-qualified location | |
| +| `hostclaim.commitOrigins` | New `nonrecord`, `sel:Put`; create-only inert provenance candidates, never app record mutation | |
| +| `hostops.lifecycleFamily` | Existing `nonrecord`, `str:Put`; exact candidate-family classification with digest/shape checks | |
| + |
| +`AdmitWriter`'s private pending tuple, `AdmittedOrigin`'s read-only traversal, |
| +`ClaimOriginProof` and the console grant's head-preservation check introduce |
| +no record write site selected by B8. The console authority and SQL inventories |
| +have no new selected call. Run them unchanged; do not regenerate inventories. |
| +No root-local path or unit is added, so the installation census is unchanged. |
| + |
| +Command after the patch: |
| +`go test ./internal/provision ./internal/console -run 'TestPlan9B8.*Census|TestClaimAuthoritySourceCensus|TestClaimSQL.*Census' -count=1`. |
| + |
| +<!-- apply: D implementation --> |
| +```diff |
| +--- a/internal/provision/plan9_b8_census_test.go |
| ++++ b/internal/provision/plan9_b8_census_test.go |
| +@@ -736,13 +736,15 @@ |
| + {"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"}, |
| +- {"internal/hostclaim/claim.go", "Claim.Validate", "key-site", "lit:instances", "3278f2b4b24b6627b9dcd0863c3bef58c6694a65c8a19d05ce280ae34801bdfc"}, |
| ++ {"internal/hostclaim/claim.go", "Claim.Validate", "key-site", "lit:instances", "2debcd30dda4747b8b3b00d576998f1fdc1d364ad9bacba1e1279df0b7dfb694"}, |
| + {"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"}, |
| + {"internal/hostclaim/lossrecord.go", "CreateLossRecord", "foreign-recordkey", "key:LossRecordKey,sel:Put", "e62263731fa572ed6f195a267abb63ff5a1bf44284db3db76a819ccaec238191"}, |
| + {"internal/hostclaim/lossrecord.go", "ReplaceLossRecord", "foreign-recordkey", "key:LossRecordKey,sel:Put", "576589457e4a18b64e6b56892603051982bd01770c355504305e72d53149bc52"}, |
| +- {"internal/hostclaim/store.go", "Update", "nonrecord", "sel:Put", "68f97a49f9a936228cb26e49d7bed3a4ab17c3a4c4255e32c2d2af1dfccd73a6"}, |
| ++ {"internal/hostclaim/origins.go", "originTuple.valid", "key-site", "lit:instances", "7f6f20f51e51694d665588f0b986def85bda4789f24623ee1993289265b01355"}, |
| ++ {"internal/hostclaim/origins.go", "commitOrigins", "nonrecord", "sel:Put", "56a6eb6b4c9e67acccfb8f2b97ce5e338e21c56ab226359b12f3aacd75cd7f7f"}, |
| ++ {"internal/hostclaim/store.go", "Update", "nonrecord", "sel:Put", "ec5de8702797ce97a24147cbf16ad8f8e254e2da82d3a0859c0407501f0b575f"}, |
| + {"internal/hostevidence/signature.go", "immutable", "nonrecord", "sel:Put", "54fb2bdc230c2cb4193f3b808004b63bcfd092266846fc0c03e61617c3ad0ab5"}, |
| + {"internal/hostexec/controller.go", "Controller.run", "nonrecord", "sel:Put", "03df11b375761730700ce30c0db05c57f0828629eda0a5de126ea1a2f2f7501c"}, |
| + {"internal/hostfence/termination.go", "TerminationWorker.persist", "nonrecord", "sel:Put", "47758114adcc3c493a9d1ead0fa7087ad0d1e49595c891bab4dfb51c9058a8a1"}, |
| +@@ -770,7 +772,7 @@ |
| + {"internal/hostops/mutation_boundary.go", "mutationBoundary.Put", "record-transport", "def:Put,sel:Put,str:Put", "c4222199cdbfa8cce1f97f91bf41fe94857f4d833e30b17636c40f1aae39ff67"}, |
| + {"internal/hostops/mutation_boundary_putfrom.go", "mutationBoundary.PutFrom", "record-transport", "def:PutFrom,sel:Put,sel:PutObjectFrom,str:Put", "8d015a924901f0c41e142181f986cb9d2a3bdfa6a9e078dfda05a2d1e7cf9b24"}, |
| + {"internal/hostops/mutation_boundary_putfrom.go", "conditionalRecordStore.PutFrom", "record-transport", "def:PutFrom,sel:Put,sel:PutObjectFrom", "3c3cb33a601eab5c4d74042a7ca8baf411c0549508b5a8273ef88f4ee0e9636c"}, |
| +- {"internal/hostops/mutation_classifier.go", "lifecycleFamily", "reference", "str:Put", "75df2a3966d0bfe4b4b367617de3ebe28055a38ef4738f352859ba94ddbff241"}, |
| ++ {"internal/hostops/mutation_classifier.go", "lifecycleFamily", "reference", "str:Put", "f43b261b71020751372753c17d0de3b5ea51866a9c2c08e7b514c38d775e6f90"}, |
| + // Classifies mutation keys and method names without executing a store write. |
| + {"internal/hostops/mutation_classifier.go", "ClassifyMutation", "reference", "str:Put", "2d22e1d4a99a69ebff6f033a667f59c778855a66828ee5c7646a6b1d18573b65"}, |
| + {"internal/hostops/mutation_classifier.go", "ownerUnion", "reference", "str:Put", "0c35233653dfe9ca6cc78bc54bac2c6991f82c9a7c2fd27c228c125f099f489b"}, |
| +``` |
| + |
| +## Task E: Amend the spec to rev 6.35 |
| + |
| +Apply only to the identified rev 6.34 spec input. The design worktree's original |
| +spec remains unchanged during plan authoring. The amendment records the durable |
| +origin tuple, digest/atomicity contract, size/refusal bounds, migration and |
| +moves API, plus the credential-separation and backfill activation prerequisites. |
| + |
| +<!-- apply: E implementation --> |
| +```diff |
| +--- a/docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md |
| ++++ b/docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md |
| +@@ -1,5 +1,7 @@ |
| + # The host lifecycle claim |
| + |
| ++**Rev 6.35 (2026-10-12):** retains admitted-generation provenance beyond the admission pruning window. Claim Plan 15 defines the bounded digest-linked record, exact origin query, migration refusals and independent claim-writer activation prerequisite. |
| ++ |
| + **Rev 6.34 (2026-10-12):** separates root fence, execution and release authority from service-writable DataDir state; requires guarded migration and independent root provenance. Claim Plan 14 addresses amadan #280. |
| + |
| + **Rev 6.33 (2026-10-12):** binds home placements to claim engine regions, preserves existing roster-name evidence, and completes the home move enrollment and loss boundaries. Claim Plan 13 supplies the implementation. |
| +@@ -8,7 +10,7 @@ |
| + |
| + **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. |
| + |
| +-Design, 2026-10-12, **rev 6.34**. Joint rounds 6 and 7 judged the claim ready for primitive planning. Rev |
| ++Design, 2026-10-12, **rev 6.35**. Joint rounds 6 and 7 judged the claim ready for primitive planning. Rev |
| + 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 |
| +@@ -2066,6 +2068,90 @@ |
| + the operation that admitted the crashed writer has since been replaced |
| + (R9-2). |
| + |
| ++#### Durable admitted origins (rev 6.35) |
| ++ |
| ++A files origin is the exact tuple `{host, incarnation, location, region, |
| ++generation}`. Its prefix is `<location>/files/g-<generation>/`. The `g-` |
| ++belongs to the storage layout, not to the generation token. Generation |
| ++names are random or operation IDs, not a consecutive sequence. |
| ++ |
| ++The claim retains `admitted_origins: {digest, count}` at claim level, |
| ++including while free and after the 32-incarnation retirement window. |
| ++Immutable nodes under |
| ++`control/host-origins/<host>/<domain-separated-sha256>.json` contain one |
| ++origin and the preceding node digest. Nodes are at most 4096 bytes; the |
| ++committed chain is at most 1,048,576 nodes. Exhaustion refuses admission; |
| ++it never drops proof or takes an unproven origin as admitted. Adjacent |
| ++identical origins share the existing head. The rest of the history is |
| ++append-only. These nodes belong to the authoritative deployment store, |
| ++not a regional files bucket, and no ordinary park or purge deletes them. |
| ++ |
| ++The successful admission claim CAS commits both its admission and the |
| ++new head. Candidate nodes are create-only before that CAS. Candidates |
| ++left by a crash or conflict have no authority until a committed head |
| ++reaches them. A conflicting candidate is reusable only after byte-exact |
| ++verification. Head removal, substitution and rollback by another claim |
| ++transition refuse. A commit timeout is an unknown result: reread the |
| ++claim; do not grant authority from a candidate upload acknowledgement. |
| ++ |
| ++`hostclaim.AdmittedOrigin(ctx, claims, observed, incarnation, location, |
| ++region, generation)` reads the authoritative claim at the observed ETag |
| ++and verifies hashes, host and descending counts on the path to the |
| ++requested origin. A match grants provenance only, not permission to |
| ++execute, write files, bypass stop evidence, or read another incarnation. |
| ++Unknown returns false; malformed, missing or unreadable reachable nodes |
| ++return an error. Both mean refuse. A changed ETag requires a fresh |
| ++observation. Region comparison is exact; the moves adapter may query the |
| ++verified home roster name and `@home`, and no other alias. |
| ++ |
| ++Migration seeds only `AdmissionRecord.Generation` and its `Region` from |
| ++retained admissions of the observed holder, bound to that holder's exact |
| ++incarnation and location. It never seeds from a manifest, predecessor |
| ++name alone, retired-location entry, listing, or caller-supplied next |
| ++generation. The first enabled admission commits the seed and its own |
| ++origin together. Before that admission, queries may prove only exact |
| ++retained admission entries. Empty legacy root generations have no |
| ++`files/g-<generation>/` origin. Missing pre-window entries remain unproven. |
| ++ |
| ++Every origin carried in a files manifest, including `Origins` hints and |
| ++entries/platform objects, must be re-proven at establishment. The prepared |
| ++generation becomes an admitted origin only after the admission CAS. |
| ++Cross-region imports re-copy verified bytes into the admitted destination |
| ++prefix; a source-region grant never proves a destination-region origin. |
| ++The files live set still protects referenced objects; historical admission |
| ++does not prove those objects exist or certify their contents. |
| ++ |
| ++Hosts whose live manifests retain older unproven objects cannot establish |
| ++through this API. Activation's files-manifest backfill can resolve that |
| ++only by a separately authorized, frozen and verified reconstruction into |
| ++an admitted prefix, checking every app/platform/env object and committing |
| ++and rereading the replacement manifest before release. Merely filling in |
| ++`Origins` or copying the manifest leaves the problem unchanged. The |
| ++available #250 record does not establish that this reconstruction exists; |
| ++activation stays blocked until its implementation and witness are supplied. |
| ++ |
| ++Credential separation is a prerequisite, not a property of hashing. |
| ++Box/data credentials must be unable to put or delete the claim anchor by |
| ++any storage path. All claim writes must pass through an independently |
| ++trusted transition authority. Its admission endpoint rereads the claim, |
| ++validates the existing admission/evidence rules itself, derives the origin |
| ++from that accepted admission, and performs the one CAS; it accepts no |
| ++caller-provided head, arbitrary transition closure or pre-approved bytes. |
| ++Other endpoints preserve the head. The pinned region-host IAM policy |
| ++allows direct deployment-bucket writes, so the Plan 15 library alone does |
| ++not provide this boundary and must remain inactive until that is closed. |
| ++A test under the actual box credential must fail to rewrite/delete a head, |
| ++and a broker test must reject an invented origin before activation. |
| ++ |
| ++Ship strict readers and raise the release floor before emitting the new |
| ++field. `CARLOS_HOST_CLAIM_ORIGINS=1` additionally gates new recording; |
| ++`CARLOS_HOST_CLAIM=1` is also required. With no origins field and either |
| ++switch off, claim writes retain the base wire shape and storage calls. |
| ++Once a head exists, an admission with recording disabled refuses rather |
| ++than creating an unrecorded session. Downgrade is not supported after |
| ++activation. These switches do not authorize activation or production IAM |
| ++changes. |
| ++ |
| + #### Admissions, coverage and retirement (operative, rev 6.14) |
| + |
| + This subsection and the ones above are the operative rules. The |
| +``` |
| + |
| +Verify the result contains `Design, 2026-10-12, **rev 6.35**`, the new |
| +`Durable admitted origins (rev 6.35)` section, and Plan 14's P6-D9 section. |
| +Expected result SHA-256: |
| +`d22efa116cd3385a9bdd42cfa0ede6f1246fcb80711585e7ee32912732e30174`. |
| + |
| +## Task M: Integrate with the separately pinned C8 branch |
| + |
| +This task has an explicit second code input: moves tip |
| +`6bb9d4171e5e4fcf7e2130a89ed403c88bf4c5bf`, read from |
| +`/home/paulca/amadan.net/carlos/platform-moves-p5f`. Export it to another |
| +scratch subdirectory. All existing production files changed by A and B are |
| +byte-identical at that tip and the required main base; verify this before |
| +applying A, B and C to the export. Do not overlay the whole moves tree onto |
| +main or modify the moves worktree. Apply M tests and implementation here, |
| +then N's branch-specific census patch. D's original line positions belong |
| +to the main replay; N carries the same five classifications at this tip. |
| + |
| +Inputs: `internal/host/movesfiles.go` SHA-256 |
| +`1ce801cfaf50468f9ee0443af7530dfbcc86e90c89a076c2c43aa2c0cd9ad581`, |
| +and `internal/host/claimfiles.go` SHA-256 |
| +`fba6daebee74d7237dc525dca727fbb74cdc43457fb9c5930c99cb3d78cd8795`. |
| + |
| +The existing `movesOriginProof.Proven` signature matches Task C. Keep |
| +`movesFilesAllowed`'s proof of every candidate, entry and `Origins` hint. |
| +Change `movesFilesSource.Open` to retain its existing deployment-store argument |
| +and set `Proof: ClaimOriginProof{Claims: claims, HomeRegion: actor.HomeRegion, |
| +Region: region}`. A nil claim store refuses construction. The manifest/data |
| +store remains the actor's regional bucket; never pass that store as Claims. |
| + |
| +Focused command: |
| +`go test ./internal/host -run 'TestPlan15|TestProductionFilesSource|TestEstablish' -count=1`. |
| +Before the implementation, `TestPlan15ProductionProofWiring` fails because the |
| +production provider still returns the retained-chain proof. The integration |
| +witness creates a real claim, admits and establishes 16 generations through |
| +the real publisher, marks coverage and observes actual pruning to eight. |
| +Its newest manifest still references an object from generation 1. Then it |
| +adds a never-admitted `Origins` hint and requires establishment to refuse. |
| +The constructor test proves the actual deployment store and verified home |
| +binding reach the provider; an injected permissive map cannot pass it. |
| + |
| +<!-- apply: M tests --> |
| +```diff |
| +--- /dev/null |
| ++++ b/internal/host/plan15_moves_integration_test.go |
| +@@ -0,0 +1,101 @@ |
| ++package host |
| ++ |
| ++import ( |
| ++ "context" |
| ++ "errors" |
| ++ "fmt" |
| ++ "strings" |
| ++ "testing" |
| ++ |
| ++ "github.com/carlosframework/platform/internal/hostbox" |
| ++ "github.com/carlosframework/platform/internal/hostclaim" |
| ++ "github.com/carlosframework/platform/internal/hostfiles" |
| ++ "github.com/carlosframework/platform/internal/hostwrite" |
| ++ "github.com/carlosframework/platform/internal/lease/leasetest" |
| ++ "github.com/carlosframework/platform/internal/release" |
| ++) |
| ++ |
| ++func TestPlan15ProductionProofWiring(t *testing.T) { |
| ++ claims := release.NewDirStore(t.TempDir()) |
| ++ src := &movesFilesSource{DataDir: t.TempDir(), newFiles: func(context.Context, string, string) (release.Store, error) { |
| ++ return release.NewDirStore(t.TempDir()), nil |
| ++ }} |
| ++ actor := hostbox.Actor{Box: "box", Region: "", HomeRegion: "ie", Bucket: "data", Cloud: hostbox.CloudIdentity{Region: "eu-west-1"}} |
| ++ p, err := src.Open(t.Context(), claims, leasetest.NewVersionedBucket(), actor) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ proof, ok := p.(*movesFiles).proof().(ClaimOriginProof) |
| ++ if !ok || proof.Claims != claims || proof.HomeRegion != "ie" || proof.Region != "@home" { |
| ++ t.Fatalf("wrong production proof %+v", proof) |
| ++ } |
| ++ if _, err := src.Open(t.Context(), nil, leasetest.NewVersionedBucket(), actor); !errors.Is(err, hostwrite.ErrUnavailable) { |
| ++ t.Fatalf("missing claims store: %v", err) |
| ++ } |
| ++} |
| ++ |
| ++func TestPlan15EstablishAfterRealPruning(t *testing.T) { |
| ++ t.Setenv("CARLOS_HOST_CLAIM", "1") |
| ++ t.Setenv("CARLOS_HOST_CLAIM_ORIGINS", "1") |
| ++ x := newFilesFixture(t) |
| ++ x.writeEnv("K=1\n") |
| ++ claims := release.NewDirStore(t.TempDir()) |
| ++ o, err := hostclaim.Read(t.Context(), claims, filesHost) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ nh := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + filesHost + ".json", Account: "bab", App: "jam", Host: filesHost, Region: "uk"} |
| ++ o, err = hostclaim.Update(t.Context(), claims, o, hostclaim.Adopt(nh, "uk-box", nil, nil, "adopt", func() string { return "id" })) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ x.f.Proof = ClaimOriginProof{Claims: claims, HomeRegion: "ie", Region: "uk"} |
| ++ location := o.Claim.Holder.Location |
| ++ for i := 1; i <= 16; i++ { |
| ++ h := o.Claim.Holder |
| ++ gen := fmt.Sprintf("g%d", i) |
| ++ o, err = hostclaim.Update(t.Context(), claims, o, hostclaim.AdmitWriter(hostclaim.Admission{ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: "uk-box", Mode: "cold", Region: "uk", PredecessorRegion: "uk", NewGeneration: gen, PredecessorCertified: true})) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if i == 1 { |
| ++ p, err := hostfiles.Open(t.Context(), x.files, movesFilesPrefix(location, gen)) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if err = p.Stage(t.Context(), "a.txt", []byte("A"), 0600, false); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if err = p.StagePlatform(t.Context(), hostfiles.EnvItem, []byte("K=1\n")); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if _, err = p.Commit(t.Context()); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ } else if err = x.f.Establish(t.Context(), o, launchInputs(gen), x); err != nil { |
| ++ t.Fatalf("generation %s: %v", gen, err) |
| ++ } |
| ++ o, err = hostclaim.Update(t.Context(), claims, o, func(c *hostclaim.Claim) error { |
| ++ for i := range c.Holder.Admissions { |
| ++ c.Holder.Admissions[i].Covered = true |
| ++ } |
| ++ return nil |
| ++ }) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ } |
| ++ if len(o.Claim.Holder.Admissions) != 8 { |
| ++ t.Fatal("did not exercise pruning") |
| ++ } |
| ++ m, found, err := hostfiles.Load(t.Context(), x.files, movesFilesPrefix(location, "g16")) |
| ++ if err != nil || !found || !strings.HasPrefix(m.Entries["a.txt"].Object, movesFilesPrefix(location, "g1")) { |
| ++ t.Fatalf("lost ancestor: %+v %v", m, err) |
| ++ } |
| ++ // A manifest cannot grant itself another origin, even with an otherwise valid chain. |
| ++ m.Origins = append(m.Origins, movesFilesPrefix(location, "never")) |
| ++ x.bucket.Put("data", hostfiles.ManifestKey(movesFilesPrefix(location, "g16")), mustJSON(t, m)) |
| ++ if err := x.f.Establish(t.Context(), o, launchInputs("g16"), x); !errors.Is(err, hostfiles.ErrOrigin) { |
| ++ t.Fatalf("manifest self proof: %v", err) |
| ++ } |
| ++} |
| +``` |
| + |
| +<!-- apply: M implementation --> |
| +```diff |
| +--- a/internal/host/claimfiles.go |
| ++++ b/internal/host/claimfiles.go |
| +@@ -100,16 +100,16 @@ |
| + } |
| + } |
| + |
| +-func (s *movesFilesSource) Open(ctx context.Context, _ release.Store, data lease.VersionedStore, actor hostbox.Actor) (ClaimFilesManifest, error) { |
| ++func (s *movesFilesSource) Open(ctx context.Context, claims release.Store, data lease.VersionedStore, actor hostbox.Actor) (ClaimFilesManifest, error) { |
| + region := park.PlacementRegion(actor.Region) |
| +- if s == nil || s.DataDir == "" || s.newFiles == nil || data == nil || actor.Bucket == "" || actor.Box == "" || region == "" || actor.Cloud.Region == "" { |
| ++ if s == nil || s.DataDir == "" || s.newFiles == nil || claims == nil || data == nil || actor.Bucket == "" || actor.Box == "" || region == "" || actor.Cloud.Region == "" { |
| + return nil, hostwrite.ErrUnavailable |
| + } |
| + files, err := s.newFiles(ctx, actor.Bucket, actor.Cloud.Region) |
| + if err != nil { |
| + return nil, err |
| + } |
| +- return &movesFiles{Files: files, Data: data, Bucket: actor.Bucket, HomeRegion: actor.HomeRegion, Region: region, Box: actor.Box, DataDir: s.DataDir, FilesDir: movesFilesDirFor(s.DataDir)}, nil |
| ++ return &movesFiles{Proof: ClaimOriginProof{Claims: claims, HomeRegion: actor.HomeRegion, Region: region}, Files: files, Data: data, Bucket: actor.Bucket, HomeRegion: actor.HomeRegion, Region: region, Box: actor.Box, DataDir: s.DataDir, FilesDir: movesFilesDirFor(s.DataDir)}, nil |
| + } |
| + |
| + func (s *movesFilesSource) Close(context.Context) error { return nil } |
| +``` |
| + |
| +### Task N: Moves replay census |
| + |
| +The classifications are exactly Task D's. This patch changes only those five |
| +rows in the moves tip's existing inventory. `movesFilesSource.Open` introduces |
| +no selected record write or console authority call. Run B8 and the unchanged |
| +console authority inventory after applying it. |
| + |
| +<!-- apply: N implementation --> |
| +```diff |
| +--- a/internal/provision/plan9_b8_census_test.go |
| ++++ b/internal/provision/plan9_b8_census_test.go |
| +@@ -734,13 +734,15 @@ |
| + {"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"}, |
| +- {"internal/hostclaim/claim.go", "Claim.Validate", "key-site", "lit:instances", "3278f2b4b24b6627b9dcd0863c3bef58c6694a65c8a19d05ce280ae34801bdfc"}, |
| ++ {"internal/hostclaim/claim.go", "Claim.Validate", "key-site", "lit:instances", "2debcd30dda4747b8b3b00d576998f1fdc1d364ad9bacba1e1279df0b7dfb694"}, |
| + {"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"}, |
| + {"internal/hostclaim/lossrecord.go", "CreateLossRecord", "foreign-recordkey", "key:LossRecordKey,sel:Put", "e62263731fa572ed6f195a267abb63ff5a1bf44284db3db76a819ccaec238191"}, |
| + {"internal/hostclaim/lossrecord.go", "ReplaceLossRecord", "foreign-recordkey", "key:LossRecordKey,sel:Put", "576589457e4a18b64e6b56892603051982bd01770c355504305e72d53149bc52"}, |
| +- {"internal/hostclaim/store.go", "Update", "nonrecord", "sel:Put", "68f97a49f9a936228cb26e49d7bed3a4ab17c3a4c4255e32c2d2af1dfccd73a6"}, |
| ++ {"internal/hostclaim/origins.go", "originTuple.valid", "key-site", "lit:instances", "7f6f20f51e51694d665588f0b986def85bda4789f24623ee1993289265b01355"}, |
| ++ {"internal/hostclaim/origins.go", "commitOrigins", "nonrecord", "sel:Put", "56a6eb6b4c9e67acccfb8f2b97ce5e338e21c56ab226359b12f3aacd75cd7f7f"}, |
| ++ {"internal/hostclaim/store.go", "Update", "nonrecord", "sel:Put", "ec5de8702797ce97a24147cbf16ad8f8e254e2da82d3a0859c0407501f0b575f"}, |
| + {"internal/hostevidence/signature.go", "immutable", "nonrecord", "sel:Put", "54fb2bdc230c2cb4193f3b808004b63bcfd092266846fc0c03e61617c3ad0ab5"}, |
| + {"internal/hostexec/controller.go", "Controller.run", "nonrecord", "sel:Put", "03df11b375761730700ce30c0db05c57f0828629eda0a5de126ea1a2f2f7501c"}, |
| + {"internal/hostfence/termination.go", "TerminationWorker.persist", "nonrecord", "sel:Put", "47758114adcc3c493a9d1ead0fa7087ad0d1e49595c891bab4dfb51c9058a8a1"}, |
| +@@ -772,7 +774,7 @@ |
| + {"internal/hostops/mutation_boundary.go", "mutationBoundary.Put", "record-transport", "def:Put,sel:Put,str:Put", "c4222199cdbfa8cce1f97f91bf41fe94857f4d833e30b17636c40f1aae39ff67"}, |
| + {"internal/hostops/mutation_boundary_putfrom.go", "mutationBoundary.PutFrom", "record-transport", "def:PutFrom,sel:Put,sel:PutObjectFrom,str:Put", "8d015a924901f0c41e142181f986cb9d2a3bdfa6a9e078dfda05a2d1e7cf9b24"}, |
| + {"internal/hostops/mutation_boundary_putfrom.go", "conditionalRecordStore.PutFrom", "record-transport", "def:PutFrom,sel:Put,sel:PutObjectFrom", "3c3cb33a601eab5c4d74042a7ca8baf411c0549508b5a8273ef88f4ee0e9636c"}, |
| +- {"internal/hostops/mutation_classifier.go", "lifecycleFamily", "reference", "str:Put", "75df2a3966d0bfe4b4b367617de3ebe28055a38ef4738f352859ba94ddbff241"}, |
| ++ {"internal/hostops/mutation_classifier.go", "lifecycleFamily", "reference", "str:Put", "f43b261b71020751372753c17d0de3b5ea51866a9c2c08e7b514c38d775e6f90"}, |
| + // Classifies mutation keys and method names without executing a store write. |
| + {"internal/hostops/mutation_classifier.go", "ClassifyMutation", "reference", "str:Put", "2d22e1d4a99a69ebff6f033a667f59c778855a66828ee5c7646a6b1d18573b65"}, |
| + {"internal/hostops/mutation_classifier.go", "ownerUnion", "reference", "str:Put", "0c35233653dfe9ca6cc78bc54bac2c6991f82c9a7c2fd27c228c125f099f489b"}, |
| +``` |
| + |
| +## Final checks and cleanup |
| + |
| +On the main replay, run: |
| + |
| +```bash |
| +go test ./internal/hostclaim -count=1 |
| +go test ./internal/hostops ./internal/host -run 'TestPlan15|TestPlan9C2|TestPlan12.*Loss|TestPlan12.*Fence|TestParkKeepsTheFilesLiveSet|TestPlan13.*Admission|TestClaimProductionCensus' -count=1 |
| +go test ./internal/provision ./internal/console -run 'TestPlan9B8.*Census|TestClaimAuthoritySourceCensus|TestClaimSQL.*Census' -count=1 |
| +go build ./... |
| +go vet ./... |
| +gofmt -l . |
| +``` |
| + |
| +A later landing still requires the repository's full gate and reviewed branch |
| +workflow. This authoring task makes no commits. The tests intentionally use |
| +local stores and mocks; no live writes or IAM probes are authorized here. |
| + |
| +As a mutation witness, temporarily remove the `pendingOrigin` call from |
| +`AdmitWriter` only in scratch. `TestPlan15PruningMigrationAndScope` must fail |
| +when a retained seed ages out. Restore the exact function, then rerun it. |
| + |
| +Delete the exact scratch directory created by `mktemp` after leaving it: |
| + |
| +```bash |
| +cd "$CLAIM15_REPO" |
| +test -n "$CLAIM15_SCRATCH" && rm -r -- "$CLAIM15_SCRATCH" |
| +``` |
| + |
| +Never delete `/home/paulca/.cache/tmp/claude-1001/gocache`. Record actual |
| +red/green, build, vet, census, integration and cleanup outcomes in |
| +`/home/paulca/.cache/tmp/claude-1001/own/after236/plan15-write-report.md`. |
| +The report must retain the unresolved credential-boundary and #250 backfill |
| +conditions; neither a passing replay nor this plan authorizes activation. |