carlos / platform Public

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>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 4a1127755eb34517d6c9e96a22de015cb087f6d7 parent ad75a26
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.