plan: claim Plan 14 rev 1, root-trusted state out of /data (amadan #280): root-owned claim authority under /var/lib/carlos-claims/authority, root lease broker, root-owned D3 health history, release-history ledger and floor enforcement; offline idempotent migration; spec rev 6.34
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+1316
−0
docs/superpowers/plans/2026-10-12-host-lifecycle-claim-14-root-trusted-state.md+1316 −0
CI failed — run details
CI log
amadan: sync for CI failed: exit status 128: error: RPC failed; HTTP 429 curl 22 The requested URL returned error: 429 fatal: expected 'acknowledgments' (and could not reset the workspace: unlinkat /var/lib/amadan-runner/w2/repos/carlos__platform/cache/go-mod/google.golang.org/protobuf@v1.36.10/reflect/protopath/path.go: permission denied)
diff --git a/docs/superpowers/plans/2026-10-12-host-lifecycle-claim-14-root-trusted-state.md b/docs/superpowers/plans/2026-10-12-host-lifecycle-claim-14-root-trusted-state.md| new file mode 100644 |
| index 000000000..c30ca89d3 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-12-host-lifecycle-claim-14-root-trusted-state.md |
| @@ -0,0 +1,1316 @@ |
| +# Host lifecycle claim, Plan 14: root-trusted state |
| + |
| +Implementation plan, 2026-10-12, rev 1, for amadan #280. Based on `39986908150d2e3c90477de4320c0d2941ffaac6`, the available `origin/main`. The claim-spec amendment is rev 6.34. This plan authorizes no production changes, commits, or activation. |
| + |
| +Requirements: `/home/paulca/.cache/tmp/claude-1001/own/after236/plan14-brief.md`. The initiating finding is P3-3 in `opus-task7b-authority.md` beside that brief. This plan and the write report are implementation instructions for the controller. No product copy is proposed. |
| + |
| +## Step 1: Findings from the code |
| + |
| +### The trust boundary |
| + |
| +`internal/host/bootstrap.go` gives `/data` to `carlos`. Its tmpfiles entries create `/data/hostguard` and `/run/carlos/hostguard` as `2770 carlos:carlos`. The service user can rename a directory it does not own if it can write its parent. Making a leaf `root:root 0600`, using `O_NOFOLLOW` on its final component, or opening a descriptor safely does not establish that its content came from root. |
| + |
| +Three checks are separate: safe traversal, ownership of the entire namespace, and authority to assert the fact. A root-owned file populated by copying an agent's assertion passes the second check but fails the third. Agent-populated root journal entries remain proposals until the journal operation's own validation establishes their meaning. |
| + |
| +The local attack considered here is replacement, deletion, editing and concurrent rename by the service UID. Root, the kernel and the configured deployment verification keys remain trusted. The existing claim/control-store authorization model remains in force: claims are not signed local files, and this plan does not claim protection against an actor allowed to rewrite authoritative claims in the deployment store. Release bytes still need the separate signed authorization in release §4.1. Moving a local assertion into an ordinary bucket object does not, by itself, authenticate it. |
| + |
| +### Who writes the two reported files |
| + |
| +| State | Writer and process UID | Root reader and decision | Independent source | |
| +|---|---|---|---| |
| +| `/data/hostguard/<host>.json`: incarnation, active/retired stamps | `hostguard.Guard.Activate`, `RetireInstallation*`, and cold-handover methods. The agent (`carlos`) activates through `hostbox.Reconciler.Install`, `ClaimWakeAssembly`, `movesland`, `movesrecover`, and recovery handover. Root instance-control also changes the same record during cleanup/cold handover. It is a shared-writer file, not root's record. | `hostbox.WorkSource.Select` calls package `Capture`, then `Guard.With` checks installation and fences. Root adoption builds this same WorkSource. Plan 11's `ClaimLaunchRoot.Launch` calls `Guard.With` directly. Stop, cleanup and cold handover read it too. | Fresh claim gives the holder tuple. Root's typed installation inventory establishes the local installation. Root must retain its own retired-stamp decisions; a fresh claim alone cannot prove a retired local installation was reinstalled. | |
| +| Same file: `ActiveFence` | Root `ClaimStopCoordinator.FenceAndStop` calls `BeginFence` before drain/stop; root cold handover calls `BeginColdHandover`. All writes go through `recordDir.write`, mode `0660`. The service UID can also modify those bytes directly. | `Guard.With` denies the exact session; `ResumeFence`, `ExistingFence` and `PendingFences` determine recovery; `CompleteFence` uses the active target to authorize completion. | Root's own durable fence intent. The current claim does not encode an in-progress local fence. Kernel observation can prove a stop, but cannot reconstruct an erased intent while the claim still permits the session. | |
| +| Same file: `FenceJournal` | Root `CompleteFence` after kernel/drain verification; root recovery calls it too. No ownership check makes those later bytes root-authentic. | `With` preserves completed-session denial; `FenceReceiptFor`, `CompletedColdPredecessor`, `CompletedWriterStops`, cleanup and retirement use receipts. Agent cold admission and artifact retirement also read them. | Exact bytes retained by root, or an already published root-signed receipt verified against the enrolled key, tuple and digest. The local raw receipt is JSON, not itself a signature. Never infer provenance merely from its fields. | |
| +| `/data/healthy/<acct>/<app>.json` | Root adoption's `proofRoot.observeAndRecord` calls `release.RecordHealthy` after `proofObserver.observe`; `recordSettled` writes after a checked named-unit restart. The ordinary agent reads the history; it is not the intended writer. | `ProofGate.admit` bypasses the prover wait on a manifest hash; `bindRecording` permits staged bytes by hash. Root `runAdopt`, `heldStagePass` and moves Task 7b all build a gate over DataDir. | Re-observe the running executable, systemd invocation, binding and readiness as today; retain the result under root-owned ancestors. A valid signed prover proof is the other existing route. Neither a manifest hash alone nor the old local file proves health. | |
| + |
| +`hostguard/guard.go:116–160`, `record_unix.go`, `fence.go`, `recoveryfence.go`, `coldhandover.go` and `writerstop.go` contain these readers and writers. `hostbox/work.go:57–113` is the selector/recheck path. The current adoption wiring is at `host/agent.go:4198,4322–4348`; the brief's older line numbers have moved. `release/healthy.go`, `proofgate.go:264,345`, `host/proofroot.go` and `proofobserve.go` establish the D3 chain. |
| + |
| +The hostguard design currently does make agent-writable state authoritative for fences root relies on. This is not fixed by a different filename or by making only `BeginFence` write as root. The record, lock namespace, recovery enumeration, receipt readers and admission checks must be split together. |
| + |
| +### Plan 11 and moves Task 7b |
| + |
| +Plan 11's root launch checks the claim's retry reservation, exact launch-input digest, actor, staged executable and kernel identities. Those checks remain necessary. They do not repair a stripped local fence: `ClaimLaunchRoot` still enters through the shared Guard. It also reads `hostexec.InspectLocal` and `CheckLocal` in `claimlaunch_authority.go`; the service UID owns both the state and its lock namespace. |
| + |
| +Read the moves work as these deltas, not as an export of its older whole branch: |
| + |
| +- `27b83074c34225e7dff60deafb2fd2787399bbbc` adds `moves.JournalFile` and `DecodeJournal`. |
| +- `cf0b6de5e1a1262b23ccf3b5c63906acf301805a` registers the claim-only held-release route. |
| +- `ed97cbc73c471f3fbff53fc9c9f5e9186ac12fb2` adds the root handler, publication selector, render-journal reader and receipt. |
| + |
| +`movesheldrelease_production.go` copies `a.Guard` into a new WorkSource and constructs `NewProofGate(ProofPolicyPath, dataDir)`. Therefore both P3-3 attacks reach root through the new on-demand route. Root selects the work itself, but selects it from the same compromised local authority. `movesheldrelease_linux.go` safely reads the agent render journal; that makes it a bounded, no-symlink hint, not authenticated render evidence. |
| + |
| +Task 7b keeps receipts at **`/var/lib/carlos-claims/held-release`**, under the claim-mode service's `StateDirectory`. Use the same root namespace for claim authority. There is an integration trap: main's `hostbox.PrepareRootJournalDirectory` accepts only `retire` and `migration-*` subdirectories, so both Task 7b's `held-release` and this plan's `authority` need explicit root-owned-directory classifications. Do not broadly permit arbitrary subdirectories. |
| + |
| +The three named deltas alone do not compile on the pinned main: `StageHeldVerified` is absent. The narrow prerequisites are `b3d423390c5f733c31c4b06e6df5a72dfdb06f6d` (that function and tests) and `5a46611265d20775a9f18e2979c69bb01959ec1b` (claim-mode read-back). The replay keeps these separate from main and labels the resulting integration check. |
| + |
| +Task 7b's render hash is not permission to publish another kind/phase. In particular, a missing agent journal cannot be the reason a forbidden revert or clean-return probe is believed unreachable. Keep the permitted operation/version table explicit. A publication receipt must bind action, kind and phase as well as the holder, operation, version and render identity, and replay must still respect a later root fence. Those are integration requirements in Task C, not assumptions that the three historical commits already satisfy. |
| + |
| +### Wider root-read census |
| + |
| +The investigation followed the root entrypoints `runAdopt` (ordinary, stage, restart-only and config-only), `RunInstanceControl`, `RunSystemApply`/recovery, guard-arm/guard-exec, floor-allows-prev, journal migration, bootstrap, their embedded units, and the helpers they invoke. It also inspected the agent producers of their inputs. The classifications below describe decisions, including decisions made from **absence**. An empty directory or missing marker is not automatically harmless. |
| + |
| +| Data-dir input | Producer | Root use and classification | Required treatment | |
| +|---|---|---|---| |
| +| `.hostexec/<host>.json`, pending/lost state; `/run/carlos/hostexec/*.{lock,live}` | Agent execution controller, `hostexec/controller.go`, `local_unix.go`; participation directories service-owned | Root `productionClaimLocal.Prepare/Check`, root launch, update participation and pending-controller recovery. A forged `SafeUntil` plus held replacement locks can grant work; erasure can claim no participation. **Authority, additional gap.** | Task D: root owns its participation record and lifetime/deadline decisions. Root never takes a positive lease verdict from the agent file or from a probe of an agent-replaceable lock. | |
| +| `.hostclaims` legacy journals | Old agent journal writers | Root migration discovers files, parses legacy records through the isolated child, and accepts imported evidence only with `legacyJournalProof`'s signed stop verification. Current journals are root-owned at `/var/lib/carlos-claims/journals.db`. | Preserve the existing isolation and proof checks. Discovery names are hints; absence of this directory is not the new fence-migration completion marker. Current journal ownership does not make every agent-proposed inventory field a kernel fact. | |
| +| `.moves/<host>/<op>/journal.json`, render steps | Agent moves worker | Task 7b compares render hashes before publication; held request selects a record-pinned version. **Hint only if no authority row depends on it.** | Task C: explicit policy table, root recheck and root-owned publication receipt. If a row requires completed render as authority, root must reproduce/verify the render and retain its own result; an agent hash is insufficient. | |
| +| `healthy/` | Root observation, replaceable parent | D3 proof bypass, described above | Task B: new trusted history, no import of old assertions. | |
| +| `refusals/`, `moves-held/refusals/`, `programs/`, `proof-required/` | Root checked restart/proof path; release passes can also write program history; all under service parent | D1 retry suppression, D2 old-program exceptions, D3 proof mint's prior-refusal exclusion, and break-glass diagnostics. Removing a refusal can remove a deny; a fabricated program-history entry can add an exception. **Not all diagnostic.** | Task E: root's D1/D2/mint decisions read its own release ledger. Agent copies remain status. `proof-required` is diagnostic only; preserve it as such. | |
| +| `channel-first-seen.json` (FirstSeenPath), production/hold ratchets | Both release passes | Root bake timing and remembered pipeline/production holds. **Authority; current spec accepts a weaker box-file threat boundary.** | Task E: independent root observations and ratchets, never an agent-supplied past first-seen time. Unknown historical ratchets require migration refusal/reconstruction, not an empty permissive map. | |
| +| `restart-marks.json`, its lock | Agent seeds baselines; root advances unit restart marks | Root decides which signed/control-store restart request is unconsumed. Replacement can cause replay or erase work. | Task E: root-owned consumption record; seed from a fresh request read without issuing a restart. Agent baseline is a hint. Never widen an unknown scoped selector. | |
| +| `adopt-pass.lock`, moves held-stage locks | Root and agent on shared writable parents | Serialization of root publication, held read-back and staged-version sweep. **Coordination that affects correctness.** | Root-owned stable root-effect lock from Task A, shared among root callers. Preserve bounded agent read-back coordination, but it cannot substitute for root exclusion. Do not unlink a root lock while a waiter may hold its old inode. | |
| +| `.moves/floor.json`, `.moves/floor.min-release`, `.moves/floor.lock` | Root apply plus hourly agent refresh | Root apply/rollback and `carlos.prev system floor-allows-prev`; systemd shell also treats absence as permission to select prev. **Authority, additional gap.** `movesfloor.go` explicitly says this was not a compromised-agent defence. | Task F: root floor and boot selector together, keep maximum accepted floor independently of agent cache. Root's signed hostfloor already lives under opt and remains separate. | |
| +| `system/router/requested.json`, cached bundle | Agent system leg | Root update's requested version/cache. Root independently verifies the signed system pointer, manifest digest and artifact bytes; writes its transaction under `/opt/carlos/system/router`. | Keep as bounded hints/content. Neither requested version nor `NoOp` may skip signature, floor, current transaction or artifact verification. Existing descriptor input tests must remain. | |
| +| `registry.db`, `-wal`, `-shm` | Service-user registry child and agent | Routes are read through #267's unprivileged registry child. Root uses rows to discover candidate work, holds, units and inventories. **Discovery/proposal, not root provenance.** | Preserve the child and typed transport. Derive claim tuple/owner from fresh claim and deployment record; unit binding from root-installed unit/kernel; executable permission from signed release; root inventory from checked descriptors. A matching row alone cannot clear a fence or assert absence. Bootstrap's own new empty registry is a separate root-authorized provisioning operation. | |
| +| Instance DBs, markers, env/config/launch files, sockets, layout journals, parked artifacts | Agent, tenants where delegated, layout/moves writers | Root launch prepares namespaces, stop scans kernel-held files, retirement compares inode inventory, system update detects legacy layouts. Most are **payloads or candidate paths**, not executable permission for root. | Retain descriptor traversal and root-derived paths/inode comparisons. A `.gen`, layout completion bit or service journal alone cannot certify cleanup or safe downgrade. Legacy layout discovery must be backed by kernel mounts/root installation census before an absence verdict. No root SQLite parse. | |
| +| `stage-requested`, `config-sync-requested`, `restart-requested`, held-stage request directory, proof pending/wait files, status reports, outbox files | Agent | Triggers, candidate request bodies and diagnostics | May request a bounded operation. Root re-reads its authority and derives paths; no new trust from the marker's owner or content. | |
| +| Root write staging left under DataDir | Root safefile writers | `reapStagingWrites`/`safefile.Reap` removes only its checked staging shapes using descriptor traversal, owner/type/link checks and no mount crossing | Keep existing deletion proof; do not broaden it to remove arbitrary service data. This cleanup does not turn a root-owned leaf under `/data` into read authority. | |
| + |
| +The litestream, restore-verify and hibernate-verify units were checked: they run as `carlos`, not root. Reading `/data/litestream.env` in those units does not make it a root authority input. Ordinary application data is intentionally service/tenant-writable. The changes below target the privilege decision, not wholesale relocation of `/data`. |
| + |
| +Root proving keys, proof policy, deployment public key, enrollment and signed hostfloor snapshots are outside `/data`. Their ancestor ownership must also be checked when configurable paths can name a writable tree. Preserve the private-key mode check; a key copied into a service-owned parent is not root-only. No new AWS loader is necessary: use existing rootaws-backed providers. |
| + |
| +## Step 2: Chosen change and ordering |
| + |
| +Implement Tasks A–I. A and B include exact replayable patches for the storage primitive and D3 path. C–H specify the remaining production integration, migration and regression work by owning function and invariant; they are requirements, not claims that the A/B patch set implements the whole plan. I contains the exact spec amendment. The reference replay proves the included code compiles on the named base; **it is not a whole-branch implementation or an activation witness**. |
| + |
| +The smallest safe split keeps the agent's installation bookkeeping where it is, retains root decisions separately, and passes typed requests over the existing control socket. Moving the whole hostguard tree would require the agent to write root authority. Giving the agent write access to a root directory would recreate the defect. Trusting UID 0 only on the leaf would leave the rename attack. |
| + |
| +Use these namespaces: |
| + |
| +| Root | Owner/mode | Contents and reader | |
| +|---|---|---| |
| +| `/var/lib/carlos-claims/authority` | root:root 0700 | Root installation/retirement decisions, fence intents and exact receipts, execution participation, migration record and stable locks. Read-only typed projections reach the agent over the existing authenticated control socket. | |
| +| `/var/lib/carlos-claims/held-release` | root:root 0700 | Task 7b's exact publication receipts. Shares root host exclusion with authority. | |
| +| `/var/lib/carlos-release/healthy/<acct>` | root:root directories 0755, records 0644 | Root-observed healthy hashes. Both root and the agent can read; only root writes. No secrets. | |
| +| `/var/lib/carlos-release/private` | root:root 0700 | Root refusal/program/bake/restart state and root release locks. Agent gets diagnostic projections, never a writable authority copy. | |
| +| Existing `/opt/carlos/system/guard` and `/opt/carlos/system/router` | existing root ownership | Signed floor, root update transaction, plus Task F's root-owned moves-floor ratchet and boot selection. | |
| + |
| +Provision or open each namespace through checked descriptors from `/`. Refuse symlinks, a non-root owner, group/other write permission, a nonregular leaf, multiple links or an overlarge record. Check the opened object, not a prior `Lstat` pathname. Do not repair ownership of an existing object. Root writes a fresh temporary file within its checked directory, fsyncs it, renames within that descriptor and fsyncs the parent. A trusted-parent missing leaf is different from an untrusted-parent failure. Critical ledgers treat missing state as uninitialized, not an empty allow set. |
| + |
| +Claim-only construction remains after `CARLOS_HOST_CLAIM`'s existing off return. The existing moves switch remains required. D3 and the additional pre-claim root consumers are explicitly tightening exceptions: their old local files cease to grant exceptions or prove absence; valid signed authorization, observed kernel facts and existing stronger denials still apply. No change may turn a refusal into permission during migration. |
| + |
| +## Task A: A descriptor-backed root-state store |
| + |
| +Add the following small package. It introduces no database, network client, initializer, process or authorization policy. `Open` validates every ancestor; its private test helper starts at a test-owned descriptor so non-root CI can exercise the same traversal and writes without pretending the fixture is production root. Production has no configurable trusted UID. |
| + |
| +Focused command: `go test ./internal/rootstate -run TestPlan14 -count=1`. |
| + |
| +First failure: tests cannot compile before the package exists. The additional mutation witness removes the ownership/mode test inside `trusted`; the real non-root ownership and writable-file cases must then fail. |
| + |
| +<!-- apply: A tests --> |
| +```diff |
| +--- /dev/null |
| ++++ b/internal/rootstate/state_linux_test.go |
| +@@ -0,0 +1,191 @@ |
| ++package rootstate |
| ++ |
| ++import ( |
| ++ "context" |
| ++ "errors" |
| ++ "os" |
| ++ "path/filepath" |
| ++ "strings" |
| ++ "testing" |
| ++ "time" |
| ++ |
| ++ "golang.org/x/sys/unix" |
| ++) |
| ++ |
| ++func fixture(t *testing.T) (string, Store) { |
| ++ t.Helper() |
| ++ parent := t.TempDir() |
| ++ if err := os.Chmod(parent, 0700); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ f, err := os.Open(parent) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ defer f.Close() |
| ++ d, err := descend(f, []string{"trusted"}, true, false, uint32(os.Geteuid())) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ t.Cleanup(func() { d.Close() }) |
| ++ return filepath.Join(parent, "trusted"), d |
| ++} |
| ++ |
| ++func TestPlan14HostileOwnership(t *testing.T) { |
| ++ if os.Geteuid() == 0 { |
| ++ t.Skip("nonroot witness; root ownership substitution is covered separately") |
| ++ } |
| ++ path, _ := fixture(t) |
| ++ if _, err := Open(path, false, false); err == nil { |
| ++ t.Fatal("root trusted real non-root-owned ancestors") |
| ++ } |
| ++ f, err := os.Create(filepath.Join(path, "file")) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ defer f.Close() |
| ++ if err := trusted(int(f.Fd()), 0, unix.S_IFREG); !errors.Is(err, os.ErrPermission) { |
| ++ t.Fatalf("real non-root-owned leaf accepted: %v", err) |
| ++ } |
| ++} |
| ++ |
| ++func TestPlan14SymlinksHardlinksFIFOAndModes(t *testing.T) { |
| ++ for _, attack := range []string{"leaf-link", "hardlink", "fifo", "writable", "directory-writable", "ancestor-link"} { |
| ++ t.Run(attack, func(t *testing.T) { |
| ++ path, d := fixture(t) |
| ++ leaf := filepath.Join(path, "record") |
| ++ switch attack { |
| ++ case "leaf-link": |
| ++ if err := os.Symlink("missing", leaf); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ case "hardlink": |
| ++ if err := d.Write("other", []byte("safe")); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if err := os.Link(filepath.Join(path, "other"), leaf); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ case "fifo": |
| ++ if err := unix.Mkfifo(leaf, 0600); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ case "writable": |
| ++ if err := os.WriteFile(leaf, []byte("false proof"), 0600); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if err := os.Chmod(leaf, 0666); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ case "directory-writable": |
| ++ if err := d.Write("record", []byte("safe")); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if err := os.Chmod(path, 0777); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ case "ancestor-link": |
| ++ parent, err := os.Open(filepath.Dir(path)) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ defer parent.Close() |
| ++ if err := os.Symlink("trusted", filepath.Join(filepath.Dir(path), "alias")); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if got, err := descend(parent, []string{"alias"}, false, false, uint32(os.Geteuid())); err == nil { |
| ++ got.Close() |
| ++ t.Fatal("followed ancestor") |
| ++ } |
| ++ return |
| ++ } |
| ++ if _, err := d.Read("record"); err == nil { |
| ++ t.Fatal("read accepted hostile file") |
| ++ } |
| ++ if err := d.Write("record", []byte("replacement")); err == nil { |
| ++ t.Fatal("write repaired hostile file") |
| ++ } |
| ++ }) |
| ++ } |
| ++} |
| ++ |
| ++func TestPlan14RenameKeepsDescriptor(t *testing.T) { |
| ++ path, d := fixture(t) |
| ++ if err := d.Write("record", []byte("original")); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ moved := path + "-old" |
| ++ if err := os.Rename(path, moved); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if err := os.Mkdir(path, 0700); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if err := os.WriteFile(filepath.Join(path, "record"), []byte("forged"), 0600); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ b, err := d.Read("record") |
| ++ if err != nil || string(b) != "original" { |
| ++ t.Fatalf("%q %v", b, err) |
| ++ } |
| ++ if err := d.Write("record", []byte("updated")); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ b, err = os.ReadFile(filepath.Join(path, "record")) |
| ++ if err != nil || string(b) != "forged" { |
| ++ t.Fatalf("touched replacement: %q %v", b, err) |
| ++ } |
| ++} |
| ++ |
| ++func TestPlan14LockAndLimits(t *testing.T) { |
| ++ _, d := fixture(t) |
| ++ unlock, err := d.Lock(t.Context(), "host.lock") |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) |
| ++ defer cancel() |
| ++ if release, err := d.Lock(ctx, "host.lock"); err == nil { |
| ++ release() |
| ++ t.Fatal("split lock") |
| ++ } else if !errors.Is(err, context.DeadlineExceeded) { |
| ++ t.Fatal(err) |
| ++ } |
| ++ unlock() |
| ++ unlock, err = d.Lock(t.Context(), "host.lock") |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ unlock() |
| ++ if err := d.Write("large", []byte(strings.Repeat("x", MaxRecord+1))); err == nil { |
| ++ t.Fatal("unbounded write") |
| ++ } |
| ++ if err := d.Write("../escape", []byte("x")); err == nil { |
| ++ t.Fatal("escaped store") |
| ++ } |
| ++ for i := 0; i < 2; i++ { |
| ++ names, err := d.Names() |
| ++ if err != nil || len(names) != 1 { |
| ++ t.Fatalf("scan %d: %v %v", i, names, err) |
| ++ } |
| ++ } |
| ++} |
| ++ |
| ++func TestPlan14RootOwnedParentRejectsServiceLeaf(t *testing.T) { |
| ++ if os.Geteuid() != 0 { |
| ++ t.Skip("requires uid 0 and chown; run in disposable root-owned test root") |
| ++ } |
| ++ path, d := fixture(t) |
| ++ if err := d.Write("record", []byte("forged")); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if err := os.Chown(filepath.Join(path, "record"), 65534, 65534); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if _, err := d.Read("record"); !errors.Is(err, os.ErrPermission) { |
| ++ t.Fatalf("%v", err) |
| ++ } |
| ++ if err := d.Write("record", []byte("replacement")); !errors.Is(err, os.ErrPermission) { |
| ++ t.Fatalf("%v", err) |
| ++ } |
| ++} |
| +``` |
| + |
| +<!-- apply: A implementation --> |
| +```diff |
| +--- /dev/null |
| ++++ b/internal/rootstate/state.go |
| +@@ -0,0 +1,37 @@ |
| ++// Package rootstate opens local authority through root-owned directories. |
| ++package rootstate |
| ++ |
| ++import ( |
| ++ "context" |
| ++ "os" |
| ++) |
| ++ |
| ++const MaxRecord = 1 << 20 |
| ++ |
| ++// Store retains the directory descriptor. Close it after the last operation. |
| ++type Store interface { |
| ++ Read(string) ([]byte, error) |
| ++ Write(string, []byte) error |
| ++ Names() ([]string, error) |
| ++ Lock(context.Context, string) (func(), error) |
| ++ Close() error |
| ++} |
| ++ |
| ++func component(s string) bool { |
| ++ if s == "" || s == "." || s == ".." || len(s) > 255 { |
| ++ return false |
| ++ } |
| ++ for _, r := range s { |
| ++ if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_') { |
| ++ return false |
| ++ } |
| ++ } |
| ++ return true |
| ++} |
| ++ |
| ++func fileMode(public bool) os.FileMode { |
| ++ if public { |
| ++ return 0644 |
| ++ } |
| ++ return 0600 |
| ++} |
| +--- /dev/null |
| ++++ b/internal/rootstate/state_linux.go |
| +@@ -0,0 +1,212 @@ |
| ++package rootstate |
| ++ |
| ++import ( |
| ++ "context" |
| ++ "errors" |
| ++ "io" |
| ++ "os" |
| ++ "path/filepath" |
| ++ "strings" |
| ++ "time" |
| ++ |
| ++ "github.com/carlosframework/platform/internal/safefile" |
| ++ "golang.org/x/sys/unix" |
| ++) |
| ++ |
| ++type directory struct { |
| ++ file *os.File |
| ++ owner uint32 |
| ++ public bool |
| ++} |
| ++ |
| ++// Open validates every ancestor, including /. It never repairs ownership. |
| ++// Creation is only possible for root, beneath an already checked parent. |
| ++func Open(path string, create, public bool) (Store, error) { |
| ++ if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" || (create && os.Geteuid() != 0) { |
| ++ return nil, os.ErrPermission |
| ++ } |
| ++ f, err := os.Open("/") |
| ++ if err != nil { |
| ++ return nil, err |
| ++ } |
| ++ defer f.Close() |
| ++ return descend(f, strings.Split(strings.TrimPrefix(path, "/"), "/"), create, public, 0) |
| ++} |
| ++ |
| ++func trusted(fd int, owner uint32, kind uint32) error { |
| ++ var st unix.Stat_t |
| ++ if err := unix.Fstat(fd, &st); err != nil { |
| ++ return err |
| ++ } |
| ++ if st.Uid != owner || st.Mode&unix.S_IFMT != kind || st.Mode&0022 != 0 || (kind == unix.S_IFREG && st.Nlink != 1) { |
| ++ return os.ErrPermission |
| ++ } |
| ++ return nil |
| ++} |
| ++ |
| ++// The owner argument is private; tests use a real user-owned descriptor. |
| ++func descend(parent *os.File, parts []string, create, public bool, owner uint32) (Store, error) { |
| ++ fd, err := unix.FcntlInt(parent.Fd(), unix.F_DUPFD_CLOEXEC, 0) |
| ++ if err != nil { |
| ++ return nil, err |
| ++ } |
| ++ defer func() { |
| ++ if fd >= 0 { |
| ++ unix.Close(fd) |
| ++ } |
| ++ }() |
| ++ if err := trusted(fd, owner, unix.S_IFDIR); err != nil { |
| ++ return nil, err |
| ++ } |
| ++ mode := uint32(0700) |
| ++ if public { |
| ++ mode = 0755 |
| ++ } |
| ++ for _, name := range parts { |
| ++ if !component(name) { |
| ++ return nil, os.ErrPermission |
| ++ } |
| ++ next, e := unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| ++ if errors.Is(e, unix.ENOENT) && create { |
| ++ if e = unix.Mkdirat(fd, name, mode); e != nil && !errors.Is(e, unix.EEXIST) { |
| ++ return nil, e |
| ++ } |
| ++ if e = unix.Fsync(fd); e != nil { |
| ++ return nil, e |
| ++ } |
| ++ next, e = unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) |
| ++ } |
| ++ if e != nil { |
| ++ return nil, e |
| ++ } |
| ++ if e = trusted(next, owner, unix.S_IFDIR); e != nil { |
| ++ unix.Close(next) |
| ++ return nil, e |
| ++ } |
| ++ var st unix.Stat_t |
| ++ if e = unix.Fstat(next, &st); e != nil || (public && st.Mode&0055 != 0055) { |
| ++ unix.Close(next) |
| ++ if e != nil { |
| ++ return nil, e |
| ++ } |
| ++ return nil, os.ErrPermission |
| ++ } |
| ++ unix.Close(fd) |
| ++ fd = next |
| ++ } |
| ++ var final unix.Stat_t |
| ++ if err := unix.Fstat(fd, &final); err != nil { |
| ++ return nil, err |
| ++ } |
| ++ if !public && final.Mode&0077 != 0 { |
| ++ return nil, os.ErrPermission |
| ++ } |
| ++ f := os.NewFile(uintptr(fd), "rootstate") |
| ++ fd = -1 |
| ++ return &directory{file: f, owner: owner, public: public}, nil |
| ++} |
| ++ |
| ++func (d *directory) Close() error { return d.file.Close() } |
| ++func (d *directory) check() error { return trusted(int(d.file.Fd()), d.owner, unix.S_IFDIR) } |
| ++func (d *directory) Read(name string) ([]byte, error) { |
| ++ if !component(name) { |
| ++ return nil, os.ErrPermission |
| ++ } |
| ++ if err := d.check(); err != nil { |
| ++ return nil, err |
| ++ } |
| ++ f, err := safefile.OpenFileAt(int(d.file.Fd()), name, os.O_RDONLY, 0) |
| ++ if err != nil { |
| ++ return nil, err |
| ++ } |
| ++ defer f.Close() |
| ++ if err := trusted(int(f.Fd()), d.owner, unix.S_IFREG); err != nil { |
| ++ return nil, err |
| ++ } |
| ++ b, err := io.ReadAll(io.LimitReader(f, MaxRecord+1)) |
| ++ if err == nil && len(b) > MaxRecord { |
| ++ err = os.ErrPermission |
| ++ } |
| ++ return b, err |
| ++} |
| ++ |
| ++func (d *directory) Write(name string, body []byte) error { |
| ++ if !component(name) || len(body) > MaxRecord || uint32(os.Geteuid()) != d.owner { |
| ++ return os.ErrPermission |
| ++ } |
| ++ if err := d.check(); err != nil { |
| ++ return err |
| ++ } |
| ++ if _, err := d.Read(name); err != nil && !errors.Is(err, os.ErrNotExist) { |
| ++ return err |
| ++ } |
| ++ return safefile.WriteAt(int(d.file.Fd()), name, body, fileMode(d.public)) |
| ++} |
| ++ |
| ++func (d *directory) Names() ([]string, error) { |
| ++ if err := d.check(); err != nil { |
| ++ return nil, err |
| ++ } |
| ++ // A fresh description gives each scan its own directory offset. |
| ++ fd, err := unix.Openat(int(d.file.Fd()), ".", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) |
| ++ if err != nil { |
| ++ return nil, err |
| ++ } |
| ++ f := os.NewFile(uintptr(fd), "rootstate") |
| ++ defer f.Close() |
| ++ entries, err := f.ReadDir(-1) |
| ++ if err != nil { |
| ++ return nil, err |
| ++ } |
| ++ var names []string |
| ++ for _, entry := range entries { |
| ++ if !component(entry.Name()) { |
| ++ return nil, os.ErrPermission |
| ++ } |
| ++ names = append(names, entry.Name()) |
| ++ } |
| ++ return names, nil |
| ++} |
| ++ |
| ++// Lock serializes root readers and writers on an inode the agent cannot replace. |
| ++func (d *directory) Lock(ctx context.Context, name string) (func(), error) { |
| ++ if ctx == nil || !component(name) || uint32(os.Geteuid()) != d.owner { |
| ++ return nil, os.ErrPermission |
| ++ } |
| ++ if err := ctx.Err(); err != nil { |
| ++ return nil, err |
| ++ } |
| ++ if err := d.check(); err != nil { |
| ++ return nil, err |
| ++ } |
| ++ f, err := safefile.OpenFileAt(int(d.file.Fd()), name, os.O_RDWR|os.O_CREATE, 0600) |
| ++ if err != nil { |
| ++ return nil, err |
| ++ } |
| ++ if err := trusted(int(f.Fd()), d.owner, unix.S_IFREG); err != nil { |
| ++ f.Close() |
| ++ return nil, err |
| ++ } |
| ++ tick := time.NewTicker(10 * time.Millisecond) |
| ++ defer tick.Stop() |
| ++ for { |
| ++ if err := ctx.Err(); err != nil { |
| ++ f.Close() |
| ++ return nil, err |
| ++ } |
| ++ err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) |
| ++ if err == nil { |
| ++ return func() { _ = unix.Flock(int(f.Fd()), unix.LOCK_UN); _ = f.Close() }, nil |
| ++ } |
| ++ if !errors.Is(err, unix.EAGAIN) && !errors.Is(err, unix.EWOULDBLOCK) && !errors.Is(err, unix.EINTR) { |
| ++ f.Close() |
| ++ return nil, err |
| ++ } |
| ++ select { |
| ++ case <-ctx.Done(): |
| ++ f.Close() |
| ++ return nil, ctx.Err() |
| ++ case <-tick.C: |
| ++ } |
| ++ } |
| ++} |
| +--- /dev/null |
| ++++ b/internal/rootstate/state_other.go |
| +@@ -0,0 +1,7 @@ |
| ++//go:build !linux |
| ++ |
| ++package rootstate |
| ++ |
| ++import "os" |
| ++ |
| ++func Open(string, bool, bool) (Store, error) { return nil, os.ErrPermission } |
| +``` |
| + |
| +The root-only chown test is intentionally separate. A skipped chown witness is not a pass. On a disposable privileged runner, also open the production `Open` entrypoint beneath a root-owned test root (not `/tmp`, whose writable parent should be rejected), then race service-UID rename/unlink/chmod and assert kernel denial. The non-root replay covers real hostile ownership, links, FIFO, directory replacement, bounded bytes and lock exclusion; it cannot attest UID-0 setup or service-unit permissions. |
| + |
| +## Task B: Move D3 healthy authority without importing old assertions |
| + |
| +Keep `ProofGate.bakeDir` for diagnostic/refusal routing and give it a separate healthy-history directory. Switch **every production** proof-gate constructor: root adoption, legacy root held-stage, the ordinary agent gate and moves pass. Root observation and settle hooks write the new history. The legacy helper remains for existing isolated tests/callers; no production gate is allowed to select it. The root and agent read the same root-owned history, so an honest agent does not wait forever on a proof root already bypassed. |
| + |
| +The migration is lazy and idempotent: root creates the checked namespace on its first observation, records only the independently observed running hash, and never copies `/data/healthy`. `recordLive` stays before the adoption pass. An old historical hash not currently running needs an existing signed prover proof until it is independently observed again. A damaged new history grants no health exception; its writer refuses to repair it silently. A valid signed proof can still satisfy D3. No policy means D3 is still off. |
| + |
| +Focused command: `go test ./internal/release ./internal/host -run 'TestPlan14|TestHealthy|TestProof|TestRecordSettled' -count=1`. |
| + |
| +First failure: `NewRootProofGate` and the trusted decoder are absent. The runtime mutation witness points that constructor back at the supplied BakeDir: the forged-history admission and bind tests must both fail, before and after a whole-parent rename. |
| + |
| +<!-- apply: B tests --> |
| +```diff |
| +--- /dev/null |
| ++++ b/internal/release/plan14_healthy_test.go |
| +@@ -0,0 +1,71 @@ |
| ++package release |
| ++ |
| ++import ( |
| ++ "os" |
| ++ "path/filepath" |
| ++ "testing" |
| ++ "time" |
| ++) |
| ++ |
| ++func TestPlan14ForgedHealthyCannotAdmit(t *testing.T) { |
| ++ e := newProofEnv(t) |
| ++ e.requireDev(t) |
| ++ e.gate = NewRootProofGate(e.policy, e.bake).WithClock(func() time.Time { return e.clock }) |
| ++ if err := RecordHealthy(e.bake, "bab", "console", HealthyBuild{SHA256: e.newSHA, How: "live", At: e.clock}); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ // Rename the whole parent, then plant a valid, attacker-owned replacement. |
| ++ dir := filepath.Join(e.bake, "healthy") |
| ++ if err := os.Rename(dir, dir+"-old"); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if err := RecordHealthy(e.bake, "bab", "console", HealthyBuild{SHA256: e.newSHA, How: "settle", At: e.clock}); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ e.pass(t, e.store) |
| ++ if got := e.live(); got != "old" { |
| ++ t.Fatalf("forged history adopted %q", got) |
| ++ } |
| ++ // A separately verified proof still opens the same gate. |
| ++ e.putAt(t, e.newSHA, e.signed(t, e.prover, "console", e.newSHA, "dev")) |
| ++ e.clock = e.clock.Add(ProofMissTTL + time.Second) |
| ++ e.pass(t, e.store) |
| ++ if got := e.live(); got != "new" { |
| ++ t.Fatalf("signed proof refused: %q", got) |
| ++ } |
| ++} |
| ++ |
| ++func TestPlan14ForgedHealthyCannotBind(t *testing.T) { |
| ++ e := newProofEnv(t) |
| ++ e.requireDev(t) |
| ++ g := NewRootProofGate(e.policy, e.bake) |
| ++ if err := StageArtifact(e.opt, "bab", "new", "console", []byte("bad build")); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ if err := RecordHealthy(e.bake, "bab", "console", HealthyBuild{SHA256: e.newSHA, How: "live", At: e.clock}); err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ rt, err := e.reg.Get(proofHost) |
| ++ if err != nil || rt == nil { |
| ++ t.Fatalf("%v", err) |
| ++ } |
| ++ if g.bind(e.opt, *rt, &ChannelPointer{Version: "new"}, proofVerdict{required: true, sha: e.newSHA}, "old", e.clock) { |
| ++ t.Fatal("bind trusted agent history") |
| ++ } |
| ++} |
| ++ |
| ++func TestPlan14RootHistoryStrictShape(t *testing.T) { |
| ++ for _, raw := range []string{ |
| ++ `{"schema":1,"builds":[]} {}`, |
| ++ `{"schema":1,"extra":true,"builds":[]}`, |
| ++ `{"schema":1,"builds":[{"sha256":"fake","how":"live","at":"2026-10-12T00:00:00Z"}]}`, |
| ++ `{"schema":2,"builds":[]}`, |
| ++ } { |
| ++ if _, err := decodeRootHealthy([]byte(raw)); err == nil { |
| ++ t.Fatalf("accepted %s", raw) |
| ++ } |
| ++ } |
| ++ if b, err := decodeRootHealthy([]byte(`{"schema":1,"builds":[]}`)); err != nil || len(b) != 0 { |
| ++ t.Fatalf("%v %v", b, err) |
| ++ } |
| ++} |
| +``` |
| + |
| +<!-- apply: B implementation --> |
| +```diff |
| +--- /dev/null |
| ++++ b/internal/release/healthy_root.go |
| +@@ -0,0 +1,104 @@ |
| ++package release |
| ++ |
| ++import ( |
| ++ "bytes" |
| ++ "context" |
| ++ "encoding/json" |
| ++ "errors" |
| ++ "io" |
| ++ "os" |
| ++ "path/filepath" |
| ++ "time" |
| ++ |
| ++ "github.com/carlosframework/platform/internal/rootstate" |
| ++) |
| ++ |
| ++// Public history is separate from the private claim journals. Agents may read |
| ++// these records, but only root can create or replace them. |
| ++const RootHealthyDir = "/var/lib/carlos-release" |
| ++ |
| ++func rootHealthyStore(acct, app string, create bool) (rootstate.Store, error) { |
| ++ if !ValidAccount(acct) || !ValidApp(app) { |
| ++ return nil, os.ErrPermission |
| ++ } |
| ++ return rootstate.Open(filepath.Join(RootHealthyDir, "healthy", acct), create, true) |
| ++} |
| ++ |
| ++func decodeRootHealthy(raw []byte) ([]HealthyBuild, error) { |
| ++ var f healthyFile |
| ++ d := json.NewDecoder(bytes.NewReader(raw)) |
| ++ d.DisallowUnknownFields() |
| ++ if err := d.Decode(&f); err != nil { |
| ++ return nil, err |
| ++ } |
| ++ if d.Decode(new(any)) != io.EOF || f.Schema != 1 || len(f.Builds) > healthyMax { |
| ++ return nil, os.ErrPermission |
| ++ } |
| ++ for _, b := range f.Builds { |
| ++ if !sha256Re.MatchString(b.SHA256) || b.At.IsZero() || (b.How != "live" && b.How != "settle") { |
| ++ return nil, os.ErrPermission |
| ++ } |
| ++ } |
| ++ return f.Builds, nil |
| ++} |
| ++ |
| ++func readRootHealthy(acct, app string) []HealthyBuild { |
| ++ d, err := rootHealthyStore(acct, app, false) |
| ++ if err != nil { |
| ++ return nil |
| ++ } |
| ++ defer d.Close() |
| ++ raw, err := d.Read(app + ".json") |
| ++ if err != nil { |
| ++ return nil |
| ++ } |
| ++ b, err := decodeRootHealthy(raw) |
| ++ if err != nil { |
| ++ return nil |
| ++ } |
| ++ return b |
| ++} |
| ++ |
| ++func recordRootHealthy(acct, app string, b HealthyBuild) error { |
| ++ if !sha256Re.MatchString(b.SHA256) || b.At.IsZero() || (b.How != "live" && b.How != "settle") { |
| ++ return os.ErrPermission |
| ++ } |
| ++ d, err := rootHealthyStore(acct, app, true) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ defer d.Close() |
| ++ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| ++ defer cancel() |
| ++ unlock, err := d.Lock(ctx, app+".lock") |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ defer unlock() |
| ++ return addRootHealthy(d, app, b) |
| ++} |
| ++ |
| ++func addRootHealthy(d rootstate.Store, app string, b HealthyBuild) error { |
| ++ var have []HealthyBuild |
| ++ raw, err := d.Read(app + ".json") |
| ++ if err == nil { |
| ++ have, err = decodeRootHealthy(raw) |
| ++ } |
| ++ if err != nil && !errors.Is(err, os.ErrNotExist) { |
| ++ return err |
| ++ } |
| ++ for _, h := range have { |
| ++ if h.SHA256 == b.SHA256 { |
| ++ return nil |
| ++ } |
| ++ } |
| ++ have = append([]HealthyBuild{b}, have...) |
| ++ if len(have) > healthyMax { |
| ++ have = have[:healthyMax] |
| ++ } |
| ++ body, err := json.Marshal(healthyFile{Schema: 1, Builds: have}) |
| ++ if err != nil { |
| ++ return err |
| ++ } |
| ++ return d.Write(app+".json", append(body, '\n')) |
| ++} |
| +--- a/internal/release/healthy.go |
| ++++ b/internal/release/healthy.go |
| +@@ -43,6 +43,9 @@ |
| + } |
| + |
| + func ReadHealthy(bakeDir, acct, app string) []HealthyBuild { |
| ++ if bakeDir == RootHealthyDir { |
| ++ return readRootHealthy(acct, app) |
| ++ } |
| + p, err := healthyPath(bakeDir, acct, app) |
| + if err != nil { |
| + return nil |
| +@@ -75,6 +78,9 @@ |
| + // and the newest healthyMax builds. Written atomically and world-readable, |
| + // because the reader is the sandboxed agent. |
| + func RecordHealthy(bakeDir, acct, app string, b HealthyBuild) error { |
| ++ if bakeDir == RootHealthyDir { |
| ++ return recordRootHealthy(acct, app, b) |
| ++ } |
| + p, err := healthyPath(bakeDir, acct, app) |
| + if err != nil { |
| + return err |
| +--- a/internal/release/proofgate.go |
| ++++ b/internal/release/proofgate.go |
| +@@ -65,6 +65,7 @@ |
| + type ProofGate struct { |
| + policyPath string |
| + bakeDir string |
| ++ healthyDir string |
| + now func() time.Time |
| + |
| + mu sync.Mutex |
| +@@ -78,8 +79,16 @@ |
| + } |
| + |
| + func NewProofGate(policyPath, bakeDir string) *ProofGate { |
| +- return &ProofGate{policyPath: policyPath, bakeDir: bakeDir, now: time.Now, |
| ++ return &ProofGate{policyPath: policyPath, bakeDir: bakeDir, healthyDir: bakeDir, now: time.Now, |
| + shas: map[promotionKey]string{}, misses: map[missKey]time.Time{}, waits: map[string]ProofWait{}} |
| ++} |
| ++ |
| ++// NewRootProofGate keeps diagnostic output in bakeDir. Health authority is |
| ++// always read from the root-owned history, including in the agent's gate. |
| ++func NewRootProofGate(policyPath, bakeDir string) *ProofGate { |
| ++ g := NewProofGate(policyPath, bakeDir) |
| ++ g.healthyDir = RootHealthyDir |
| ++ return g |
| + } |
| + |
| + // WithClock replaces the gate's clock (tests). |
| +@@ -261,7 +270,7 @@ |
| + if sha == "" { |
| + return v, true // no build for this platform: ensureStaged says so |
| + } |
| +- if IsHealthy(g.bakeDir, acct, rt.App, sha) { |
| ++ if IsHealthy(g.healthyDir, acct, rt.App, sha) { |
| + g.clear(rt.Host) |
| + return v, true |
| + } |
| +@@ -342,7 +351,7 @@ |
| + if v.proof != nil && got == v.proof.SHA256 { |
| + return true |
| + } |
| +- if IsHealthy(g.bakeDir, acct, rt.App, got) { |
| ++ if IsHealthy(g.healthyDir, acct, rt.App, got) { |
| + return true |
| + } |
| + reason := fmt.Sprintf("staged sha256 %s, manifest sha256 %s", got, v.sha) |
| +--- a/internal/host/agent.go |
| ++++ b/internal/host/agent.go |
| +@@ -4195,7 +4195,7 @@ |
| + // (D3, proofobserve.go). Root is the process that can hash the unit's |
| + // executable. |
| + restart := rootRestartRoute(reg, release.OptDir(), |
| +- recordSettled(systemdUnits{}, procExePath, release.OptDir(), dataDir(), time.Now)) |
| ++ recordSettled(systemdUnits{}, procExePath, release.OptDir(), release.RootHealthyDir, time.Now)) |
| + // The restart-request half, and the ONE place it dispatches from — |
| + // including for exec-backed routes, whose children belong to the agent: |
| + // rootRestartRoute's try-restart arm reaches a carlos-app@ unit, and an |
| +@@ -4324,10 +4324,9 @@ |
| + release.WithDigestRatchet(pointerRatchet(true, release.OptDir(), dataDir(), os.Getenv)), |
| + release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv)), |
| + // D3's gate: a fresh gate per root pass, so the policy file is read |
| +- // at the start of this pass (BeginPass) like every other. Its bake |
| +- // directory is BakeDir's above: the healthy history it reads is the |
| +- // one this pass and recordSettled write. |
| +- release.WithProofGate(release.NewProofGate(release.ProofPolicyPath, dataDir()))} |
| ++ // at the start of this pass (BeginPass) like every other. Healthy |
| ++ // history is root-owned; diagnostic refusals still use BakeDir. |
| ++ release.WithProofGate(release.NewRootProofGate(release.ProofPolicyPath, dataDir()))} |
| + if !*stageOnly { |
| + passOpts = append(passOpts, release.NagExpiredCanaries(os.Stdout)) |
| + } |
| +@@ -4342,6 +4341,7 @@ |
| + reg: reg, |
| + store: store, |
| + bakeDir: dataDir(), |
| ++ healthyDir: release.RootHealthyDir, |
| + policyPath: release.ProofPolicyPath, |
| + readKey: readProvingKey, |
| + hourly: !*stageOnly, |
| +--- a/internal/host/proofroot.go |
| ++++ b/internal/host/proofroot.go |
| +@@ -46,10 +46,18 @@ |
| + reg *registry.Registry |
| + store release.Store |
| + bakeDir string |
| ++ healthyDir string |
| + policyPath string |
| + readKey func(path string) (ed25519.PrivateKey, error) |
| + hourly bool // carlos-adopt.service, not -stage-only |
| + logf func(format string, a ...any) |
| ++} |
| ++ |
| ++func (r proofRoot) historyDir() string { |
| ++ if r.healthyDir != "" { |
| ++ return r.healthyDir |
| ++ } |
| ++ return r.bakeDir |
| + } |
| + |
| + // liveObservation is one look at a route's live build: the live link's |
| +@@ -115,7 +123,7 @@ |
| + if err != nil { |
| + return o |
| + } |
| +- if err := release.RecordHealthy(r.bakeDir, release.RouteAccount(rt), rt.App, release.HealthyBuild{ |
| ++ if err := release.RecordHealthy(r.historyDir(), release.RouteAccount(rt), rt.App, release.HealthyBuild{ |
| + SHA256: b.SHA256, Version: b.Version, How: "live", At: r.obs.now().UTC()}); err != nil { |
| + r.logf("proof: %s: recording the healthy build: %v", rt.Host, err) |
| + } |
| +@@ -201,7 +209,7 @@ |
| + if _, err := os.Stat(r.requiredMarker(k.app)); err != nil { |
| + continue |
| + } |
| +- if n := len(release.ReadHealthy(r.bakeDir, k.acct, k.app)); n > 0 { |
| ++ if n := len(release.ReadHealthy(r.historyDir(), k.acct, k.app)); n > 0 { |
| + r.logf("proof: requirement is off for %s on this box: %s has no require entry for it (%d healthy builds on record)", |
| + k.app, r.policyPath, n) |
| + } |
| +--- a/internal/host/proofask.go |
| ++++ b/internal/host/proofask.go |
| +@@ -37,11 +37,10 @@ |
| + proofGateInst *release.ProofGate |
| + ) |
| + |
| +-// agentProofGate's bake directory is dataDir(), the same directory every |
| +-// agent pass hands release.BakeDir, so the gate's healthy-history reads and |
| +-// not-proven refusal records land where the pass's own refusal gate looks. |
| ++// agentProofGate reads root-observed healthy history. Its not-proven |
| ++// diagnostics stay in dataDir(), beside the agent pass's refusal records. |
| + func agentProofGate() *release.ProofGate { |
| +- proofGateOnce.Do(func() { proofGateInst = release.NewProofGate(release.ProofPolicyPath, dataDir()) }) |
| ++ proofGateOnce.Do(func() { proofGateInst = release.NewRootProofGate(release.ProofPolicyPath, dataDir()) }) |
| + return proofGateInst |
| + } |
| + |
| +--- a/internal/host/heldstage.go |
| ++++ b/internal/host/heldstage.go |
| +@@ -201,7 +201,7 @@ |
| + // under pub and admitted by r, root's pointer digest ratchet (the one |
| + // adoption uses). Anything else is dropped before a byte is downloaded. |
| + func heldStagePass(ctx context.Context, dataDir, optDir string, pub ed25519.PublicKey, r *release.DigestRatchet, reg *registry.Registry, store release.Store, box string) { |
| +- proof := release.NewProofGate(release.ProofPolicyPath, dataDir) |
| ++ proof := release.NewRootProofGate(release.ProofPolicyPath, dataDir) |
| + if err := release.ServeHeldStageRequests(ctx, store, pub, r, optDir, dataDir, proof, heldStageAuthority(reg, store, box, dataDir)); err != nil { |
| + log.Printf("adopt: held stage requests: %v", err) |
| + } |
| +--- a/internal/host/movespass.go |
| ++++ b/internal/host/movespass.go |
| +@@ -431,7 +431,7 @@ |
| + pub = nil |
| + } |
| + cfg.ReleaseReady = heldReleaseReady(reg, cfg.OptDir, dataDir, cfg.Box, pub, pointerRatchet(false, cfg.OptDir, dataDir, getenv), |
| +- release.NewProofGate(release.ProofPolicyPath, dataDir), stageMarkerRequester(dataDir)) |
| ++ release.NewRootProofGate(release.ProofPolicyPath, dataDir), stageMarkerRequester(dataDir)) |
| + if svc != nil { |
| + // S1 asks the instance itself, woken through the activator. |
| + cfg.ReadyProbe = func(ctx context.Context, host, path string) moves.Check { |
| +``` |
| + |
| +### Task B2: The unmerged moves consumer |
| + |
| +Apply only after the five separately named moves deltas in the replay section. The constructor in that branch is another root consumer; it cannot retain the legacy history by accident. |
| + |
| +<!-- apply: B2 implementation --> |
| +```diff |
| +--- a/internal/host/movesheldrelease_production.go |
| ++++ b/internal/host/movesheldrelease_production.go |
| +@@ -71,7 +71,7 @@ |
| + Selected: hostbox.Selected, |
| + Pub: pub, |
| + Ratchet: func() *release.DigestRatchet { return pointerRatchet(true, optDir, dataDir, getenv) }, |
| +- Proof: func() *release.ProofGate { return release.NewProofGate(release.ProofPolicyPath, dataDir) }, |
| ++ Proof: func() *release.ProofGate { return release.NewRootProofGate(release.ProofPolicyPath, dataDir) }, |
| + OptDir: optDir, |
| + DataDir: dataDir, |
| + ReceiptDir: heldReleaseReceiptDir, |
| +``` |
| + |
| +Task B changes D3 with the claim flag **off as well as on**. The exact behavior change is loss of the old writable-history exception. It does not change the configured require/prove set, signature verification, release authorization, staged hash binding, restart semantics or route selection. Task E completes the separate refusal/mint-history trust boundary; passing Task B alone does not establish all release defences against a hostile agent. |
| + |
| +## Task C: Separate root fences from the agent guard |
| + |
| +Implement the root backend in `internal/hostguard`, using Task A's store. Preserve the existing record decoder for the agent path. Do not repurpose `Guard.DataDir`: it also locates workload data in assembly, park and moves callers. Add a separately constructed root-authority backend and a read-only fence-view provider. Construct the backend only in `OpenProductionClaimAssembly` for `adopt` and `instance-control`; the agent uses the view provider, not a writable root store. |
| + |
| +Root's per-host record contains a schema version, migration generation, exact installation identity and installed/retired state, unfinished cold-handover barrier, pending fence target, completed fence receipts, and monotonically retained retirement/deny history. Retain all fence tuples still needed by local root journals; do not apply the agent's 32-stamp trim to root stop evidence. Store files by validated host, not caller-supplied paths. A missing/corrupt record is unavailable until root has established that host's installation or explicit empty state. |
| + |
| +The root installation record is established from a fresh claim and the existing typed root inventory operation. `ClaimRootHTTP.serveJournal` must verify the matching deployment instance record/operation, box enrollment, exact holder stamp, expected row ownership, permitted managed roots and descriptor/kernel identities before recording it. `ValidateAgentInventory`'s row/shape check alone is not that proof. With no authoritative remote record, retain the existing explicit creation/adoption authority and root empty-installation census; refuse an unsupported source. Do not seed root installation solely because an agent's `Capture` names the current claim stamp. |
| + |
| +Change the readers and writers as one task: |
| + |
| +1. Give WorkSource a guard-backed capture operation. For root, read the root installation record and match it to a fresh claim. For the agent, keep its existing local `Capture`. Update `WorkSource.Select`, root reconcile/capture and root admin-token paths to use it. A selection remains bound to its original tuple; do not recapture at effect time. |
| +2. `Guard.With` in root mode holds a stable root-owned host lock, re-reads the claim and root installation, checks retired state, pending fence, completed fences and migration barrier, then runs the existing Local and Policy checks and effect. Both Plan 11 root launch checks (including final `StartChecked`) and Task 7b's selection must use that backend. Root adoption must use it too. Never fall back to the agent record when the root record is missing. |
| +3. Route `BeginFence`, `CompleteFence`, `ExistingFence`, `ResumeFence`, `FenceReceiptFor`, `PendingFences`, `CompletedColdPredecessor`, `CompletedWriterStops`, `CleanupWith`, `CleanupRetireInstallationWith`, `RetireInstallation*`, `WithNoInstallation`, `BeginColdHandover` and `CompleteColdHandover` to root authority in root callers. Root recovery enumerates the checked root directory. An attacker removing `/data/hostguard` must not hide pending recovery or a completed deny. |
| +4. Persist the root fence and fsync before sending a drain frame, killing a process or acknowledging the fence. Persist exact completed evidence before replying. On ambiguous completion, reread the same root record and reuse the same bytes and original evidence time. A failed directory fsync is an error, including after rename; retry syncs the already-visible state before proceeding. Old completed sessions stay denied when a new incarnation is installed. |
| +5. Cold handover first records the successor barrier in root authority while retaining the predecessor's receipt. Only the existing verified retirement/inventory-rebind completion clears that exact barrier. An ordinary `Activate`, a claim session increment, a registry edit or a recreated agent guard file cannot clear it. Record root retirement before artifact effects; replay never relabels artifacts to a successor. |
| +6. Serve a **read-only** typed root fence view on the existing authenticated journal transport. Extend `JournalRequest/Response` additively and classify the operation as a read. The request identifies host and captured tuple; it supplies no fence records. The view returns root's exact deny/receipt data and migration status. The agent's guard checks the view for every guarded admission and its receipt helpers use that view. Missing old-server support is unavailable, never an empty view. Agent writes cannot erase or complete a root fence. No generic root write-file, chmod, raw-JSON or SQL verb is added. |
| +7. Preserve honest-agent serialization with the existing local hostguard, but never let its replaceable inode serialize two root effects. Define the order as root host lock before legacy host lock for operations needing both. A fence-view read is a descriptor-pinned atomic snapshot and acquires neither lock, so an agent holding the legacy lock cannot deadlock with root. Root's final grant check reads under its root lock. Receipts and views are immutable copies; never hold a root lock while waiting for the agent's drain. On return, reacquire and revalidate the exact target. |
| +8. `ClaimRootWithLaunch`, `NewProductionRootClaimStop`, `ClaimStopHTTP.RecoverPending`, root cleanup and the Task 7b WorkSource all receive the same backend from assembly. For Task 7b, stage remains host-independent signed release staging; publish, rollback and receipt replay are root-guarded. Bind the publication receipt to action/kind/phase. A receipt says an earlier effect completed, not that the session may publish again after it was fenced. Use the same root release lock for root adoption and held publication. |
| + |
| +Task 7b may retain the agent render journal only as a not-yet/readiness hint within an independently authorized publication row. Explicitly refuse reverting and clean-return probe rows unless the controller's binding publication table authorizes them and root independently verifies their required render. For landing/dirty-return rows, re-read the pinned record and claim under the root guard, require the authorized version and existing signed release/D3 checks. Do not describe the matching agent journal as root evidence. Where a root decision needs completed render rather than mere readiness, factor the existing render-input calculation into a read-only function, have root fetch the pinned config/record inputs itself, verify the exact rendered digest, and record that result in its receipt. |
| + |
| +Required regression files: `internal/hostguard/plan14_root_fences_test.go`, `internal/host/plan14_root_launch_test.go`, and moves' `internal/host/plan14_held_root_test.go`. Use real agent and root fixture directories; exercise the real root handler/selector and an effect counter, not just an error helper. Fence a session, erase/replace/rename its agent record and its legacy lock, then try launch, held publish and adoption. All effects stay at zero, including replay of a previously successful publication receipt. Pending recovery must still enumerate the original root target. Test cold-handover crash before and after each durable boundary, restart, new boot, successor stamp and old receipt replay. A valid successor after verified completion must run, so an always-refuse implementation does not pass. |
| + |
| +## Task D: Stop trusting execution deadlines and absence |
| + |
| +`InspectLocal` plus `CheckLocal` is not an independent root proof: the same service UID can author `SafeUntil`, choose the asserted process identity and hold a replacement lock. A fresh claim does not prove that a local lease-loss latch was never set. Checking an agent-written remote lease's `Expires` instead would move the same assertion, not fix it. |
| + |
| +Keep the agent's `.hostexec` bookkeeping, but make root the writer of a separate execution record whenever privileged claim work depends on it. Extend the existing instance-control broker with typed begin/renew/settle requests, authenticated to the exact controller process. The broker performs the acquisition/renewal that it attests using the existing execution-lease implementation and configured store, reads fresh claim/roster authority, samples its own suspend-aware clock, and persists the resulting bounded deadline. It never accepts a requested `SafeUntil`, `Expires`, lock path, PID or cleared-loss bit as evidence. There must be exactly one lease owner for an operation: the agent's claim-mode controller becomes the client of this broker, not a second concurrent renewer. Keep the flag-off controller implementation unchanged. |
| + |
| +Bind the root record to box, enrollment, boot, controller PID/start, host, incarnation, operation, holder session and installation, lease identity/ETag and root-calculated safe deadline. Use existing TTL/renewal/margin values (90s/20s/5s); do not add a longer lease. Hold the lifetime lock in the root namespace. Root's broker watches the authenticated process, owns expiry/loss and persists a deny before cleanup. Shutdown, failed renewal, uncertain conditional release and suspend preserve loss until the existing positive cleanup/CC9 transition finishes. A request from a replacement controller never reuses the old permit. Reboot invalidates the old monotonic deadline and keeps unresolved cleanup denied. |
| + |
| +Wire `productionClaimLocal.Prepare/Check`, Plan 11 `authorize` and its last executable check to this root record. Serving requires a positively settled root operation record/absence under a completed migration generation, not just absence of an agent file. Agent status can still expose its mirror. Stop/retirement recovery must remain available after work admission is closed; do not make completion depend on a new lease grant. |
| + |
| +For `hostfloor.CheckControllersIdle`, `Disk.Begin`, `systemApplier.beginExecutableChange`, `controllersIdle`, `controllersRecovered` and legacy participation inspection, obtain the census from root participation and root launch journals. Root writes update-begun under the existing floor transaction lock before excluding new broker acquisitions; acquisition checks that marker under the same ordering. An empty **agent** `.hostexec` directory contributes no evidence. Enumerate outstanding root reservations and verify their kernel processes/containment. Preserve unknown or ambiguous effects. Root update recovery cannot require the very new broker process it is replacing: the durable root ledger plus the existing root kernel recovery supplies the offline path. |
| + |
| +Required tests in `plan14_root_participation_test.go`: real forged future deadline with real held `.lock`/`.live` files; erased/renamed state; old inode still locked while a replacement is empty; PID reuse and controller restart; suspend crossing deadline; root crash between remote acquisition and record fsync; renewal failure; cleanup failure; update-begun racing acquisition; serving after correctly settled cleanup. Mutate the production root reader back to `.hostexec` and watch the deadline/absence witnesses fail. Until this task exists, privileged operation work must refuse agent-owned participation files after validating every ancestor; that temporary refusal is an activation block, not the final implementation. |
| + |
| +## Task E: Root release and restart decisions |
| + |
| +Complete the adjacent pre-claim trust boundary found in the census. The A/B patch set deliberately does not silently reassign `BakeDir`, which would also move many diagnostic/request paths and lose existing denial state. |
| + |
| +Add separate root authority stores to release pass configuration and root restart bookkeeping. Route root `ReadPrograms`/program exception writes, blocking `ReadRefusal`/refusal writes, `proofRoot.mint`'s refusal exclusion, FirstSeen/production/hold ratchets and restart consumption marks through `/var/lib/carlos-release/private`. Preserve the existing wire formats and existing decisions once provenance is established. The agent retains its cache and watches read-only status projections. Existing `WithProofGate`/BakeDir diagnostic output remains compatible. Root's private ledger is never readable or writable through a tenant path. |
| + |
| +Migrate by fact, not directory copy: |
| + |
| +- Healthy history uses Task B's rebuild. Program history is seeded only from root-staged, signed-authorized releases whose bytes root verifies, and root-observed current bindings. An old program exception that cannot be established remains unavailable; do not create a fresh-install loophole on a previously installed route. |
| +- Refusals are sticky negative evidence. Root may conservatively retain a bounded legacy refusal as a **deny-only** quarantine, but it cannot use a `Recovered` field or missing legacy file to assert successful recovery. Re-observe the current binding/release, use existing explicit signed retry authorization, and clear only that exact refusal through the existing rule. Unknown migration state refuses adoption/minting for that route until reconstructed. Do not simply drop old refusals and thereby retry known failed builds. |
| +- Seed first-seen clocks at root's current observation, never earlier from the legacy file. Preserve root-known stricter holds. Re-read authoritative pipeline/production state; if a deleted remote field makes the historical ratchet unknowable, keep an unknown-history deny for that app until root/operator-authorized reconstruction. A fresh empty map is not an acceptable migration default. |
| +- Baseline root restart consumption from the current authoritative app/scoped request without restarting. Preserve any existing trusted root consumed ID. Keep the existing selector semantics and the rebaseline behavior when an ID has rolled out of the log. Agent seed writes cannot advance root's mark or re-trigger an old request. |
| +- Place root adoption/publication serialization in the checked private store. Failure to take the root authority lock refuses mutation; retain the existing restricted read/status behavior where safe. Root never continues an authority-changing pass unserialized because a service-owned lock was replaced. |
| + |
| +Pointer digest ratchets already have a root copy under opt and an agent copy under DataDir (`host/pointerdigests.go`). Keep the agent copy as an additional deny only: a mark may require stronger digest proof, but its absence/malformed content cannot remove the root mark or permit an unsigned digest. Preserve fail-closed parse behavior. Root records every newly accepted digest requirement in its own copy before the effect. Tests must demonstrate this monotonic combination. |
| + |
| +This task changes flag-off root behavior only by removing service-file grants and by conservative rebaseline/denial while history is unresolved. It must not silently erase a D1 refusal, enable a program change, shorten bake time, loosen a pipeline hold, broaden a restart, or retry an already consumed request. Add real-file before/after witnesses for each case, including old histories intentionally missing and a failed fsync after visible rename. |
| + |
| +## Task F: Root moves floors, update and legacy layout reads |
| + |
| +Preserve the distinction between the signed claim release floor (`hostfloor`) and the older moves floor. The latter intentionally trusted service files against honest mistakes; this plan tightens the root path rather than pretending that comment means it is already secure. |
| + |
| +Root apply reads the existing authoritative moves-protocol object itself through the existing provider and records a nondecreasing root floor under `/opt/carlos/system/guard`. The agent may continue refreshing its `/data/.moves` mirror. Root activation, rollback, `judgePrevWith`, `floor-allows-prev` and both systemd boot selectors must read the root floor. Root must first verify its parent chain, bind the candidate binary identity to its existing signed system release/floor, and retain the root verdict. An absent service mirror cannot choose `carlos.prev` or remove a remembered minimum. Never infer “no floor” from a failed remote read. A legacy box with no root floor marker has **unmigrated** state until root positively resolves the deployment's floor; this may defer an offline update/recovery rather than downgrade unsafely. |
| + |
| +Update `carlos-system-update.service`, `carlos-system-recover.service`, the generated/bootstrap plan and their source-shape tests together. Keep the old files readable for older agents, but install the root boot selector before allowing an older service-file check to select a fallback. Retain the currently executing certified recovery binary while migrating; do not replace the only recovery route before its floor copy and selector are durable. `carlos.prev` must have the root-floor-aware reader before it can be selected. The local root authorization to lower/reset a floor stays an explicit operator operation; the agent has no reset RPC. |
| + |
| +`system/router/requested.json` and bundle caches stay in DataDir. Their readers keep bounds, no-follow opens and signed pointer/artifact checks. Migration/layout discovery under `layout/` and instance directories is a candidate census, not proof that no nested/held installation exists. Back an allow-to-downgrade verdict with root installation/launch records and actual mount/kernel observation. If these disagree or are incomplete, refuse the downgrade. Do not derive a permissive legacy layout from a removed layout journal. |
| + |
| +Regression witnesses: erase/rename both legacy floor files and their parent while the root floor remains; attempt older update, rollback and both boot selectors. Repeat with an absent/corrupt/hostile root floor, stale root verdict, unexpected signed target, cached bundle substitution and pending root participation. No privileged executable change occurs. A valid same/newer target still applies and recovers. Exercise the generated units themselves on the disposable systemd runner; Go helper tests alone cannot verify shell branching. |
| + |
| +## Task G: Guarded migration and startup |
| + |
| +Extend the existing offline claim journal migration and startup recovery. Do not add an online “trust this directory” importer or another service. Use `quiesceLegacyClaimJournals`' exact edge-mask ownership, inactive-edge check and service-UID process exclusion. Preserve pre-existing operator masks. Hold exclusion through root fence migration and recovery. Agent absence asserted by a file is insufficient; check the kernel. There is no automatic chown-and-copy path. |
| + |
| +Under a root-owned migration lock, create a versioned root migration record with deployment/bucket, box/enrollment, schema generation and phase. Record root-observed host/installation inventory, the claim ETags and signed directory revision used, and every old admitted session that must remain denied. Enumerate the union of fresh authoritative claims for this box, root installation/launch/retirement journals and validated managed-root census. The old hostguard directory may supply extra candidates or deny-only suspicions; it may neither shrink the union nor establish emptiness. |
| + |
| +The phases are `prepared`, `fenced`, `recovered`, `complete`: |
| + |
| +1. `prepared` durably names the census and blocks new root grants. Include current sessions, including a current serving session with no operation. For each existing installation, retain a migration deny floor at least its current session/stamp. Do not import an agent's old active/retired stamp as root provenance. |
| +2. `fenced` means each known session's root deny is durable. Root then verifies kernel termination/descriptor absence and reconciles the exact root inventory. A new boot alone does not prove remote producers drained or erase unfinished local retirement. Preserve quiet windows and old evidence obligations. |
| +3. `recovered` means complete stopped evidence was verified or freshly collected. Imported evidence is accepted only from the exact enrolled root-signed immutable object and digest under the existing migration verifier. A raw legacy `FenceJournal` entry never qualifies. Missing historic inventory or evidence leaves that item pending; do not sign a reconstruction root did not observe. Already signed receipts keep original evidence time. Newly collected evidence gets its actual observation time and the full existing horizon. |
| +4. `complete` is written and fsynced only after all items settle, root readers are installed, root execution participation is empty/settled, and the root boot/floor prerequisites are satisfied. The first root installation of a new host uses the same positive empty census, without importing a legacy allow record. A subsequent startup verifies the same enrollment/schema and root records, then reuses the completed migration. Deleting `/data/hostguard` cannot make migration run as a fresh box. |
| + |
| +After completion, the agent activates its own bookkeeping from fresh claims as before, but existing admitted sessions remain denied. New admissions must advance beyond migration's deny floor and complete the existing predecessor stop/retirement handover. Do not grant the old session merely because the claim still says serving. A late claim change while the census is prepared requires a new observation under the exclusion, never relabelling an old receipt. Root cleanup remains available when the roster has become quarantined; preserve the existing cleanup enrollment path. |
| + |
| +Crash-test every durable phase, including rename visible but fsync failed; retry with the same migration identity and ensure it never reduces deny coverage. Recovery enumerates root records even if the entire old DataDir has been replaced. Do not delete legacy files during migration. They can be retained as non-authoritative diagnostics and removed later only under their existing inventory/retirement rules. |
| + |
| +Health migration is independently safe to deploy first: no historical legacy hash is imported and current live health can be re-observed. Claim migration requires the approved offline stop/re-admission window. Those are different rollout operations; the D3 patch does not require turning on claims. |
| + |
| +## Task H: Census, production wiring and acceptance |
| + |
| +Census classifications must accompany implementation diffs: |
| + |
| +| Site/change | Classification and reason | |
| +|---|---| |
| +| `rootstate` | Local root-owned descriptor persistence; no claim/control-store mutation, DB open or AWS construction. Do not classify it as a remote authority writer. | |
| +| Root fence begin/complete/retire/cold-handover backend | Existing local lifecycle authority transferred from agent-writable records to root records. It is authority-bearing; preserve action labels and inventory of effect sites. | |
| +| Root installation and execution broker operations | New authenticated local authority transitions. Bind the exact peer and fresh claim, list in the journal RPC census, and require negative dispatch tests for old/unknown messages. A new op must not be hidden among `journalRead` or settlement operations. | |
| +| Read-only root fence/participation view | Read-only projection. No registration, fence clearing, expiry extension, receipt manufacture or root write. Fresh request IDs; no stale replay cache can return an older permissive view. | |
| +| `PrepareRootJournalDirectory` | Exactly `authority` and moves' `held-release` are additional checked root-owned subdirectories. Preserve existing `retire` and `migration-*` classification and refusal of other entries. | |
| +| `claimDataCensus` | Existing `.hostclaims`, `.hostexec`, `hostguard` remain agent/legacy shared bookkeeping. Update its misleading “verify their own contents” comment. Do not delete those classifications just because root authority moved, and do not add a wildcard for DataDir. | |
| +| Root release/health/lock stores and root moves floor | Shared box state outside host workload roots. Not per-host retireable artifacts. No `/var/lib/**` wildcard and no managed-root expansion solely to exempt these files. | |
| +| Existing root journal and registry child | No new SQLite open in root. Reuse the already-reviewed root-owned journal boundary; never open a DataDir DB from root. Registry reads/mutations retain the isolated child. | |
| +| Constructors and changed function fingerprints | Update only the specific source inventory rows that the changed expression actually affects, naming their preserved authority role. No whole-file inventory regeneration to hide new sites. | |
| + |
| +The A/B/B2 reference patches add no claim mutation site and require no existing census snapshot update; the authoring replay runs the existing census. Tasks C–G necessarily change authority sites and need the above explicit entries with their final concrete function names. Do not claim their census has passed before those changes exist. |
| + |
| +Root service filesystem access must be deliberate. Claim-mode instance-control already has `StateDirectory=carlos-claims`; add access only for the public healthy store and required root release directory when held-stage root needs to record a result. `ProtectSystem=strict` must not make successful D3 writes impossible. Prefer bootstrap creation of the public history tree with fixed root ownership and mode; do not let systemd create it at its default private mode and then quietly hide it from the agent. Reject a pre-existing hostile directory. Check both clean boot and upgrade. Claim-off instance-control keeps its existing unit/mux/constructor. Adoption and legacy held-stage continue to work with D3 off. |
| + |
| +Acceptance is a matrix, not just compilation: |
| + |
| +- Both flags off, claim on/moves off, both on, and pre-claim D3 require/prove/off. For claim-only code, flag-off traces must match the pinned base: no new claim constructors, root ledger, journal RPCs or guards. Pre-claim differences must be exactly Tasks B/E/F's named tightening. |
| +- Real root/service UID ownership, symlink at every ancestor, linked leaf, hard links, FIFO/device, wrong mode, wrong UID, replacement parent, concurrent rename, retained descriptor and lock-inode replacement. Run beneath a root-owned disposable base for positive production-open tests. Every hostile case needs a positive permitted counterpart. |
| +- Same fence after process restart/reboot; same completed receipt bytes; denial after erased shared record; root receipt replay after fence; no-process receipt still needs its positive kernel/inventory proof; cold handover only clears its exact barrier; new valid session works. |
| +- Root lease expiry/loss, suspend, replacement controller, cleanup failure, update exclusion and recovery. Agent file deletion cannot authorize serving or system update. |
| +- D3 forged history rejected at admit and bind, signed proof accepted, actual live observation recorded before replacement, duplicate hashes keep original evidence and concurrent root observations do not lose each other's records. |
| +- Preserve #267's filesystem-effect, root-registry and rootaws tests; RunAdopt's root route discovery still uses the unprivileged child. Never broaden ReadWritePaths to all `/var/lib`. |
| + |
| +Before landing the implementation, run `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .`, the real UID/systemd acceptance matrix and the repository's cross-platform gate once on the integrated tip. Do not call a sandbox-denied socket or skipped privileged witness a pass. This authoring task makes no commit and runs no production test. |
| + |
| +## Task I: Amend the claim spec to rev 6.34 |
| + |
| +The design spec is a separate input, not part of the main export. Its rev 6.33 SHA-256 is `5432be6e12e8fc134eabcf3b19733a1a5935971e81c3ebd2f34f70e333945000`. Copy that exact file to scratch before applying this patch. Keep the design worktree's spec unchanged during authoring. |
| + |
| +<!-- apply: I implementation --> |
| +```diff |
| +--- a/docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md |
| ++++ b/docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md |
| +@@ -1,12 +1,14 @@ |
| + # The host lifecycle claim |
| + |
| ++**Rev 6.34 (2026-10-12):** separates root fence, execution and release authority from service-writable DataDir state; requires guarded migration and independent root provenance. Claim Plan 14 addresses amadan #280. |
| ++ |
| + **Rev 6.33 (2026-10-12):** binds home placements to claim engine regions, preserves existing roster-name evidence, and completes the home move enrollment and loss boundaries. Claim Plan 13 supplies the implementation. |
| + |
| + **Rev 6.32 (2026-10-10):** completes the claim interfaces for moves M2, M3, M5, M6, M7, M9, M12 and M21. Claim Plan 12 supplies loss-bound Group D admission, recovery predecessor evidence, atomic fenced-target release and restore retirement, held-lease parks, local park authority and retained retirement-location evidence. |
| + |
| + **Rev 6.31 (2026-10-09):** records the accepted region HA contracts in amadan #236: `AutomaticLoss` in claim-level `evidence_refs`, the stop-only `relocation-release` obligation, and immutable signed `enrolled_at`. Section 6.2a binds moves' push, landing-install and rollback-return parks by name and region; R6 refuses automatic empty failover after any writer session. Claim Plan 11 supplies the integration. |
| + |
| +-Design, 2026-10-12, **rev 6.33**. Joint rounds 6 and 7 judged the claim ready for primitive planning. Rev |
| ++Design, 2026-10-12, **rev 6.34**. Joint rounds 6 and 7 judged the claim ready for primitive planning. Rev |
| + 6.5 added the R6-4 rule. Rev 6.6 added writer epochs (R7-1, amadan #225). |
| + Rev 6.7 answered the moves session's notes on §6.4a. Rev 6.8 rewrote §6.4a |
| + around writer sessions, for joint round 8. Rev 6.10 answers joint round 10 |
| +@@ -2574,7 +2576,7 @@ |
| + |
| + The collector derives artifact paths, unit names and cgroups from validated inventory and configured roots. Requests cannot supply arbitrary paths, PIDs or unit names. |
| + |
| +-Under hostguard, the collector validates ordinary operation authority or the cleanup authority defined in P6-D6. It durably records a fresh `fence_id` and the exact target tuple in the existing local guard state. Every app-code admission, root launch and host-work reservation checks this fence. The fence denies new work for the evidenced session without retiring its installation stamp. |
| ++Under hostguard, the collector validates ordinary operation authority or the cleanup authority defined in P6-D6. It durably records a fresh `fence_id` and the exact target tuple in root-owned authority state (P6-D9). Every app-code admission, root launch and host-work reservation checks this fence. The fence denies new work for the evidenced session without retiring its installation stamp. |
| + |
| + The collector then releases hostguard. The agent closes its matching |
| + in-memory admission fence, cancels and joins selected application work, |
| +@@ -2977,6 +2979,92 @@ |
| + - Using a route status as the operation identity: it cannot distinguish old and current holds. |
| + - Clearing failure on a heartbeat or cached response: neither proves this admitted process is healthy. |
| + - Re-admitting a session for each restart under a live replicator: it violates the session boundary. |
| ++ |
| ++#### P6-D9. Root-owned local authority (rev 6.34, #280) |
| ++ |
| ++Every privileged local decision uses root-owned state, verified signed |
| ++state, the existing authoritative claim/control-store read, or root's own |
| ++kernel observation. Safe descriptor traversal alone does not authenticate |
| ++content. A root-owned leaf under a service-writable ancestor is untrusted, |
| ++as is a root-owned record populated from an unverified agent assertion. |
| ++Local agent state cannot prove the absence of a fence, lost execution lease, |
| ++unfinished cleanup, release refusal, remembered floor or unconsumed effect. |
| ++ |
| ++The service user's installation bookkeeping remains under DataDir. Root |
| ++retains its installation/retirement decisions, pending fences, exact completed |
| ++stop receipts, cold-handover barriers, execution participation and migration |
| ++state under `/var/lib/carlos-claims/authority`. Root publication receipts |
| ++remain under `/var/lib/carlos-claims/held-release`. Every ancestor is owned |
| ++by uid 0 and is not group/other writable. The namespaces are private to root; |
| ++the agent receives typed, authenticated read-only views over the existing |
| ++control socket. No generic privileged file-write or SQL operation is added. |
| ++ |
| ++Root persists a fence before drain, stop or acknowledgment. A completed fence |
| ++continues to deny its exact session. Restart, a changed claim, a recreated |
| ++agent guard file or a new incarnation cannot erase that deny. Root cleanup, |
| ++cold handover, admission, final executable launch, adoption, held publication |
| ++and publication-receipt replay consult the same root record. A receipt is |
| ++proof of an earlier completed effect, not permission to repeat it after a |
| ++fence. Recovery enumerates root records independently of DataDir. The |
| ++agent's guard also checks root's read-only fence view and cannot complete or |
| ++clear it. Missing view support refuses; it never means no fence. |
| ++ |
| ++Root's installation identity is established from fresh claim/record authority |
| ++and a verified local installation census. Agent inventory fields and a |
| ++matching registry row are proposals, not proof of kernel identities. Root |
| ++retirement is durable before artifact effects. The successor cold-handover |
| ++barrier clears only after the existing verified retirement and inventory |
| ++rebind; ordinary activation or session advancement cannot clear it. |
| ++ |
| ++For claim-mode execution participation, the root broker is the single lease |
| ++owner whenever privileged work relies on the lease. It validates the exact |
| ++controller process and performs the acquisition/renewal it records. The |
| ++agent remains the work orchestrator and a client of that broker. Root uses |
| ++its own suspend-aware clock and the existing 90-second TTL, 20-second renewal |
| ++and 5-second margin. It accepts no caller-supplied expiry or safe deadline. |
| ++A lost or uncertain lease remains denied until positive cleanup and CC9 |
| ++settlement. Agent file deletion or replacement locks cannot prove settlement. |
| ++Root update exclusion and recovery use the root participation and launch |
| ++journals under the existing update-begun ordering. Reboot invalidates an old |
| ++monotonic deadline without erasing unfinished cleanup. |
| ++ |
| ++Root-observed D3 healthy history is stored under |
| ++`/var/lib/carlos-release/healthy/<acct>/<app>.json`, readable by the agent but |
| ++writable only by root through root-owned ancestors. An old DataDir healthy |
| ++hash is not imported as evidence. The existing running-executable/systemd/ |
| ++readiness observation or a valid prover signature supplies the fact anew. |
| ++An unprovable historical rollback waits for proof. The healthy exception |
| ++and staged-byte binding use this same trusted history. |
| ++ |
| ++Root release refusals, program exceptions, bake/production/pipeline ratchets, |
| ++restart-consumption state and moves-floor/boot-selection decisions likewise |
| ++have independent root authority. Agent mirrors may request work, describe |
| ++status or add a conservative deny; they cannot remove a remembered deny or |
| ++supply an exception. Root never treats a missing service file as permission |
| ++to downgrade, retry, skip a hold or replay a consumed restart. Signed release |
| ++and artifact checks remain in force. Ordinary instance payloads remain under |
| ++DataDir with the existing descriptor, containment and typed-inventory checks. |
| ++ |
| ++Migration is offline, guarded and idempotent. Reuse the existing edge-mask |
| ++ownership and kernel process-exclusion protocol. Persist a root migration |
| ++record, census authoritative claims plus root journals and managed artifacts, |
| ++then retain denies for old admitted sessions before collecting/verifying stop |
| ++evidence. A raw legacy fence journal grants nothing. Import completed evidence |
| ++only through the existing enrolled-signature, exact-digest and tuple checks; |
| ++otherwise independently collect it or remain pending. Preserve original signed |
| ++evidence time; freshly collected evidence has its actual observation time. |
| ++Crash recovery never reduces deny coverage. Completion does not readmit an old |
| ++session: admission advances through the existing predecessor-stop and |
| ++handover rules. Missing inventory or ambiguous cleanup blocks completion. |
| ++ |
| ++All claim-only changes stay behind CARLOS_HOST_CLAIM. Moves still requires its |
| ++own switch. Pre-claim root adoption, D3, history and floor changes are explicit |
| ++strict tightenings: no unverified import, shortened hold, dropped refusal, |
| ++program-change exception, widened restart or downgrade is permitted. Plans |
| ++11-13, #267 and existing activation prerequisites remain required. No change |
| ++here authorizes production migration or activation. Claim activation requires |
| ++the real root/service-UID, rename, reboot, fsync-recovery and systemd witnesses |
| ++listed in Plan 14, together with scoped rollout approval. |
| + |
| + ## 7. Lost boxes and execution fencing (J-D6, J-N3) |
| + |
| +@@ -3450,16 +3538,18 @@ |
| + |
| + An active operation means a non-serving operation with an operation ID. Retained participants in `serving` do not cause lease writes. |
| + |
| +-**One controller.** A box has one controller per host, serialized across processes by a dedicated lifetime lock: |
| +- |
| +-```text |
| +-/run/carlos/hostexec/<host>.lock |
| ++**One controller (rev 6.34).** The root broker is the single claim-mode |
| ++execution-lease controller per host (P6-D9); the agent orchestrates work |
| ++through its authenticated client. It serializes lifetime ownership at: |
| ++ |
| ++```text |
| ++/var/lib/carlos-claims/authority/exec/<host>.lock |
| + ``` |
| + |
| + It keeps durable local state at: |
| + |
| + ```text |
| +-<DataDir>/.hostexec/<host>.json |
| ++/var/lib/carlos-claims/authority/exec/<host>.json |
| + ``` |
| + |
| + with: |
| +@@ -3471,7 +3561,10 @@ |
| + state: "pending" | "active" | "lost" |
| + ``` |
| + |
| +-Record updates use the host guard and durable-write protocol. A different boot never inherits an acknowledged lease deadline. Missing, malformed or pending state refuses active-operation work. |
| ++The agent may retain its former DataDir record and runtime locks as mirrors; |
| ++root never reads those mirrors as lease or absence authority. The root record |
| ++also binds the authenticated controller process and holder session/stamp. |
| ++Record updates use root host exclusion and the durable-write protocol. A different boot never inherits an acknowledged lease deadline. Missing, malformed or pending state refuses active-operation work. |
| + |
| + Each PUT proposes `expires = request_start + 90s`. The local deadline is derived from that request’s start, never its response time. A five-second termination margin, matching the existing lease health machinery, starts denial and cleanup early. Linux uses a suspend-aware boot clock for cross-process deadline checks. Wall-clock rollback cannot extend authority. |
| + |
| +``` |
| + |
| +### Activation-checklist text |
| + |
| +Add the following item after the existing offline root journal migration/recovery prerequisite in Plan 9's activation checklist. It is additive to Plans 11–13 and their still-open gates. |
| + |
| +> **Root authority outside DataDir (Plan 14 / #280).** Before enabling claim launch, held publication or root claim adoption, install and review the root fence/installation backend, root execution-participation broker and all readers on the box, including recovery and the final executable gate. Verify the full root-owned ancestor chain, stable root locks, fsync/rename recovery and authenticated read-only agent views. Complete the offline, kernel-verified legacy migration under the rollout-owned edge mask; verify its durable completion and retain denies for old sessions. Re-admit through the existing stop/handover flow; never import an agent's allow record. Verify the root release/floor ledgers and both recovery boot selectors. Exercise real hostile service-UID ownership, parent rename, lock replacement and file deletion against launch, held publish/replay, adoption, stop recovery and system update. A skipped privileged witness is a blocker. D3 history moves independently with the claim flag off: rebuild from root observation or signed proofs and record any historical rollback that must wait. Preserve existing #267, C8, launch, moves, region-HA, floor and filesystem gates. Obtain scoped approval for the actual deployment, machine list, maintenance/traffic effects and rollback window; this plan provides no production authorization. |
| + |
| +## Replay without git writes |
| + |
| +A/B are the replayable foundation code; C–H are the remaining implementation requirements and acceptance criteria. Do not present a passing A/B replay as an implementation of the root guard or lease broker. B2 is the separately labeled moves-integration constructor change; I is the document patch. |
| + |
| +```bash |
| +CLAIM14_REPO=/home/paulca/amadan.net/carlos/platform/.claude/worktrees/replica-ownership |
| +CLAIM14_PLAN="$CLAIM14_REPO/docs/superpowers/plans/2026-10-12-host-lifecycle-claim-14-root-trusted-state.md" |
| +CLAIM14_BASE=39986908150d2e3c90477de4320c0d2941ffaac6 |
| +CLAIM14_SCRATCH=$(mktemp -d "$TMPDIR/claim14.XXXXXX") |
| +export CLAIM14_REPO CLAIM14_PLAN CLAIM14_BASE CLAIM14_SCRATCH |
| +mkdir "$CLAIM14_SCRATCH/tree" |
| +git -C "$CLAIM14_REPO" archive "$CLAIM14_BASE" | tar -x -C "$CLAIM14_SCRATCH/tree" |
| +mkdir -p "$CLAIM14_SCRATCH/tree/docs/superpowers/specs" |
| +cp "$CLAIM14_REPO/docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md" "$CLAIM14_SCRATCH/tree/docs/superpowers/specs/" |
| +export GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache |
| +export GOFLAGS='-mod=mod -buildvcs=false' GOPROXY=off |
| +cd "$CLAIM14_SCRATCH/tree" |
| +``` |
| + |
| +Save this helper at `$CLAIM14_SCRATCH/apply.py`. It requires an exact match and never writes Git metadata. |
| + |
| +```python |
| +import pathlib, re, subprocess, sys |
| +plan, task, phase = sys.argv[1:] |
| +blocks = [body for name, stage, body in re.findall( |
| + r'<!-- apply: (\w+) (tests|implementation) -->\n```diff\n(.*?)\n```', |
| + pathlib.Path(plan).read_text(), re.S) if name == task and stage == phase] |
| +if not blocks: |
| + raise SystemExit('missing task/phase') |
| +for body in blocks: |
| + p = subprocess.run(['patch', '--batch', '--forward', '--fuzz=0', '-p1'], |
| + input=body+'\n', text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) |
| + print(p.stdout, end='') |
| + if p.returncode or re.search(r'offset|fuzz|previously applied|Reversed', p.stdout): |
| + raise SystemExit('patch did not apply exactly') |
| +``` |
| + |
| +Apply A tests, observe the missing implementation, apply A implementation and rerun. Do the same for B. Apply I only to the verified spec copy. Then run: |
| + |
| +```bash |
| +go test ./internal/rootstate ./internal/release -run TestPlan14 -count=1 |
| +go test ./internal/release ./internal/host -run 'TestHealthy|TestProof|TestRecordSettled' -count=1 |
| +go test ./internal/provision -run Census -count=1 |
| +go build ./... |
| +go vet ./internal/rootstate ./internal/release ./internal/hostguard ./internal/hostbox ./internal/host |
| +gofmt -l internal/rootstate internal/release/healthy.go internal/release/healthy_root.go internal/release/plan14_healthy_test.go internal/release/proofgate.go internal/host/proofroot.go internal/host/agent.go internal/host/heldstage.go internal/host/proofask.go internal/host/movespass.go |
| +``` |
| + |
| +For the moves integration check, use another export of the same pinned main and apply A/B first. Apply the exact deltas, in order, for `b3d423390c5f733c31c4b06e6df5a72dfdb06f6d`, `5a46611265d20775a9f18e2979c69bb01959ec1b`, `27b83074c34225e7dff60deafb2fd2787399bbbc`, `cf0b6de5e1a1262b23ccf3b5c63906acf301805a`, and `ed97cbc73c471f3fbff53fc9c9f5e9186ac12fb2`. Obtain each delta with `git diff <commit>^ <commit>`. Reject offsets/fuzz; never replace a whole file with an older branch version. Apply B2, then build and run the held-release/D3 tests. Report any missing prerequisite as such, rather than omitting a failing file. |
| + |
| +Inspect the report for actual commands, results and limits. After collecting results, leave the scratch working directory, close every process started here, and remove only the exact scratch path created above. Never delete the shared GOCACHE, the pre-existing brief/review directory or a glob of other sessions' scratch. |
| + |
| +## Review focus and decisions to carry forward |
| + |
| +The review must establish that absence is checked from root-owned state; a trustworthy-looking leaf under `/data` does not suffice. Both root publication and receipt replay must see root's fence. The agent still needs a working read-only view for its own guard and cold-receipt readers, and the cold transition must synchronize the root installation rather than accidentally granting a new stamp before retirement. |
| + |
| +Migration deliberately costs a cold re-admission for existing claim sessions and may cost a prover wait for old healthy hashes. There is no safe transparent migration of an unauthenticated positive assertion. Deployment approval must cover that interruption. The controller should assign Tasks C–G as one authority integration before activation; it can land the independently tightening D3 change earlier after ordinary branch review. |
| + |
| +The wider census exposes previously accepted service-file trust in moves floors and bake/history state. This brief asks for all root decisions, so the plan includes their tightening. If Paul wants those older threat models retained, that is an explicit scope reduction and leaves the corresponding rows unresolved; it cannot be described as completing the all-root property. No such exception is assumed here. |
| + |
| +The code patches, comments, protocol operation names, this plan, report and amendment are internal implementation material. Existing user-visible refusal wording is reused; implementation owners must inventory/review any new product diagnostic wording before shipping it. No Claude, Anthropic model or subagent was used to author this plan. |