carlos / platform Public

plan: claim Plan 8 rev 1, the claim-internal contracts with complete code

Loss records and bound transitions (D4, D5), quarantine interface and
retirement receipt, exact compute observation (D2/D3 library parts), the
durable execution controller (D6), flag-gated CLI and console refusal
paths. D1/D7 and the live wiring are Plan 8b, waiting on Paul (#236).
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 1eacbf835f5f38a2389242d89f76932d7896b53c parent 8b8c9ef
1 file changed, +4469 −274
  • docs/superpowers/plans/2026-10-05-host-lifecycle-claim-8-leases-floor.md +4469 −274

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-8-leases-floor.md b/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-8-leases-floor.md
index 15b8990b4..3ad056e76 100644
--- a/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-8-leases-floor.md
+++ b/docs/superpowers/plans/2026-10-05-host-lifecycle-claim-8-leases-floor.md
@@ -1,371 +1,4557 @@
-# Host Lifecycle Claim, Plan 8: execution leases, lost boxes and the protocol floor
-
-> **For agentic workers:** implement this plan task by task. Steps use checkbox (`- [ ]`) syntax. Commands below belong to the implementation session. Authoring this plan changes only this file, without staging, committing, creating a branch or changing production.
-
-**Goal:** implement the claim's execution leases and self-fence, the operator's compute-fenced declare-lost and readmission paths, and the shared claim/moves version floor at every executable activation path. Keep lifecycle callers inert until Paul completes the rollout runbook.
-
-**Architecture:** `internal/lease` supplies the conditional object transport for the spec's execution-lease record. A separate `internal/hostexec` controller owns its clock, renewal and local admission state; it never grants claim authority. Lost-box transitions use `hostclaim.Update`, exact operation observations and durable fencing evidence. The remote CLI uses the console for authorization and claim mutation and the operator's credentials for a fresh, read-only cloud check. One `internal/hostfloor` reader/checker serves console admission and root executable restoration. Plans 5 and 6 remain responsible for their lifecycle state machines and guarded callers.
-
-**Tech stack:** Go 1.26, existing AWS SDK v2 clients, `release.Store`, `release.DeleteIfMatch`, `hostclaim`, `hostguard`, `stopevidence`, `cgroup`, existing status and system-update machinery. No new cloud write integration, console EC2 grant, infrastructure apply or live fencing test.
-
-**Depends on:** Plans **1 and 3**. Cut `feat/hostclaim-leases` from `feat/hostclaim-boxguard`, then `git merge origin/main`. Integration with Plans 4–7, Plan 7b and moves is a rollout prerequisite, not permission to implement their state machines here.
-
-## Sources and actual interfaces
-
-Read before editing:
-
-- Claim design `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`, **rev 6.15**: §2 (`lost`, execution authority and stamps), C1–C6, §6.1, §6.3, §6.4a's **operative** admissions rules, §7, §10–§12. Historical readings in §6.4a do not override the operative subsection.
-- Plans 2, 3, 4, 7 and 7b in this directory for task structure and consumer boundaries; Plan 1's roadmap and activation boundary.
-- `feat/hostclaim-library` at `47426cc1`, also present in the sibling `hostclaim` worktree: `Claim.Lost` is `[]string`; `Op.Intent` and `Op.Journal` are `map[string]string`; `AdmissionRecord.WritesLost` is a **single** `*WritesLost`. `Read` uses strict decoding. `Update(ctx, store, observed, transition)` clones the observation, enforces its ETag and assigns `Gen` centrally. `CompleteObligation` matches the chore's own operation and maintains discovery completion. `FreeAfterPark` preserves `Lost`.
-- `feat/hostclaim-boxguard` at `01ed2bd9`: `Guard.With`, `Activate` and `RetireInstallation`; `stopevidence.Collect` and `Cleanup`; `cgroup.OpenCleanup`. This branch already includes `origin/main` at the authoring baseline. `Guard.With` needs a fresh holder and active installation. It cannot be used unchanged to retire a lost installation after the host became free or changed holder.
-- All other source references below are **`origin/main` at `04cd2d78`**, not the older sources in this planning checkout.
-- The shared contract requires the companion moves spec too: `/home/paulca/amadan.net/carlos/platform-moves-design/docs/superpowers/specs/2026-10-02-move-any-app-design.md`, §7.10 and §11. It specifies `control/moves-protocol.json`, `{"min":1,"min_release":"vX.Y.Z"}`, create-only publication, and `moves_protocol: 1` reports. It also supplies the dirty-return cleanup and record-commit restrictions cited below. Do not introduce a second claim-only floor.
-
-| Existing source | What is available; what is missing |
-|---|---|
-| `internal/lease/lease.go`, `health.go`, `endpoint.go` | `Acquire` returns `*Held`; `Replicate` consumes that handle; `Lost`, `Err` and `SafeUntil` expose health. The replica lease uses `<prefix>/lock.json`, a **15-minute** TTL and TTL/3 renewal. `newLeaserClient` supplies the existing S3 configuration. This is not the execution lease's key, body or timing. |
-| Pinned Litestream v0.5.14 `s3/leaser.go` | Conditional creation/replacement, ETag ownership and conditional release. Its lock suffix and `litestream.Lease` encoding are fixed; its private methods cannot serialize the claim's `op`/`expires` record. |
-| `internal/release/store.go`, `conditional_delete.go` | `Put(..., "")` is create-only; nonempty ETag is replace-only. `DeleteIfMatch` refuses missing ETags and unsupported stores. `S3Store{Client, Bucket}` can wrap the lease client's configured S3 client. Never fall back to `Store.Delete`. |
-| `main.go`, `internal/ops/ops.go` | `main` sends all `ops` arguments to the box-side package. Only `litestream`, `restore-verify`, `hibernate-verify` exist. Remote host resolution needs root CLI dispatch so it can use API sessions. |
-| `apimode.go`, `deploy.go`, `system.go` | `requireDeployAPISession`, `apiSession.getJSON/postJSON`, console selection and destructive confirmation patterns. The new host command must work off-box. |
-| `internal/console/authcli.go`, `store.go`, `apisystemupdate.go` | `cliRoute` with empty `svcrole.Set` excludes service credentials. `Store.IsOperator` is the fleet-operator bit; `apiSystemRoller` is a different authority (ops owner or configured system roller). Reuse session/routing mechanisms without widening declare-lost to system rollers or tenant owners. |
-| `internal/awsinv/clients.go`, `ec2.go` | Existing **read-only** `DescribeInstances` seam and AWS configuration. No `StopInstances` or `TerminateInstances` call exists. `infra/inventory.json` is hourly inventory, not current fencing evidence. |
-| `internal/status/instanceid.go`, `snapshot.go`, `internal/host/agent.go` | Private box metrics carry IMDS `InstanceID`, `Release = version.Build`, platform, system channel and last root update result. The running release is generally a short SHA, not ordered semver. |
-| `internal/console/apisystemupdate.go`, `server.go` | System status exposes box releases and outcomes. Console `/health` reports its own `version.Build`; there is no complete console-version census or implemented protocol floor. |
-| `internal/host/systemapply.go`, `systemstate.go`, `system.go` | Signed pointer verification, root staging, `.prev`, durable journals, rollback and boot recovery. No protocol check. The exact activation inventory is in Task 7. |
-
-## Global constraints
-
-- **Inert by default (R1).** With `CARLOS_HOST_CLAIM` unset and no published floor, current production behavior stays unchanged. Read the flag once at process construction and pass it through configuration; use the existing Plan 7 convention: trimmed nonempty enables, including `0`. Do not write the flag into infrastructure, unit defaults or deployment configuration. Plans 5–7 stay inert until this plan and their integrations have landed and the floor has been published. Publishing a floor and enabling callers are separate operator steps.
-- **One specified execution record.** `control/hosts/<host>/exec/<box>.json` contains exactly `op` and `expires`. TTL **90 seconds**, renewal **20 seconds**, only for participants in an active operation. A serving claim can retain `Op.Participants`; that does **not** mean an operation is running. Serving/free hosts have no execution-lease traffic.
-- **Leases are liveness, never stop evidence.** Whole-VM suspension also suspends the watchdog. No lease expiry, old heartbeat, empty activator memory, failed SSH connection or missing cloud inventory row authorizes revocation. C5 requires complete §6.1 evidence or the verified §7.2 compute fence.
-- **Fresh authority.** Read the exact host claim, check holder, operation ID/kind/phase, participant role and quarantine before a lease is established and before host work. Lease ownership does not substitute for `op.exec`, an installation stamp, a replica lease or writer-session admission. No claim listing on a hot admission path.
-- **Loss refuses writes.** By the acknowledged expiry, a participant closes host admission, cancels host work and runs cleanup-only termination. Renewing cannot resurrect a latched lost handle. Ordinary background writes, timers, replication and probes use the same host denial. Cleanup remains possible while the control bucket is unreachable.
-- **CC9 clearing is narrow.** A successful fresh claim read showing serving/free or this box no longer participating removes stale local execution-lease state. It clears that lease fence only. It never clears quarantine, another operation's fence, retirement or a failed process cleanup, and never grants a new spawn by itself.
-- **Conditional cleanup.** Release only the ETag owned by this lease controller. Join/cancel renewal before release. An old controller must not erase a successor's record. Claims are never deleted; version-bound data retirement remains Plan 4's job.
-- **Compute fencing.** The operator first stops or terminates the instance through their authorized infrastructure workflow. This command verifies the exact instance through a fresh cloud API read with the operator's credentials. It does not invent a stop/terminate API. The console receives no new EC2 rights; an inventory page is not the check.
-- **Quarantine survives release and reuse.** Preserve `Lost` through serving, free, adoption and successor creates. A listed box may serve **no host** until its old state is retired and an operator readmits it. Host-local `Lost` checks alone cannot implement this deployment-wide rule; close the contract in Decision D3 before enabling.
-- **Recovery preserves data obligations.** Lost compute does not destroy remote replicas or excuse regional parking/horizon work. Only the proved destroyed disk's local chores are discharged. A stopped instance's disk must not be described as gone. Recovery of validated durable data and cleanup of partial return lanes follow moves §7.10.
-- **Floor and downgrade.** One shared floor guards lifecycle starts and all executable activations. Unknown versions or failed reads refuse protected work. Removing the lifecycle flag must not provide a route around an already published floor; the persistent enforcement contract is Decision D1. Flag-off deployments with no floor keep their current behavior.
-- **Portability and scope.** Linux implements cleanup, with refusing stubs elsewhere; CLI, lease and floor packages build on Darwin and Windows. Use the default Go cache; if sandbox read-only, use the existing shared on-disk `$TMPDIR/gocache`. Never use a cache under `/tmp`. All tests use local fakes; no production resources are changed.
-
-## Decisions the specifications do not finish
-
-These are explicit implementation/activation gates. Implement and test the defined pieces below, but do not replace a missing contract with a permissive default or claim the flag is safe to enable. Record the resolution in the owning design before writing its production authority path.
-
-1. **D1 — release identity and offline floor enforcement.** The shared object and protocol number are specified. The actual minimum release is not: `vX.Y.Z` is a placeholder. Running boxes report short SHAs; signed system pointers and journals also accept non-semver builds. The specs do not define an authenticated mapping from those builds to ordered releases, local root persistence for offline recovery, the trusted pre-exec checker used by the `.prev` shell shims, or floor persistence when the feature flag is removed. Choose these jointly with the moves/release owners. Do not compare hashes lexically, use timestamps as precedence, trust an agent-written file as root policy, or replace `min_release` with the protocol number alone.
-2. **D2 — fencing evidence binding and transport.** The CLI must perform a fresh `DescribeInstances`, but the specs give no evidence schema, validity interval, CLI-to-console trust contract, or authoritative binding for box label → account/region/instance incarnation. Current IMDS metrics are a useful input, not a durable enrollment record. A signed console challenge can bind a request, but cannot make a client assertion into a cloud attestation. Define what the operator attests, what the console independently validates, how a stopped instance stays fenced until quarantine startup, and what survives an operation's release. Missing/aged-out instance IDs are not proof of termination.
-3. **D3 — deployment-wide quarantine and readmission.** `Lost` is per-host, but denial applies to every host. Specify how that deny reaches all admission callers without hot-path claim listings, how completeness of startup local inventory is established, how the returning box reports durable §6.3 retirement, and what `--readmit` removes across hosts. Define concurrent declare-lost/readmit ordering and replacement compute with a reused label. A host with no local inventory entry must not bypass a global quarantine. A console cache alone is not fresh admission authority.
-4. **D4 — durable loss records.** The console must declare registered timers and alert, but the record key/schema and source for a destroyed box's timer inventory are unspecified. Mark unknown inventory explicitly; never call it an empty set. A lost-target return with `src_covered == false` requires a second `writes_lost`; the current library has one pointer per admission and no source-cut loss entry API. Agree its additive representation, provenance and retry ownership with moves. Preserve existing admission records; never fabricate an extra `AdmitWriter` session to fit a second loss.
-5. **D5 — lost-box transition scope.** §7.2 says remove the box's obligations; the destroyed-local-only exception does not authorize dropping remotely stored data chores owned by that box. Define reassignment of regional work, persistence of the fence time for Plan 4, and the legal executor/stamp result when the current executor is lost. Revoke the lost executor atomically without minting a replacement writer. Recovery's destination/stamp is the owning operation's decision, including R6-4's journalled old stamp on a return.
-6. **D6 — lease failure and acknowledgement details.** §7.1 defines the wire record and timings, but not persistence of local lease state, handling an ambiguous PUT, same-box overlapping controllers, or how first-phase acknowledgement survives phase changes. Keep those choices conservative: no unacknowledged extension, one serialized local controller, expiry loss latched, stale operation responses ignored. Get the cross-process fence/local-state contract reviewed with Plan 6. A watchdog alone cannot deny writes in independently running root passes.
-7. **D7 — full floor census.** The specs require every eligible box and console, including both move regions, but existing status covers boxes and only individual console health. Define the authoritative eligible membership and freshness rules, fleet/pool namespaces and console reporting. Do not infer the entire serving set from currently visible heartbeat files or from the one current executor. Until a complete census is available, refuse protected starts.
-
-D1–D7 do not change the specified timings, payloads, CLI shape, compute-only fence, quarantine, floor or rollout order. They identify contracts that cannot be honestly inferred from current code. A primitive-only implementation can land inert while these gates remain open; completion of Plan 8's production wiring requires closing them.
-
-## Review focus
-
-1. A blocked renewal cannot prevent expiry handling, and a late success cannot reopen a lost handle. Lease expiry never satisfies a declare-lost request.
-2. Cleanup can kill a legacy populated cgroup during a control outage without requiring a successful fresh claim read or granting spawn authority.
-3. A stale resolve request cannot revoke a new operation or installation. A stopped unrelated instance cannot stand in for the named box.
-4. Quarantine covers a second host and a reused host, not only the claim on which the operator acted.
-5. A lost dirty target cannot silently erase unreturned writes, registered timers or a partial return lane. Destruction of local disks cannot erase remote obligations.
-6. Floor tests begin **before** selecting `.prev` in systemd, and include gated recovery. Testing only `restoreFiles` misses an older executable running the recovery itself.
-7. Flag-off parity, an ordinary serving host during a bucket outage, and disabled legacy CLI verbs remain unchanged.
-
-## File structure
-
-New filenames below are planned, not claims that these interfaces already exist. D1–D7 control wire additions and integration details.
-
-| Files | Responsibility |
-|---|---|
-| `internal/lease/execution_store.go`, `execution_store_test.go` | Narrow conditional object adapter using existing S3 configuration and conditional storage implementation |
-| `internal/hostexec/record.go`, `record_test.go`, `controller.go`, `controller_test.go` | Exact execution record, injected clock, acknowledgement barrier, renewal, loss and release |
-| `internal/host/hostexec.go`, `hostexec_test.go`; `internal/host/agent.go` | Flag-gated observation and cleanup adapter; independent renewal/deadline work; startup sequencing |
-| `internal/activator/maintenance.go` and Plan 6 admission callers | Fence ownership and lease-denial integration; no replacement for existing stop evidence |
-| `internal/hostclaim/lost.go`, `lost_test.go` | Validated evidence-backed transition, quarantine preservation, conditional readmission |
-| `internal/console/apihostresolve.go`, `apihostresolve_test.go`, `authcli.go`, `server.go` | Operator-only inspect/resolve API, durable loss/audit records and retry handling |
-| `opshost.go`, `opshost_test.go`, `main.go`, `internal/ops/ops.go` | Remote CLI dispatch, exact positional host syntax, explicit confirmation, existing ops help integration |
-| `internal/awsinv/fence.go`, `fence_test.go` | Fresh read-only verification through the existing EC2 client seam, used only by the CLI |
-| `internal/host/hostquarantine.go`, `hostquarantine_test.go` and Plan 4/6 startup hooks | Startup retirement and global admission denial, after D3 is settled |
-| `internal/hostfloor/floor.go`, `floor_test.go` | Shared floor record and version/census policy; no publishing as a side effect of reads |
-| `internal/status/snapshot.go`, `contract_test.go`; `internal/host/agent.go`; console status/startup | Additive protocol reporting, using existing release identities and private status scope |
-| `internal/console/hostfloor.go`, `hostfloor_test.go`, `apisystemupdate.go` | Lifecycle preflight and platform update/rollback refusals |
-| `internal/host/systemapply.go`, `systemstate.go`, new `systemfloor.go`, `systemfloor_test.go` | Candidate, `.prev`, journal, recovery and offline enforcement |
-| `internal/host/units/carlos-system-{update,recover}.service`, `units_isolation_test.go`, system unit tests | Floor-aware executable selection before launching a recovery binary |
-| `docs/site/cli.md` | Document `ops host resolve`, fencing prerequisite and `--readmit`; review final user-facing copy during implementation |
-
-### Task 1: Establish the implementation base and refusal contracts
-
-- [ ] **Step 1: Create the branch in a clean implementation checkout.** Do not run these commands while merely authoring this plan.
+# Host Lifecycle Claim, Plan 8: inert lease and lost-box contracts
+
+Rev 1 follows claim spec **rev 6.17**, §7.3 D2–D6. These contracts remain **PROPOSED**. Paul has not ruled on P1–P3 in §10.1 and amadan discussion **#236**. Landing these libraries does not authorize activation.
+
+> **For agentic workers:** implement the checked tasks in order. This document contains complete new files and exact patches for existing files. The branch and gate commands belong to the later implementation session. Authoring this plan changes only this Markdown file: no branch change, staging, commit or production action.
+
+**Goal:** supply execution leases, loss records and narrowly bound claim transitions as inert libraries; expose an operator-only resolve route that refuses until the missing deployment authority is implemented and approved.
+
+**Architecture:** reuse `release.Store` conditional writes and `DeleteIfMatch`, with the existing lease package's S3 configuration. Keep the specified two-field execution record separate from the replica lease. `hostexec` owns local controller exclusion and deadlines. `hostclaim` owns conditional journal and loss changes. `hostquarantine` consumes an `ActorDirectory` interface; only a test fake exists here. `awsinv` supplies a read-only exact-instance check, and `hostfence` supplies a challenge signed with the console's existing root key. No new live caller can mutate a claim through the CLI or console routes in this plan.
+
+**Depends on:** Plans 1 and 3. In a clean implementation checkout:
```bash
git switch -c feat/hostclaim-leases feat/hostclaim-boxguard
git merge origin/main
-git merge-base --is-ancestor feat/hostclaim-boxguard HEAD
-git merge-base --is-ancestor origin/main HEAD
```
-Expected: Plans 1 and 3 and main's conditional release implementation are present. Do not copy uncommitted sibling work into the branch.
+Use the repository's reviewed-branch workflow when the implementation is ready. Do not run these commands while editing this plan.
+
+## Sources and boundaries
+
+- `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`, rev 6.17: §2, C1–C6, §6.1, §6.3, operative §6.4a, §7.1–§7.3 and §10–§10.1. §7.3 supplies the proposed claim contracts; §10.1 records the still-open approval boundaries.
+- Plans 2, 3, 4, 7 and 7b in this directory supply the storage, local guard, retirement and consumer conventions. Plan 3's R9 applies to every local lease file and lock. Plan 4 owns immutable parking scopes and the late-completion horizon; this plan preserves those journals and retirement identities.
+- Compile baseline: `feat/hostclaim-boxguard` at `8c40aca61d6b25ea5e936ffab171d237f1b13483`, plus the disjoint `origin/main` changes at `562f3dfcb4934c0dec027f730e34bbff08bbbd15`. Existing `hostclaim.Update` clones the observation, checks its ETag and assigns the next claim generation. `CompleteObligation` handles discovery completion using the chore's original operation ID.
+- `internal/lease/{lease,health,endpoint}.go`: replica leases use a different object and 15-minute TTL. Reuse their configured S3 client, not their wire encoding or TTL. `internal/release/{store,conditional_delete}.go` supplies create-only PUT, conditional replacement and conditional deletion.
+- `internal/awsinv/{clients,ec2}.go` supplies `DescribeInstances`. There is no existing stop/terminate primitive to call. The operator fences compute through their authorized infrastructure workflow; this plan verifies a positive cloud result and never changes compute.
+- `main.go`, `apimode.go`, `internal/console/{authcli,server}.go` supply CLI dispatch, person sessions and the console signer. `Store.IsOperator` is the required authority. Tenant ownership, system-roller permission and service credentials are insufficient.
+
+## Invariants
+
+1. **R1:** with `CARLOS_HOST_CLAIM` unset, existing production paths stay unchanged. The CLI and console snapshot trimmed nonempty flag semantics; `0` is enabled, whitespace is disabled. The only new live route returns `awaiting Paul's ruling P2/P3 (amadan #236)` after person/operator authorization. It never creates a challenge, changes a claim, writes a loss record or invokes EC2. Libraries have no production callers here.
+2. **D6:** the remote object is exactly `control/hosts/<host>/exec/<box>.json` with `op` and `expires`. TTL is 90 seconds, renewal 20 seconds, denial margin five seconds. The deadline starts with the request, includes suspension through Linux `CLOCK_BOOTTIME`, and never extends from an ambiguous response or wall-clock rollback. A lost handle never revives.
+3. The lifetime lock is `/run/carlos/hostexec/<host>.lock`; local state is `<DataDir>/.hostexec/<host>.json`. The host guard serializes record changes. Libraries never create directories or change pathname ownership/mode. Provision the directories with the same setgid `2770 carlos carlos` boundary as Plan 3 before connecting callers. Tests provision only their scratch directories. Darwin and Windows compile; an unsupported suspend-aware clock refuses controller startup.
+4. Lease network requests and claim acknowledgements never hold the host guard. Denial and cleanup run independently of renewal and persistence. Cleanup must terminate the captured host processes without waiting for that guard. An uncertain active-file write retains the guard until a durable lost record is written; a timed-out `Close` retains controller exclusion. Independent writers use `CheckLocal` **inside** `Guard.With`, alongside fresh claim/quarantine checks and their separate cleanup-denial state. A file left by a dead controller cannot authorize work.
+5. CC9 clears only the matching local lease record after a successful fresh claim read proves serving, free or exclusion of that box. It preserves quarantine, retired stamps and cleanup failures. `Ready` returns a coherent durable acknowledgement tuple. The `exec_ack.<box>` journal entry is historical evidence; it is not permission to keep working after lease loss.
+6. **D4/D5:** a loss record is prepared durably before a claim CAS. A declaration binds holder, operation, phase, enrolled box, claim revision and confirmed effects. The transition revokes only that box, sets its executor to `none` while preserving `holder.stamp`, retains remote obligations, and uses `CompleteObligation` only for exact destroyed-local scopes. Stopped or terminated compute alone never proves that disks were destroyed. Lost revisions and declaration references survive free/reuse; readmission removes only the matching revision and grants no writer.
+7. Missing or unreadable evidence refuses. Timers explicitly distinguish `complete`, `partial` and `unknown`; unknown never becomes an empty complete inventory. Additional write loss belongs to the same admission, with the original source-cut session and validated baseline position. Durable archival precedes removal of loss-bearing admission evidence.
+8. **D2/D3:** admission checks freshly consult the directory interface on every call. The real root-signed directory, startup retirement execution, global quarantine CAS and readmission coordinator are outside this scope. Cloud verification accepts only one exact enrolled instance, a matching reservation owner, and `stopped` or `terminated`. Missing, paginated/partial, ambiguous and failed responses refuse. The challenge expires in 120 seconds; observations start after issuance, are at most 60 seconds old and at most five seconds in the console's future. The signed challenge authenticates console intent, not AWS evidence.
+
+Each task below includes its implementation and tests. Save each existing-file patch and apply it with `git apply --unidiff-zero` against the stated baseline; context is omitted to keep Go tabs intact inside Markdown. Apply patches exactly; do not substitute prose for omitted code. Source filenames above are baseline references; the complete additions and changes follow.
+
+## Task 1: Signed loss records, additive claim state and bound transitions
+
+**Produces:** root-only envelope helpers; D4 timer/write-loss records and archival checks; D5 `DeclareLost` and matching-revision `Readmit`; the D6 `exec_ack.<box>` journal helper. **Consumes:** existing `release.Store`, `hostclaim.Update`, `CompleteObligation` and operation-owner validation. Apply this task as a unit: the schema, validator and transition changes refer to one another. No CLI or console mutation caller is added.
+
+- [ ] **Apply the exact existing-file changes.**
+
+```diff
+--- a/internal/hostclaim/claim.go
++++ b/internal/hostclaim/claim.go
+@@ -63,10 +63,13 @@
+- Region string `json:"region"`
+- PredecessorRegion string `json:"predecessor_region,omitempty"`
+- Session uint64 `json:"session"`
+- Box string `json:"box"`
+- Generation string `json:"generation"`
+- Predecessor string `json:"predecessor,omitempty"`
+- Mode string `json:"mode"`
+- Coverage string `json:"coverage,omitempty"`
+- Covered bool `json:"covered"`
+- WritesLost *WritesLost `json:"writes_lost,omitempty"`
++ Region string `json:"region"`
++ PredecessorRegion string `json:"predecessor_region,omitempty"`
++ Session uint64 `json:"session"`
++ Box string `json:"box"`
++ Generation string `json:"generation"`
++ Predecessor string `json:"predecessor,omitempty"`
++ Mode string `json:"mode"`
++ Coverage string `json:"coverage,omitempty"`
++ Covered bool `json:"covered"`
++ WritesLost *WritesLost `json:"writes_lost,omitempty"`
++ AdditionalWritesLost []WritesLost `json:"additional_writes_lost,omitempty"`
++ LossID string `json:"loss_id,omitempty"`
++ LossArchivedClaimGen uint64 `json:"loss_archived_claim_gen,omitempty"`
+@@ -134,0 +138,2 @@
++ LostRevisions map[string]uint64 `json:"lost_revisions,omitempty"`
++ LossRefs []string `json:"loss_refs,omitempty"`
+@@ -228,4 +233,23 @@
+- if record.Region == "" {
+- return fmt.Errorf("%w: admission without region", ErrMalformed)
+- }
+- }
++ if record.Region == "" || !validWriteLosses(record.WritesLost, record.AdditionalWritesLost) {
++ return fmt.Errorf("%w: invalid admission", ErrMalformed)
++ }
++ if len(record.AdditionalWritesLost) > 0 && !safeLossID(record.LossID) {
++ return fmt.Errorf("%w: additional loss without declaration", ErrMalformed)
++ }
++ if record.LossArchivedClaimGen != 0 && (!admissionHasLoss(record) || record.LossArchivedClaimGen != record.Session) {
++ return fmt.Errorf("%w: invalid archived admission loss", ErrMalformed)
++ }
++ }
++ }
++ lost := set(c.Lost)
++ for box, revision := range c.LostRevisions {
++ if box == "" || revision == 0 || !lost[box] {
++ return fmt.Errorf("%w: invalid lost revision", ErrMalformed)
++ }
++ }
++ seenLossRefs := map[string]bool{}
++ for _, id := range c.LossRefs {
++ if !safeLossID(id) || seenLossRefs[id] {
++ return fmt.Errorf("%w: invalid loss references", ErrMalformed)
++ }
++ seenLossRefs[id] = true
+@@ -244 +268 @@
+- if o.Region == "" || o.Generation == "" || o.Box != "" || o.Owner != "region:"+o.Region {
++ if o.Region == "" || o.Generation == "" || o.Box != "" || !validRegionalOwner(o.Owner, o.Region) {
+@@ -248 +272 @@
+- if o.Region == "" || o.Box != "" || o.Generation != "" || o.Owner != "region:"+o.Region {
++ if o.Region == "" || o.Box != "" || o.Generation != "" || !validRegionalOwner(o.Owner, o.Region) {
+--- a/internal/hostclaim/generation.go
++++ b/internal/hostclaim/generation.go
+@@ -3 +3,4 @@
+-import "fmt"
++import (
++ "fmt"
++ "reflect"
++)
+@@ -19,0 +23,3 @@
++ AdditionalWritesLost []WritesLost
++ LossID string
++ lossVerified bool
+@@ -41,0 +48,15 @@
++ if (in.LossID != "" || len(in.AdditionalWritesLost) != 0) && !in.lossVerified {
++ return fmt.Errorf("%w: loss-bearing admission requires verified durable declaration", ErrWrongState)
++ }
++ if in.LossID != "" {
++ for _, prior := range h.Admissions {
++ if prior.LossID != in.LossID {
++ continue
++ }
++ if prior.Box == in.Box && prior.Mode == in.Mode && prior.Generation == in.NewGeneration &&
++ reflect.DeepEqual(prior.AdditionalWritesLost, in.AdditionalWritesLost) {
++ return ErrLossAlreadyApplied
++ }
++ return fmt.Errorf("%w: declaration id already used by another admission", ErrWrongState)
++ }
++ }
+@@ -60,0 +82,5 @@
++ if len(in.AdditionalWritesLost) > 0 {
++ if in.Mode != "cold" || in.PredecessorCertified || in.Coverage != "" || !safeLossID(in.LossID) || !validWriteLosses(nil, in.AdditionalWritesLost) {
++ return fmt.Errorf("%w: invalid additional write-loss declaration", ErrWrongState)
++ }
++ }
+@@ -76,0 +103 @@
++ AdditionalWritesLost: append([]WritesLost(nil), in.AdditionalWritesLost...), LossID: in.LossID,
+@@ -88 +115 @@
+- for len(h.Admissions) > 8 && h.Admissions[0].Covered {
++ for len(h.Admissions) > 8 && h.Admissions[0].Covered && admissionLossArchived(h.Admissions[0]) {
+--- a/internal/hostclaim/delete.go
++++ b/internal/hostclaim/delete.go
+@@ -111,0 +112,3 @@
++ if err := RequireLossEvidenceArchived(c); err != nil {
++ return err
++ }
+--- a/internal/hostclaim/create.go
++++ b/internal/hostclaim/create.go
+@@ -112,0 +113,3 @@
++ if err := RequireLossEvidenceArchived(c); err != nil {
++ return err
++ }
+--- a/internal/hostclaim/generation_test.go
++++ b/internal/hostclaim/generation_test.go
+@@ -700,6 +700,4 @@
+- drop := 3
+- if name == "uncovered first" {
+- drop = 0
+- } else if name == "uncovered middle" {
+- drop = 1
+- }
++ // Every row carries ordinary writes_lost evidence. D4 now
++ // retains it until a durable archival contract exists; coverage
++ // alone no longer makes any of these rows discardable.
++ drop := 0
+```
+
+- [ ] **Create `internal/release/rootenvelope.go` with this complete content.**
+
+<!-- plan8:file internal/release/rootenvelope.go -->
+```go
+package release
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "io"
+)
+
+// MaxRootEnvelopeBytes bounds both the signed wire object and its decoded
+// payload. Loss declarations can contain timer snapshots, so the worker
+// control record's smaller bound is not suitable here.
+const MaxRootEnvelopeBytes = 1 << 20
+
+var ErrRootEnvelope = errors.New("release: root envelope refused")
+
+// RootPublicKey returns a copy of the verification anchor carried by a
+// signer. It lets create-only callers resolve an ambiguous retry by reading
+// and verifying the object they may already have created.
+func (s Signer) RootPublicKey() ed25519.PublicKey {
+ return append(ed25519.PublicKey(nil), s.root...)
+}
+
+// SignRootEnvelope writes the existing root-signed {payload,sig} envelope.
+// Scoped release keys are deliberately refused: these records are deployment
+// control authority, not channel publication.
+func SignRootEnvelope(sg Signer, payload any) ([]byte, error) {
+ if sg.usable() != nil || sg.scope != nil || sg.grant != nil {
+ return nil, ErrRootEnvelope
+ }
+ raw, err := json.Marshal(payload)
+ if err != nil || len(raw) == 0 || len(raw) > MaxRootEnvelopeBytes/2 {
+ return nil, ErrRootEnvelope
+ }
+ b64 := base64.StdEncoding.EncodeToString(raw)
+ sig := ed25519.Sign(sg.key, []byte(b64))
+ wire, err := json.Marshal(signedWrapper{Payload: b64, Sig: base64.StdEncoding.EncodeToString(sig)})
+ if err != nil || len(wire) > MaxRootEnvelopeBytes {
+ return nil, ErrRootEnvelope
+ }
+ return wire, nil
+}
+
+// VerifyRootEnvelope verifies an envelope against the deployment root and
+// requires canonical JSON for both wrapper and typed payload. dst must be a
+// non-nil pointer to the exact expected payload type.
+func VerifyRootEnvelope(pub ed25519.PublicKey, wire []byte, dst any) error {
+ if len(pub) != ed25519.PublicKeySize || len(wire) == 0 || len(wire) > MaxRootEnvelopeBytes || dst == nil {
+ return ErrRootEnvelope
+ }
+ var wrapper signedWrapper
+ if strictCanonicalJSON(wire, &wrapper) != nil || wrapper.Grant != nil {
+ return ErrRootEnvelope
+ }
+ sig, err := base64.StdEncoding.Strict().DecodeString(wrapper.Sig)
+ if err != nil || base64.StdEncoding.EncodeToString(sig) != wrapper.Sig || len(sig) != ed25519.SignatureSize ||
+ !ed25519.Verify(pub, []byte(wrapper.Payload), sig) {
+ return ErrRootEnvelope
+ }
+ raw, err := base64.StdEncoding.Strict().DecodeString(wrapper.Payload)
+ if err != nil || base64.StdEncoding.EncodeToString(raw) != wrapper.Payload || len(raw) == 0 || len(raw) > MaxRootEnvelopeBytes/2 {
+ return ErrRootEnvelope
+ }
+ if strictCanonicalJSON(raw, dst) != nil {
+ return ErrRootEnvelope
+ }
+ return nil
+}
+
+func strictCanonicalJSON(raw []byte, dst any) error {
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(dst); err != nil {
+ return err
+ }
+ var trailing any
+ if err := dec.Decode(&trailing); err != io.EOF {
+ return ErrRootEnvelope
+ }
+ canonical, err := json.Marshal(dst)
+ if err != nil {
+ return err
+ }
+ var compact bytes.Buffer
+ if err := json.Compact(&compact, raw); err != nil || !bytes.Equal(compact.Bytes(), canonical) {
+ return ErrRootEnvelope
+ }
+ return nil
+}
+```
+
+- [ ] **Create `internal/release/rootenvelope_test.go` with this complete content.**
+
+<!-- plan8:file internal/release/rootenvelope_test.go -->
+```go
+package release
+
+import (
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "testing"
+)
+
+func TestRootEnvelopeRoundTripAndRefusals(t *testing.T) {
+ pub, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ type payload struct {
+ V int `json:"v"`
+ ID string `json:"id"`
+ }
+ wire, err := SignRootEnvelope(RootSigner(priv), payload{V: 1, ID: "loss-1"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got payload
+ if err := VerifyRootEnvelope(pub, wire, &got); err != nil || got != (payload{V: 1, ID: "loss-1"}) {
+ t.Fatalf("verify = %+v, %v", got, err)
+ }
+
+ var wrapper map[string]any
+ if err := json.Unmarshal(wire, &wrapper); err != nil {
+ t.Fatal(err)
+ }
+ wrapper["extra"] = true
+ bad, _ := json.Marshal(wrapper)
+ if err := VerifyRootEnvelope(pub, bad, &got); !errors.Is(err, ErrRootEnvelope) {
+ t.Fatalf("unknown wrapper field = %v", err)
+ }
+ wire[len(wire)-2] ^= 1
+ if err := VerifyRootEnvelope(pub, wire, &got); !errors.Is(err, ErrRootEnvelope) {
+ t.Fatalf("tampered envelope = %v", err)
+ }
+ if _, err := SignRootEnvelope(Signer{}, got); !errors.Is(err, ErrRootEnvelope) {
+ t.Fatalf("zero signer = %v", err)
+ }
+}
+```
+
+- [ ] **Create `internal/hostclaim/execution_ack.go` with this complete content.**
+
+<!-- plan8:file internal/hostclaim/execution_ack.go -->
+```go
+package hostclaim
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+var ErrAckAlreadyApplied = errors.New("hostclaim: execution acknowledgement already applied")
+
+type ExecutionAcknowledgement struct {
+ Op string `json:"op"`
+ Enrollment string `json:"enrollment"`
+ BootID string `json:"boot_id"`
+ ControllerID string `json:"controller_id"`
+ LeaseETag string `json:"lease_etag"`
+ Expires time.Time `json:"expires"`
+}
+
+// AcknowledgeExecution records the first acknowledged lease for one required
+// participant. The lease write and durable local state happen before this
+// transition; a conflicting acknowledgement is never overwritten.
+func AcknowledgeExecution(box string, ack ExecutionAcknowledgement) func(*Claim) error {
+ return func(c *Claim) error {
+ if !validBoxReference(box) || c.Op.Kind == "" || c.Op.Kind == KindServing || c.Op.ID == "" || ack.Op != c.Op.ID ||
+ ack.Enrollment == "" || ack.BootID == "" || ack.ControllerID == "" || ack.LeaseETag == "" || ack.Expires.IsZero() ||
+ !stringPresent(c.Op.Participants, box) {
+ return fmt.Errorf("%w: execution acknowledgement is not for a required active participant", ErrWrongState)
+ }
+ raw, err := json.Marshal(ack)
+ if err != nil {
+ return err
+ }
+ if c.Op.Journal == nil {
+ c.Op.Journal = map[string]string{}
+ }
+ key := "exec_ack." + box
+ if previous, ok := c.Op.Journal[key]; ok {
+ if previous == string(raw) {
+ return ErrAckAlreadyApplied
+ }
+ return fmt.Errorf("%w: participant already acknowledged different lease", ErrWrongState)
+ }
+ c.Op.Journal[key] = string(raw)
+ return nil
+ }
+}
+
+func validBoxReference(box string) bool {
+ if box == "" || len(box) > 128 || strings.ContainsAny(box, "/\\\x00\r\n\t ") {
+ return false
+ }
+ return true
+}
+```
+
+- [ ] **Create `internal/hostclaim/execution_ack_test.go` with this complete content.**
+
+<!-- plan8:file internal/hostclaim/execution_ack_test.go -->
+```go
+package hostclaim
+
+import (
+ "encoding/json"
+ "errors"
+ "testing"
+ "time"
+)
+
+func TestAcknowledgeExecutionWritesExactJournalValue(t *testing.T) {
+ expires := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
+ ack := ExecutionAcknowledgement{Op: "op-move", Enrollment: "enrollment-1", BootID: "boot-1", ControllerID: "controller-1", LeaseETag: `"lease-etag"`, Expires: expires}
+ c := &Claim{Op: Op{Kind: KindMoving, ID: "op-move", Participants: []string{"box-a"}}}
+ if err := AcknowledgeExecution("box-a", ack)(c); err != nil {
+ t.Fatal(err)
+ }
+ want, _ := json.Marshal(ack)
+ if got := c.Op.Journal["exec_ack.box-a"]; got != string(want) {
+ t.Fatalf("journal = %q, want %q", got, want)
+ }
+ if err := AcknowledgeExecution("box-a", ack)(c); !errors.Is(err, ErrAckAlreadyApplied) {
+ t.Fatalf("idempotent retry = %v", err)
+ }
+ changed := ack
+ changed.LeaseETag = `"other"`
+ if err := AcknowledgeExecution("box-a", changed)(c); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("conflicting ack = %v", err)
+ }
+}
+
+func TestAcknowledgeExecutionRefusesServingAndNonparticipants(t *testing.T) {
+ ack := ExecutionAcknowledgement{Op: "op", Enrollment: "e", BootID: "b", ControllerID: "c", LeaseETag: "tag", Expires: time.Now()}
+ for name, c := range map[string]*Claim{
+ "serving": {Op: Op{Kind: KindServing, Participants: []string{"box"}}},
+ "nonparticipant": {Op: Op{Kind: KindMoving, ID: "op", Participants: []string{"other"}}},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if err := AcknowledgeExecution("box", ack)(c); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+ }
+}
+```
+
+- [ ] **Create `internal/hostclaim/lossrecord.go` with this complete content.**
+
+<!-- plan8:file internal/hostclaim/lossrecord.go -->
+```go
+package hostclaim
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "reflect"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const lossRecordVersion = 1
+
+var (
+ ErrLossRecord = errors.New("hostclaim: loss record refused")
+ ErrLossRecordAbsent = errors.New("hostclaim: loss record absent")
+)
+
+type TimerKnowledge string
+
+const (
+ TimersComplete TimerKnowledge = "complete"
+ TimersPartial TimerKnowledge = "partial"
+ TimersUnknown TimerKnowledge = "unknown"
+)
+
+type TimerSource struct {
+ Bucket string `json:"bucket"`
+ Key string `json:"key"`
+ VersionID string `json:"version_id"`
+ SHA256 string `json:"sha256"`
+ CapturedAt time.Time `json:"captured_at"`
+}
+
+type TimerLoss struct {
+ Knowledge TimerKnowledge `json:"knowledge"`
+ Source *TimerSource `json:"source,omitempty"`
+ Entries []json.RawMessage `json:"entries,omitempty"`
+}
+
+type LossCloudObservation struct {
+ ObservedAt time.Time `json:"observed_at"`
+ Account string `json:"account"`
+ Region string `json:"region"`
+ InstanceID string `json:"instance_id"`
+ State string `json:"state"`
+ RequestID string `json:"request_id"`
+}
+
+type LossOperatorAttestation struct {
+ MaintainComputeFence bool `json:"maintain_compute_fence"`
+ ColdQuarantineStartOnly bool `json:"cold_quarantine_start_only"`
+ DestroyedStorage []string `json:"destroyed_storage,omitempty"`
+}
+
+type ObligationRef struct {
+ ID string `json:"id"`
+ Op string `json:"op"`
+ Retirement string `json:"retirement"`
+}
+
+type LossConsequences struct {
+ RevokeExecution bool `json:"revoke_execution"`
+ RemoveParticipation bool `json:"remove_participation"`
+ DischargeLocal []ObligationRef `json:"discharge_local,omitempty"`
+ ReassignRegional []ObligationRef `json:"reassign_regional,omitempty"`
+ DestroyedStorage []string `json:"destroyed_storage,omitempty"`
+ WritesPotentiallyLost bool `json:"writes_potentially_lost"`
+ TimersPotentiallyLost bool `json:"timers_potentially_lost"`
+ RecoveryIntent json.RawMessage `json:"recovery_intent"`
+}
+
+type WriteSource struct {
+ Reason string `json:"reason"` // target | source-cut
+ Box string `json:"box"`
+ Generation string `json:"generation"`
+ Session uint64 `json:"session"`
+ Region string `json:"region"`
+ Baseline string `json:"baseline"`
+}
+
+// ArchivedAdmissionLoss is the exact evidence copied out of a claim before a
+// loss-bearing admission can be pruned or its holder removed.
+type ArchivedAdmissionLoss struct {
+ LossID string `json:"loss_id"`
+ AdmissionClaimGen uint64 `json:"admission_claim_gen"`
+ WritesLost *WritesLost `json:"writes_lost,omitempty"`
+ AdditionalWritesLost []WritesLost `json:"additional_writes_lost,omitempty"`
+}
+
+type LossRecord struct {
+ V int `json:"v"`
+ ID string `json:"id"`
+ State string `json:"state"` // prepared | committed | conflicted
+ Actor string `json:"actor"`
+ AcceptedAt time.Time `json:"accepted_at"`
+ Host string `json:"host"`
+ HolderIncarnation string `json:"holder_incarnation"`
+ HolderStamp uint64 `json:"holder_stamp"`
+ HolderSession uint64 `json:"holder_session"`
+ ExpectedClaimGen uint64 `json:"expected_claim_gen"`
+ ExpectedClaimETag string `json:"expected_claim_etag"`
+ OpID string `json:"op_id"`
+ OpKind string `json:"op_kind"`
+ OpPhase string `json:"op_phase"`
+ Box string `json:"box"`
+ Enrollment string `json:"enrollment"`
+ QuarantineRevision uint64 `json:"quarantine_revision"`
+ CloudObservation LossCloudObservation `json:"cloud_observation"`
+ OperatorAttestation LossOperatorAttestation `json:"operator_attestation"`
+ FencedAt time.Time `json:"fenced_at"`
+ ConfirmedConsequences LossConsequences `json:"confirmed_consequences"`
+ Timers TimerLoss `json:"timers"`
+ WriteSources []WriteSource `json:"write_sources,omitempty"`
+ ResultClaimGen uint64 `json:"result_claim_gen,omitempty"`
+ ArchivedAdmissionLosses []ArchivedAdmissionLoss `json:"archived_admission_losses,omitempty"`
+}
+
+type ObservedLossRecord struct {
+ Record LossRecord
+ ETag string
+}
+
+func LossRecordKey(id string) (string, error) {
+ if !safeLossID(id) {
+ return "", fmt.Errorf("%w: invalid declaration id", ErrLossRecord)
+ }
+ return "control/host-loss/" + id + ".json", nil
+}
+
+func safeLossID(id string) bool {
+ if id == "" || len(id) > 128 {
+ return false
+ }
+ for _, r := range id {
+ if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_') {
+ return false
+ }
+ }
+ return true
+}
+
+func (r LossRecord) Validate() error {
+ if r.V != lossRecordVersion || !safeLossID(r.ID) || (r.State != "prepared" && r.State != "committed" && r.State != "conflicted") ||
+ r.Actor == "" || r.AcceptedAt.IsZero() || r.Host == "" || !isHex64(r.HolderIncarnation) || r.HolderStamp == 0 || r.ExpectedClaimGen == 0 ||
+ r.ExpectedClaimETag == "" || r.OpID == "" || !knownKinds[r.OpKind] || r.Box == "" || r.Enrollment == "" ||
+ r.QuarantineRevision == 0 || r.FencedAt.IsZero() {
+ return ErrLossRecord
+ }
+ if _, err := Key(r.Host); err != nil {
+ return ErrLossRecord
+ }
+ if r.State == "committed" && r.ResultClaimGen == 0 || r.State == "prepared" && r.ResultClaimGen != 0 {
+ return ErrLossRecord
+ }
+ o := r.CloudObservation
+ if o.ObservedAt.IsZero() || o.Account == "" || o.Region == "" || o.InstanceID == "" || o.RequestID == "" || (o.State != "stopped" && o.State != "terminated") {
+ return ErrLossRecord
+ }
+ if !r.OperatorAttestation.MaintainComputeFence || !r.OperatorAttestation.ColdQuarantineStartOnly || !validConsequences(r.ConfirmedConsequences) ||
+ !reflect.DeepEqual(r.OperatorAttestation.DestroyedStorage, r.ConfirmedConsequences.DestroyedStorage) || !validTimerLoss(r.Timers) {
+ return ErrLossRecord
+ }
+ seenSources := map[string]bool{}
+ for _, s := range r.WriteSources {
+ if (s.Reason != "target" && s.Reason != "source-cut") || s.Box == "" || s.Session == 0 || s.Region == "" || s.Baseline == "" {
+ return ErrLossRecord
+ }
+ key := fmt.Sprintf("%s\x00%s\x00%d", s.Box, s.Generation, s.Session)
+ if seenSources[key] {
+ return ErrLossRecord
+ }
+ seenSources[key] = true
+ }
+ seenArchived := map[string]bool{}
+ for _, a := range r.ArchivedAdmissionLosses {
+ if a.LossID != r.ID || a.AdmissionClaimGen == 0 || (a.WritesLost == nil && len(a.AdditionalWritesLost) == 0) || !validWriteLosses(a.WritesLost, a.AdditionalWritesLost) {
+ return ErrLossRecord
+ }
+ key := fmt.Sprintf("%s\x00%d", a.LossID, a.AdmissionClaimGen)
+ if seenArchived[key] {
+ return ErrLossRecord
+ }
+ seenArchived[key] = true
+ }
+ return nil
+}
+
+func validConsequences(c LossConsequences) bool {
+ if len(c.RecoveryIntent) == 0 || !json.Valid(c.RecoveryIntent) {
+ return false
+ }
+ var object map[string]json.RawMessage
+ if json.Unmarshal(c.RecoveryIntent, &object) != nil || object == nil {
+ return false
+ }
+ for _, refs := range [][]ObligationRef{c.DischargeLocal, c.ReassignRegional} {
+ seen := map[string]bool{}
+ for _, ref := range refs {
+ if ref.ID == "" || ref.Op == "" || ref.Retirement == "" || seen[ref.Op+"\x00"+ref.ID] {
+ return false
+ }
+ seen[ref.Op+"\x00"+ref.ID] = true
+ }
+ }
+ return true
+}
+
+func validTimerLoss(t TimerLoss) bool {
+ switch t.Knowledge {
+ case TimersUnknown:
+ return t.Source == nil && len(t.Entries) == 0
+ case TimersComplete, TimersPartial:
+ if t.Source == nil || t.Source.Bucket == "" || t.Source.Key == "" || t.Source.VersionID == "" || !isHex64(t.Source.SHA256) || t.Source.CapturedAt.IsZero() {
+ return false
+ }
+ default:
+ return false
+ }
+ for _, entry := range t.Entries {
+ if len(entry) == 0 || !json.Valid(entry) || strings.TrimSpace(string(entry)) == "null" {
+ return false
+ }
+ }
+ return true
+}
+
+func validWriteLosses(primary *WritesLost, additional []WritesLost) bool {
+ seen := map[string]bool{}
+ // writes_lost predates durable declarations and legitimately contains
+ // legacy session zero / empty provenance. Decode it additively. New
+ // additional losses are the tuples this contract validates strictly.
+ if primary != nil && primary.Box != "" {
+ seen[fmt.Sprintf("%s\x00%s\x00%d", primary.Box, primary.Generation, primary.Session)] = true
+ }
+ for _, w := range additional {
+ if w.Box == "" || w.Session == 0 {
+ return false
+ }
+ key := fmt.Sprintf("%s\x00%s\x00%d", w.Box, w.Generation, w.Session)
+ if seen[key] {
+ return false
+ }
+ seen[key] = true
+ }
+ return true
+}
+
+func ReadLossRecord(ctx context.Context, s release.Store, pub ed25519.PublicKey, id string) (ObservedLossRecord, error) {
+ key, err := LossRecordKey(id)
+ if err != nil {
+ return ObservedLossRecord{}, err
+ }
+ wire, etag, err := s.Get(ctx, key)
+ if errors.Is(err, os.ErrNotExist) {
+ return ObservedLossRecord{}, ErrLossRecordAbsent
+ }
+ if err != nil {
+ return ObservedLossRecord{}, err
+ }
+ var record LossRecord
+ if err := release.VerifyRootEnvelope(pub, wire, &record); err != nil || record.ID != id || record.Validate() != nil || etag == "" {
+ return ObservedLossRecord{}, ErrLossRecord
+ }
+ return ObservedLossRecord{Record: record, ETag: etag}, nil
+}
+
+func CreateLossRecord(ctx context.Context, s release.Store, sg release.Signer, record LossRecord) (ObservedLossRecord, error) {
+ if record.State != "prepared" || record.Validate() != nil {
+ return ObservedLossRecord{}, ErrLossRecord
+ }
+ key, _ := LossRecordKey(record.ID)
+ wire, err := release.SignRootEnvelope(sg, record)
+ if err != nil {
+ return ObservedLossRecord{}, fmt.Errorf("%w: %v", ErrLossRecord, err)
+ }
+ etag, err := s.Put(ctx, key, wire, "")
+ if errors.Is(err, release.ErrCASConflict) {
+ existing, readErr := ReadLossRecord(ctx, s, sg.RootPublicKey(), record.ID)
+ if readErr == nil && reflect.DeepEqual(existing.Record, record) {
+ return existing, nil
+ }
+ return ObservedLossRecord{}, fmt.Errorf("%w: declaration id already names different evidence", ErrLossRecord)
+ }
+ if err != nil {
+ return ObservedLossRecord{}, err
+ }
+ return ObservedLossRecord{Record: record, ETag: etag}, nil
+}
+
+// ReplaceLossRecord conditionally advances delivery/accounting fields while
+// proving that the accepted evidence and consequences prepared earlier are
+// byte-for-byte unchanged.
+func ReplaceLossRecord(ctx context.Context, s release.Store, sg release.Signer, before ObservedLossRecord, next LossRecord) (ObservedLossRecord, error) {
+ if before.ETag == "" || before.Record.Validate() != nil || next.Validate() != nil || !immutableLossFieldsEqual(before.Record, next) || !validLossProgression(before.Record, next) {
+ return ObservedLossRecord{}, ErrLossRecord
+ }
+ key, _ := LossRecordKey(next.ID)
+ wire, err := release.SignRootEnvelope(sg, next)
+ if err != nil {
+ return ObservedLossRecord{}, fmt.Errorf("%w: %v", ErrLossRecord, err)
+ }
+ etag, err := s.Put(ctx, key, wire, before.ETag)
+ if errors.Is(err, release.ErrCASConflict) {
+ return ObservedLossRecord{}, ErrConflict
+ }
+ if err != nil {
+ return ObservedLossRecord{}, err
+ }
+ return ObservedLossRecord{Record: next, ETag: etag}, nil
+}
+
+func immutableLossFieldsEqual(a, b LossRecord) bool {
+ a.State, b.State = "", ""
+ a.ResultClaimGen, b.ResultClaimGen = 0, 0
+ a.ArchivedAdmissionLosses, b.ArchivedAdmissionLosses = nil, nil
+ return reflect.DeepEqual(a, b)
+}
+
+func validLossProgression(before, next LossRecord) bool {
+ if next.ID != before.ID || len(next.ArchivedAdmissionLosses) < len(before.ArchivedAdmissionLosses) {
+ return false
+ }
+ if len(before.ArchivedAdmissionLosses) > 0 && !reflect.DeepEqual(next.ArchivedAdmissionLosses[:len(before.ArchivedAdmissionLosses)], before.ArchivedAdmissionLosses) {
+ return false
+ }
+ switch before.State {
+ case "prepared":
+ return next.State == "prepared" || next.State == "committed" || next.State == "conflicted"
+ case "committed":
+ return next.State == "committed" && next.ResultClaimGen == before.ResultClaimGen
+ case "conflicted":
+ return next.State == "conflicted" && next.ResultClaimGen == before.ResultClaimGen
+ }
+ return false
+}
+```
+
+- [ ] **Create `internal/hostclaim/lossrecord_test.go` with this complete content.**
+
+<!-- plan8:file internal/hostclaim/lossrecord_test.go -->
+```go
+package hostclaim
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func validLossRecord(t *testing.T) LossRecord {
+ t.Helper()
+ now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
+ inc := strings.Repeat("a", 64)
+ return LossRecord{
+ V: 1, ID: "loss_1", State: "prepared", Actor: "operator", AcceptedAt: now,
+ Host: testHost, HolderIncarnation: inc, HolderStamp: 3, HolderSession: 2, ExpectedClaimGen: 4, ExpectedClaimETag: `"claim"`,
+ OpID: "op", OpKind: KindMoving, OpPhase: "precommit", Box: "box-a", Enrollment: "enroll-a", QuarantineRevision: 9,
+ CloudObservation: LossCloudObservation{ObservedAt: now, Account: "123", Region: "eu-west-1", InstanceID: "i-1", State: "stopped", RequestID: "request-1"},
+ OperatorAttestation: LossOperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true}, FencedAt: now,
+ ConfirmedConsequences: LossConsequences{RevokeExecution: true, RemoveParticipation: true, RecoveryIntent: json.RawMessage(`{"kind":"lost-target"}`), TimersPotentiallyLost: true},
+ Timers: TimerLoss{Knowledge: TimersUnknown},
+ WriteSources: []WriteSource{{Reason: "target", Box: "box-a", Generation: "g1", Session: 3, Region: "ie", Baseline: "snapshot:v1"}},
+ }
+}
+
+func TestLossRecordCreateOnlyConditionalAndImmutable(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ pub, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sg := release.RootSigner(priv)
+ record := validLossRecord(t)
+ created, err := CreateLossRecord(ctx, s, sg, record)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if created.ETag == "" {
+ t.Fatal("create returned no ETag")
+ }
+ retry, err := CreateLossRecord(ctx, s, sg, record)
+ if err != nil || retry.ETag != created.ETag {
+ t.Fatalf("idempotent create = %+v, %v", retry, err)
+ }
+ read, err := ReadLossRecord(ctx, s, pub, record.ID)
+ if err != nil || !reflect.DeepEqual(read.Record, record) {
+ t.Fatalf("read = %+v, %v", read, err)
+ }
+
+ committed := read.Record
+ committed.State, committed.ResultClaimGen = "committed", 5
+ next, err := ReplaceLossRecord(ctx, s, sg, read, committed)
+ if err != nil {
+ t.Fatal(err)
+ }
+ mutated := next.Record
+ mutated.FencedAt = mutated.FencedAt.Add(time.Second)
+ if _, err := ReplaceLossRecord(ctx, s, sg, next, mutated); !errors.Is(err, ErrLossRecord) {
+ t.Fatalf("immutable evidence mutation = %v", err)
+ }
+ if _, err := ReplaceLossRecord(ctx, s, sg, read, committed); !errors.Is(err, ErrConflict) {
+ t.Fatalf("stale conditional replace = %v", err)
+ }
+}
+
+func TestTimerKnowledgeNeverConflatesUnknownWithEmptyComplete(t *testing.T) {
+ r := validLossRecord(t)
+ if err := r.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ r.Timers.Knowledge = TimersComplete
+ if err := r.Validate(); !errors.Is(err, ErrLossRecord) {
+ t.Fatalf("complete without source = %v", err)
+ }
+ r.Timers = TimerLoss{}
+ if err := r.Validate(); !errors.Is(err, ErrLossRecord) {
+ t.Fatalf("empty knowledge = %v", err)
+ }
+}
+
+func TestAdmissionLossRetentionRequiresExactDurableCopy(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ pub, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lost := WritesLost{Box: "target", Generation: "g-target", Session: 4, RemoteMaxTXID: 12}
+ source := WritesLost{Box: "source", Generation: "g-source", Session: 2, RemoteMaxTXID: 10}
+ obs := servingClaim(t, ctx, s)
+ obs, err = Update(ctx, s, obs, func(c *Claim) error {
+ c.LossRefs = []string{"loss_1"}
+ c.Holder.Admissions = []AdmissionRecord{{Region: "ie", Session: 8, LossID: "loss_1", WritesLost: &lost, AdditionalWritesLost: []WritesLost{source}}}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := RequireLossEvidenceArchived(obs.Claim); !errors.Is(err, ErrLossEvidencePending) {
+ t.Fatalf("unarchived evidence = %v", err)
+ }
+ record := validLossRecord(t)
+ record.Host = obs.Host
+ created, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record)
+ if err != nil {
+ t.Fatal(err)
+ }
+ exact := []ArchivedAdmissionLoss{{LossID: "loss_1", AdmissionClaimGen: 8, WritesLost: &lost, AdditionalWritesLost: []WritesLost{source}}}
+ committed := created.Record
+ committed.State, committed.ResultClaimGen, committed.ArchivedAdmissionLosses = "committed", obs.Claim.Gen, exact
+ if _, err := ReplaceLossRecord(ctx, s, release.RootSigner(priv), created, committed); err != nil {
+ t.Fatal(err)
+ }
+ archived, err := ArchiveAdmissionLosses(ctx, s, pub, obs, "loss_1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := RequireLossEvidenceArchived(archived.Claim); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestLossBearingAdmissionRequiresSignedSourceTuples(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ pub, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs := servingClaim(t, ctx, s)
+ obs, err = Update(ctx, s, obs, func(c *Claim) error {
+ c.Op = Op{Kind: KindReverting, ID: "return", Phase: "recover", Exec: "recovery", Participants: []string{"recovery"}}
+ c.Holder.WriterBox, c.Holder.Generation, c.Holder.Session = "target", "g-target", 4
+ c.LossRefs = []string{"loss_1"}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ source := WritesLost{Box: "source", Generation: "g-source", Session: 2, RemoteMaxTXID: 10}
+ in := Admission{ObservedOpID: "return", ObservedPhase: "recover", ObservedSession: 4, ObservedGeneration: "g-target", Box: "recovery", Mode: "cold", NewGeneration: "g-recovery", Region: "ie", PredecessorRegion: "ie", AdditionalWritesLost: []WritesLost{source}}
+ if _, err := Update(ctx, s, obs, AdmitWriter(Admission{ObservedOpID: in.ObservedOpID, ObservedPhase: in.ObservedPhase, ObservedSession: in.ObservedSession, ObservedGeneration: in.ObservedGeneration, Box: in.Box, Mode: in.Mode, NewGeneration: in.NewGeneration, Region: in.Region, PredecessorRegion: in.PredecessorRegion, LossID: "loss_1", AdditionalWritesLost: in.AdditionalWritesLost})); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("direct loss admission = %v", err)
+ }
+ record := validLossRecord(t)
+ record.Host = obs.Host
+ record.HolderIncarnation, record.HolderStamp, record.HolderSession = obs.Claim.Holder.Incarnation, obs.Claim.Holder.Stamp, 4
+ record.OpID, record.OpKind, record.OpPhase, record.Box = "return", KindReverting, "recover", "target"
+ record.WriteSources = []WriteSource{
+ {Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot"},
+ {Reason: "source-cut", Box: "source", Generation: "g-source", Session: 2, Region: "ie", Baseline: "source-seal"},
+ }
+ created, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record)
+ if err != nil {
+ t.Fatal(err)
+ }
+ committed := created.Record
+ committed.State, committed.ResultClaimGen = "committed", obs.Claim.Gen
+ if _, err := ReplaceLossRecord(ctx, s, release.RootSigner(priv), created, committed); err != nil {
+ t.Fatal(err)
+ }
+ validated := []ValidatedWriteSource{
+ {Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot", RemoteMaxTXID: in.RemoteMaxTXID},
+ {Reason: "source-cut", Box: "source", Generation: "g-source", Session: 2, Region: "ie", Baseline: "source-seal", RemoteMaxTXID: source.RemoteMaxTXID},
+ }
+ admitted, err := AdmitWriterWithLoss(ctx, s, pub, obs, in, "loss_1", validated)
+ if err != nil {
+ t.Fatal(err)
+ }
+ last := admitted.Claim.Holder.Admissions[len(admitted.Claim.Holder.Admissions)-1]
+ if last.LossID != "loss_1" || !reflect.DeepEqual(last.AdditionalWritesLost, []WritesLost{source}) {
+ t.Fatalf("loss admission = %+v", last)
+ }
+}
+
+func TestTargetOnlyLossAdmissionRequiresColdUncoveredRecovery(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ pub, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs := servingClaim(t, ctx, s)
+ obs, err = Update(ctx, s, obs, func(c *Claim) error {
+ c.Op = Op{Kind: KindReverting, ID: "return", Phase: "recover", Exec: "recovery", Participants: []string{"recovery"}}
+ c.Holder.WriterBox, c.Holder.Generation, c.Holder.Session = "target", "g-target", 4
+ c.LossRefs = []string{"loss_1"}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ record := validLossRecord(t)
+ record.Host, record.HolderIncarnation = obs.Host, obs.Claim.Holder.Incarnation
+ record.HolderStamp, record.HolderSession = obs.Claim.Holder.Stamp, 4
+ record.OpID, record.OpKind, record.OpPhase, record.Box = "return", KindReverting, "recover", "target"
+ record.WriteSources = []WriteSource{{Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot"}}
+ created, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record)
+ if err != nil {
+ t.Fatal(err)
+ }
+ committed := created.Record
+ committed.State, committed.ResultClaimGen = "committed", obs.Claim.Gen
+ if _, err := ReplaceLossRecord(ctx, s, release.RootSigner(priv), created, committed); err != nil {
+ t.Fatal(err)
+ }
+ base := Admission{ObservedOpID: "return", ObservedPhase: "recover", ObservedSession: 4, ObservedGeneration: "g-target", Box: "recovery", Mode: "cold", NewGeneration: "g-recovery", Region: "ie", PredecessorRegion: "ie", RemoteMaxTXID: 12}
+ validated := []ValidatedWriteSource{{Reason: "target", Box: "target", Generation: "g-target", Session: 4, Region: "ie", Baseline: "target-snapshot", RemoteMaxTXID: 12}}
+ for name, mutate := range map[string]func(*Admission){
+ "warm": func(a *Admission) { a.Mode, a.NewGeneration = "warm", "" },
+ "certified": func(a *Admission) { a.PredecessorCertified = true },
+ "sealed": func(a *Admission) { a.Coverage = "sealed" },
+ } {
+ t.Run(name, func(t *testing.T) {
+ in := base
+ mutate(&in)
+ if _, err := AdmitWriterWithLoss(ctx, s, pub, obs, in, "loss_1", validated); !errors.Is(err, ErrLossRecord) {
+ t.Fatalf("unsafe target-loss mode = %v", err)
+ }
+ })
+ }
+ fresh, err := Read(ctx, s, obs.Host)
+ if err != nil || fresh.Claim.Gen != obs.Claim.Gen {
+ t.Fatalf("refusals mutated claim: gen %d, err %v", fresh.Claim.Gen, err)
+ }
+}
+
+func TestLegacyLossWithoutDeclarationRetainsEvidence(t *testing.T) {
+ a := AdmissionRecord{Region: "ie", Session: 3, WritesLost: &WritesLost{Box: "legacy", Session: 0}}
+ if !validWriteLosses(a.WritesLost, nil) {
+ t.Fatal("legacy zero-session loss no longer parses")
+ }
+ c := &Claim{Holder: &Holder{Admissions: []AdmissionRecord{a}}}
+ if err := RequireLossEvidenceArchived(c); !errors.Is(err, ErrLossEvidencePending) {
+ t.Fatalf("unarchived legacy loss discarded: %v", err)
+ }
+}
+```
+
+- [ ] **Create `internal/hostclaim/lost.go` with this complete content.**
+
+<!-- plan8:file internal/hostclaim/lost.go -->
+```go
+package hostclaim
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "reflect"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+var (
+ ErrLossOwnerRequired = errors.New("hostclaim: operation owner must authorize lost-box transition")
+ ErrLossAlreadyApplied = errors.New("hostclaim: loss transition already applied")
+ ErrLossEvidencePending = errors.New("hostclaim: admission loss evidence is not archived")
+)
+
+// LostDeclaration is the exact claim observation and approved effect set from
+// one prepared durable loss record. ExpectedClaimETag is checked before Update
+// and the generation is checked again inside its transition.
+type LostDeclaration struct {
+ LossID string
+ ExpectedClaimGen uint64
+ ExpectedClaimETag string
+ HolderIncarnation string
+ HolderStamp uint64
+ HolderSession uint64
+ OpID string
+ OpKind string
+ OpPhase string
+ Box string
+ QuarantineRevision uint64
+ Enrollment string
+ FencedAt time.Time
+ Consequences LossConsequences
+}
+
+// LossOperationOwner owns the legal transition for its operation kind. A
+// generic declare-lost path cannot infer moves' source/target or record-commit
+// state, so a missing owner always refuses.
+type LossOperationOwner interface {
+ ValidateDeclareLost(Claim, LostDeclaration) error
+}
+
+// DeclareLost reads and verifies the root-signed prepared declaration, then
+// conditionally commits its exact effects. Owning both reads and Update here
+// makes the durable record, claim ETag and claim generation enforceable API
+// inputs rather than caller conventions.
+func DeclareLost(ctx context.Context, s release.Store, pub ed25519.PublicKey, observed Observed, lossID string, owner LossOperationOwner) (Observed, error) {
+ if owner == nil {
+ return Observed{}, ErrLossOwnerRequired
+ }
+ loss, err := ReadLossRecord(ctx, s, pub, lossID)
+ if err != nil {
+ return Observed{}, err
+ }
+ in := declarationFromRecord(loss.Record)
+ if observed.Absent || observed.Claim == nil || observed.ETag == "" || observed.Host != observed.Claim.Host || observed.Host != loss.Record.Host {
+ return Observed{}, ErrConflict
+ }
+ if lossRefPresent(observed.Claim, in.LossID) {
+ // loss_refs is the permanent commit marker. Readmission intentionally
+ // removes Lost/LostRevisions while preserving this evidence.
+ return observed, nil
+ }
+ if loss.Record.State != "prepared" || observed.ETag != in.ExpectedClaimETag || observed.Claim.Gen != in.ExpectedClaimGen {
+ return Observed{}, ErrConflict
+ }
+ return Update(ctx, s, observed, declareLostTransition(in, owner))
+}
+
+func declarationFromRecord(r LossRecord) LostDeclaration {
+ return LostDeclaration{
+ LossID: r.ID, ExpectedClaimGen: r.ExpectedClaimGen, ExpectedClaimETag: r.ExpectedClaimETag,
+ HolderIncarnation: r.HolderIncarnation, HolderStamp: r.HolderStamp, HolderSession: r.HolderSession,
+ OpID: r.OpID, OpKind: r.OpKind, OpPhase: r.OpPhase, Box: r.Box, Enrollment: r.Enrollment,
+ QuarantineRevision: r.QuarantineRevision, FencedAt: r.FencedAt, Consequences: r.ConfirmedConsequences,
+ }
+}
+
+// AdmitWriterWithLoss is the only production entry for a loss-bearing
+// admission. It binds target and source-cut tuples to the root-signed durable
+// declaration before the claim CAS. Direct AdmitWriter refuses these fields.
+type ValidatedWriteSource struct {
+ Reason string
+ Box string
+ Generation string
+ Session uint64
+ Region string
+ Baseline string
+ RemoteMaxTXID uint64
+}
+
+func AdmitWriterWithLoss(ctx context.Context, s release.Store, pub ed25519.PublicKey, observed Observed, in Admission, lossID string, validated []ValidatedWriteSource) (Observed, error) {
+ if observed.Absent || observed.Claim == nil || observed.Claim.Holder == nil || !lossRefPresent(observed.Claim, lossID) {
+ return Observed{}, ErrLossRecord
+ }
+ for _, prior := range observed.Claim.Holder.Admissions {
+ if prior.LossID == lossID {
+ if prior.Box == in.Box && prior.Mode == in.Mode && prior.Generation == in.NewGeneration && reflect.DeepEqual(prior.AdditionalWritesLost, in.AdditionalWritesLost) {
+ return observed, nil
+ }
+ return Observed{}, fmt.Errorf("%w: declaration id already used by another admission", ErrWrongState)
+ }
+ }
+ loss, err := ReadLossRecord(ctx, s, pub, lossID)
+ if err != nil {
+ return Observed{}, err
+ }
+ if loss.Record.State != "committed" || loss.Record.Host != observed.Host || loss.Record.HolderIncarnation != observed.Claim.Holder.Incarnation ||
+ loss.Record.OpID != observed.Claim.Op.ID || loss.Record.OpKind != observed.Claim.Op.Kind || loss.Record.OpPhase != observed.Claim.Op.Phase {
+ return Observed{}, ErrLossRecord
+ }
+ if in.Mode != "cold" || in.PredecessorCertified || in.Coverage != "" {
+ return Observed{}, fmt.Errorf("%w: a declared target loss requires an uncovered cold admission", ErrLossRecord)
+ }
+ h := observed.Claim.Holder
+ predecessor := AdmissionRecord{Session: h.Session, Box: h.WriterBox, Generation: h.Generation, Region: in.PredecessorRegion}
+ if len(h.Admissions) > 0 {
+ predecessor = h.Admissions[len(h.Admissions)-1]
+ }
+ want := map[string]WriteSource{}
+ for _, source := range loss.Record.WriteSources {
+ key := fmt.Sprintf("%s\x00%s\x00%d", source.Box, source.Generation, source.Session)
+ want[key] = source
+ }
+ targetKey := fmt.Sprintf("%s\x00%s\x00%d", predecessor.Box, predecessor.Generation, predecessor.Session)
+ if want[targetKey].Reason != "target" {
+ return Observed{}, fmt.Errorf("%w: predecessor is not the declared lost target", ErrLossRecord)
+ }
+ for _, additional := range in.AdditionalWritesLost {
+ key := fmt.Sprintf("%s\x00%s\x00%d", additional.Box, additional.Generation, additional.Session)
+ if want[key].Reason != "source-cut" {
+ return Observed{}, fmt.Errorf("%w: additional source tuple was not frozen by the declaration", ErrLossRecord)
+ }
+ }
+ provided := map[string]ValidatedWriteSource{}
+ for _, source := range validated {
+ key := fmt.Sprintf("%s\x00%s\x00%d", source.Box, source.Generation, source.Session)
+ if _, duplicate := provided[key]; duplicate {
+ return Observed{}, ErrLossRecord
+ }
+ provided[key] = source
+ }
+ if len(provided) != len(want) {
+ return Observed{}, fmt.Errorf("%w: admission omitted validated baseline evidence", ErrLossRecord)
+ }
+ for key, frozen := range want {
+ actual, ok := provided[key]
+ if !ok || actual.Reason != frozen.Reason || actual.Region != frozen.Region || actual.Baseline != frozen.Baseline {
+ return Observed{}, fmt.Errorf("%w: validated baseline differs from frozen source", ErrLossRecord)
+ }
+ if frozen.Reason == "target" {
+ if actual.RemoteMaxTXID != in.RemoteMaxTXID {
+ return Observed{}, fmt.Errorf("%w: target remote maximum differs from validated baseline", ErrLossRecord)
+ }
+ continue
+ }
+ matched := false
+ for _, additional := range in.AdditionalWritesLost {
+ if additional.Box == actual.Box && additional.Generation == actual.Generation && additional.Session == actual.Session && additional.RemoteMaxTXID == actual.RemoteMaxTXID {
+ matched = true
+ }
+ }
+ if !matched {
+ return Observed{}, fmt.Errorf("%w: source-cut remote maximum differs from validated baseline", ErrLossRecord)
+ }
+ }
+ in.LossID, in.lossVerified = lossID, true
+ return Update(ctx, s, observed, AdmitWriter(in))
+}
+
+func declareLostTransition(in LostDeclaration, owner LossOperationOwner) func(*Claim) error {
+ return func(c *Claim) error {
+ if owner == nil {
+ return ErrLossOwnerRequired
+ }
+ if !safeLossID(in.LossID) || in.ExpectedClaimGen == 0 || in.ExpectedClaimETag == "" || in.QuarantineRevision == 0 ||
+ in.Box == "" || in.Enrollment == "" || in.FencedAt.IsZero() || !validConsequences(in.Consequences) {
+ return ErrLossRecord
+ }
+ if c.Gen != in.ExpectedClaimGen || c.Holder == nil || c.Holder.Incarnation != in.HolderIncarnation ||
+ c.Holder.Stamp != in.HolderStamp || c.Holder.Session != in.HolderSession || c.Op.ID != in.OpID ||
+ c.Op.Kind != in.OpKind || c.Op.Phase != in.OpPhase {
+ return fmt.Errorf("%w: loss declaration no longer matches the holder and operation", ErrStaleOp)
+ }
+ if lossRefPresent(c, in.LossID) {
+ return ErrLossAlreadyApplied
+ }
+ copy, err := cloneClaim(c)
+ if err != nil {
+ return err
+ }
+ // The policy callback may inspect copies, never alter the signed effects.
+ ownerBytes, err := json.Marshal(in)
+ if err != nil {
+ return err
+ }
+ var ownerInput LostDeclaration
+ if err := json.Unmarshal(ownerBytes, &ownerInput); err != nil {
+ return err
+ }
+ if err := owner.ValidateDeclareLost(*copy, ownerInput); err != nil {
+ return err
+ }
+
+ isExec := c.Op.Exec == in.Box
+ isParticipant := stringPresent(c.Op.Participants, in.Box)
+ if isExec != in.Consequences.RevokeExecution || isParticipant != in.Consequences.RemoveParticipation {
+ return fmt.Errorf("%w: confirmed authority consequences do not match the claim", ErrWrongState)
+ }
+ stamp, session := c.Holder.Stamp, c.Holder.Session
+ intent, err := canonicalRaw(in.Consequences.RecoveryIntent)
+ if err != nil {
+ return ErrLossRecord
+ }
+ if c.Op.Journal == nil {
+ c.Op.Journal = map[string]string{}
+ }
+ journalKey := "loss." + in.LossID
+ if prior, exists := c.Op.Journal[journalKey]; exists && prior != intent {
+ return fmt.Errorf("%w: declaration recovery intent changed", ErrWrongState)
+ }
+ // Recovery intent is recorded before any authority or chore effects in
+ // this transition. Update makes the entire sequence one claim CAS.
+ c.Op.Journal[journalKey] = intent
+
+ if isExec {
+ c.Op.Exec = "none"
+ }
+ if isParticipant {
+ c.Op.Participants = removeString(c.Op.Participants, in.Box)
+ }
+ ownedRegional := map[string]bool{}
+ for _, o := range c.Obligations {
+ if (o.Kind == "park-replica" || o.Kind == "park-generation") && o.Owner == "box:"+in.Box {
+ ownedRegional[o.Op+"\x00"+o.ID+"\x00"+o.Retirement] = true
+ }
+ }
+ confirmedRegional := map[string]bool{}
+ for _, ref := range in.Consequences.ReassignRegional {
+ confirmedRegional[ref.Op+"\x00"+ref.ID+"\x00"+ref.Retirement] = true
+ }
+ if !reflect.DeepEqual(ownedRegional, confirmedRegional) {
+ return fmt.Errorf("%w: confirmed regional reassignments do not exactly cover lost-box work", ErrWrongState)
+ }
+ for _, ref := range in.Consequences.ReassignRegional {
+ o, ok := exactObligation(c, ref)
+ if !ok || (o.Kind != "park-replica" && o.Kind != "park-generation") || o.Region == "" || o.Owner != "box:"+in.Box {
+ return fmt.Errorf("%w: regional obligation %q changed", ErrWrongState, ref.ID)
+ }
+ o.Owner = "region:" + o.Region
+ }
+ for _, ref := range in.Consequences.DischargeLocal {
+ o, ok := exactObligation(c, ref)
+ if !ok || o.Kind != "park-local" || o.Box != in.Box || !destroyedStorageCovers(in, ref) {
+ return fmt.Errorf("%w: local obligation %q lacks exact destroyed-storage evidence", ErrWrongState, ref.ID)
+ }
+ if err := CompleteObligation(ref.Op, ref.ID)(c); err != nil {
+ return err
+ }
+ }
+ if c.Holder.Stamp != stamp || c.Holder.Session != session {
+ return fmt.Errorf("%w: declare-lost changed installation authority", ErrMalformed)
+ }
+ if !stringPresent(c.Lost, in.Box) {
+ c.Lost = append(c.Lost, in.Box)
+ }
+ if c.LostRevisions == nil {
+ c.LostRevisions = map[string]uint64{}
+ }
+ if old := c.LostRevisions[in.Box]; old != 0 && old != in.QuarantineRevision {
+ return fmt.Errorf("%w: box has a different quarantine revision", ErrWrongState)
+ }
+ c.LostRevisions[in.Box] = in.QuarantineRevision
+ c.LossRefs = append(c.LossRefs, in.LossID)
+ return nil
+ }
+}
+
+func destroyedStorageCovers(in LostDeclaration, ref ObligationRef) bool {
+ needle := ref.Op + "/" + ref.ID + "/" + ref.Retirement
+ for _, covered := range in.Consequences.DestroyedStorage {
+ if covered == needle {
+ return true
+ }
+ }
+ return false
+}
+
+func exactObligation(c *Claim, ref ObligationRef) (*Obligation, bool) {
+ for i := range c.Obligations {
+ o := &c.Obligations[i]
+ if o.ID == ref.ID && o.Op == ref.Op && o.Retirement == ref.Retirement {
+ return o, true
+ }
+ }
+ return nil, false
+}
+
+func cloneClaim(c *Claim) (*Claim, error) {
+ raw, err := json.Marshal(c)
+ if err != nil {
+ return nil, err
+ }
+ return decodeStrict(raw)
+}
+
+func canonicalRaw(raw json.RawMessage) (string, error) {
+ var value any
+ if err := json.Unmarshal(raw, &value); err != nil {
+ return "", err
+ }
+ canonical, err := json.Marshal(value)
+ return string(canonical), err
+}
+
+func lossRefPresent(c *Claim, id string) bool { return stringPresent(c.LossRefs, id) }
+
+func stringPresent(values []string, wanted string) bool {
+ for _, value := range values {
+ if value == wanted {
+ return true
+ }
+ }
+ return false
+}
+
+func removeString(values []string, unwanted string) []string {
+ out := values[:0]
+ for _, value := range values {
+ if value != unwanted {
+ out = append(out, value)
+ }
+ }
+ return out
+}
+
+func validRegionalOwner(owner, region string) bool {
+ if owner == "region:"+region {
+ return true
+ }
+ return len(owner) > len("box:") && owner[:len("box:")] == "box:" && validBoxReference(owner[len("box:"):])
+}
+
+// Readmit removes only the quarantine entry for the exact revision being
+// cleared. Durable declaration references intentionally survive.
+func Readmit(box string, quarantineRevision uint64) func(*Claim) error {
+ return func(c *Claim) error {
+ if box == "" || quarantineRevision == 0 || c.LostRevisions[box] != quarantineRevision || !stringPresent(c.Lost, box) {
+ return fmt.Errorf("%w: quarantine revision does not match", ErrWrongState)
+ }
+ c.Lost = removeString(c.Lost, box)
+ delete(c.LostRevisions, box)
+ if len(c.LostRevisions) == 0 {
+ c.LostRevisions = nil
+ }
+ return nil
+ }
+}
+
+func admissionHasLoss(a AdmissionRecord) bool {
+ return a.WritesLost != nil || len(a.AdditionalWritesLost) != 0
+}
+
+func admissionLossArchived(a AdmissionRecord) bool {
+ return !admissionHasLoss(a) || a.LossArchivedClaimGen == a.Session
+}
+
+// RequireLossEvidenceArchived is called by every holder-removal transition.
+func RequireLossEvidenceArchived(c *Claim) error {
+ if c.Holder == nil {
+ return nil
+ }
+ for _, a := range c.Holder.Admissions {
+ if !admissionLossArchived(a) {
+ return ErrLossEvidencePending
+ }
+ }
+ return nil
+}
+
+// ArchiveAdmissionLosses reads the root-signed committed record and marks only
+// entries whose exact declarations and admission claim generation are already
+// durable there. The pure transition is private so callers cannot fabricate
+// archive proof.
+func ArchiveAdmissionLosses(ctx context.Context, s release.Store, pub ed25519.PublicKey, observed Observed, lossID string) (Observed, error) {
+ loss, err := ReadLossRecord(ctx, s, pub, lossID)
+ if err != nil {
+ return Observed{}, err
+ }
+ if loss.Record.State != "committed" || loss.Record.Host != observed.Host {
+ return Observed{}, ErrLossEvidencePending
+ }
+ return Update(ctx, s, observed, archiveAdmissionLossesTransition(lossID, loss.Record.ArchivedAdmissionLosses))
+}
+
+func archiveAdmissionLossesTransition(lossID string, archived []ArchivedAdmissionLoss) func(*Claim) error {
+ return func(c *Claim) error {
+ if c.Holder == nil || !lossRefPresent(c, lossID) {
+ return ErrLossEvidencePending
+ }
+ byGen := map[uint64]ArchivedAdmissionLoss{}
+ for _, a := range archived {
+ if a.LossID != lossID || a.AdmissionClaimGen == 0 || !validWriteLosses(a.WritesLost, a.AdditionalWritesLost) {
+ return ErrLossEvidencePending
+ }
+ byGen[a.AdmissionClaimGen] = a
+ }
+ matched := false
+ for i := range c.Holder.Admissions {
+ a := &c.Holder.Admissions[i]
+ if a.LossID != lossID || !admissionHasLoss(*a) {
+ continue
+ }
+ copy, ok := byGen[a.Session]
+ if !ok || !reflect.DeepEqual(copy.WritesLost, a.WritesLost) || !reflect.DeepEqual(copy.AdditionalWritesLost, a.AdditionalWritesLost) {
+ return ErrLossEvidencePending
+ }
+ a.LossArchivedClaimGen = a.Session
+ matched = true
+ }
+ if !matched {
+ return ErrLossEvidencePending
+ }
+ return nil
+ }
+}
+```
+
+- [ ] **Create `internal/hostclaim/lost_test.go` with this complete content.**
+
+<!-- plan8:file internal/hostclaim/lost_test.go -->
+```go
+package hostclaim
+
+import (
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type testLossOwner struct {
+ err error
+ mutate bool
+}
+
+func (o testLossOwner) ValidateDeclareLost(c Claim, in LostDeclaration) error {
+ if o.mutate {
+ c.Holder.Stamp++
+ in.Consequences.RecoveryIntent[2] = 'X'
+ }
+ return o.err
+}
+
+func TestDeclareLostExactScopeAndReadmitRevision(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ obs := servingClaim(t, ctx, s)
+ obs, err := Update(ctx, s, obs, func(c *Claim) error {
+ c.Op = Op{Kind: KindMoving, ID: "op-move", Phase: "precommit", Exec: "box-a", Participants: []string{"box-a", "box-b"}}
+ c.Obligations = []Obligation{
+ {ID: "local", Op: "discover", Kind: "park-local", Box: "box-a", Retirement: "ret-local", Owner: "box:box-a"},
+ {ID: "remote", Op: "op-move", Kind: "park-replica", Region: "ie", Retirement: "ret-remote", Owner: "box:box-a"},
+ }
+ c.Touched.Complete, c.Touched.DiscoveryOp = false, "discover"
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ stamp, session := obs.Claim.Holder.Stamp, obs.Claim.Holder.Session
+ in := LostDeclaration{
+ LossID: "loss_1", ExpectedClaimGen: obs.Claim.Gen, ExpectedClaimETag: obs.ETag,
+ HolderIncarnation: obs.Claim.Holder.Incarnation, HolderStamp: stamp, HolderSession: session,
+ OpID: "op-move", OpKind: KindMoving, OpPhase: "precommit", Box: "box-a", Enrollment: "enroll-a",
+ QuarantineRevision: 7, FencedAt: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC),
+ Consequences: LossConsequences{
+ RevokeExecution: true, RemoveParticipation: true,
+ DischargeLocal: []ObligationRef{{ID: "local", Op: "discover", Retirement: "ret-local"}},
+ ReassignRegional: []ObligationRef{{ID: "remote", Op: "op-move", Retirement: "ret-remote"}},
+ DestroyedStorage: []string{"discover/local/ret-local"},
+ RecoveryIntent: []byte(`{"kind":"lost-target","destination":"owner-decides"}`),
+ },
+ }
+ pub, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ record := validLossRecord(t)
+ record.ID, record.Host, record.HolderIncarnation = in.LossID, obs.Host, in.HolderIncarnation
+ record.HolderStamp, record.HolderSession = stamp, session
+ record.ExpectedClaimGen, record.ExpectedClaimETag = in.ExpectedClaimGen, in.ExpectedClaimETag
+ record.OpID, record.OpKind, record.OpPhase = in.OpID, in.OpKind, in.OpPhase
+ record.Box, record.Enrollment, record.QuarantineRevision, record.FencedAt = in.Box, in.Enrollment, in.QuarantineRevision, in.FencedAt
+ record.ConfirmedConsequences = in.Consequences
+ record.OperatorAttestation.DestroyedStorage = append([]string(nil), in.Consequences.DestroyedStorage...)
+ if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record); err != nil {
+ t.Fatal(err)
+ }
+ got, err := DeclareLost(ctx, s, pub, obs, in.LossID, testLossOwner{mutate: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Claim.Op.Exec != "none" || len(got.Claim.Op.Participants) != 1 || got.Claim.Op.Participants[0] != "box-b" {
+ t.Fatalf("authority = exec %q participants %v", got.Claim.Op.Exec, got.Claim.Op.Participants)
+ }
+ if got.Claim.Holder.Stamp != stamp || got.Claim.Holder.Session != session {
+ t.Fatalf("installation authority changed: stamp/session %d/%d", got.Claim.Holder.Stamp, got.Claim.Holder.Session)
+ }
+ if len(got.Claim.Obligations) != 1 || got.Claim.Obligations[0].ID != "remote" || got.Claim.Obligations[0].Owner != "region:ie" ||
+ got.Claim.Obligations[0].Retirement != "ret-remote" || got.Claim.Obligations[0].Op != "op-move" {
+ t.Fatalf("obligations = %+v", got.Claim.Obligations)
+ }
+ if got.Claim.Touched.DiscoveryOp != "" || !got.Claim.Touched.Complete {
+ t.Fatalf("discovery completion stranded: %+v", got.Claim.Touched)
+ }
+ if got.Claim.LostRevisions["box-a"] != 7 || !stringPresent(got.Claim.Lost, "box-a") || !lossRefPresent(got.Claim, "loss_1") {
+ t.Fatalf("loss state = lost %v revisions %v refs %v", got.Claim.Lost, got.Claim.LostRevisions, got.Claim.LossRefs)
+ }
+ if got.Claim.Op.Journal["loss.loss_1"] != `{"destination":"owner-decides","kind":"lost-target"}` {
+ t.Fatalf("recovery intent = %q", got.Claim.Op.Journal["loss.loss_1"])
+ }
+
+ if retry, err := DeclareLost(ctx, s, pub, got, in.LossID, testLossOwner{}); err != nil || retry.ETag != got.ETag {
+ t.Fatalf("lost-response retry = %+v, %v", retry, err)
+ }
+ if _, err := Update(ctx, s, got, Readmit("box-a", 6)); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("old readmit revision = %v", err)
+ }
+ readmitted, err := Update(ctx, s, got, Readmit("box-a", 7))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stringPresent(readmitted.Claim.Lost, "box-a") || readmitted.Claim.LostRevisions["box-a"] != 0 || !lossRefPresent(readmitted.Claim, "loss_1") {
+ t.Fatalf("readmission state = lost %v revisions %v refs %v", readmitted.Claim.Lost, readmitted.Claim.LostRevisions, readmitted.Claim.LossRefs)
+ }
+}
+
+func TestDeclareLostFailsClosedWithoutOperationOwnerOrExactDiskProof(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ obs := servingClaim(t, ctx, s)
+ obs, err := Update(ctx, s, obs, func(c *Claim) error {
+ c.Op = Op{Kind: KindDeleting, ID: "delete", Phase: "parking", Exec: "box-a", Participants: []string{"box-a"}}
+ c.Obligations = []Obligation{{ID: "local", Op: "delete", Kind: "park-local", Box: "box-a", Retirement: "ret", Owner: "box:box-a"}}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ in := LostDeclaration{LossID: "loss", ExpectedClaimGen: obs.Claim.Gen, ExpectedClaimETag: obs.ETag,
+ HolderIncarnation: obs.Claim.Holder.Incarnation, HolderStamp: obs.Claim.Holder.Stamp, HolderSession: obs.Claim.Holder.Session,
+ OpID: "delete", OpKind: KindDeleting, OpPhase: "parking", Box: "box-a", Enrollment: "e", QuarantineRevision: 1, FencedAt: time.Now(),
+ Consequences: LossConsequences{RevokeExecution: true, RemoveParticipation: true, DischargeLocal: []ObligationRef{{ID: "local", Op: "delete", Retirement: "ret"}}, RecoveryIntent: []byte(`{"kind":"local-only"}`)}}
+ pub, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ record := validLossRecord(t)
+ record.ID, record.Host, record.HolderIncarnation = in.LossID, obs.Host, in.HolderIncarnation
+ record.HolderStamp, record.HolderSession = in.HolderStamp, in.HolderSession
+ record.ExpectedClaimGen, record.ExpectedClaimETag = in.ExpectedClaimGen, in.ExpectedClaimETag
+ record.OpID, record.OpKind, record.OpPhase = in.OpID, in.OpKind, in.OpPhase
+ record.Box, record.Enrollment, record.QuarantineRevision, record.FencedAt = in.Box, in.Enrollment, in.QuarantineRevision, in.FencedAt
+ record.ConfirmedConsequences = in.Consequences
+ record.OperatorAttestation.DestroyedStorage = append([]string(nil), in.Consequences.DestroyedStorage...)
+ if _, err := CreateLossRecord(ctx, s, release.RootSigner(priv), record); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := DeclareLost(ctx, s, pub, obs, in.LossID, nil); !errors.Is(err, ErrLossOwnerRequired) {
+ t.Fatalf("nil owner = %v", err)
+ }
+ if _, err := DeclareLost(ctx, s, pub, obs, in.LossID, testLossOwner{}); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("missing disk assertion = %v", err)
+ }
+ denied := errors.New("move record already committed")
+ if _, err := DeclareLost(ctx, s, pub, obs, in.LossID, testLossOwner{err: denied}); !errors.Is(err, denied) {
+ t.Fatalf("owner denial = %v", err)
+ }
+ fresh, err := Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fresh.Claim.Gen != obs.Claim.Gen || len(fresh.Claim.Lost) != 0 || len(fresh.Claim.LossRefs) != 0 {
+ t.Fatalf("refusal mutated claim: %+v", fresh.Claim)
+ }
+}
+```
+
+- [ ] **Run the task tests.**
+
+```bash
+go test ./internal/hostclaim -race -count=1
+go test ./internal/release -run RootEnvelope -count=1
+```
+
+## Task 2: Quarantine interface, retirement receipt and exact compute observation
+
+**Produces:** `ActorDirectory`, a fresh admission gate, the returning-machine completion type, the read-only EC2 verifier and signed challenge types. **Consumes:** Task 1’s existing-envelope helpers and the existing `awsinv` EC2 read seam. The tests supply the only directory implementation. The challenge functions authenticate and validate the bound data; the live console still cannot issue or accept a declaration.
+
+- [ ] **Create `internal/hostquarantine/quarantine.go` with this complete content.**
+
+<!-- plan8:file internal/hostquarantine/quarantine.go -->
+```go
+// Package hostquarantine is the deployment-wide admission gate for enrolled
+// boxes. The authoritative directory implementation is deliberately outside
+// this package; callers supply an exact-object reader and every Check performs
+// a fresh lookup.
+package hostquarantine
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ StateActive = "active"
+ StateQuarantined = "quarantined"
+ StateRetired = "retired"
+)
+
+var (
+ ErrAdmission = errors.New("hostquarantine: admission refused")
+ ErrRecord = errors.New("hostquarantine: malformed retirement completion")
+)
+
+type Actor struct {
+ Enrollment string `json:"enrollment"`
+ State string `json:"state"`
+ QuarantineRevision uint64 `json:"quarantine_revision"`
+}
+
+// ActorDirectory reads one exact actor from the root-signed deployment
+// directory. Implementations must return an error for missing, unreadable or
+// unverifiable authority.
+type ActorDirectory interface {
+ Lookup(context.Context, string) (Actor, error)
+}
+
+// Check admits only the exact active enrollment returned by a fresh lookup.
+// Missing actors, unreadable authority, identity mismatches, quarantine,
+// retirement and unknown states all fail closed.
+func Check(ctx context.Context, dir ActorDirectory, box, enrollment string) error {
+ if dir == nil || box == "" || enrollment == "" {
+ return fmt.Errorf("%w: actor identity or directory is absent", ErrAdmission)
+ }
+ a, err := dir.Lookup(ctx, box)
+ if err != nil {
+ return fmt.Errorf("%w: actor directory lookup: %v", ErrAdmission, err)
+ }
+ if a.Enrollment == "" || a.Enrollment != enrollment {
+ return fmt.Errorf("%w: enrollment mismatch", ErrAdmission)
+ }
+ if a.State != StateActive {
+ return fmt.Errorf("%w: actor state %q", ErrAdmission, a.State)
+ }
+ return nil
+}
+
+type RetirementEntry struct {
+ Host string `json:"host"`
+ Incarnation string `json:"incarnation"`
+ Stamp uint64 `json:"stamp"`
+ RetirementID string `json:"retirement_id"`
+ JournalSHA256 string `json:"journal_sha256"`
+}
+
+// RetirementCompletion is the returning machine's durable retirement
+// receipt. Readmission also requires the matching fresh private box report;
+// this object alone does not grant admission.
+type RetirementCompletion struct {
+ V int `json:"v"`
+ Box string `json:"box"`
+ Enrollment string `json:"enrollment"`
+ InstanceID string `json:"instance_id"`
+ QuarantineRevision uint64 `json:"quarantine_revision"`
+ BootID string `json:"boot_id"`
+ CompletedAt time.Time `json:"completed_at"`
+ Complete bool `json:"complete"`
+ InventorySHA256 string `json:"inventory_sha256"`
+ Entries []RetirementEntry `json:"entries"`
+}
+
+var sha256RE = regexp.MustCompile(`^[0-9a-f]{64}$`)
+
+func safeSegment(s string) bool {
+ return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\")
+}
+
+func RetirementKey(box, enrollment string, revision uint64) (string, error) {
+ if !safeSegment(box) || !safeSegment(enrollment) || revision == 0 {
+ return "", fmt.Errorf("%w: invalid key identity", ErrRecord)
+ }
+ return "control/host-retirement/" + box + "/" + enrollment + "/" + strconv.FormatUint(revision, 10) + ".json", nil
+}
+
+func (r RetirementCompletion) Validate() error {
+ if r.V != 1 || !safeSegment(r.Box) || !safeSegment(r.Enrollment) || r.InstanceID == "" ||
+ r.QuarantineRevision == 0 || r.BootID == "" {
+ return fmt.Errorf("%w: invalid header", ErrRecord)
+ }
+ if r.Complete {
+ if r.CompletedAt.IsZero() || !sha256RE.MatchString(r.InventorySHA256) || r.Entries == nil {
+ return fmt.Errorf("%w: completed receipt lacks durable inventory evidence", ErrRecord)
+ }
+ } else if !r.CompletedAt.IsZero() {
+ return fmt.Errorf("%w: incomplete receipt has completed_at", ErrRecord)
+ }
+ for i, e := range r.Entries {
+ if e.Host == "" || e.Incarnation == "" || e.Stamp == 0 || e.RetirementID == "" || !sha256RE.MatchString(e.JournalSHA256) {
+ return fmt.Errorf("%w: invalid entry %d", ErrRecord, i)
+ }
+ }
+ return nil
+}
+```
+
+- [ ] **Create `internal/hostquarantine/quarantine_test.go` with this complete content.**
+
+<!-- plan8:file internal/hostquarantine/quarantine_test.go -->
+```go
+package hostquarantine
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+ "time"
+)
+
+type fakeDirectory struct {
+ actor Actor
+ err error
+ calls int
+}
+
+func (f *fakeDirectory) Lookup(_ context.Context, _ string) (Actor, error) {
+ f.calls++
+ return f.actor, f.err
+}
+
+func TestCheckRequiresFreshExactActiveEnrollment(t *testing.T) {
+ d := &fakeDirectory{actor: Actor{Enrollment: "enroll-1", State: StateActive, QuarantineRevision: 3}}
+ if err := Check(t.Context(), d, "box-1", "enroll-1"); err != nil {
+ t.Fatal(err)
+ }
+ d.actor.State = StateQuarantined
+ if err := Check(t.Context(), d, "box-1", "enroll-1"); !errors.Is(err, ErrAdmission) {
+ t.Fatalf("quarantine error = %v", err)
+ }
+ if d.calls != 2 {
+ t.Fatalf("directory calls = %d, want a fresh lookup per check", d.calls)
+ }
+}
+
+func TestCheckFailsClosed(t *testing.T) {
+ cases := map[string]*fakeDirectory{
+ "missing": {err: os.ErrNotExist},
+ "unreadable": {err: errors.New("signature refused")},
+ "mismatch": {actor: Actor{Enrollment: "other", State: StateActive}},
+ "retired": {actor: Actor{Enrollment: "enroll-1", State: StateRetired}},
+ "unknown": {actor: Actor{Enrollment: "enroll-1", State: "paused"}},
+ }
+ for name, d := range cases {
+ t.Run(name, func(t *testing.T) {
+ if err := Check(t.Context(), d, "box-1", "enroll-1"); !errors.Is(err, ErrAdmission) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+ }
+}
+
+func TestRetirementCompletionKeyAndValidation(t *testing.T) {
+ key, err := RetirementKey("box-1", "enroll-1", 7)
+ if err != nil || key != "control/host-retirement/box-1/enroll-1/7.json" {
+ t.Fatalf("key = %q, %v", key, err)
+ }
+ hash := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+ r := RetirementCompletion{
+ V: 1, Box: "box-1", Enrollment: "enroll-1", InstanceID: "i-123", QuarantineRevision: 7,
+ BootID: "boot-1", CompletedAt: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC), Complete: true,
+ InventorySHA256: hash,
+ Entries: []RetirementEntry{{Host: "app.example.com", Incarnation: "inc-1", Stamp: 8, RetirementID: "retire-1", JournalSHA256: hash}},
+ }
+ if err := r.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ r.Entries = nil
+ if err := r.Validate(); !errors.Is(err, ErrRecord) {
+ t.Fatalf("nil entries error = %v", err)
+ }
+ r.Entries = []RetirementEntry{{Host: "app.example.com", Incarnation: "inc-1", Stamp: 8, RetirementID: "retire-1", JournalSHA256: hash}}
+ r.Entries[0].JournalSHA256 = ""
+ if err := r.Validate(); !errors.Is(err, ErrRecord) {
+ t.Fatalf("error = %v", err)
+ }
+}
+```
+
+- [ ] **Create `internal/awsinv/exact_instance.go` with this complete content.**
+
+<!-- plan8:file internal/awsinv/exact_instance.go -->
+```go
+package awsinv
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/ec2"
+)
+
+// ErrExactInstance marks every result that fails to prove that the exact
+// enrolled EC2 instance is stopped or terminated. Callers must treat it as a
+// refusal, including when AWS says that the instance does not exist.
+var ErrExactInstance = errors.New("awsinv: exact EC2 instance is not fenced")
+
+// ExactInstance is the complete observation accepted by the declare-lost
+// client. Account comes from Reservation.OwnerId, not from a request field or
+// the caller's default profile.
+type ExactInstance struct {
+ Account string `json:"account"`
+ Region string `json:"region"`
+ InstanceID string `json:"instance_id"`
+ State string `json:"state"`
+}
+
+// ExactEC2API is the DescribeInstances slice of EC2API. The production EC2
+// client and every existing awsinv EC2 fake satisfy it; keeping the verifier
+// on this one-method view makes a write-capable client unnecessary.
+type ExactEC2API interface {
+ DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error)
+}
+
+// NewEC2Reader resolves the operator's ordinary AWS credential chain and
+// returns the existing read-only EC2 seam in the enrolled region. It grants
+// no stop, terminate or restart operation.
+func NewEC2Reader(ctx context.Context, region string) (ExactEC2API, error) {
+ if strings.TrimSpace(region) == "" {
+ return nil, fmt.Errorf("%w: enrolled region is empty", ErrExactInstance)
+ }
+ cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
+ if err != nil {
+ return nil, fmt.Errorf("%w: AWS config: %v", ErrExactInstance, err)
+ }
+ probeCtx, cancel := context.WithTimeout(ctx, credentialProbe)
+ defer cancel()
+ if _, err := cfg.Credentials.Retrieve(probeCtx); err != nil {
+ return nil, fmt.Errorf("%w: no usable AWS credential: %v", ErrExactInstance, err)
+ }
+ return factoryFor(cfg)(region).EC2, nil
+}
+
+// VerifyExactStoppedInstance makes one DescribeInstances call constrained to
+// the exact enrolled ID. Any API error, pagination, missing field, duplicate
+// result, owner mismatch or live state refuses; InvalidInstanceID.NotFound is
+// therefore never treated as termination evidence.
+func VerifyExactStoppedInstance(ctx context.Context, api ExactEC2API, account, region, instanceID string) (ExactInstance, error) {
+ wantAccount := strings.TrimSpace(account)
+ wantRegion := strings.TrimSpace(region)
+ wantID := strings.TrimSpace(instanceID)
+ if api == nil || wantAccount == "" || wantRegion == "" || wantID == "" {
+ return ExactInstance{}, fmt.Errorf("%w: incomplete enrolled cloud binding", ErrExactInstance)
+ }
+ out, err := api.DescribeInstances(ctx, &ec2.DescribeInstancesInput{InstanceIds: []string{wantID}}, func(o *ec2.Options) {
+ o.Region = wantRegion
+ })
+ if err != nil {
+ return ExactInstance{}, fmt.Errorf("%w: DescribeInstances %s in %s/%s: %v", ErrExactInstance, wantID, wantAccount, wantRegion, err)
+ }
+ if out == nil || aws.ToString(out.NextToken) != "" || len(out.Reservations) != 1 {
+ return ExactInstance{}, fmt.Errorf("%w: DescribeInstances returned a partial or ambiguous result", ErrExactInstance)
+ }
+ r := out.Reservations[0]
+ owner := aws.ToString(r.OwnerId)
+ if owner == "" || owner != wantAccount || len(r.Instances) != 1 {
+ return ExactInstance{}, fmt.Errorf("%w: reservation owner or instance count does not match enrollment", ErrExactInstance)
+ }
+ in := r.Instances[0]
+ gotID := aws.ToString(in.InstanceId)
+ if gotID == "" || gotID != wantID || in.State == nil {
+ return ExactInstance{}, fmt.Errorf("%w: returned instance does not exactly match enrollment", ErrExactInstance)
+ }
+ state := string(in.State.Name)
+ if state != "stopped" && state != "terminated" {
+ return ExactInstance{}, fmt.Errorf("%w: instance %s is %q", ErrExactInstance, wantID, state)
+ }
+ return ExactInstance{Account: owner, Region: wantRegion, InstanceID: gotID, State: state}, nil
+}
+```
+
+- [ ] **Create `internal/awsinv/exact_instance_test.go` with this complete content.**
+
+<!-- plan8:file internal/awsinv/exact_instance_test.go -->
+```go
+package awsinv
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/ec2"
+ "github.com/aws/aws-sdk-go-v2/service/ec2/types"
+)
+
+type exactEC2 struct {
+ out *ec2.DescribeInstancesOutput
+ err error
+ in *ec2.DescribeInstancesInput
+ region string
+}
+
+func (f *exactEC2) DescribeInstances(_ context.Context, in *ec2.DescribeInstancesInput, options ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) {
+ f.in = in
+ var opts ec2.Options
+ for _, option := range options {
+ option(&opts)
+ }
+ f.region = opts.Region
+ return f.out, f.err
+}
+
+func exactOutput(owner, id string, state types.InstanceStateName) *ec2.DescribeInstancesOutput {
+ return &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{
+ OwnerId: aws.String(owner),
+ Instances: []types.Instance{{InstanceId: aws.String(id), State: &types.InstanceState{Name: state}}},
+ }}}
+}
+
+func TestVerifyExactStoppedInstanceAcceptsOnlyStoppedOrTerminated(t *testing.T) {
+ for _, state := range []types.InstanceStateName{types.InstanceStateNameStopped, types.InstanceStateNameTerminated} {
+ t.Run(string(state), func(t *testing.T) {
+ api := &exactEC2{out: exactOutput("123456789012", "i-123", state)}
+ got, err := VerifyExactStoppedInstance(t.Context(), api, "123456789012", "eu-west-1", "i-123")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Account != "123456789012" || got.Region != "eu-west-1" || got.InstanceID != "i-123" || got.State != string(state) {
+ t.Fatalf("observation = %+v", got)
+ }
+ if len(api.in.InstanceIds) != 1 || api.in.InstanceIds[0] != "i-123" {
+ t.Fatalf("DescribeInstances input = %+v", api.in)
+ }
+ if api.region != "eu-west-1" {
+ t.Fatalf("DescribeInstances region = %q", api.region)
+ }
+ })
+ }
+}
+
+func TestVerifyExactStoppedInstanceRefusesEveryIncompleteOrLiveAnswer(t *testing.T) {
+ notFound := errors.New("InvalidInstanceID.NotFound")
+ cases := map[string]*exactEC2{
+ "not-found": {err: notFound},
+ "nil": {},
+ "no-reservation": {out: &ec2.DescribeInstancesOutput{}},
+ "partial-page": {out: func() *ec2.DescribeInstancesOutput {
+ o := exactOutput("123456789012", "i-123", types.InstanceStateNameStopped)
+ o.NextToken = aws.String("more")
+ return o
+ }()},
+ "wrong-owner": {out: exactOutput("999999999999", "i-123", types.InstanceStateNameStopped)},
+ "missing-owner": {out: exactOutput("", "i-123", types.InstanceStateNameStopped)},
+ "wrong-instance": {out: exactOutput("123456789012", "i-other", types.InstanceStateNameStopped)},
+ "running": {out: exactOutput("123456789012", "i-123", types.InstanceStateNameRunning)},
+ "missing-state": {out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{OwnerId: aws.String("123456789012"), Instances: []types.Instance{{InstanceId: aws.String("i-123")}}}}}},
+ "two-reservations": {out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{OwnerId: aws.String("123456789012")}, {OwnerId: aws.String("123456789012")}}}},
+ "two-instances": {out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{OwnerId: aws.String("123456789012"), Instances: []types.Instance{{InstanceId: aws.String("i-123")}, {InstanceId: aws.String("i-123")}}}}}},
+ }
+ for name, api := range cases {
+ t.Run(name, func(t *testing.T) {
+ if _, err := VerifyExactStoppedInstance(t.Context(), api, "123456789012", "eu-west-1", "i-123"); !errors.Is(err, ErrExactInstance) {
+ t.Fatalf("error = %v, want ErrExactInstance", err)
+ }
+ })
+ }
+}
+```
+
+- [ ] **Create `internal/hostfence/challenge.go` with this complete content.**
+
+<!-- plan8:file internal/hostfence/challenge.go -->
+```go
+// Package hostfence defines the operator-bound evidence used by a host loss
+// declaration. It does not stop or terminate compute and it does not mutate a
+// host claim.
+package hostfence
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/carlosframework/platform/internal/awsinv"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const (
+ ChallengeTTL = 120 * time.Second
+ ObservationMaxAge = 60 * time.Second
+ ObservationMaxSkew = 5 * time.Second
+)
+
+var (
+ ErrChallenge = errors.New("hostfence: challenge refused")
+ ErrObservation = errors.New("hostfence: cloud observation refused")
+)
+
+type CloudBinding struct {
+ Provider string `json:"provider"`
+ Account string `json:"account"`
+ Region string `json:"region"`
+ InstanceID string `json:"instance_id"`
+}
+
+type Operation struct {
+ ID string `json:"id"`
+ Kind string `json:"kind"`
+ Phase string `json:"phase"`
+}
+
+type RecordObservation struct {
+ Key string `json:"key"`
+ ETag string `json:"etag"`
+ CommitState string `json:"commit_state"`
+}
+
+// Consequences is signed by the console so the confirmation cannot be
+// replayed for a different transition or a different set of claimed losses.
+type Consequences struct {
+ Transition string `json:"transition"`
+ Chores []string `json:"chores,omitempty"`
+ PossibleWriteLoss bool `json:"possible_write_loss,omitempty"`
+ PossibleTimerLoss bool `json:"possible_timer_loss,omitempty"`
+ DestroyedStorageAssertion []string `json:"destroyed_storage_assertion,omitempty"`
+}
+
+// Challenge is the console's exact, short-lived snapshot of the transition
+// the operator inspected. Its signed envelope is submitted unchanged.
+type Challenge struct {
+ ID string `json:"id"`
+ Actor string `json:"actor"`
+ IssuedAt time.Time `json:"issued_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+ Host string `json:"host"`
+ HolderIncarnation string `json:"holder_incarnation"`
+ HolderStamp uint64 `json:"holder_stamp"`
+ HolderSession uint64 `json:"holder_session"`
+ ClaimGen uint64 `json:"claim_gen"`
+ ClaimETag string `json:"claim_etag"`
+ Op Operation `json:"op"`
+ Record RecordObservation `json:"record"`
+ Box string `json:"box"`
+ Enrollment string `json:"enrollment"`
+ Cloud CloudBinding `json:"cloud"`
+ Consequences Consequences `json:"consequences"`
+}
+
+type CloudObservation struct {
+ ObservedAt time.Time `json:"observed_at"`
+ Account string `json:"account"`
+ Region string `json:"region"`
+ InstanceID string `json:"instance_id"`
+ State string `json:"state"`
+}
+
+type OperatorAttestation struct {
+ MaintainComputeFence bool `json:"maintain_compute_fence"`
+ ColdQuarantineStartOnly bool `json:"cold_quarantine_start_only"`
+ Disks []string `json:"disks,omitempty"`
+}
+
+type Submission struct {
+ Challenge json.RawMessage `json:"challenge"`
+ Observation CloudObservation `json:"observation"`
+ RequestID string `json:"request_id"`
+ Attestation OperatorAttestation `json:"attestation"`
+ Confirmation string `json:"confirmation"`
+}
+
+// PrepareChallenge supplies the one permitted lifetime. The caller fills the
+// exact claim, record, actor, enrollment and consequence bindings first.
+func PrepareChallenge(now time.Time, c Challenge) (Challenge, error) {
+ c.IssuedAt = now.UTC()
+ c.ExpiresAt = c.IssuedAt.Add(ChallengeTTL)
+ if err := validateChallenge(c); err != nil {
+ return Challenge{}, err
+ }
+ return c, nil
+}
+
+func validateChallenge(c Challenge) error {
+ if c.ID == "" || c.Actor == "" || c.Host == "" || c.HolderIncarnation == "" ||
+ c.ClaimGen == 0 || c.ClaimETag == "" || c.Op.ID == "" || c.Op.Kind == "" ||
+ c.Record.Key == "" || c.Record.CommitState == "" || c.Box == "" || c.Enrollment == "" ||
+ c.Cloud.Provider != "aws" || c.Cloud.Account == "" || c.Cloud.Region == "" || c.Cloud.InstanceID == "" ||
+ c.Consequences.Transition == "" || c.IssuedAt.IsZero() || c.ExpiresAt.IsZero() ||
+ !c.ExpiresAt.Equal(c.IssuedAt.Add(ChallengeTTL)) {
+ return fmt.Errorf("%w: incomplete or incorrectly timed challenge", ErrChallenge)
+ }
+ if (c.Record.CommitState == "absent") != (c.Record.ETag == "") {
+ return fmt.Errorf("%w: record absence and ETag are inconsistent", ErrChallenge)
+ }
+ return nil
+}
+
+// SignChallenge uses the existing root release signer and its existing
+// payload envelope. A scoped release signer is rejected by that primitive.
+func SignChallenge(sg release.Signer, c Challenge) ([]byte, error) {
+ if err := validateChallenge(c); err != nil {
+ return nil, err
+ }
+ wire, err := release.SignRootEnvelope(sg, c)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrChallenge, err)
+ }
+ return wire, nil
+}
+
+// VerifyChallenge authenticates and decodes the envelope before checking its
+// lifetime. A challenge at its expiry instant is expired.
+func VerifyChallenge(pub ed25519.PublicKey, wire []byte, now time.Time) (Challenge, error) {
+ var c Challenge
+ if err := release.VerifyRootEnvelope(pub, wire, &c); err != nil {
+ return Challenge{}, fmt.Errorf("%w: %v", ErrChallenge, err)
+ }
+ if err := validateChallenge(c); err != nil {
+ return Challenge{}, err
+ }
+ now = now.UTC()
+ if c.IssuedAt.After(now.Add(ObservationMaxSkew)) || !now.Before(c.ExpiresAt) {
+ return Challenge{}, fmt.Errorf("%w: expired or future challenge", ErrChallenge)
+ }
+ return c, nil
+}
+
+// ObserveForChallenge is the CLI-side verifier. It asks AWS for the exact
+// enrolled instance after inspection and returns only an accepted stopped or
+// terminated observation. It never makes a cloud write call.
+func ObserveForChallenge(ctx context.Context, api awsinv.ExactEC2API, c Challenge, now func() time.Time) (CloudObservation, error) {
+ if err := validateChallenge(c); err != nil {
+ return CloudObservation{}, err
+ }
+ if now == nil {
+ return CloudObservation{}, fmt.Errorf("%w: no observation clock", ErrObservation)
+ }
+ started := now().UTC()
+ if !started.After(c.IssuedAt) {
+ return CloudObservation{}, fmt.Errorf("%w: observation did not begin after challenge issuance", ErrObservation)
+ }
+ got, err := awsinv.VerifyExactStoppedInstance(ctx, api, c.Cloud.Account, c.Cloud.Region, c.Cloud.InstanceID)
+ if err != nil {
+ return CloudObservation{}, fmt.Errorf("%w: %v", ErrObservation, err)
+ }
+ consumedAt := now().UTC()
+ // observed_at is the conservative request start, not the response time:
+ // a DescribeInstances call blocked for a minute cannot make an old read
+ // appear fresh merely by returning now.
+ o := CloudObservation{ObservedAt: started, Account: got.Account, Region: got.Region, InstanceID: got.InstanceID, State: got.State}
+ if err := ValidateObservation(c, o, consumedAt); err != nil {
+ return CloudObservation{}, err
+ }
+ return o, nil
+}
+
+// ValidateObservation is the console's binding and freshness check. It does
+// not claim that AWS signed the observation: it validates the authenticated
+// operator assertion carried by the submission.
+func ValidateObservation(c Challenge, o CloudObservation, now time.Time) error {
+ now = now.UTC()
+ if err := validateChallenge(c); err != nil {
+ return err
+ }
+ if o.Account != c.Cloud.Account || o.Region != c.Cloud.Region || o.InstanceID != c.Cloud.InstanceID ||
+ (o.State != "stopped" && o.State != "terminated") {
+ return fmt.Errorf("%w: cloud identity or state does not match the challenge", ErrObservation)
+ }
+ if !o.ObservedAt.After(c.IssuedAt) || o.ObservedAt.After(now.Add(ObservationMaxSkew)) || now.Sub(o.ObservedAt) > ObservationMaxAge {
+ return fmt.Errorf("%w: observation is stale, predates inspection, or is too far in the future", ErrObservation)
+ }
+ if !now.Before(c.ExpiresAt) {
+ return fmt.Errorf("%w: challenge expired", ErrObservation)
+ }
+ return nil
+}
+```
+
+- [ ] **Create `internal/hostfence/challenge_test.go` with this complete content.**
+
+<!-- plan8:file internal/hostfence/challenge_test.go -->
+```go
+package hostfence
+
+import (
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/ec2"
+ "github.com/aws/aws-sdk-go-v2/service/ec2/types"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type ec2Answer struct {
+ out *ec2.DescribeInstancesOutput
+ err error
+}
+
+func (f ec2Answer) DescribeInstances(_ context.Context, _ *ec2.DescribeInstancesInput, _ ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) {
+ return f.out, f.err
+}
+
+func challengeFixture(t *testing.T, now time.Time) Challenge {
+ t.Helper()
+ c, err := PrepareChallenge(now, Challenge{
+ ID: "loss-1", Actor: "operator@example.com", Host: "app.example.com",
+ HolderIncarnation: "inc-1", HolderStamp: 7, HolderSession: 8,
+ ClaimGen: 9, ClaimETag: "etag-claim",
+ Op: Operation{ID: "op-1", Kind: "moving", Phase: "copying"},
+ Record: RecordObservation{Key: "apps/bab/app/instances/app.example.com.json", ETag: "etag-record", CommitState: "committed"},
+ Box: "box-1", Enrollment: "0123456789abcdef0123456789abcdef",
+ Cloud: CloudBinding{Provider: "aws", Account: "123456789012", Region: "eu-west-1", InstanceID: "i-123"},
+ Consequences: Consequences{Transition: "declare-lost", Chores: []string{"park-local/1"}, PossibleWriteLoss: true},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return c
+}
+
+func TestChallengeRootEnvelopeAndLifetime(t *testing.T) {
+ pub, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
+ c := challengeFixture(t, now)
+ wire, err := SignChallenge(release.RootSigner(priv), c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := VerifyChallenge(pub, wire, now.Add(119*time.Second))
+ if err != nil || got.ID != c.ID || got.Cloud != c.Cloud {
+ t.Fatalf("verify = %+v, %v", got, err)
+ }
+ if _, err := VerifyChallenge(pub, wire, c.ExpiresAt); !errors.Is(err, ErrChallenge) {
+ t.Fatalf("expiry error = %v", err)
+ }
+ wire[len(wire)/2] ^= 1
+ if _, err := VerifyChallenge(pub, wire, now); !errors.Is(err, ErrChallenge) {
+ t.Fatalf("tamper error = %v", err)
+ }
+}
+
+func TestChallengeModelsPresentAndAbsentRecordObservations(t *testing.T) {
+ now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
+ _, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := challengeFixture(t, now)
+ c.HolderStamp, c.HolderSession = 0, 0
+ if _, err := SignChallenge(release.RootSigner(priv), c); err != nil {
+ t.Fatalf("explicit zero holder binding refused: %v", err)
+ }
+ c.Record = RecordObservation{Key: c.Record.Key, CommitState: "absent"}
+ if _, err := SignChallenge(release.RootSigner(priv), c); err != nil {
+ t.Fatalf("explicit absent record refused: %v", err)
+ }
+ for name, breakIt := range map[string]func(*Challenge){
+ "present-without-etag": func(c *Challenge) { c.Record.ETag = "" },
+ "absent-with-etag": func(c *Challenge) { c.Record.CommitState = "absent" },
+ } {
+ t.Run(name, func(t *testing.T) {
+ c := challengeFixture(t, now)
+ breakIt(&c)
+ if _, err := SignChallenge(release.RootSigner(priv), c); !errors.Is(err, ErrChallenge) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+ }
+}
+
+func TestObserveForChallengeBindsExactStoppedInstance(t *testing.T) {
+ issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
+ c := challengeFixture(t, issued)
+ api := ec2Answer{out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{
+ OwnerId: aws.String(c.Cloud.Account), Instances: []types.Instance{{InstanceId: aws.String(c.Cloud.InstanceID), State: &types.InstanceState{Name: types.InstanceStateNameStopped}}},
+ }}}}
+ times := []time.Time{issued.Add(time.Second), issued.Add(2 * time.Second)}
+ i := 0
+ o, err := ObserveForChallenge(t.Context(), api, c, func() time.Time { v := times[i]; i++; return v })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if o.State != "stopped" || o.ObservedAt != times[0] {
+ t.Fatalf("observation = %+v", o)
+ }
+}
+
+func TestObserveForChallengeRefusesAReadThatBlockedPastFreshness(t *testing.T) {
+ issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
+ c := challengeFixture(t, issued)
+ api := ec2Answer{out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{
+ OwnerId: aws.String(c.Cloud.Account), Instances: []types.Instance{{InstanceId: aws.String(c.Cloud.InstanceID), State: &types.InstanceState{Name: types.InstanceStateNameStopped}}},
+ }}}}
+ times := []time.Time{issued.Add(time.Second), issued.Add(62 * time.Second)}
+ i := 0
+ if _, err := ObserveForChallenge(t.Context(), api, c, func() time.Time { v := times[i]; i++; return v }); !errors.Is(err, ErrObservation) {
+ t.Fatalf("error = %v", err)
+ }
+}
+
+func TestValidateObservationFreshnessAndBinding(t *testing.T) {
+ issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
+ c := challengeFixture(t, issued)
+ valid := CloudObservation{ObservedAt: issued.Add(10 * time.Second), Account: c.Cloud.Account, Region: c.Cloud.Region, InstanceID: c.Cloud.InstanceID, State: "terminated"}
+ if err := ValidateObservation(c, valid, issued.Add(60*time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ checks := []CloudObservation{
+ func() CloudObservation { o := valid; o.ObservedAt = issued; return o }(),
+ func() CloudObservation { o := valid; o.ObservedAt = issued.Add(11 * time.Second); return o }(),
+ func() CloudObservation { o := valid; o.Account = "999999999999"; return o }(),
+ func() CloudObservation { o := valid; o.State = "running"; return o }(),
+ }
+ for i, o := range checks {
+ now := issued.Add(70 * time.Second)
+ if i == 1 {
+ now = issued.Add(5 * time.Second)
+ }
+ if err := ValidateObservation(c, o, now); !errors.Is(err, ErrObservation) {
+ t.Errorf("case %d error = %v", i, err)
+ }
+ }
+}
+```
+
+- [ ] **Run the task tests.**
+
+```bash
+go test ./internal/hostquarantine ./internal/hostfence ./internal/awsinv -race -count=1
+```
+
+## Task 3: Durable execution controller and independent writer checks
+
+**Produces:** the exact execution record, configured conditional store adapter, suspend-aware clock, descriptor-relative local state, controller lifetime lock, renewal/watchdog loops, conditional release and CC9 clearing. **Consumes:** Tasks 1–2 and Plan 3’s host guard. `Open` performs cleanup and fresh authority checks; `Ready` waits for the first acknowledged PUT and durable state. Map that returned tuple into `hostclaim.ExecutionAcknowledgement` when the operation owner records the first-phase acknowledgement. Neither that claim CAS nor lease I/O runs under `WithLocal`. There are no host-agent, timer, activator or root-pass call-site changes in this task.
+
+- [ ] **Create `internal/hostguard/local.go` with this complete content.**
+
+<!-- plan8:file internal/hostguard/local.go -->
+```go
+package hostguard
+
+import "context"
+
+// WithLocal holds the existing host lock for local bookkeeping or cleanup.
+// It grants no claim, installation, spawn or write authority. Ordinary host
+// work must still use With. The callback must not wait on a network request.
+func (g Guard) WithLocal(ctx context.Context, host string, fn func() error) error {
+ if fn == nil {
+ return ErrInvalidInput
+ }
+ return g.locked(ctx, host, func(_ *recordDir) error { return fn() })
+}
+```
+
+- [ ] **Create `internal/hostguard/local_test.go` with this complete content.**
+
+<!-- plan8:file internal/hostguard/local_test.go -->
+```go
+//go:build unix
+
+package hostguard
+
+import (
+ "context"
+ "errors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestWithLocalSerializesWithoutClaimAuthority(t *testing.T) {
+ dir := t.TempDir()
+ locks := t.TempDir()
+ if err := os.Mkdir(filepath.Join(dir, "hostguard"), 0770); err != nil {
+ t.Fatal(err)
+ }
+ g := Guard{DataDir: dir, LockDir: locks}
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ done := make(chan error, 1)
+ go func() {
+ done <- g.WithLocal(t.Context(), "shop.example.com", func() error { close(entered); <-release; return nil })
+ }()
+ <-entered
+ ctx, cancel := context.WithTimeout(t.Context(), 25*time.Millisecond)
+ defer cancel()
+ called := false
+ err := g.WithLocal(ctx, "shop.example.com", func() error { called = true; return nil })
+ close(release)
+ if first := <-done; first != nil {
+ t.Fatal(first)
+ }
+ if !errors.Is(err, context.DeadlineExceeded) || called {
+ t.Fatalf("concurrent local write: %v %v", err, called)
+ }
+ // No Claims reader is configured: WithLocal may clean up, but With cannot
+ // turn the same lock into installation authority.
+ if err := g.With(t.Context(), "shop.example.com", Captured{}, func(_ *hostclaim.Claim) error { return nil }, func() error { return nil }); !errors.Is(err, ErrNoClaims) {
+ t.Fatal(err)
+ }
+}
+```
+
+- [ ] **Create `internal/lease/execution_store.go` with this complete content.**
+
+<!-- plan8:file internal/lease/execution_store.go -->
+```go
+package lease
+
+import (
+ "context"
+ "errors"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ExecutionStore reuses the replica leaser's S3 configuration and the release
+// store's conditional primitives. The execution controller owns its wire schema.
+func ExecutionStore(ctx context.Context, bucket, region string) (release.Store, error) {
+ if bucket == "" {
+ return nil, errors.New("lease: control bucket required")
+ }
+ client, _, err := newLeaserClient(ctx, region)
+ if err != nil {
+ return nil, err
+ }
+ return &release.S3Store{Client: client, Bucket: bucket}, nil
+}
+```
+
+- [ ] **Create `internal/lease/execution_store_test.go` with this complete content.**
+
+<!-- plan8:file internal/lease/execution_store_test.go -->
+```go
+package lease
+
+import (
+ "github.com/carlosframework/platform/internal/release"
+ "testing"
+)
+
+func TestExecutionStoreUsesExistingConditionalTransport(t *testing.T) {
+ t.Setenv("AWS_ACCESS_KEY_ID", "test")
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "test")
+ t.Setenv("AWS_EC2_METADATA_DISABLED", "true")
+ t.Setenv("AWS_CONFIG_FILE", t.TempDir()+"/absent")
+ t.Setenv("AWS_SHARED_CREDENTIALS_FILE", t.TempDir()+"/absent")
+ t.Setenv("CARLOS_S3_ENDPOINT", "https://objects.invalid")
+ if _, err := ExecutionStore(t.Context(), "", "eu-west-1"); err == nil {
+ t.Fatal("empty bucket accepted")
+ }
+ store, err := ExecutionStore(t.Context(), "control", "eu-west-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ s, ok := store.(*release.S3Store)
+ if !ok || s.Bucket != "control" {
+ t.Fatal("wrong store")
+ }
+ opts := s.Client.Options()
+ if opts.Region != "eu-west-1" || opts.BaseEndpoint == nil || *opts.BaseEndpoint != "https://objects.invalid" || !opts.UsePathStyle {
+ t.Fatalf("lost leaser configuration: %+v", opts)
+ }
+ if _, ok := store.(release.ConditionalDeleter); !ok {
+ t.Fatal("no conditional release")
+ }
+}
+```
+
+- [ ] **Create `internal/hostexec/clock_linux.go` with this complete content.**
+
+<!-- plan8:file internal/hostexec/clock_linux.go -->
+```go
+//go:build linux
+
+package hostexec
+
+import (
+ "golang.org/x/sys/unix"
+ "os"
+ "strings"
+ "time"
+)
+
+type SystemClock struct{}
+
+func (SystemClock) Now() (time.Time, int64, error) {
+ var ts unix.Timespec
+ if err := unix.ClockGettime(unix.CLOCK_BOOTTIME, &ts); err != nil {
+ return time.Time{}, 0, err
+ }
+ return time.Now(), ts.Nano(), nil
+}
+func (SystemClock) After(d time.Duration) <-chan time.Time { return time.After(d) }
+func BootID() (string, error) {
+ b, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
+ if err != nil {
+ return "", err
+ }
+ id := strings.TrimSpace(string(b))
+ if !validID(id) {
+ return "", ErrState
+ }
+ return id, nil
+}
+```
+
+- [ ] **Create `internal/hostexec/clock_other.go` with this complete content.**
+
+<!-- plan8:file internal/hostexec/clock_other.go -->
+```go
+//go:build !linux
+
+package hostexec
+
+import "time"
+
+type SystemClock struct{}
+
+func (SystemClock) Now() (time.Time, int64, error) { return time.Time{}, 0, ErrUnsupported }
+func (SystemClock) After(d time.Duration) <-chan time.Time { return time.After(d) }
+func BootID() (string, error) { return "", ErrUnsupported }
+```
+
+- [ ] **Create `internal/hostexec/clock_other_test.go` with this complete content.**
+
+<!-- plan8:file internal/hostexec/clock_other_test.go -->
+```go
+//go:build !linux
+
+package hostexec
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestSystemClockRefusesUnsupportedPlatform(t *testing.T) {
+ if _, _, err := (SystemClock{}).Now(); !errors.Is(err, ErrUnsupported) {
+ t.Fatal(err)
+ }
+ if _, err := BootID(); !errors.Is(err, ErrUnsupported) {
+ t.Fatal(err)
+ }
+}
+```
+
+- [ ] **Create `internal/hostexec/controller.go` with this complete content.**
+
+<!-- plan8:file internal/hostexec/controller.go -->
+```go
+package hostexec
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "os"
+ "slices"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostquarantine"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type Config struct {
+ Binding Binding
+ Store release.Store
+ Guard hostguard.Guard
+ Directory hostquarantine.ActorDirectory
+ Clock Clock
+ LockDir string
+ // These close/cancel hooks must be nonblocking and safe to call repeatedly.
+ CloseAdmission func()
+ CancelWork func()
+ // Cleanup terminates only this host's processes and closes its replicators.
+ // It must not wait for the host guard: uncertain disk writes retain that
+ // guard to deny other processes, while termination must proceed independently.
+ // Its failure remains a separate denial owned by the caller, even after CC9.
+ Cleanup func(context.Context) error
+}
+
+type Controller struct {
+ cfg Config
+ mu sync.Mutex
+ state State
+ err error
+ cleanupErr error
+ releaseETag string
+ cleanupOnce sync.Once
+ cleanupDone chan struct{}
+ ready chan struct{}
+ readyOnce sync.Once
+ lost chan struct{}
+ ctx context.Context
+ cancel context.CancelFunc
+ done chan struct{}
+ watchDone chan struct{}
+ unlock func()
+ closeMu sync.Mutex
+ closed bool
+}
+
+func Open(ctx context.Context, cfg Config) (*Controller, error) {
+ if cfg.Clock == nil {
+ cfg.Clock = SystemClock{}
+ }
+ if cfg.LockDir == "" {
+ cfg.LockDir = "/run/carlos/hostexec"
+ }
+ var nonce [16]byte
+ if _, err := rand.Read(nonce[:]); err != nil {
+ return nil, err
+ }
+ cfg.Binding.ControllerID = hex.EncodeToString(nonce[:])
+ if !cfg.Binding.valid() || cfg.Store == nil || cfg.Guard.Claims == nil || cfg.Directory == nil ||
+ cfg.CloseAdmission == nil || cfg.CancelWork == nil || cfg.Cleanup == nil {
+ return nil, ErrState
+ }
+ wall, boot, err := cfg.Clock.Now()
+ if err != nil {
+ return nil, err
+ }
+ cctx, cancel := context.WithCancel(ctx)
+ c := &Controller{cfg: cfg, state: State{V: 1, Binding: cfg.Binding, State: "pending", Expires: wall.Add(TTL), SafeUntil: boot + int64(TTL-Margin)}, ctx: cctx, cancel: cancel,
+ ready: make(chan struct{}), lost: make(chan struct{}), done: make(chan struct{}), watchDone: make(chan struct{}), cleanupDone: make(chan struct{})}
+ // A replacement may not hold the lifetime lock while its predecessor's
+ // active bytes are visible. Hold the host guard across acquisition and the
+ // pending write. Never wait for the lifetime lock while holding the guard.
+ for {
+ err = cfg.Guard.WithLocal(ctx, cfg.Binding.Host, func() error {
+ var lockErr error
+ c.unlock, lockErr = tryAcquire(cfg.LockDir, cfg.Binding.Host)
+ if lockErr != nil {
+ return lockErr
+ }
+ pending := false
+ defer func() {
+ if !pending {
+ c.unlock()
+ c.unlock = nil
+ }
+ }()
+ cfg.CloseAdmission()
+ cfg.CancelWork()
+ d, openErr := openLocal(cfg.Guard.DataDir)
+ if openErr != nil {
+ return openErr
+ }
+ defer d.close()
+ writeErr := d.write(c.state)
+ pending = writeErr == nil
+ return writeErr
+ })
+ if !errors.Is(err, ErrControllerBusy) {
+ break
+ }
+ select {
+ case <-ctx.Done():
+ err = ctx.Err()
+ case <-time.After(10 * time.Millisecond):
+ continue
+ }
+ break
+ }
+ if err == nil {
+ err = cfg.Cleanup(ctx)
+ }
+ if err == nil {
+ err = c.authority(ctx)
+ }
+ if err != nil {
+ c.state.State = "lost"
+ if c.unlock != nil {
+ _ = c.save(c.state)
+ c.unlock()
+ }
+ cancel()
+ return nil, err
+ }
+ go c.watch()
+ go c.run()
+ return c, nil
+}
+
+func (c *Controller) save(s State) error {
+ return c.cfg.Guard.WithLocal(context.Background(), s.Host, func() error {
+ d, err := openLocal(c.cfg.Guard.DataDir)
+ if err != nil {
+ return err
+ }
+ defer d.close()
+ return d.write(s)
+ })
+}
+func (c *Controller) authority(ctx context.Context) error {
+ b := c.cfg.Binding
+ if err := hostquarantine.Check(ctx, c.cfg.Directory, b.Box, b.Enrollment); err != nil {
+ return err
+ }
+ obs, err := c.cfg.Guard.Claims.Read(ctx, b.Host)
+ if err != nil {
+ return err
+ }
+ if obs.Absent || obs.Claim == nil || obs.Host != b.Host || obs.Claim.Host != b.Host {
+ return ErrInactive
+ }
+ q := obs.Claim
+ if q.Validate() != nil || q.Holder == nil || q.Holder.Incarnation != b.Incarnation || q.Op.ID != b.Op ||
+ q.Op.Kind == hostclaim.KindServing || q.Free() || !slices.Contains(q.Op.Participants, b.Box) || slices.Contains(q.Lost, b.Box) {
+ return ErrInactive
+ }
+ return nil
+}
+
+// loseLocked latches denial before attempting disk I/O or process cleanup.
+func (c *Controller) loseLocked(err error) {
+ if c.err != nil {
+ return
+ }
+ c.err = err
+ c.state.State = "lost"
+ close(c.lost)
+ c.cancel()
+ c.cfg.CloseAdmission()
+ c.cfg.CancelWork()
+ c.readyOnce.Do(func() { close(c.ready) })
+ s := c.state
+ c.cleanupOnce.Do(func() {
+ go func() {
+ // Neither a blocked disk nor blocked cleanup can prevent the other.
+ cleaned := make(chan error, 1)
+ go func() { cleaned <- c.cfg.Cleanup(context.Background()) }()
+ persistErr := c.save(s)
+ cleanupErr := <-cleaned
+ c.mu.Lock()
+ c.cleanupErr = errors.Join(persistErr, cleanupErr)
+ c.mu.Unlock()
+ close(c.cleanupDone)
+ }()
+ })
+}
+func (c *Controller) checkLocked() error {
+ if c.err != nil {
+ return c.err
+ }
+ _, boot, err := c.cfg.Clock.Now()
+ if err != nil {
+ c.loseLocked(err)
+ } else if boot >= c.state.SafeUntil {
+ c.loseLocked(ErrLost)
+ }
+ return c.err
+}
+func (c *Controller) Lost() <-chan struct{} { return c.lost }
+func (c *Controller) Ready(ctx context.Context) (State, error) {
+ select {
+ case <-ctx.Done():
+ return State{}, ctx.Err()
+ case <-c.ready:
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if err := c.checkLocked(); err != nil {
+ return State{}, err
+ }
+ if c.state.State != "active" {
+ return State{}, ErrState
+ }
+ return c.state, nil
+}
+func (c *Controller) CleanupError() error { c.mu.Lock(); defer c.mu.Unlock(); return c.cleanupErr }
+
+func (c *Controller) watch() {
+ defer close(c.watchDone)
+ for {
+ c.mu.Lock()
+ err := c.checkLocked()
+ c.mu.Unlock()
+ if err != nil {
+ return
+ }
+ // CLOCK_BOOTTIME is sampled on every wake and every admission. A normal Go
+ // timer does not itself prove that a suspended machine has remained stopped.
+ select {
+ case <-c.ctx.Done():
+ c.mu.Lock()
+ c.loseLocked(c.ctx.Err())
+ c.mu.Unlock()
+ return
+ case <-c.cfg.Clock.After(100 * time.Millisecond):
+ }
+ }
+}
+func (c *Controller) run() {
+ defer close(c.done)
+ key, _ := Key(c.cfg.Binding.Host, c.cfg.Binding.Box)
+ body, etag, err := c.cfg.Store.Get(c.ctx, key)
+ if errors.Is(err, os.ErrNotExist) {
+ err = nil
+ etag = ""
+ } else if err == nil {
+ var r Record
+ if decode(body, &r) != nil || r.Op == "" || r.Expires.IsZero() || etag == "" {
+ err = ErrState
+ }
+ }
+ if err != nil {
+ c.mu.Lock()
+ c.loseLocked(err)
+ c.mu.Unlock()
+ return
+ }
+ for {
+ if err = c.authority(c.ctx); err != nil {
+ c.mu.Lock()
+ c.loseLocked(err)
+ c.mu.Unlock()
+ return
+ }
+ wall, start, clockErr := c.cfg.Clock.Now()
+ if clockErr != nil {
+ c.mu.Lock()
+ c.loseLocked(clockErr)
+ c.mu.Unlock()
+ return
+ }
+ c.mu.Lock()
+ err = c.checkLocked()
+ captured := c.state.Binding
+ c.mu.Unlock()
+ if err != nil {
+ return
+ }
+ proposed := Record{Op: captured.Op, Expires: wall.Add(TTL)}
+ bytes, _ := json.Marshal(proposed)
+ next, putErr := c.cfg.Store.Put(c.ctx, key, bytes, etag)
+ c.mu.Lock()
+ // A late acknowledgement can only contribute its exact ETag to release.
+ if captured != c.state.Binding {
+ c.mu.Unlock()
+ return
+ }
+ if putErr == nil && next != "" {
+ c.releaseETag = next
+ etag = next
+ }
+ if err = c.checkLocked(); err == nil && (putErr != nil || next == "") {
+ if putErr == nil {
+ putErr = ErrState
+ }
+ c.loseLocked(putErr)
+ err = putErr
+ }
+ c.mu.Unlock()
+ if err != nil {
+ return
+ }
+ // Network reads never hold either the controller mutex or the host guard.
+ if err = c.authority(c.ctx); err != nil {
+ c.mu.Lock()
+ c.loseLocked(err)
+ c.mu.Unlock()
+ return
+ }
+ c.mu.Lock()
+ if err = c.checkLocked(); err != nil {
+ c.mu.Unlock()
+ return
+ }
+ nextState := c.state
+ nextState.State = "active"
+ nextState.LeaseETag = next
+ nextState.Expires = proposed.Expires
+ nextState.SafeUntil = start + int64(TTL-Margin)
+ // Never use a response-relative deadline, including when the wall clock moved.
+ _, now, clockErr := c.cfg.Clock.Now()
+ if clockErr != nil || now >= nextState.SafeUntil {
+ c.loseLocked(ErrLost)
+ c.mu.Unlock()
+ return
+ }
+ c.mu.Unlock()
+ // Publication and any intervening loss are serialized under the same host
+ // guard, so another process never observes a stale active overwrite.
+ if err = c.publish(nextState); err != nil {
+ c.mu.Lock()
+ c.loseLocked(err)
+ c.mu.Unlock()
+ return
+ }
+ _, now, clockErr = c.cfg.Clock.Now()
+ if clockErr != nil {
+ c.mu.Lock()
+ c.loseLocked(clockErr)
+ c.mu.Unlock()
+ return
+ }
+ delay := time.Duration(start + int64(Renewal) - now)
+ if delay < 0 {
+ delay = 0
+ }
+ select {
+ case <-c.ctx.Done():
+ return
+ case <-c.cfg.Clock.After(delay):
+ }
+ }
+}
+
+// publish leaves mu available to the watchdog during filesystem calls. If a
+// write has an uncertain outcome, it retains the host guard until denial is
+// durable; releasing it over possibly active bytes would admit another process.
+func (c *Controller) publish(next State) error {
+ return c.cfg.Guard.WithLocal(context.Background(), next.Host, func() error {
+ d, err := openLocal(c.cfg.Guard.DataDir)
+ if err != nil {
+ return err
+ }
+ defer d.close()
+ c.mu.Lock()
+ err = c.checkLocked()
+ if err == nil && next.Binding != c.state.Binding {
+ c.loseLocked(ErrState)
+ err = ErrState
+ }
+ c.mu.Unlock()
+ if err != nil {
+ return err
+ }
+ writeErr := d.write(next)
+ c.mu.Lock()
+ if writeErr != nil {
+ c.loseLocked(writeErr)
+ }
+ err = c.checkLocked()
+ if err == nil {
+ _, boot, clockErr := c.cfg.Clock.Now()
+ if clockErr != nil || boot >= next.SafeUntil {
+ c.loseLocked(ErrLost)
+ err = c.err
+ }
+ }
+ if err == nil {
+ c.state = next
+ c.readyOnce.Do(func() { close(c.ready) })
+ c.mu.Unlock()
+ return nil
+ }
+ denied := c.state
+ denied.State = "lost"
+ c.mu.Unlock()
+ // Cancellation does not release the guard over uncertain active bytes.
+ // Close may time out; it keeps the lifetime lock until this join completes.
+ for {
+ if persistErr := d.write(denied); persistErr == nil {
+ return err
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ })
+}
+
+// Close cancels and joins all lease requests before conditional release. It
+// retains the lifetime lock on a timed-out join; call Close again to finish.
+func (c *Controller) Close(ctx context.Context) error {
+ c.closeMu.Lock()
+ defer c.closeMu.Unlock()
+ if c.closed {
+ return nil
+ }
+ c.mu.Lock()
+ c.loseLocked(ErrLost)
+ c.mu.Unlock()
+ for _, done := range []<-chan struct{}{c.done, c.watchDone, c.cleanupDone} {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-done:
+ }
+ }
+ c.mu.Lock()
+ etag := c.releaseETag
+ c.mu.Unlock()
+ var err error
+ if etag != "" {
+ key, _ := Key(c.cfg.Binding.Host, c.cfg.Binding.Box)
+ err = release.DeleteIfMatch(ctx, c.cfg.Store, key, etag)
+ }
+ c.closed = true
+ c.unlock()
+ return errors.Join(err, c.CleanupError())
+}
+
+// CheckLocal must run inside Guard.With, before each bounded host write. It
+// does not grant authority and never clears quarantine or cleanup denial.
+func CheckLocal(dataDir string, expected Binding, clock Clock, lockDir string) error {
+ if !expected.valid() || clock == nil {
+ return ErrState
+ }
+ if lockDir == "" {
+ lockDir = "/run/carlos/hostexec"
+ }
+ unlock, err := tryAcquire(lockDir, expected.Host)
+ if err == nil {
+ unlock()
+ return ErrLost
+ }
+ if !errors.Is(err, ErrControllerBusy) {
+ return err
+ }
+ d, err := openLocal(dataDir)
+ if err != nil {
+ return err
+ }
+ defer d.close()
+ s, err := d.read(expected.Host)
+ if err != nil {
+ return err
+ }
+ if s.Binding != expected || s.State != "active" {
+ return ErrLost
+ }
+ _, boot, err := clock.Now()
+ if err != nil {
+ return err
+ }
+ if boot >= s.SafeUntil {
+ return ErrLost
+ }
+ return nil
+}
+
+// ClearStale performs CC9 with the lifetime lock held, after any old controller
+// has joined. A failed or mismatched claim read leaves local denial intact.
+func ClearStale(ctx context.Context, cfg Config) error {
+ lockDir := cfg.LockDir
+ if lockDir == "" {
+ lockDir = "/run/carlos/hostexec"
+ }
+ if cfg.Guard.Claims == nil || !validID(cfg.Binding.Box) || !validID(cfg.Binding.Enrollment) {
+ return ErrState
+ }
+ var unlock func()
+ var err error
+ for {
+ err = cfg.Guard.WithLocal(ctx, cfg.Binding.Host, func() error {
+ var lockErr error
+ unlock, lockErr = tryAcquire(lockDir, cfg.Binding.Host)
+ if lockErr != nil {
+ return lockErr
+ }
+ denied := false
+ defer func() {
+ if !denied {
+ unlock()
+ unlock = nil
+ }
+ }()
+ d, openErr := openLocal(cfg.Guard.DataDir)
+ if openErr != nil {
+ return openErr
+ }
+ defer d.close()
+ old, readErr := d.read(cfg.Binding.Host)
+ if errors.Is(readErr, os.ErrNotExist) {
+ denied = true
+ return nil
+ }
+ if readErr != nil {
+ return readErr
+ }
+ if old.Box != cfg.Binding.Box || old.Enrollment != cfg.Binding.Enrollment ||
+ old.Op != cfg.Binding.Op || old.Incarnation != cfg.Binding.Incarnation {
+ return ErrState
+ }
+ // Holding the lifetime lock must never make an orphaned active record
+ // appear to belong to a running controller during the claim read.
+ old.State = "lost"
+ writeErr := d.write(old)
+ denied = writeErr == nil
+ return writeErr
+ })
+ if !errors.Is(err, ErrControllerBusy) {
+ break
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(10 * time.Millisecond):
+ }
+ }
+ if err != nil {
+ return err
+ }
+ defer unlock()
+ obs, err := cfg.Guard.Claims.Read(ctx, cfg.Binding.Host)
+ if err != nil {
+ return err
+ }
+ if obs.Absent || obs.Claim == nil || obs.Host != cfg.Binding.Host || obs.Claim.Host != cfg.Binding.Host || obs.Claim.Validate() != nil {
+ return ErrState
+ }
+ q := obs.Claim
+ return cfg.Guard.WithLocal(ctx, cfg.Binding.Host, func() error {
+ d, err := openLocal(cfg.Guard.DataDir)
+ if err != nil {
+ return err
+ }
+ defer d.close()
+ old, err := d.read(cfg.Binding.Host)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if old.Box != cfg.Binding.Box || old.Enrollment != cfg.Binding.Enrollment ||
+ old.Op != cfg.Binding.Op || old.Incarnation != cfg.Binding.Incarnation {
+ return ErrState
+ }
+ // Only the caller's expected operation is cleared. A fresh successor
+ // operation may exclude this box; that is also CC9 discharge evidence.
+ if !q.Free() && q.Op.Kind != hostclaim.KindServing &&
+ slices.Contains(q.Op.Participants, old.Box) {
+ return ErrInactive
+ }
+ return d.remove(old.Host)
+ })
+}
+```
+
+- [ ] **Create `internal/hostexec/controller_test.go` with this complete content.**
+
+<!-- plan8:file internal/hostexec/controller_test.go -->
+```go
+//go:build linux
+
+package hostexec
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostquarantine"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type alarm struct {
+ at int64
+ ch chan time.Time
+}
+type testClock struct {
+ mu sync.Mutex
+ wall time.Time
+ boot int64
+ alarms []alarm
+}
+
+func (k *testClock) Now() (time.Time, int64, error) {
+ k.mu.Lock()
+ defer k.mu.Unlock()
+ return k.wall, k.boot, nil
+}
+func (k *testClock) After(d time.Duration) <-chan time.Time {
+ k.mu.Lock()
+ defer k.mu.Unlock()
+ ch := make(chan time.Time, 1)
+ if d <= 0 {
+ ch <- k.wall
+ } else {
+ k.alarms = append(k.alarms, alarm{k.boot + int64(d), ch})
+ }
+ return ch
+}
+func (k *testClock) advance(d time.Duration) {
+ k.mu.Lock()
+ defer k.mu.Unlock()
+ k.boot += int64(d)
+ k.wall = k.wall.Add(d)
+ rest := k.alarms[:0]
+ for _, a := range k.alarms {
+ if a.at <= k.boot {
+ a.ch <- k.wall
+ } else {
+ rest = append(rest, a)
+ }
+ }
+ k.alarms = rest
+}
+
+type claims struct {
+ mu sync.Mutex
+ q hostclaim.Claim
+ err error
+}
+
+func (r *claims) Read(context.Context, string) (hostclaim.Observed, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ raw, _ := json.Marshal(r.q)
+ var q hostclaim.Claim
+ _ = json.Unmarshal(raw, &q)
+ return hostclaim.Observed{Host: q.Host, Claim: &q, ETag: "claim-etag"}, r.err
+}
+
+type directory struct {
+ state string
+ err error
+}
+
+func (d directory) Lookup(context.Context, string) (hostquarantine.Actor, error) {
+ return hostquarantine.Actor{Enrollment: "enroll", State: d.state}, d.err
+}
+
+type delayedStore struct {
+ release.Store
+ mu sync.Mutex
+ calls int
+ onPut func(int)
+ failAt int
+ called chan int
+}
+
+func (s *delayedStore) Put(ctx context.Context, key string, b []byte, etag string) (string, error) {
+ s.mu.Lock()
+ s.calls++
+ n := s.calls
+ s.mu.Unlock()
+ if s.called != nil {
+ s.called <- n
+ }
+ if s.onPut != nil {
+ s.onPut(n)
+ }
+ if n == s.failAt {
+ return "", errors.New("ambiguous network failure")
+ }
+ // Deliberately permit a late response even after cancellation.
+ return s.Store.Put(context.Background(), key, b, etag)
+}
+func (s *delayedStore) DeleteIfMatch(ctx context.Context, key, etag string) error {
+ return release.DeleteIfMatch(ctx, s.Store, key, etag)
+}
+
+func fixture(t *testing.T) (Config, *claims, *testClock, *delayedStore, *atomic.Int32) {
+ t.Helper()
+ dir := t.TempDir()
+ for _, name := range []string{"hostguard", ".hostexec", "locks", "exec-locks"} {
+ if err := os.Mkdir(filepath.Join(dir, name), 0770); err != nil {
+ t.Fatal(err)
+ }
+ }
+ host := "shop.example.com"
+ inc, _ := hostclaim.Incarnation("bab", "jam", host, time.Time{})
+ q := hostclaim.Claim{V: 2, Host: host, Gen: 7, Holder: &hostclaim.Holder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", Incarnation: inc, Location: "instances/" + host, Stamp: 3}, Op: hostclaim.Op{Kind: hostclaim.KindDeleting, ID: "op", Phase: "stop", Exec: "box", Participants: []string{"box"}}}
+ if err := q.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ r := &claims{q: q}
+ clock := &testClock{wall: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC), boot: int64(time.Hour)}
+ store := &delayedStore{Store: release.NewDirStore(t.TempDir()), called: make(chan int, 20)}
+ cleaned := &atomic.Int32{}
+ cfg := Config{Binding: Binding{Box: "box", Enrollment: "enroll", Host: host, Incarnation: inc, Op: "op", BootID: "boot"}, Store: store, Guard: hostguard.Guard{DataDir: dir, LockDir: filepath.Join(dir, "locks"), Claims: r}, Directory: directory{state: "active"}, Clock: clock, LockDir: filepath.Join(dir, "exec-locks"), CloseAdmission: func() {}, CancelWork: func() {}, Cleanup: func(context.Context) error { cleaned.Add(1); return nil }}
+ return cfg, r, clock, store, cleaned
+}
+func ready(t *testing.T, c *Controller) State {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
+ defer cancel()
+ s, err := c.Ready(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s
+}
+func closeController(t *testing.T, c *Controller) {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := c.Close(ctx); err != nil {
+ t.Error(err)
+ }
+}
+func until(t *testing.T, fn func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for !fn() {
+ if time.Now().After(deadline) {
+ t.Fatal("condition did not become true")
+ }
+ time.Sleep(time.Millisecond)
+ }
+}
+
+func TestRenewalRequestStartAndIndependentExpiry(t *testing.T) {
+ cfg, _, clock, store, cleaned := fixture(t)
+ blocked := make(chan struct{})
+ store.onPut = func(n int) {
+ if n == 2 {
+ <-blocked
+ }
+ }
+ c, err := Open(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer closeController(t, c)
+ first := ready(t, c)
+ key, _ := Key(first.Host, first.Box)
+ b, _, err := store.Get(t.Context(), key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var wire map[string]any
+ _ = json.Unmarshal(b, &wire)
+ if len(wire) != 2 || wire["op"] != "op" {
+ t.Fatalf("wire %s", b)
+ }
+ until(t, func() bool { clock.mu.Lock(); defer clock.mu.Unlock(); return len(clock.alarms) >= 2 })
+ clock.advance(Renewal)
+ for {
+ select {
+ case n := <-store.called:
+ if n == 2 {
+ goto renewing
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("no renewal")
+ }
+ }
+renewing:
+ clock.advance(65 * time.Second)
+ select {
+ case <-c.Lost():
+ case <-time.After(2 * time.Second):
+ t.Fatal("blocked PUT blocked watchdog")
+ }
+ if err := CheckLocal(cfg.Guard.DataDir, first.Binding, clock, cfg.LockDir); err == nil {
+ t.Fatal("writer admitted at deadline")
+ }
+ close(blocked)
+ <-c.done
+ if _, err := c.Ready(t.Context()); err == nil {
+ t.Fatal("late success revived controller")
+ }
+ until(t, func() bool { return cleaned.Load() >= 2 })
+}
+
+func TestRenewalUsesStartAndWallRollbackCannotExtend(t *testing.T) {
+ cfg, _, clock, store, _ := fixture(t)
+ store.onPut = func(n int) {
+ if n == 1 {
+ clock.advance(9 * time.Second)
+ }
+ }
+ c, err := Open(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer closeController(t, c)
+ s := ready(t, c)
+ if s.SafeUntil != int64(time.Hour+85*time.Second) {
+ t.Fatalf("response-relative deadline %d", s.SafeUntil)
+ }
+ clock.mu.Lock()
+ clock.wall = clock.wall.Add(-time.Hour)
+ clock.mu.Unlock()
+ clock.advance(76 * time.Second)
+ if err := CheckLocal(cfg.Guard.DataDir, s.Binding, clock, cfg.LockDir); err == nil {
+ t.Fatal("wall rollback extended deadline")
+ }
+}
+
+func TestAmbiguousPUTLatchesAndConditionalReleasePreservesSuccessor(t *testing.T) {
+ cfg, _, _, store, _ := fixture(t)
+ store.failAt = 1
+ c, err := Open(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = c.Ready(t.Context()); err == nil {
+ t.Fatal("ambiguous PUT admitted")
+ }
+ closeController(t, c)
+ cfg, _, _, store, _ = fixture(t)
+ c, err = Open(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := ready(t, c)
+ key, _ := Key(s.Host, s.Box)
+ successor := []byte(`{"op":"successor","expires":"2030-01-01T00:00:00Z"}`)
+ if _, err = store.Store.Put(t.Context(), key, successor, s.LeaseETag); err != nil {
+ t.Fatal(err)
+ }
+ if err = c.Close(t.Context()); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("release = %v", err)
+ }
+ got, _, err := store.Get(t.Context(), key)
+ if err != nil || string(got) != string(successor) {
+ t.Fatal("deleted successor")
+ }
+}
+
+func TestControllerExclusionBootAndCC9(t *testing.T) {
+ cfg, r, clock, _, _ := fixture(t)
+ c, err := Open(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := ready(t, c)
+ ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond)
+ defer cancel()
+ if _, err = Open(ctx, cfg); !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("second controller = %v", err)
+ }
+ wrong := s.Binding
+ wrong.BootID = "next-boot"
+ if err = CheckLocal(cfg.Guard.DataDir, wrong, clock, cfg.LockDir); err == nil {
+ t.Fatal("inherited prior boot")
+ }
+ closeController(t, c)
+ if err = ClearStale(t.Context(), cfg); !errors.Is(err, ErrInactive) {
+ t.Fatalf("cleared active op: %v", err)
+ }
+ r.mu.Lock()
+ r.q.Op = hostclaim.Op{Kind: hostclaim.KindServing, Participants: []string{"box"}}
+ r.mu.Unlock()
+ if err = ClearStale(t.Context(), cfg); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = os.Stat(filepath.Join(cfg.Guard.DataDir, ".hostexec", cfg.Binding.Host+".json")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("stale state remains", err)
+ }
+ // No lease traffic for retained serving participants, even when enabled.
+ if _, err = Open(t.Context(), cfg); !errors.Is(err, ErrInactive) {
+ t.Fatalf("serving controller = %v", err)
+ }
+}
+
+func TestMalformedPendingMissingAndQuarantineRefuse(t *testing.T) {
+ cfg, _, clock, _, _ := fixture(t)
+ b := cfg.Binding
+ b.ControllerID = "controller"
+ if err := CheckLocal(cfg.Guard.DataDir, b, clock, cfg.LockDir); err == nil {
+ t.Fatal("missing state admitted")
+ }
+ p := filepath.Join(cfg.Guard.DataDir, ".hostexec", b.Host+".json")
+ if err := os.WriteFile(p, []byte(`{"v":1}`), 0660); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Open(t.Context(), cfg); err == nil {
+ t.Fatal("malformed state replaced")
+ }
+ if err := os.Remove(p); err != nil {
+ t.Fatal(err)
+ }
+ cfg.Directory = directory{state: "quarantined"}
+ if _, err := Open(t.Context(), cfg); err == nil {
+ t.Fatal("quarantined box admitted")
+ }
+}
+
+func TestLocalPathsPinnedAndSymlinkRefused(t *testing.T) {
+ cfg, _, _, _, _ := fixture(t)
+ outside := filepath.Join(t.TempDir(), "outside")
+ if err := os.WriteFile(outside, []byte("untouched"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ record := filepath.Join(cfg.Guard.DataDir, ".hostexec", cfg.Binding.Host+".json")
+ if err := os.Symlink(outside, record); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Open(t.Context(), cfg); err == nil {
+ t.Fatal("symlink admitted")
+ }
+ b, _ := os.ReadFile(outside)
+ st, _ := os.Stat(outside)
+ if string(b) != "untouched" || st.Mode().Perm() != 0600 {
+ t.Fatal("changed symlink target")
+ }
+ if err := os.Remove(record); err != nil {
+ t.Fatal(err)
+ }
+ d, err := openLocal(cfg.Guard.DataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.close()
+ old := filepath.Join(cfg.Guard.DataDir, ".hostexec")
+ if err = os.Rename(old, old+".pinned"); err != nil {
+ t.Fatal(err)
+ }
+ if err = os.Mkdir(old, 0770); err != nil {
+ t.Fatal(err)
+ }
+ s := State{V: 1, Binding: cfg.Binding, State: "pending", Expires: time.Now().Add(TTL), SafeUntil: 99}
+ s.ControllerID = "controller"
+ if err = d.write(s); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = os.Stat(filepath.Join(old+".pinned", s.Host+".json")); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = os.Stat(filepath.Join(old, s.Host+".json")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("followed directory replacement")
+ }
+ d.sync = func(int) error { return errors.New("fsync failure") }
+ if err = d.write(s); err == nil {
+ t.Fatal("ignored persistence failure")
+ }
+ entries, _ := os.ReadDir(old + ".pinned")
+ for _, e := range entries {
+ if strings.HasPrefix(e.Name(), ".tmp-") {
+ t.Fatal("leaked temp file")
+ }
+ }
+}
+
+func TestSuccessfulRenewalDurablyExtendsFromItsRequestStart(t *testing.T) {
+ cfg, _, clock, store, _ := fixture(t)
+ c, err := Open(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer closeController(t, c)
+ first := ready(t, c)
+ until(t, func() bool {
+ clock.mu.Lock()
+ defer clock.mu.Unlock()
+ for _, a := range clock.alarms {
+ if a.at == clock.boot+int64(Renewal) {
+ return true
+ }
+ }
+ return false
+ })
+ clock.advance(Renewal)
+ until(t, func() bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.state.SafeUntil == first.SafeUntil+int64(Renewal)
+ })
+ renewed := ready(t, c)
+ if renewed.LeaseETag == first.LeaseETag || !renewed.Expires.Equal(first.Expires.Add(Renewal)) {
+ t.Fatalf("renewal did not advance coherent tuple: %+v", renewed)
+ }
+ d, err := openLocal(cfg.Guard.DataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.close()
+ disk, err := d.read(renewed.Host)
+ if err != nil || disk != renewed {
+ t.Fatalf("renewal not durable: %+v %v", disk, err)
+ }
+ key, _ := Key(renewed.Host, renewed.Box)
+ raw, etag, err := store.Get(t.Context(), key)
+ if err != nil || etag != renewed.LeaseETag {
+ t.Fatalf("renewed remote ownership: %q %v", etag, err)
+ }
+ var wire Record
+ if err = json.Unmarshal(raw, &wire); err != nil || wire.Op != renewed.Op || !wire.Expires.Equal(renewed.Expires) {
+ t.Fatalf("renewed wire: %+v %v", wire, err)
+ }
+}
+```
+
+- [ ] **Create `internal/hostexec/local_other.go` with this complete content.**
+
+<!-- plan8:file internal/hostexec/local_other.go -->
+```go
+//go:build !unix
+
+package hostexec
+
+import "context"
-- [ ] **Step 2: Recheck the source table.** Inspect branch commits, not only worktree files. Reconcile Plan 4's guarded retirement extension and Plans 5/6's construction seams before editing them. Keep this branch independently buildable when those integrations are not yet landed.
-- [ ] **Step 3: Turn D1–D7 into explicit dependency checks.** Missing policy/proof providers return a refusal, not a successful no-op. A compiled interface with no production provider is not completed runtime wiring. Keep the integration completion checklist in Task 8.
-- [ ] **Step 4: Write the flag-off regression harness first.** Inject spies for execution storage, claim reads, cleanup, cloud reads and floor admission. With no flag and no published floor, run existing serving/wake, local ops and console lifecycle fixtures: no new execution-lease writes, no cleanup invocation and unchanged legacy results. A serving host with the flag on still makes no execution renewals. Floor persistence after publication gets a separate test under D1.
+type localDir struct{}
-### Task 2: The specified execution object and conditional transport
+func openLocal(string) (*localDir, error) { return nil, ErrUnsupported }
+func (*localDir) close() {}
+func (*localDir) read(string) (State, error) { return State{}, ErrUnsupported }
+func (*localDir) write(State) error { return ErrUnsupported }
+func (*localDir) remove(string) error { return ErrUnsupported }
+func acquire(context.Context, string, string) (func(), error) { return nil, ErrUnsupported }
-**Files:** `internal/lease/execution_store.go`, `execution_store_test.go`; `internal/hostexec/record.go`, `record_test.go`.
+func tryAcquire(string, string) (func(), error) { return nil, ErrUnsupported }
+```
-- [ ] **Step 1: Add this narrow adapter.** It reuses the lease package's client configuration and the existing conditional implementation instead of copying an S3 lease engine. The replica lease's `Acquire`, `Held`, `Replicate`, TTL and format stay unchanged.
+- [ ] **Create `internal/hostexec/local_unix.go` with this complete content.**
+<!-- plan8:file internal/hostexec/local_unix.go -->
```go
-package lease
+//go:build unix
+
+package hostexec
import (
"context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
"errors"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
- "github.com/carlosframework/platform/internal/release"
+ "golang.org/x/sys/unix"
)
-type ExecutionStore interface {
- Get(context.Context, string) ([]byte, string, error)
- Put(context.Context, string, []byte, string) (string, error)
- DeleteIfMatch(context.Context, string, string) error
+type localDir struct {
+ fd int
+ sync func(int) error
}
-type executionStore struct{ store release.Store }
-
-func ExecutionStoreFrom(s release.Store) (ExecutionStore, error) {
- if s == nil {
- return nil, errors.New("lease: execution store required")
+func openDir(path string) (int, error) {
+ fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
+ if err != nil {
+ return -1, err
}
- if _, ok := s.(release.ConditionalDeleter); !ok {
- return nil, release.ErrConditionalDeleteUnsupported
+ var st unix.Stat_t
+ if err = unix.Fstat(fd, &st); err != nil {
+ unix.Close(fd)
+ return -1, err
}
- return executionStore{store: s}, nil
-}
-
-func NewExecutionStore(ctx context.Context, bucket, region string) (ExecutionStore, error) {
- if bucket == "" {
- return nil, errors.New("lease: execution bucket required")
+ if st.Mode&unix.S_IFMT != unix.S_IFDIR {
+ unix.Close(fd)
+ return -1, ErrState
}
- c, _, err := newLeaserClient(ctx, region)
+ return fd, nil
+}
+func openLocal(dataDir string) (*localDir, error) {
+ fd, err := openDir(filepath.Join(dataDir, ".hostexec"))
if err != nil {
return nil, err
}
- return ExecutionStoreFrom(&release.S3Store{Client: c, Bucket: bucket})
+ return &localDir{fd: fd, sync: unix.Fsync}, nil
}
-
-func (s executionStore) Get(ctx context.Context, key string) ([]byte, string, error) {
- return s.store.Get(ctx, key)
+func (d *localDir) close() { _ = unix.Close(d.fd) }
+func (d *localDir) read(host string) (State, error) {
+ var s State
+ if _, err := Key(host, "check"); err != nil {
+ return s, err
+ }
+ fd, err := unix.Openat(d.fd, host+".json", unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0)
+ if err != nil {
+ return s, err
+ }
+ f := os.NewFile(uintptr(fd), host)
+ defer f.Close()
+ var st unix.Stat_t
+ if err = unix.Fstat(fd, &st); err != nil {
+ return s, err
+ }
+ if st.Mode&unix.S_IFMT != unix.S_IFREG {
+ return s, ErrState
+ }
+ b, err := io.ReadAll(io.LimitReader(f, 65537))
+ if err != nil {
+ return s, err
+ }
+ if len(b) > 65536 {
+ return s, ErrState
+ }
+ if err = decode(b, &s); err != nil {
+ return s, err
+ }
+ if err = s.validate(); err != nil {
+ return s, err
+ }
+ if s.Host != host {
+ return s, ErrState
+ }
+ return s, nil
}
-
-func (s executionStore) Put(ctx context.Context, key string, body []byte, etag string) (string, error) {
- return s.store.Put(ctx, key, body, etag)
+func (d *localDir) write(s State) error {
+ if err := s.validate(); err != nil {
+ return err
+ }
+ if _, err := d.read(s.Host); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ var nonce [16]byte
+ if _, err := rand.Read(nonce[:]); err != nil {
+ return err
+ }
+ tmp := ".tmp-" + hex.EncodeToString(nonce[:])
+ fd, err := unix.Openat(d.fd, tmp, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0660)
+ if err != nil {
+ return err
+ }
+ defer unix.Unlinkat(d.fd, tmp, 0)
+ f := os.NewFile(uintptr(fd), tmp)
+ defer f.Close()
+ if err = unix.Fchmod(fd, 0660); err != nil {
+ return err
+ }
+ b, err := json.Marshal(s)
+ if err != nil {
+ return err
+ }
+ if _, err = f.Write(append(b, '\n')); err != nil {
+ return err
+ }
+ if err = d.sync(fd); err != nil {
+ return err
+ }
+ if err = f.Close(); err != nil {
+ return err
+ }
+ if err = unix.Renameat(d.fd, tmp, d.fd, s.Host+".json"); err != nil {
+ return err
+ }
+ return d.sync(d.fd)
+}
+func (d *localDir) remove(host string) error {
+ if _, err := d.read(host); err != nil {
+ return err
+ }
+ if err := unix.Unlinkat(d.fd, host+".json", 0); err != nil {
+ return err
+ }
+ return d.sync(d.fd)
+}
+func tryAcquire(dir, host string) (func(), error) {
+ if _, err := Key(host, "check"); err != nil {
+ return nil, err
+ }
+ fd, err := openDir(dir)
+ if err != nil {
+ return nil, err
+ }
+ defer unix.Close(fd)
+ flags := unix.O_RDWR | unix.O_CREAT | unix.O_NOFOLLOW | unix.O_CLOEXEC | unix.O_NONBLOCK
+ lock, err := unix.Openat(fd, host+".lock", flags|unix.O_EXCL, 0660)
+ created := err == nil
+ if errors.Is(err, unix.EEXIST) {
+ lock, err = unix.Openat(fd, host+".lock", flags, 0660)
+ }
+ if err != nil {
+ return nil, err
+ }
+ fail := func(e error) (func(), error) { unix.Close(lock); return nil, e }
+ var st unix.Stat_t
+ if err = unix.Fstat(lock, &st); err != nil {
+ return fail(err)
+ }
+ if st.Mode&unix.S_IFMT != unix.S_IFREG {
+ return fail(ErrState)
+ }
+ if created || st.Mode&07777 != 0660 {
+ if err = unix.Fchmod(lock, 0660); err != nil {
+ return fail(err)
+ }
+ }
+ err = unix.Flock(lock, unix.LOCK_EX|unix.LOCK_NB)
+ if err == nil {
+ return func() { _ = unix.Flock(lock, unix.LOCK_UN); _ = unix.Close(lock) }, nil
+ }
+ if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
+ return fail(ErrControllerBusy)
+ }
+ return fail(err)
}
-func (s executionStore) DeleteIfMatch(ctx context.Context, key, etag string) error {
- return release.DeleteIfMatch(ctx, s.store, key, etag)
+func acquire(ctx context.Context, dir, host string) (func(), error) {
+ for {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ unlock, err := tryAcquire(dir, host)
+ if !errors.Is(err, ErrControllerBusy) {
+ return unlock, err
+ }
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-time.After(10 * time.Millisecond):
+ }
+ }
}
```
-Use the deployment/control bucket, never the host's data bucket. A configured `release.Store` can be adapted directly, including existing prefix wrappers; do not add a fleet prefix unless that deployment's authoritative claim namespace uses it. This interface exposes no unconditional deletion or listing.
-
-- [ ] **Step 2: Add the wire definition and key validation.** This is the whole specified payload, not Litestream's `Lease` with renamed fields.
+- [ ] **Create `internal/hostexec/record.go` with this complete content.**
+<!-- plan8:file internal/hostexec/record.go -->
```go
package hostexec
import (
+ "bytes"
+ "encoding/json"
"errors"
+ "io"
+ "strings"
"time"
"github.com/carlosframework/platform/internal/controlname"
)
-const (
- TTL = 90 * time.Second
- RenewEvery = 20 * time.Second
-)
+const TTL = 90 * time.Second
+const Renewal = 20 * time.Second
+const Margin = 5 * time.Second
+var ErrControllerBusy = errors.New("hostexec: controller already holds lifetime lock")
+var ErrLost = errors.New("hostexec: lease lost")
+var ErrInactive = errors.New("hostexec: no active participation")
+var ErrState = errors.New("hostexec: invalid local state")
+var ErrUnsupported = errors.New("hostexec: suspend-aware clock unavailable")
+
+// Record is the complete remote wire object. It grants no claim authority.
type Record struct {
Op string `json:"op"`
Expires time.Time `json:"expires"`
}
+type Binding struct {
+ Box string `json:"box"`
+ Enrollment string `json:"enrollment"`
+ Host string `json:"host"`
+ Incarnation string `json:"incarnation"`
+ Op string `json:"op"`
+ BootID string `json:"boot_id"`
+ ControllerID string `json:"controller_id"`
+}
+
+type State struct {
+ V int `json:"v"`
+ Binding
+ LeaseETag string `json:"lease_etag"`
+ Expires time.Time `json:"expires"`
+ SafeUntil int64 `json:"safe_until_boottime_ns"`
+ State string `json:"state"`
+}
+
+func validID(s string) bool {
+ return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00\r\n\t ")
+}
+func (b Binding) valid() bool {
+ return controlname.ValidHostShape(b.Host) && validID(b.Box) && validID(b.Enrollment) &&
+ validID(b.Incarnation) && validID(b.Op) && validID(b.BootID) && validID(b.ControllerID)
+}
+func (s State) validate() error {
+ if s.V != 1 || !s.Binding.valid() || s.SafeUntil <= 0 || s.Expires.IsZero() {
+ return ErrState
+ }
+ switch s.State {
+ case "pending", "lost":
+ case "active":
+ if s.LeaseETag == "" {
+ return ErrState
+ }
+ default:
+ return ErrState
+ }
+ return nil
+}
+func decode(b []byte, dst any) error {
+ d := json.NewDecoder(bytes.NewReader(b))
+ d.DisallowUnknownFields()
+ if err := d.Decode(dst); err != nil {
+ return err
+ }
+ var extra any
+ if err := d.Decode(&extra); err != io.EOF {
+ return ErrState
+ }
+ return nil
+}
func Key(host, box string) (string, error) {
- if !controlname.ValidHostShape(host) || !controlname.ValidLabel(box) {
- return "", errors.New("hostexec: invalid host or box")
+ if !controlname.ValidHostShape(host) || !validID(box) {
+ return "", ErrState
}
return "control/hosts/" + host + "/exec/" + box + ".json", nil
}
+
+// BootNanos includes time spent suspended. Wall time is used only on the wire.
+type Clock interface {
+ Now() (wall time.Time, bootNanos int64, err error)
+ After(time.Duration) <-chan time.Time
+}
```
-Add a strict decoder: require a nonempty operation ID, nonzero expiry, one JSON value and nonempty ETag on an existing object. Null, malformed timestamps, trailing data and unknown fields refuse. Encode UTC RFC3339 through `time.Time`. Only `os.ErrNotExist` is absence; missing buckets, access denied and network errors remain errors. Never serialize a monotonic clock deadline as recoverable ownership.
+- [ ] **Create `internal/hostexec/review_test.go` with this complete content.**
+
+<!-- plan8:file internal/hostexec/review_test.go -->
+```go
+//go:build linux
+
+package hostexec
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+)
+
+func TestLostControllerCannotPublishStaleActiveState(t *testing.T) {
+ cfg, _, clock, _, _ := fixture(t)
+ c, err := Open(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer closeController(t, c)
+ active := ready(t, c)
+ c.mu.Lock()
+ c.loseLocked(ErrLost)
+ c.mu.Unlock()
+ <-c.cleanupDone
+ if err = c.publish(active); !errors.Is(err, ErrLost) {
+ t.Fatalf("stale publication = %v", err)
+ }
+ if err = CheckLocal(cfg.Guard.DataDir, active.Binding, clock, cfg.LockDir); err == nil {
+ t.Fatal("stale active state revived cross-process admission")
+ }
+}
+
+func TestWaitingPublicationDoesNotBlockWatchdogOrCleanup(t *testing.T) {
+ cfg, _, clock, _, cleaned := fixture(t)
+ c, err := Open(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer closeController(t, c)
+ active := ready(t, c)
+ held, release := make(chan struct{}), make(chan struct{})
+ guardDone := make(chan error, 1)
+ go func() {
+ guardDone <- cfg.Guard.WithLocal(context.Background(), active.Host, func() error {
+ close(held)
+ <-release
+ return nil
+ })
+ }()
+ <-held
+ published := make(chan error, 1)
+ go func() { published <- c.publish(active) }()
+ clock.advance(TTL)
+ // Ready samples the suspend-aware clock even if a watchdog wake races.
+ if _, err = c.Ready(t.Context()); err == nil {
+ t.Fatal("expired controller remained ready")
+ }
+ select {
+ case <-c.Lost():
+ case <-time.After(2 * time.Second):
+ t.Fatal("host guard blocked watchdog")
+ }
+ until(t, func() bool { return cleaned.Load() >= 2 })
+ close(release)
+ if err = <-guardDone; err != nil {
+ t.Fatal(err)
+ }
+ if err = <-published; !errors.Is(err, ErrLost) {
+ t.Fatalf("publication after expiration = %v", err)
+ }
+ <-c.cleanupDone
+ if err = CheckLocal(cfg.Guard.DataDir, active.Binding, clock, cfg.LockDir); err == nil {
+ t.Fatal("expired publication admitted work")
+ }
+}
-- [ ] **Step 3: Write the conditional contract tests.** Use `release.DirStore` for deterministic object races and the existing conditional-store wire test pattern for HTTP. Two creators contend; exactly one succeeds. A stale renewal cannot replace the winner, and a stale release cannot delete it. Empty ETag release and unsupported conditional-delete stores send no delete. A control object key is exact; invalid hosts/box labels send no request. The same tests exercise a prefixed store. Verify existing replica lease tests still pass.
-- [ ] **Step 4: Observe failure, then implement the decoder.** Run `go test ./internal/lease ./internal/hostexec -run 'Execution|Record|Key' -count=1`. Expected: all defined contract tests pass; the runtime controller is the next task, not implied by passing transport tests.
+func TestOrphanActiveStateCannotAuthorizeAfterControllerExit(t *testing.T) {
+ cfg, _, clock, _, _ := fixture(t)
+ c, err := Open(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ active := ready(t, c)
+ closeController(t, c)
+ // Simulate an active rename surviving a crash before its fsync outcome was
+ // known. The persisted deadline is still future, but no controller lives.
+ d, err := openLocal(cfg.Guard.DataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.close()
+ if err = d.write(active); err != nil {
+ t.Fatal(err)
+ }
+ if err = CheckLocal(cfg.Guard.DataDir, active.Binding, clock, cfg.LockDir); !errors.Is(err, ErrLost) {
+ t.Fatalf("orphan active state = %v", err)
+ }
+}
-### Task 3: Renewal, expiry and the first-phase acknowledgement barrier
+type blockedClaimRead struct {
+ base *claims
+ entered chan struct{}
+ release chan struct{}
+}
-**Files:** `internal/hostexec/controller.go`, `controller_test.go`.
+func (r blockedClaimRead) Read(ctx context.Context, host string) (hostclaim.Observed, error) {
+ close(r.entered)
+ select {
+ case <-ctx.Done():
+ return hostclaim.Observed{}, ctx.Err()
+ case <-r.release:
+ return r.base.Read(ctx, host)
+ }
+}
-**Interfaces:** consume `lease.ExecutionStore`, an injected clock/timer factory, a fresh claim reader, a serialized local-state owner and a cleanup callback. Expose lease health, cancellation and a bounded release operation. Keep the clock and timer together; replacing `Now` while retaining real timers does not make expiry deterministic.
+func TestCC9LockDoesNotReviveOrphanDuringClaimRead(t *testing.T) {
+ cfg, r, clock, _, _ := fixture(t)
+ c, err := Open(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ active := ready(t, c)
+ closeController(t, c)
+ d, err := openLocal(cfg.Guard.DataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.close()
+ if err = d.write(active); err != nil {
+ t.Fatal(err)
+ }
+ blocked := blockedClaimRead{base: r, entered: make(chan struct{}), release: make(chan struct{})}
+ cfg.Guard.Claims = blocked
+ done := make(chan error, 1)
+ go func() { done <- ClearStale(t.Context(), cfg) }()
+ <-blocked.entered
+ checkErr := CheckLocal(cfg.Guard.DataDir, active.Binding, clock, cfg.LockDir)
+ close(blocked.release)
+ if checkErr == nil {
+ t.Fatal("CC9 lifetime lock revived orphan active bytes")
+ }
+ if err = <-done; !errors.Is(err, ErrInactive) {
+ t.Fatalf("CC9 with participation retained = %v", err)
+ }
+}
-- [ ] **Step 1: Write the fake-clock state-machine tests before the controller.** Use channels to block storage calls. Never sleep for the real 90-second TTL.
+func TestCC9SuccessorExclusionClearsOnlyExpectedOperation(t *testing.T) {
+ cfg, r, _, _, _ := fixture(t)
+ c, err := Open(t.Context(), cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ready(t, c)
+ closeController(t, c)
+ r.mu.Lock()
+ r.q.Op.ID = "successor-op"
+ r.q.Op.Exec = "other-box"
+ r.q.Op.Participants = []string{"other-box"}
+ r.mu.Unlock()
+ if err = ClearStale(t.Context(), cfg); err != nil {
+ t.Fatalf("successor excludes box: %v", err)
+ }
+ d, err := openLocal(cfg.Guard.DataDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.close()
+ if _, err = d.read(cfg.Binding.Host); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("expected stale record removed: %v", err)
+ }
+ unrelated := State{V: 1, Binding: cfg.Binding, State: "lost", Expires: time.Now().Add(TTL), SafeUntil: 99}
+ unrelated.ControllerID = "other-controller"
+ unrelated.Op = "unrelated-op"
+ if err = d.write(unrelated); err != nil {
+ t.Fatal(err)
+ }
+ if err = ClearStale(t.Context(), cfg); !errors.Is(err, ErrState) {
+ t.Fatalf("unrelated local fence: %v", err)
+ }
+ still, err := d.read(cfg.Binding.Host)
+ if err != nil || still.Op != unrelated.Op {
+ t.Fatalf("unrelated fence changed: %v, %v", still, err)
+ }
+}
+```
-| Witness | Required result |
-|---|---|
-| Acquire at t=0 | Fresh permitted participant; exact record has expiry t=90; keep returned ETag |
-| Renew at t=20 | Conditional PUT for the same operation; acknowledged new expiry t=110 |
-| Renewal blocked through t=90 | Independent deadline callback closes admission and starts cleanup without waiting for the PUT |
-| Renew response arrives at/after old expiry | Handle remains lost; returned ETag may be retained only for conditional cleanup |
-| Failed/ambiguous renewal | No extension from the attempted expiry; certainly fence by the last acknowledged expiry |
-| Explicit conflict or current claim excludes this operation | Refuse host work immediately; no blind reacquire |
-| Old controller resumes after successor owns object | Old PUT/DELETE fails conditionally; successor remains intact |
-| Agent restarts with saved future expiry | Saved timestamp grants no ownership; reconcile with fresh claim and a new acknowledged lease |
-| Free/serving or nonparticipant fresh read | Clear only old local execution state; no renewal and no new admission from that action |
-| Claim unreadable/malformed/absent unexpectedly | Refuse new authority; do not infer no operation |
-| Serving host loses bucket access | No execution lease to expire; this controller does not kill serving traffic |
-| Lease acquired, claim changes before work | Fresh operation/phase validation refuses the work |
+- [ ] **Run the task tests.**
-- [ ] **Step 2: Implement establishment and renewal.** A local controller captures host, box and operation. Serialize controllers for that host/box using the reviewed D6 mechanism. Read the claim directly and refuse a lost box. Create an absent execution object conditionally; an existing live foreign operation record is not silently stolen. Resolve superseded records only against a fresh claim with conditional replacement. Initial/renewal expiry is based on the attempt's start, not a late response's arrival. A successful response that arrived too late never grants usable time.
+```bash
+go test ./internal/hostexec ./internal/hostguard -race -count=1
+go test ./internal/lease -run ExecutionStore -count=1
+```
-Keep renewal every 20 seconds distinct from TTL/3 (which would be 30 seconds). Schedule a separate expiry watcher from the last acknowledged deadline so one blocked network call cannot block self-fencing. At the deadline, atomically latch denial before dispatching cleanup. Cancel requests on loss, tolerate SDK calls returning late, and never use their completion to reopen the latch. On process shutdown, retain denial until host cleanup or an authorized handover completes; stopping the renewer alone is not cleanup.
+## Task 4: Flag-gated CLI and console refusal paths
-- [ ] **Step 3: Add the barrier.** Every operation owner must leave its initial phase only after reading the exact current execution object for **each participant**, with matching operation ID and unexpired `expires`. Capture the participant set from one claim observation, then revalidate that observation's operation, phase and participant set before its conditional transition. Missing, expired, malformed or foreign-op records leave the first phase unchanged. Do not infer acknowledgement from a LIST result. A genuinely empty participant set is vacuous, never inferred after a failed read. A declare-lost change to participants invalidates a pending barrier decision and requires a fresh attempt.
+**Produces:** `carlos ops host resolve <host> --declare-lost <box>` and `--readmit` dispatch, plus person/operator-only inspection and submission routes. **Consumes:** the current API session and console permission mechanisms. Both routes stop at the required P2/P3 refusal; the CLI sends only inspection and never invokes a cloud write or claim mutation. `--yes` is parsed but cannot override the refusal. The new route is absent from the console mux when the boot-captured flag is off.
-The barrier proves observation of the operation, not absence of a writer. Keep it separate from stop-evidence collection and from permission to start a writer. Later phases continue lease renewal. Changes to the participant set must establish the newly required acknowledgements before that participant is relied on; do not reset a lease merely because the operation's phase changed.
+- [ ] **Apply the exact existing-file changes.**
-- [ ] **Step 4: Test barrier races.** Block one participant's PUT, replace the operation while its response is pending, and attempt first-phase advance. Repeat with an expired object, ETag conflict, participant added/removed and whole-VM suspension. Assert zero phase advances without the exact required acknowledgements and zero compute-fence evidence manufactured from lease state.
-- [ ] **Step 5: Run** `go test ./internal/hostexec -count=1 -race` and the unchanged `internal/lease` tests. Check for leaked renewal goroutines and release bounded independently of a dead request context.
+```diff
+--- a/main.go
++++ b/main.go
+@@ -15 +14,0 @@
+- "github.com/carlosframework/platform/internal/ops"
+@@ -136 +135 @@
+- ops.Run(args)
++ runOps(args)
+--- a/cmd/console/main.go
++++ b/cmd/console/main.go
+@@ -294,0 +295 @@
++ HostClaim: hostClaimSwitch(os.Getenv("CARLOS_HOST_CLAIM")),
+--- a/internal/console/server.go
++++ b/internal/console/server.go
+@@ -510,0 +511,2 @@
++ // HostClaim is CARLOS_HOST_CLAIM captured once at console boot.
++ HostClaim bool
+--- a/internal/console/authcli.go
++++ b/internal/console/authcli.go
+@@ -483,0 +484,7 @@
++ if s.cfg.HostClaim {
++ // Host loss inspection and submission are person-only and repeat the
++ // fleet-operator check in their handlers. Until the follow-on integration supplies the
++ // ActorDirectory both routes stop at the same inert refusal.
++ s.cliRoute("POST /api/cli/ops/hosts/{host}/resolve/inspect", svcrole.Set{}, s.handleAPIHostResolveInspect)
++ s.cliRoute("POST /api/cli/ops/hosts/{host}/resolve", svcrole.Set{}, s.handleAPIHostResolve)
++ }
+```
-### Task 4: Minimal box self-fence wiring
+- [ ] **Create `hostclaim_cli.go` with this complete content.**
-**Files:** `internal/host/hostexec.go`, `hostexec_test.go`, `agent.go`; Plan 6's concrete guarded admission seams and `internal/activator/maintenance.go` where necessary.
+<!-- plan8:file hostclaim_cli.go -->
+```go
+package main
-- [ ] **Step 1: Construct only behind the flag.** Observe active claims through Plan 6's reconciliation work; start one controller for each local participant, including participants that are not `op.exec`. Do not wait for the five-minute status tick. Plan 5 and moves' first-phase transitions consume Task 3's barrier. Plan 6 still owns all original holder, phase, stamp, config and release checks.
-- [ ] **Step 2: Fence before stopping.** On loss, latch host denial shared by wake/Seed/probes, background local writers, schedule delivery and root passes. Cancel host-specific work. Use `activator.Service.FenceAndStop` to close and drain known admission attempts; reopen the actual per-host cgroup with `cgroup.OpenCleanup`, then `stopevidence.Cleanup`/`KillAndEmpty`. Handle already-running processes absent from activator memory. Never call `PrepareStrict` as a way of cleaning a populated group.
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net/url"
+ "os"
+ "strings"
-Do not put the emergency kill behind `Guard.With`: that helper reads the control claim and requires a current holder, precisely what an outage or retirement may remove. Cleanup is a narrowly scoped stop-only action on an already identified host tree. It grants no write, stamp activation or replacement spawn. The cross-process denial and lock ordering must allow cleanup while an ordinary guarded operation is stuck in I/O; implement D6 before claiming this property.
+ boxops "github.com/carlosframework/platform/internal/ops"
+)
-- [ ] **Step 3: Stop replication and retain evidence boundaries.** Cancel generation-bound replication and close/join its worker. Existing `Replicator.Close` performs a final sync; it is not proof that no more writes were sent. Integrate the cancellation/closure contract with Plan 6's writer session, including in-flight requests and the existing late-completion horizon. Do not publish a clean certificate on an ambiguous close. Do not call test-only `Abandon` as production fencing.
+const hostClaimPendingRuling = "awaiting Paul's ruling P2/P3 (amadan #236)"
-When a lifecycle phase needs stop proof, collect it separately with `stopevidence.Collect`: correct admission fence, drained spawns, `populated 0`, empty members, closed replicators and complete DB/WAL/SHM descriptor scan. Missing delegation, absent cgroup with possible escaped children, unreadable `/proc` or cleanup failure keeps admission closed and the operation pending. Alert and retry cleanup. A self-fence attempt does not create a lost-box fence.
+func hostClaimEnabled(raw string) bool { return strings.TrimSpace(raw) != "" }
-- [ ] **Step 4: Implement CC9 recovery.** A fresh no-operation/nonparticipant read ends only the stale execution-lease requirement, removes its local state and conditionally releases its object if still owned. The local state removal must not wait for remote release success. If a new operation requires this box, acquire its new lease before allowing its work. Keep every unrelated activator fence and the quarantine deny. Recover a new writer through the normal admission and certified-remote restore path; never reuse the old database or reattach an old agent session.
-- [ ] **Step 5: Write integration witnesses.** Lease failure while the claim store is blocked still kills the whole tree. Include a surviving child, empty activator state, stalled renewal, independent root writer, scheduled wake and sidecar membership. Denial is visible before cleanup; cleanup failure never reports success. A fresh serving claim clears stale lease state, while a `Lost` box stays denied. Unsupported cleanup platforms refuse safely and still compile.
+type hostResolveOptions struct {
+ Host string
+ Action string
+ Box string
+ Yes bool
+ Console string
+}
-### Task 5: Operator-only declare-lost and evidence-backed claim changes
+// runOps leaves the existing box-side ops surface byte-for-byte alone while
+// the claim feature is off. The remote host family only exists behind the
+// explicit deployment flag.
+func runOps(args []string) {
+ if hostClaimEnabled(os.Getenv("CARLOS_HOST_CLAIM")) && len(args) > 0 && args[0] == "host" {
+ runOpsHost(args[1:])
+ return
+ }
+ boxops.Run(args)
+}
-**Files:** `opshost.go`, `opshost_test.go`, `main.go`, `internal/ops/ops.go`; `internal/awsinv/fence.go`, `fence_test.go`; `internal/console/apihostresolve.go`, `apihostresolve_test.go`, `authcli.go`; `internal/hostclaim/lost.go`, `lost_test.go`.
+func parseHostResolve(args []string) (hostResolveOptions, error) {
+ var o hostResolveOptions
+ if len(args) == 0 || args[0] != "resolve" {
+ return o, errors.New("usage: carlos ops host resolve <host> --declare-lost <box> [--yes] [--console <url>]")
+ }
+ args = args[1:]
+ for i := 0; i < len(args); i++ {
+ a := args[i]
+ next := func() (string, error) {
+ if i+1 >= len(args) {
+ return "", fmt.Errorf("%s needs a value", a)
+ }
+ i++
+ return args[i], nil
+ }
+ switch {
+ case a == "--yes":
+ o.Yes = true
+ case a == "--declare-lost" || a == "--readmit":
+ if o.Action != "" {
+ return o, errors.New("--declare-lost and --readmit are mutually exclusive")
+ }
+ v, err := next()
+ if err != nil {
+ return o, err
+ }
+ o.Action, o.Box = strings.TrimPrefix(a, "--"), v
+ case a == "--console":
+ v, err := next()
+ if err != nil {
+ return o, err
+ }
+ o.Console = v
+ case strings.HasPrefix(a, "--console="):
+ o.Console = strings.TrimPrefix(a, "--console=")
+ case strings.HasPrefix(a, "-"):
+ return o, fmt.Errorf("unknown option %q", a)
+ case o.Host == "":
+ o.Host = a
+ default:
+ return o, fmt.Errorf("unexpected argument %q", a)
+ }
+ }
+ if o.Host == "" || o.Action == "" || o.Box == "" {
+ return o, errors.New("resolve needs a host and exactly one of --declare-lost <box> or --readmit <box>")
+ }
+ return o, nil
+}
-- [ ] **Step 1: Add remote dispatch without changing local verbs.** Route `ops host` to root-package `runOpsHost`; forward other `ops` invocations to existing `ops.Run`. Parse `host resolve` and its positional host explicitly, then parse following flags: standard `flag.FlagSet` stops at the first positional argument. Test the spec's exact order:
+func runOpsHost(args []string) {
+ o, err := parseHostResolve(args)
+ if err != nil {
+ log.Fatal(err)
+ }
+ sess := requireDeployAPISession(o.Console)
+ if err := sess.inspectHostResolve(context.Background(), o); err != nil {
+ log.Fatal(err)
+ }
+ // the follow-on integration replaces this impossible branch when ActorDirectory exists.
+ log.Fatal(hostClaimPendingRuling)
+}
-```text
-carlos ops host resolve <host> --declare-lost <box> --instance-id <id> --yes
-carlos ops host resolve <host> --readmit <box> --yes
+func (s *apiSession) inspectHostResolve(ctx context.Context, o hostResolveOptions) error {
+ status, body, err := s.postJSON(ctx, "/api/cli/ops/hosts/"+url.PathEscape(o.Host)+"/resolve/inspect", map[string]any{
+ "action": o.Action, "box": o.Box, "confirmed": o.Yes,
+ })
+ if err != nil {
+ return err
+ }
+ if status != 200 {
+ return apiErrorFromBody(status, body)
+ }
+ return nil
+}
```
-The host/declare-lost shape is specified. `--instance-id` and mandatory `--yes` are this plan's option names for the required operator inputs. Obtain deployment/account/region binding from the authorized inspect response under D2; do not guess a region from the operator's default profile. `--declare-lost` and `--readmit` are mutually exclusive. Missing confirmation produces the preview and exits without a mutation; there is no implicit noninteractive approval. Require an API session through `requireDeployAPISession`; there is no box-local registry or direct-bucket fallback for claim changes.
+- [ ] **Create `hostclaim_cli_test.go` with this complete content.**
+
+<!-- plan8:file hostclaim_cli_test.go -->
+```go
+package main
+
+import "testing"
+
+func TestHostClaimFlagUsesTheFleetConvention(t *testing.T) {
+ for _, raw := range []string{"1", "0", "on", " true "} {
+ if !hostClaimEnabled(raw) {
+ t.Errorf("hostClaimEnabled(%q) = false", raw)
+ }
+ }
+ for _, raw := range []string{"", " ", "\t\n"} {
+ if hostClaimEnabled(raw) {
+ t.Errorf("hostClaimEnabled(%q) = true", raw)
+ }
+ }
+}
-- [ ] **Step 2: Add authenticated inspect and resolve routes.** Planned endpoints are `GET /api/cli/ops/hosts/{host}` and `POST /api/cli/ops/hosts/{host}/resolve`. Register with `svcrole.Set{}`. Require a person principal and `Store.IsOperator`; reject failed role reads. Nonoperators, service credentials and instance tokens receive the normal byte-identical 404 before host existence is disclosed. Reuse API session, mutation budget and audit plumbing. Do not substitute `apiSystemRoller`: permission to roll a binary is not permission to declare host data lost.
+func TestParseHostResolveAcceptsDocumentedInterspersedShape(t *testing.T) {
+ o, err := parseHostResolve([]string{"resolve", "app.example.com", "--declare-lost", "box-1", "--yes", "--console", "https://console.example"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if o.Host != "app.example.com" || o.Action != "declare-lost" || o.Box != "box-1" || !o.Yes || o.Console != "https://console.example" {
+ t.Fatalf("options = %+v", o)
+ }
+}
-The inspect response supplies the fresh claim identity, operation/phase, relevant record commit state, executor, participant, bound cloud instance and the consequences to confirm. Carry that exact observation into resolve. The mutation handler re-reads all authority-bearing state and refuses a changed operation, phase, incarnation, record commit or box identity; it does not silently retry against a new operation. D2 defines the durable evidence and request binding before this endpoint may mutate anything.
+func TestParseHostResolveRefusesAmbiguousAction(t *testing.T) {
+ if _, err := parseHostResolve([]string{"resolve", "app.example.com", "--declare-lost", "box-1", "--readmit", "box-1"}); err == nil {
+ t.Fatal("accepted both actions")
+ }
+}
+```
-- [ ] **Step 3: Verify the operator-completed compute fence.** Add a narrow read helper alongside `awsinv`'s existing EC2 reader, using the same SDK configuration and injectable `DescribeInstances` interface. Load **the invoking operator's** credential chain in the CLI, never console credentials. Query the supplied exact instance ID in the bound account/region and require exactly one matching instance in `stopped` or `terminated`. Refuse `pending`, `running`, `stopping`, `shutting-down`, missing/ambiguous results, mismatched identity, denied credentials and network failure. Do not reuse `infra/inventory.json` for state. Do not call stop/terminate, create credentials, or widen console IAM.
+- [ ] **Create `internal/console/apihostclaim.go` with this complete content.**
-Print the host, box, bound instance/account/region, current operation and phase, execution authority to revoke, local chores to discharge, quarantine and applicable write/timer loss **before** the POST. For a move include the flip time and dirty-return condition. The confirmation is bound to this preview, including D2's operator fencing attestation. Changing state invalidates it and requires a fresh preview. The command verifies completion through the returned claim and the inspect API, with no follow-up on-box action.
+<!-- plan8:file internal/console/apihostclaim.go -->
+```go
+package console
-- [ ] **Step 4: Implement pure transitions after D2/D5.** A transition takes a validated evidence value, exact expected operation/kind/phase and the approved consequence set. `hostclaim.Update` supplies the ETag and resulting `Gen`; the transition must also compare operation identity. The same CAS records quarantine and loss-recovery intent before any cross-object effect, revokes only the lost box's execution/participation authority and records the matching holder stamp when authority changes (R6-4). Never issue a new writer session here.
+import (
+ "log"
+ "net/http"
+)
-Use `CompleteObligation` semantics for discharged chores so adoption discovery completion is not stranded. Match exact chore ID and its own operation, not just `Owner == box`. Preserve unrelated obligations, holder/admission history, region scope, quiet times and retired identities. A retry whose exact result is already durably recorded is idempotent; a retry against another operation is stale. Persist fence identity/time and consequences in the D2/D4 record so releasing `Op.Journal` cannot erase the evidence or timer-loss declaration. Cross-object audit/alert delivery retries from that durable state; a lost HTTP response cannot cause a second revocation.
+const hostClaimPendingRuling = "awaiting Paul's ruling P2/P3 (amadan #236)"
-- [ ] **Step 5: Cover the legal recovery cases.** Delegate move-specific transitions to moves' recovery owner; do not recreate them using a generic `op = serving` assignment.
+// hostClaimRefusal applies the D2 authority boundary before reporting that
+// the follow-on integration's ActorDirectory is unavailable. It deliberately performs no claim,
+// directory or cloud write.
+func (s *Server) hostClaimRefusal(w http.ResponseWriter, r *http.Request) {
+ addr, ok := s.cliUser(w, r)
+ if !ok {
+ return
+ }
+ isOp, err := s.store.IsOperator(addr)
+ if err != nil {
+ log.Printf("console: host claim operator lookup: %v", err)
+ http.NotFound(w, r)
+ return
+ }
+ if !isOp || !s.cfg.HostClaim {
+ http.NotFound(w, r)
+ return
+ }
+ jsonError(w, http.StatusServiceUnavailable, hostClaimPendingRuling)
+}
-| State | Permitted action after verified compute fencing |
-|---|---|
-| Exact move target owes stop evidence before commit | Apply moves' recovery/rollback transition; recover validated durable target data when available. Confirmation names operation, phase, flip and write loss. Never accept the source. |
-| Exact target released dirty but did not return writes | Same precommit restrictions. Account for unreturned data and queue moves' partial-return-lane `park-staging` obligation in the source region in the same recovery CAS. Respect its staging horizon. |
-| Destroyed box owes only `park-local`, including after commit | Discharge only those local chores and quarantine the box; no data/executor reassignment. A stopped instance alone does not prove the disk was destroyed. |
-| Other lifecycle recovery | Only the operation owner's defined evidence-backed transition. No automatic takeover, no dropped remote chore and no generic emergency free. |
+func (s *Server) handleAPIHostResolveInspect(w http.ResponseWriter, r *http.Request) {
+ s.hostClaimRefusal(w, r)
+}
-For moves, check **the instance record's commit CAS**, not only the claim phase: the record may be committed before the claim follows. Use the same conditional record rollback as the normal move path so resolve cannot race a commit. The postcommit local-only case is the explicit exception. Current Plan 1 validation does not implement moves' `park-staging`; integrate that with the moves owner, not by relabelling it `park-local`.
+func (s *Server) handleAPIHostResolve(w http.ResponseWriter, r *http.Request) {
+ s.hostClaimRefusal(w, r)
+}
+```
-- [ ] **Step 6: Preserve loss accounting.** A remote-only baseline is not `sealed`, and a lost-target return is not `clean-return`. Let the next genuine `AdmitWriter` record its predecessor's `writes_lost`, using the last admission (holder fallback only when admissions are empty), real remote maximum and alert. Implement the additional source cut-session loss when `src_covered` is false using D4's agreed representation. Record registered timer loss on the console's durable declaration even if no replacement writer is ever admitted. Do not report unknown counts or TXIDs as zero.
-- [ ] **Step 7: Test refusals before any mutation.** Include no `--yes`, no cloud proof, expired lease only, live/stopping box, wrong instance/account/region, stale evidence, partial cloud response, unauthorized caller, source, nonparticipant, record-committed target and claim ETag race. Snapshot claims, records, chores and remote data and assert no changes. Positive tests cover both target-loss cases, destroyed-local-only after commit, stale retry, durable audit replay, timer loss, second write loss and preservation of regional obligations.
+- [ ] **Create `internal/console/apihostclaim_test.go` with this complete content.**
-### Task 6: Returning-box quarantine and readmission
+<!-- plan8:file internal/console/apihostclaim_test.go -->
+```go
+package console
-**Files:** `internal/host/hostquarantine.go`, `hostquarantine_test.go`; claim/console readmission transitions and Plan 4/6 startup integration.
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
-- [ ] **Step 1: Implement D3's global deny before startup work.** The startup gate precedes reconcile, wake, Seed, unit reattachment, timer delivery, replication and config/release writes. Read the claim for every local inventory host. Build the required inventory from the canonical persisted inventory and local artifacts; an empty activator map is not inventory. Unknown/malformed inventory or claim reads keep admission closed. A serving host's no-lease exception does not bypass quarantine.
-- [ ] **Step 2: Retire locally on the returning box.** For each affected host run §6.3 local retirement under Plan 4's extended guard, including DB/WAL/SHM, keys, config/env, markers, registry and timers. Preserve the captured old incarnation/stamp. It must work after the claim becomes free or names another holder, without retiring the successor's installation. Use the same lock and durable artifact journal; do not relax `Guard.With` to admit ordinary writes without a holder. Retrying a crash midway resumes retirement and never restores old files. The console cannot retire another machine's disk by deleting an inventory row.
-- [ ] **Step 3: Publish and verify retirement completion.** D3 defines the evidence's identity and durability. The operator's `--readmit` must inspect that completion through the console, refuse missing/partial/stale proof and remove only the matching quarantine revision after **every** affected old state is retired. Retry cross-host updates conservatively: an incomplete readmission leaves the global deny in force. A concurrent loss declaration wins over an older readmission attempt. Removing quarantine grants no stale executor/stamp/session; subsequent starts use fresh admission and certified-remote restore.
-- [ ] **Step 4: Test the scope.** Declare box A lost for host H1, then attempt H2 admission and successor H1 creation/serving. All A admissions refuse. Release/free/recreate H1 and verify `Lost` persists. Reboot A with stale files, missing inventory entries, a new holder, malformed claims and a half-finished park journal. Readmit refuses until complete retirement. A fresh serving read clears an execution fence but not quarantine. A reused box label with a different instance ID cannot supply proof for the old instance.
+ "github.com/carlosframework/platform/internal/release"
+)
-### Task 7: One floor for lifecycle admission and every executable activation
+func TestHostClaimRoutesArePersonOperatorFlagGatedAndInert(t *testing.T) {
+ st := openTestStore(t)
+ rel := release.NewDirStore(t.TempDir())
+ disabled, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", Releases: rel}, st)
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := "/api/cli/ops/hosts/app.example.com/resolve/inspect"
+ ghostStatus, ghostBody := serveHostClaim(t, disabled, "/unknown-host-claim-route", "")
+ if status, body := serveHostClaim(t, disabled, path, ""); status != ghostStatus || !bytes.Equal(body, ghostBody) {
+ t.Fatalf("flag-off route = %d %q, want unknown route %d %q", status, body, ghostStatus, ghostBody)
+ }
-**Files:** `internal/hostfloor/*`; status reporting and console gates; `internal/host/systemapply.go`, `systemstate.go`, `systemfloor.go`; both system update/recovery unit files and their tests.
+ token := mintHostClaimToken(t, st, "paul@keymail.dev")
+ enabled, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", Releases: rel, HostClaim: true}, st)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if status, _ := serveHostClaim(t, enabled, path, ""); status != http.StatusUnauthorized {
+ t.Fatalf("unsigned enabled status = %d", status)
+ }
+ if status, _ := serveHostClaim(t, enabled, path, token); status != http.StatusNotFound {
+ t.Fatalf("enabled member status = %d", status)
+ }
+ if err := st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ status, body := serveHostClaim(t, enabled, path, token)
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("flag-on status = %d, body %s", status, body)
+ }
+ var out map[string]string
+ if err := json.Unmarshal(body, &out); err != nil {
+ t.Fatal(err)
+ }
+ if out["error"] != hostClaimPendingRuling {
+ t.Fatalf("refusal = %q", out["error"])
+ }
-- [ ] **Step 1: Parse the existing shared contract.** Implement a typed floor reader over `release.Store` with key `control/moves-protocol.json`, fields `min` and `min_release`. Distinguish absent, malformed and unreadable. Require a supported positive protocol and valid specified release identity. Leave `vX.Y.Z` out of production defaults. Reads never publish the object. Initial publication is create-only by the operator; an existing different object is a conflict, not overwritten by startup.
+ status, body = serveHostClaim(t, enabled, "/api/cli/ops/hosts/app.example.com/resolve", token)
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("mutation route = %d, body %s", status, body)
+ }
+}
-Resolve D1 before wiring comparison. Use `release.ParseSemVer`/`SemVer.Compare` for actual release tags, with deliberate leading-`v` normalization; they do not order a short SHA. Match existing `version.Build`, signed `SystemPointer.Current.Version`/artifact hashes, `systemApplied` and journal values through the approved release identity contract. `System.Outcome == applied` is not proof of the currently running binary. Unknown/dev builds refuse protected activation unless D1 supplies authenticated identity; no `force` bypass.
+func mintHostClaimToken(t *testing.T, st *Store, address string) string {
+ t.Helper()
+ now := time.Now()
+ userCode, deviceCode, err := st.NewCLIAuth("test@host", "192.0.2.1", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := st.ApproveCLIAuth(userCode, address, now); err != nil {
+ t.Fatal(err)
+ }
+ status, token, _, err := st.PollCLIAuth(deviceCode, now)
+ if err != nil || status != CLIAuthApproved || token == "" {
+ t.Fatalf("mint token = %q %v", status, err)
+ }
+ return token
+}
-- [ ] **Step 2: Publish capability additively and gate a complete census.** Add `moves_protocol` to the private box report and report the console's own capability and running identity. Missing fields from old reports mean unsupported. Update `status` schema/privacy tests so private identity does not leak into public fleet output. Bind reports to existing instance/machine identity and apply the D7 freshness/completeness rules. Reuse `Metrics.Release` for the running build; do not infer it from the channel target.
+func serveHostClaim(t *testing.T, srv *Server, path, bearer string) (int, []byte) {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{}`))
+ req.Header.Set("Content-Type", "application/json")
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, req)
+ return rec.Code, rec.Body.Bytes()
+}
+```
-Plan 5's create/delete/purge/placement starts and moves' request door/T1 call the shared admission check before writing intent or effects. Check all boxes that could serve the host, every required participant and every relevant console; include both source and target regions for moves. Include offline/unknown enrolled boxes rather than dropping them from the set. Below-floor/unknown actors refuse protected operations. Preserve today's delete behavior plus the stopgap where §10 explicitly keeps legacy behavior; never start a partial claim-based delete and then fall through to legacy cleanup. Recheck authority at the operation's start CAS. A held participant cannot downgrade, even to another build numerically above the floor, until its operation permits it.
+- [ ] **Create `cmd/console/hostclaim_flag.go` with this complete content.**
-- [ ] **Step 3: Install the root floor check before executable selection.** D1 must give root a durable, trusted floor and a checker usable when the network or candidate binary is broken. Persist it using the existing root durability/ownership boundary, never as an agent assertion. Validate `.prev` and any outstanding journal before allowing the floor to become active. Keep it outside files restored from the old update journal. Read failure after publication refuses activation; it does not mean no floor. Refusal preserves evidence/journal and prevents a later shell fallback from selecting an older executable.
+<!-- plan8:file cmd/console/hostclaim_flag.go -->
+```go
+package main
-Both existing unit shims currently execute `.prev` directly on an open journal. Change them to use the D1-approved trusted pre-exec enforcement path. A check inside the new binary alone is insufficient: the shim may choose an older binary that contains no check. The checker must not depend solely on executing the failed candidate. Review the bootstrap/recovery design and trust boundary before adding its executable or persisted metadata; this plan does not invent that missing release protocol.
+import "strings"
-- [ ] **Step 4: Apply one check at all paths in this matrix.** Where identity probes execute a binary's `version`, distinguish inspection from activating it as bootstrap, updater or serving process. Verify the selected bytes against the approved identity before activation and prevent a path replacement between verification and use.
+// hostClaimSwitch follows the fleet's feature-flag convention: any nonempty
+// trimmed value enables the flag, including "0".
+func hostClaimSwitch(raw string) bool { return strings.TrimSpace(raw) != "" }
+```
-| Entry / function | Required floor boundary |
-|---|---|
-| Console `handleAPISystemUpdate` and `handleAPISystemRollback` | Refuse publishing a below-floor target or prohibited participant downgrade, including explicit rollback into pointer history and owner-uploaded local builds. Check before pointer mutation. |
-| `systemApplier.Run` | Load persistent policy before recovery or no-op shortcuts; inspect an outstanding journal before executing either side. Recover-mode is not an exemption. |
-| `stageCandidate` → `install` | Keep signed pointer/SHA checks; check the verified staged candidate before `stagePlan` or rename to the live path. Revalidate before activation if policy changed. |
-| `decidePrev` | A retained/refreshed `.prev` must be floor-compatible before it is treated as a rollback target. Do not overwrite a valid recovery choice with an incompatible current binary. Before first publication, old behavior may prepare the upgrade; the rollout must fix `.prev` before publication. |
-| `stagePlan` | This executes a binary's bootstrap code. Check its identity for both `new` and `prev`; validate any reused staged plan against that same approved binary. |
-| `rollback` → `restoreFiles` | Check actual `.prev`, its approved identity and the journal before quiescing, copying, restoring units, restarting or arming. A journal's `PrevBuild` string alone is not proof of the file. |
-| `recoverIfNeeded`, pre-gate | Check `.prev` and journal before the same restore; cover boot (no restart) and runtime (`restart --no-block`). Preserve refusal state and the journal. |
-| `recoverIfNeeded`, `systemPhaseGated` | Check the actual current target before `stagePlan`/`finish`; a previous health gate is not a floor exemption. If target differs, the fallback restore receives the same check. |
-| Legacy `systemPhaseBackingUp` journal | Validate recovery inputs and floor implications before clearing it or treating a partly prepared backup as safe. Cover this branch explicitly. |
-| `carlos-system-update.service` and `carlos-system-recover.service` | Check the executable **before** either shell branch invokes `.prev` or the current binary. No unguarded fallback. |
-| Edge boot following failed recovery | A refused recovery must not leave systemd free to launch a below-floor current binary. Wire the approved boot gate/dependency and test it; `Before=` alone orders units but does not make a failure block the edge. |
-| Console roll/restore recipe and any bootstrap binary replacement | Include in the deployment rollout inventory. The platform repository cannot certify uninspected private scripts. Require those paths to enforce the same published floor before enabling. |
+- [ ] **Create `cmd/console/hostclaim_flag_test.go` with this complete content.**
-Search again for copies/renames to `systemBinaryPath`, `systemPrevPath`, `/opt/carlos/carlos`, shell `exec` of `.prev`, and staged bootstrap execution when implementing. Update the matrix if a new path has landed; listing only exported `system apply` is insufficient.
+<!-- plan8:file cmd/console/hostclaim_flag_test.go -->
+```go
+package main
-- [ ] **Step 5: Run a table-driven activation test over the matrix.** For each path inject: below-floor, exact-floor, newer compatible, unknown build, unsupported protocol, malformed/unreadable floor, forged agent metadata and mismatched journal/file identity. Assert forbidden paths make **zero** binary-copy/rename, bootstrap, restart, unit-enable or edge-launch calls. Assert the known-good compatible path works. Cover a floor appearing between preparation and activation, network loss on boot with a persisted floor, a stale lower remote object, flag removal after publication, an old `.prev` with an open journal, a gated-but-below-floor current binary, and a held participant downgrade.
+import "testing"
-Use executable fixtures and a fake command runner to exercise the actual unit-selection logic, not just string containment in a unit file. Test that recovery failure blocks unsafe edge startup and does not erase the journal or enter an automatic older-binary loop. No live systemd or production AWS calls are needed.
+func TestHostClaimSwitchFleetConvention(t *testing.T) {
+ for _, raw := range []string{"1", "0", "on", " true "} {
+ if !hostClaimSwitch(raw) {
+ t.Errorf("hostClaimSwitch(%q) = false", raw)
+ }
+ }
+ for _, raw := range []string{"", " ", "\t\n"} {
+ if hostClaimSwitch(raw) {
+ t.Errorf("hostClaimSwitch(%q) = true", raw)
+ }
+ }
+}
+```
-### Task 8: Integration, review and repository gate
+- [ ] **Run the task tests.**
-- [ ] **Step 1: Finish the wiring checklist.** Each item needs a concrete caller and an integration test, not merely an interface declaration:
+```bash
+go test ./internal/console -run 'HostClaim|HostResolve' -count=1
+go test . -run 'HostResolve|OpsHost|HostClaim' -count=1
+```
-| Consumer | Completion evidence |
-|---|---|
-| Plan 5 and moves operation starts | Full floor census before intent; per-participant lease barrier before leaving the first phase |
-| Plan 6 host work | Lease denial reaches activator, root/background writes, timers and replicators; emergency cleanup works without control reads |
-| Plan 4 | Durable lost-box fence time feeds retirement scope/horizon; returning local retirement works for free/successor holders |
-| Lost-box CLI/API | Operator permission, live cloud verification, exact binding, durable loss record, both target cases and local-only exception |
-| Moves | Record-commit race, partial-return-lane retirement, lost target/source-cut loss and recovery delegation |
-| Quarantine/readmission | All-host denial, complete startup retirement, safe multi-host readmission and reused-label refusal |
-| Shared floor | D1/D7 resolved, actual release selected, every executable path tested including both unit shims and boot failure |
-| Plan 7 + 7b | Generation-aware selection integrated into the door and restore callers; root-only Plan 7 refusal is not considered full rollout readiness |
+## Implementation gate
-- [ ] **Step 2: Add remote CLI documentation.** `docs/site/cli.md` must name `ops host resolve` and both actions, explicit confirmation, external fencing prerequisite, current-state verification and refusal/recovery results. Reuse existing docs headings so `internal/docsite` remains authoritative. Final CLI/UI/error copy goes through the repository's writing/copy-review workflow in the implementation session; this plan specifies behavior, not final copy.
-- [ ] **Step 3: Run focused tests and the required whole-tree gate.** Use the repository's normal cache. In this sandbox the existing shared `$TMPDIR/gocache` is the on-disk fallback; no new cache per task.
+Run focused tests first, then the repository gate before landing the implementation. No floor or executable-restore path changes in this plan, so R5's floor-refusal witnesses belong to the later work that changes those paths. Do not report them as passing here.
```bash
-go test ./internal/lease ./internal/hostclaim ./internal/hostexec ./internal/hostfloor -count=1 -race
-go test ./internal/host ./internal/activator ./internal/console ./internal/awsinv -count=1
-go test . -run 'OpsHost|System' -count=1
+go test ./internal/hostclaim ./internal/hostexec ./internal/hostguard ./internal/hostquarantine ./internal/hostfence ./internal/awsinv -race -count=1
+go test ./internal/release -run RootEnvelope -count=1
+go test ./internal/lease -run ExecutionStore -count=1
+go test ./internal/console -run 'HostClaim|HostResolve' -count=1
+go test . -run 'HostResolve|OpsHost|HostClaim' -count=1
+go test ./cmd/console -run HostClaim -count=1
go build ./...
go vet ./...
go test ./... -count=1
@@ -375,33 +4561,42 @@ GOOS=windows GOARCH=amd64 go build ./...
GOOS=linux GOARCH=arm64 go build ./...
```
-Expected: all checks pass and `gofmt -l .` prints nothing. Binary-invoking tests use `carlosTestEnv` and a scratch `cmd.Dir`; no inherited real credentials or project config. Cross-compiles do not execute platform-specific cleanup. A socket permission failure is an environment limitation to report, not a passing test.
+Use the default Go cache. If sandbox permissions make it read-only, use the existing shared on-disk `/home/paulca/.cache/tmp/gocache`; never create a task cache under `/tmp`. Tests that invoke the CLI must use `carlosTestEnv` and a scratch working directory. The new tests use local fakes and make no production cloud calls. A sandbox-blocked socket test is unverified, not green.
+
+Obtain the repository's required authority review before implementation lands. Push the described branch to amadan, squash to one change commit, require green CI on its exact tip, and land through `amadan branch merge ... -expect <tip>`. Read main's CI afterward. These instructions do not authorize staging or committing during plan authoring.
+
+## Still-open questions
+
+The proposed answers live in **§7.3/§10.1** and **amadan #236**. They are not rulings:
-- [ ] **Step 4: Check the joint simulation.** Port the lease/compute/quarantine and `floor-boot-recovery` witnesses from the moves model and run its stated 30k+ seed gate. Mutations that accept lease expiry as fencing, omit quarantine, skip first-phase acknowledgement or restore a below-floor executable must be caught. The model's own notes say cloud proof and executable activation need real integration witnesses too; simulation alone is not verification of those paths.
-- [ ] **Step 5: Obtain the required authority/production-path review.** Use Opus under the repository's delegation policy, with explicit attention to D1–D7 and the tests above. Resolve findings before landing. Push the implementation branch to amadan, describe the final scope and evidence, squash to one change commit, wait for CI on that exact tip, then use `amadan branch merge ... -expect <tip>`. Read main's CI afterward. None of those branch operations is part of authoring this Markdown plan.
+- **P1:** which real authenticated release becomes the floor? No placeholder tag, SHA ordering or locally invented version mapping may enable claims.
+- **P2:** does Paul approve the operator-attestation boundary, including maintaining the exact compute fence, cold quarantine startup and assertions about destroyed storage? A console signature does not authenticate an AWS response.
+- **P3:** does Paul approve the explicit enrolled box/console roster as membership authority, including offline actors? Missing reports cannot remove actors from that roster.
+- **Ordinary and older write-loss evidence:** D4 requires durable archival before evidence is removed, but its declaration schema requires compute fencing while an ordinary cold admission can already contain `writes_lost` without a declaration ID. This plan preserves old decoding and refuses removal when it cannot prove archival. The owning specification must define migration or archival provenance for those rows; do not synthesize a destroyed-box declaration to unblock deletion.
+- **Integration review:** operation owners must supply their legal transitions and record observations, including moves' exact-target restriction, source refusal, postcommit refusal, record-CAS race, source-cut provenance and partial-return-lane retirement. The library refuses a missing owner. The first-phase barrier must freshly read every participant lease and verify the actor reservation before crossing phases; an acknowledgement helper alone is not that wiring.
## Rollout runbook — Paul performs activation
-This runbook describes later operator work. It publishes nothing and flips no flag during implementation or plan authoring. Apply the brief's **boxes, then consoles, then flag** order even where an older companion rollout note says consoles first.
+**Plan 8b** is the follow-on scope, not planned here: D1's authenticated release identity, pre-exec guard and update/recovery changes; D7's real root-signed actor directory, console reports, complete census and lifecycle/executable reservations; and live quarantine/declare-lost/readmission wiring. It waits on P1–P3. Plans 5–7 remain inert until those integrations, the shared floor and the required reviews are complete. The refusal route in this plan is not an activation shortcut.
-1. **Close prerequisites.** Resolve D1–D7, integrate Plans 5/6 and generation-aware Plan 7/7b callers, pass the authority review and CI, and select the real release that satisfies the shared floor. Check the exact deployment inventory, including all serving/eligible boxes, consoles, fleet/pool namespaces, private bootstrap/console restore scripts and IAM version grants from earlier plans. Keep `CARLOS_HOST_CLAIM` unset throughout preparation.
-2. **Upgrade boxes to at least that release.** Use the deployment's existing approved update workflow. Verify the actual running build and `moves_protocol`, the root floor/boot enforcement path, compatible `.prev` and outstanding journals on **every** box. A target pointer or successful upload is not confirmation of the running build. Finish/recover incomplete journals and establish a compatible recovery binary before publication; an old `.prev` cannot be exempted because the live binary is new.
-3. **Upgrade all consoles.** Verify their actual running identities/capabilities and the complete floor census. Include CLI clients that use the new declare-lost or v2 pull-door protocol. An old client talking to a flagged v2 door will be refused; make this visible before enabling. Keep the flag unset.
-4. **Publish the shared floor, create-only.** Paul publishes `control/moves-protocol.json` with protocol minimum **1** and the chosen real `min_release`, through the approved release/deployment workflow defined in D1. Confirm that every box/root recovery path and every console has adopted the same floor. If an existing floor differs or any box cannot prove safe `.prev`/journal handling, stop before activation. Do this **before** any protected production rehearsal.
-5. **Enable `CARLOS_HOST_CLAIM` as Paul's operator step.** Only after the preceding checks, enable and restart the relevant processes through the deployment's approved rollout. Verify flag uptake, full floor admission, an ordinary serving host with no execution-lease writes, and operation acknowledgement/renewal behavior. Code does not infer activation from publication or set the flag itself.
-6. **Rehearse within explicit scope.** Start with local deterministic witnesses, then the approved deployment rehearsal sequence. A live stop/terminate or production resource change requires its own existing scoped authorization; this plan supplies none. Verify declare-lost refusal on unfenced compute before testing an authorized fence, quarantine across another host, retirement/readmit, and a below-floor rollback/boot refusal. Use the exact built release and preserve the evidence.
-7. **Recovery after activation.** Pause new lifecycle starts while preserving claim/quarantine/floor enforcement if a rollout fails. Use a compatible fixed release or an above-floor rollback permitted by operation state. Do not unset the flag as an excuse to run claim-unaware paths over active claims, erase the floor, force an old `.prev`, or label remote data lost merely to clear a stuck operation.
+1. **Close the gates.** Paul rules on P1–P3 in #236; the owning specifications record the decisions. Complete the follow-on work and the Plan 4/5/6/7/7b and moves integrations, including cleanup independent of claim availability, loss alerts, complete returning-box retirement, durable loss archival and the first-phase lease barrier. Verify every deployment script and independent console process in scope. Keep the flag unset.
+2. **Upgrade boxes first.** Install the approved floor-capable release through the deployment's authorized workflow. Verify each actual running image against its authenticated identity. Establish compatible `.prev`, pending journals and the root pre-exec guard; arm and durably acknowledge the prospective floor before publication. Provision the execution-lock and local-state directories under Plan 3's R9 boundary. Ordinary serving still creates no execution leases.
+3. **Upgrade all consoles.** Verify actual running identities, directory membership, reports, reservations and the complete eligible-actor census, including offline members and both move regions. A single load-balanced health response is insufficient. Keep the flag unset.
+4. **Publish the shared floor.** Through the approved release workflow, create the root-signed authorization companion first, then `control/moves-protocol.json` with minimum protocol 1 and Paul's selected real release. Both writes are create-only. Every required box must already have durably armed the matching policy; conflicts or incomplete acknowledgements stop rollout.
+5. **Paul enables the flag.** Only after the preceding checks, Paul enables `CARLOS_HOST_CLAIM` and restarts the relevant processes using the approved deployment procedure. Verify the exact built release, flag uptake, operation acknowledgements, renewal and independent writer denial. Confirm that the ordinary serving path creates no execution-lease traffic.
+6. **Verify within approved scope.** Use deterministic local witnesses first. Any live compute fence or production infrastructure change needs the applicable scoped authorization. Verify stopped/terminated instance identity, unknown-timer reporting, quarantine across another host, complete returning-box retirement and matching-revision readmission. Verify every changed executable restore/boot route against below-floor candidates; lease expiry is never fencing evidence.
+7. **Recover without downgrading authority.** Pause new lifecycle starts if rollout fails. Preserve claims, quarantine, loss history and armed floor policy. Use a compatible fixed release or an authorized above-floor rollback. Removing the flag, deleting a floor object or invoking an old recovery executable cannot cancel the persistent floor.
## Authoring verification
-The source baseline and the two complete Go blocks in Task 2 were checked in a disposable on-disk source export, outside this repository. The export used `feat/hostclaim-boxguard` at `01ed2bd9`, which already contains `origin/main` at `04cd2d78`; this is the base the branch instructions produce at authoring time. Build commands used `-buildvcs=false` because an archive is not a Git checkout. The default cache was attempted first and is read-only in this sandbox; the existing shared `/home/paulca/.cache/tmp/gocache` was the fallback.
+The complete code was checked in disposable on-disk source exports outside this repository, against the pinned merge baseline above. A second fresh export was reconstructed from this document's **33 complete Go files and two exact patches**; every Go source matched the tested export, apart from the follow-on-plan wording in comments. Archives use `-buildvcs=false` because they are not Git checkouts. The default cache was attempted first; the existing shared on-disk cache was used after the sandbox reported it read-only.
-Results on 2026-10-05:
+Checks on 2026-10-05:
-- The prerequisite source export passed `go build -buildvcs=false ./...`.
-- After extracting both complete Task 2 Go blocks into that export, the whole repository built for Linux/amd64, Darwin/amd64, Windows/amd64 and Linux/arm64.
-- `go test -buildvcs=false ./internal/hostclaim ./internal/hostguard ./internal/stopevidence ./internal/hostexec -count=1` passed. The first three packages ran their existing tests; the new record-only `hostexec` package had no tests yet.
-- An explicitly requested Opus review through the installed CLI did not complete within 45 seconds in this network-restricted sandbox. No Opus verdict was obtained; Task 8's required review remains outstanding for implementation/landing.
-- The disposable export was removed after verification. No production actions, branch changes, staging or commits were performed.
+- Whole-repository builds passed for Linux/amd64, Darwin/amd64, Windows/amd64 and Linux/arm64.
+- All repository test packages compiled with `go test -buildvcs=false -exec=/bin/true ./...`; this is compilation evidence, not a claim that the whole test suite ran.
+- The complete `hostclaim`, `hostexec`, `hostguard`, `hostquarantine`, `hostfence` and `awsinv` suites passed with `-race`. Focused new envelope, transport, console-route, CLI-parser and console-flag tests passed.
+- Whole-repository `go vet -buildvcs=false ./...` passed, and `gofmt -l .` was empty.
+- Independent GPT reviews found and corrected stale lease publication, orphan-controller admission, CC9 replacement races, premature acknowledgement ETags, unsigned archival acceptance, source-cut provenance, missing destroyed-storage assertions and target-only loss suppression. The explicitly requested Opus CLI review produced no verdict before a 45-second timeout in this sandbox; the required Opus review remains outstanding before implementation lands.
-Compiling the blocks verifies their real dependency interfaces. It does not claim that the planned controller, authority transitions or floor integrations have been implemented or tested. Their required behavioral tests and unresolved contracts are listed above.
+Only this Markdown plan was written in the repository. Scratch exports were removed after verification. No implementation files, branches, staging, commits, cloud mutations or feature-flag changes were made. The tested code remains an inert proposal; it does not implement or certify the deferred floor, real directory or live recovery integrations.