carlos / platform Public

plan: console release defences, plan 3 of 4 (D3); fixes to plans 1 and 4

Plan 3: ten tasks covering the proof record, the policy file, the healthy
history, the proof gate in adoptHost, hashing a running unit's bytes, the
root pass that mints and logs, the agent's gate and mint asker, the status
line, the carlos proof verbs, and the gate. Its author ran every task's
code in a throwaway copy.

The hourly requirement-off line now logs only on boxes that have had the
requirement before; the spec and plan 3 say so. Plan 1: settleClock
avoids an existing fakeClock, rootRestartRoute's three test callers are
updated, and the status test file is named correctly. Plan 4: the keygen
flag is stated. Spec: one proof key shape in both buckets.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5594bde0ca39e492ca5afa10410be037c20901bb parent 97f67b4
4 files changed, +3618 −13
  • docs/superpowers/plans/2026-10-06-console-release-defences-plan-1-d1.md +11 −7
  • docs/superpowers/plans/2026-10-06-console-release-defences-plan-3-d3.md +3599 −0
  • docs/superpowers/plans/2026-10-06-console-release-defences-plan-4-d4.md +1 −3
  • docs/superpowers/specs/2026-10-06-console-release-defences-design.md +7 −3

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-06-console-release-defences-plan-1-d1.md b/docs/superpowers/plans/2026-10-06-console-release-defences-plan-1-d1.md
index c053bc5b1..d8e004838 100644
--- a/docs/superpowers/plans/2026-10-06-console-release-defences-plan-1-d1.md
+++ b/docs/superpowers/plans/2026-10-06-console-release-defences-plan-1-d1.md
@@ -849,13 +849,15 @@ func (f *fakeUnits) Exe(pid int) (string, error) {
return "", errors.New("no such pid")
}
-func fakeClock() (func() time.Time, func(time.Duration)) {
+// settleClock, not fakeClock: stage_relay_test.go already declares a
+// fakeClock type in this package.
+func settleClock() (func() time.Time, func(time.Duration)) {
t := time.Unix(0, 0)
return func() time.Time { return t }, func(d time.Duration) { t = t.Add(d) }
}
func opts(ready func() error) settleOpts {
- now, sleep := fakeClock()
+ now, sleep := settleClock()
return settleOpts{Timeout: 30 * time.Second, Poll: 500 * time.Millisecond, Hold: 5 * time.Second,
WantDir: "/opt/carlos/versions/new", Ready: ready, Now: now, Sleep: sleep}
}
@@ -1527,7 +1529,9 @@ Callers:
opt-dir variable is in scope there (`d.optDir` on the fast path).
- line 3243 `rootRestartRoute`: the same, with the opt dir its caller uses
(`RunAdopt`'s). Add an `optDir string` parameter to `rootRestartRoute` and
- pass it from its one caller.
+ pass it from its one production caller. Update its three test callers too:
+ `internal/host/agent_test.go:718`, `:836` and `:963`, each passing that
+ test's own opt dir (or `t.TempDir()` where the test has none).
- `RunAdopt`'s `passOpts` (3660): append
`release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, optDir, os.Getenv))`.
- the agent loop options at 871 and 1500: append the same option. The
@@ -1704,7 +1708,7 @@ git commit -m "promote --retry: re-sign a promotion so a box retries a refused b
- Modify: `internal/status/snapshot.go` (`ReleaseRefusal` type; `Probes.ReleaseRefusals`; `BoxMetrics.ReleaseRefusals`; the copy at 719 and the emptiness check at 700)
- Modify: `internal/host/agent.go:2939` (feed the probe), and the metrics loop (`internal/host/metrics_loop.go:511` neighbourhood) to call the watcher each tick
- Modify: `cli.go:3418` `printLiveBoxes`
-- Test: `internal/host/refusalwatch_test.go`, `cli_test.go` (the existing `printLiveBoxes` test, extended)
+- Test: `internal/host/refusalwatch_test.go`, `statuslive_test.go` (the existing `printLiveBoxes` tests, extended)
**Interfaces:**
- Consumes: Task 1's `release.ListRefusals`.
@@ -1763,8 +1767,8 @@ func TestRefusalWatchSkipsOldRecordsAtStartup(t *testing.T) {
}
```
-In `cli_test.go`, extend the existing `printLiveBoxes` test (grep
-`printLiveBoxes(` in `*_test.go`) with a box whose `Metrics.ReleaseRefusals`
+In `statuslive_test.go`, extend the existing `printLiveBoxes` test with a
+box whose `Metrics.ReleaseRefusals`
has one row. Assert that the output contains
`refused console.example.com v2 since 2026-10-06T09:30:40Z: exited: status=2`.
@@ -1901,7 +1905,7 @@ Expected: PASS.
- [ ] **Step 5: Commit**
```bash
-git add internal/host/refusalwatch.go internal/host/refusalwatch_test.go internal/host/alertsink.go internal/host/agent.go internal/host/metrics_loop.go internal/status/snapshot.go cli.go cli_test.go
+git add internal/host/refusalwatch.go internal/host/refusalwatch_test.go internal/host/alertsink.go internal/host/agent.go internal/host/metrics_loop.go internal/status/snapshot.go cli.go statuslive_test.go
git commit -m "host: alert on a refused build and show it in status --live"
```
diff --git a/docs/superpowers/plans/2026-10-06-console-release-defences-plan-3-d3.md b/docs/superpowers/plans/2026-10-06-console-release-defences-plan-3-d3.md
new file mode 100644
index 000000000..1988c88a1
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-06-console-release-defences-plan-3-d3.md
@@ -0,0 +1,3599 @@
+# Console release defences, Plan 3 of 4: D3 (the flagship console adopts only builds proven on dev)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A deployment that lists an app in its proof policy adopts a new
+build of that app only with a signed proof that the proving deployment (dev)
+ran those exact bytes healthily, or when the box has itself run that build
+healthily before. Dev mints the proof on its own; an operator carries it.
+A deployment with no policy file sees none of this.
+
+**Architecture:** `internal/release` gains the proof record (`proof.go`), the
+policy file reader (`proofpolicy.go`), the healthy history (`healthy.go`) and
+a long-lived `ProofGate` (`proofgate.go`) that `adoptHost` consults twice: a
+cheap gate before `ensureStaged` and a staged-bytes check before `PointLive`.
+`internal/host` establishes a running unit's build from its bytes
+(`proofobserve.go`), records healthy builds from D1's settle and from a
+long-running live build, mints on the proving box in a root pass
+(`proofroot.go`), and on the agent re-asks for that root pass every 2 minutes
+until the proof is in the bucket (`proofask.go`). A `carlos proof` verb mints
+the key and carries proofs; `carlos status --live` shows a host that is waiting.
+
+**Tech Stack:** Go stdlib (`crypto/ed25519`, `crypto/sha256`, `encoding/json`),
+the existing release, host, status and CLI packages.
+
+**Spec:** `docs/superpowers/specs/2026-10-06-console-release-defences-design.md`
+(rev 5): §4 (D3) and §7's D3 tests. **Depends on Plan 1** (`release.Refusal`,
+`WriteRefusal`, `ReadRefusal`, `ListRefusals`; `unitProps`, `unitSystem`,
+`systemdUnits`, `parseShow`, `settleUnit`, `routeReady`, `checkedUnitRestart`
+and the test helpers `fakeUnits` and `liveTo` in `internal/host`) and on
+**Plan 2** (its D2 block in `adoptHost`, which starts
+`newProg := FileProgram(...)`, is the anchor this plan's binding check sits
+after). Land Plans 1 and 2 first.
+
+## Global Constraints
+
+- The proof is `{"schema":1,"app","platform","sha256","version","prover","active_since","proven_at","sig"}`; `sig` is base64 ed25519 over the canonical JSON of every other field (spec §4.2).
+- A proof binds `app`, `platform` and `sha256`; `version` is informational only (spec §4.2).
+- Every sha256 in D3 is hashed from bytes: minting hashes `/proc/<MainPID>/exe`, the requiring box hashes the staged file (cache hit included), the healthy history hashes the executable that passed (spec §4.2, codex 3).
+- The proving key is a fresh ed25519 key, separate from both release keys, at `/etc/carlos/proving.key`, root 0600; `carlos proof keygen` writes the private half and prints the public half (spec §4.2-§4.3).
+- The policy file is `/etc/carlos/proof-policy.json`, root-owned 0644, shape `{"schema":1,"prove":{"apps","prover","after","key_file"},"require":[{"app","prover","key"}]}` (spec §4.5).
+- Absent policy file: D3 off, no proof read or written, no bucket GET under `proofs/` (spec §4.5-§4.6).
+- Unreadable or unparseable policy file: D3 on and waiting for every named-unit route, logged every pass (spec §4.5). Unknown keys count as unparseable, so a typo cannot open the gate. A root pass is a fresh process and prints it every pass; the agent's 2 s loop prints it at most once a minute so the journal is not flooded.
+- Agent and root both re-read the policy file at the start of every pass (spec §4.5, codex 6).
+- Mint conditions, all at once: binding verified, unit `active` with `ActiveEnterTimestamp` at least `after` ago and one `InvocationID` throughout, S = sha256 of `/proc/<MainPID>/exe` equal to the live link's bytes, readiness passes now, no refusal record names S, no proof for S in the bucket (spec §4.3).
+- `after` is the `prove` block's value; 15m when there is no `prove` block or no policy file (spec §4.3, §4.5).
+- The agent re-requests a mint every 2 minutes, through the stage-request relay, until it sees the proof (spec §4.3, codex 5).
+- Proof key, in both buckets: `apps/<acct>/<app>/proofs/<sha256>/<prover>.json` (spec §4.4; see Task 1's layout note for §4.3).
+- The dev box role may write it: `infra/modules/region-host/main.tf:166-170` grants `s3:PutObject` on `${deployment_bucket_arn}/*`, and the only bucket Deny (`infra/modules/deployment-buckets/main.tf:69-70`) names `control/boxes/` alone (spec §4.3's "the plan confirms").
+- A missing proof is not a refusal: print `<host>: <ver> (sha256 <S>) is not proven on <prover>; staying on <cur>` and keep looking (spec §4.5).
+- A staged hash that is neither the proof's nor in the healthy history is a refusal record naming both hashes (spec §4.5 check 2).
+- A missing proof is cached per (route, sha256) for 30 s (spec §4.5).
+- Healthy history: added by D1's settle passing, and by a root pass finding a named-unit route's live build meeting the first three mint conditions (spec §4.5).
+- A waiting non-canary route stays in `fastMembers`, rejoining the sweep each time its 30 s miss runs out, which is what bounds its cost (spec §4.5, codex 14).
+- While the requirement is off on a box with healthy history for an app, the hourly root pass logs that it is off (spec §4.5). No bucket object or CLI flag can switch it off.
+- Gate with `GOFLAGS=-buildvcs=false`; in this sandbox set `GOCACHE=$TMPDIR/gocache` per command.
+
+## Review Focus
+
+1. **A typo in flagship's policy file** (a missing brace, or `"requires"` for `"require"`). Expect: the gate stays closed for every named-unit route and the pass says why; nothing adopts. Test in Task 4 (`TestProofPolicyUnparseableKeepsGateClosed`).
+2. **A reused version name or a stale staged cache**: the manifest's sha256 has a valid proof, but the staged file's bytes differ. Expect: refused at the binding check with a refusal record naming both hashes, never pointed live. Test in Task 4 (`TestProofBindingRefusesStagedBytesThatDiffer`).
+3. **Rolling flagship back to a build it ran this morning.** Expect: adopted at once from the healthy history, with no proof and no wait on dev. Test in Task 4 (`TestProofHealthyHistoryAdmitsWithoutProof`); the history entry itself comes from Task 6 (`TestProofRootRecordsTheLiveBuildAsHealthy`).
+4. **A deployment with no policy file** (Tito, self-hosters, every customer app). Expect: adoption byte-identical to before, and not one bucket GET under `proofs/`. Test in Task 4 (`TestProofNoPolicyMakesNoProofRead`).
+5. **Dev's mint request is dropped by the relay, or the mint write fails.** Expect: the agent asks again every 2 minutes and stops once the proof is in the bucket. Test in Task 7 (`TestMintAskerReasksUntilTheProofAppears`).
+
+---
+
+### Task 1: The proof record
+
+**Files:**
+- Create: `internal/release/proof.go`
+- Test: `internal/release/proof_test.go`
+
+**Interfaces:**
+- Consumes: `GenerateKey`, `ParseSigningKey`, `ParsePublicKey`, `ValidApp`, `ValidVersion`, `appPrefix`, `putImmutable`, `ErrCASConflict` (internal/release/release.go, store.go).
+- Produces:
+ - `type Proof struct { Schema int; App, Platform, SHA256, Version, Prover string; ActiveSince, ProvenAt time.Time; Sig string }` (JSON keys as the spec)
+ - `func SignProof(priv ed25519.PrivateKey, p Proof) (*Proof, error)`
+ - `func ParseProof(b []byte) (*Proof, error)`
+ - `func (p *Proof) Verify(pub ed25519.PublicKey) error`
+ - `func (p *Proof) Check(pub ed25519.PublicKey, app, platform, sha, prover string) error`
+ - `func ValidProver(s string) bool`
+ - `func AdoptPlatform() string`
+ - `func FileSHA256(path string) (string, error)`
+ - `func ProofKey(acct, app, sha, prover string) string`, `func ProofsPrefix(acct, app, sha string) string`, `func ProofPendingKey(acct, app, sha, prover string) string`
+ - `func GetProof(ctx context.Context, s Store, acct, app, sha, prover string) (*Proof, error)` (absent: `os.ErrNotExist`-wrapped)
+ - `func PutProof(ctx context.Context, s Store, acct string, p *Proof) error` (create-only; the same bytes again is a no-op)
+ - `type ProofPending struct { Schema int; App, Platform, SHA256, Version, Prover string; LiveSince time.Time; After string }`
+ - `func PutProofPending(ctx context.Context, s Store, acct string, p ProofPending) error` (create-only; an existing record is kept)
+ - `func GetProofPending(ctx context.Context, s Store, acct, app, sha, prover string) (*ProofPending, error)`
+
+**Layout note (for the reviewer):** spec §4.3 has dev write
+`proofs/<app>/<sha256>.json`, and §4.4 has `proof put` write
+`apps/<acct>/<app>/proofs/<sha256>/<prover>.json` on the target. This plan uses
+the §4.4 key in both buckets. `proof get` and `proof put` then move a file
+between identical keys, the minting box's "no proof for S yet" check and the
+requiring box's lookup are one function, and both sit under the app's prefix,
+which `PurgeApp` already sweeps.
+
+**Pending note:** `proof get`'s failure text names how long the build has been
+live on dev (spec §4.4). Nothing else records that, so dev's agent writes a
+`<prover>.pending.json` beside where the proof will go, once per build, when it
+first sees a listed build live (Task 7). It is advisory: no box ever reads it.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package release
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func proverKey(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey, string) {
+ t.Helper()
+ seed, pubB64, err := GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := ParseSigningKey(seed, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := ParsePublicKey(pubB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return priv, pub, pubB64
+}
+
+var testSHA = strings.Repeat("ab", 32)
+
+func sampleProof() Proof {
+ return Proof{App: "console", Platform: "linux-arm64", SHA256: testSHA, Version: "f4478b15", Prover: "dev",
+ ActiveSince: time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC),
+ ProvenAt: time.Date(2026, 10, 6, 9, 15, 0, 0, time.UTC)}
+}
+
+func TestProofSignVerifyRoundTrip(t *testing.T) {
+ priv, pub, _ := proverKey(t)
+ p, err := SignProof(priv, sampleProof())
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := json.MarshalIndent(p, "", " ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := ParseProof(b)
+ if err != nil {
+ t.Fatalf("ParseProof: %v", err)
+ }
+ if got.Schema != 1 {
+ t.Fatalf("schema = %d", got.Schema)
+ }
+ if err := got.Check(pub, "console", "linux-arm64", testSHA, "dev"); err != nil {
+ t.Fatalf("Check: %v", err)
+ }
+ // The signature is over the canonical form, not the file's bytes, so a
+ // proof re-encoded in transit still verifies.
+ compact, err := json.Marshal(got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ again, err := ParseProof(compact)
+ if err != nil || again.Verify(pub) != nil {
+ t.Fatalf("a compact re-encoding does not verify: %v", err)
+ }
+}
+
+func TestProofEditedFieldDoesNotVerify(t *testing.T) {
+ priv, pub, _ := proverKey(t)
+ p, err := SignProof(priv, sampleProof())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for name, edit := range map[string]func(*Proof){
+ "sha256": func(p *Proof) { p.SHA256 = strings.Repeat("cd", 32) },
+ "app": func(p *Proof) { p.App = "other" },
+ "platform": func(p *Proof) { p.Platform = "linux-amd64" },
+ "version": func(p *Proof) { p.Version = "v9" },
+ "prover": func(p *Proof) { p.Prover = "staging" },
+ "proven_at": func(p *Proof) { p.ProvenAt = p.ProvenAt.Add(time.Second) },
+ } {
+ q := *p
+ edit(&q)
+ if err := q.Verify(pub); err == nil {
+ t.Fatalf("%s edited and the proof still verifies", name)
+ }
+ }
+ _, other, _ := proverKey(t)
+ if err := p.Verify(other); err == nil {
+ t.Fatal("the proof verifies against another prover's key")
+ }
+}
+
+func TestProofCheckNamesTheMismatch(t *testing.T) {
+ priv, pub, _ := proverKey(t)
+ p, err := SignProof(priv, sampleProof())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range []struct{ app, plat, sha, prover, want string }{
+ {"other", "linux-arm64", testSHA, "dev", "app"},
+ {"console", "linux-amd64", testSHA, "dev", "platform"},
+ {"console", "linux-arm64", strings.Repeat("cd", 32), "dev", "sha256"},
+ {"console", "linux-arm64", testSHA, "staging", "prover"},
+ } {
+ err := p.Check(pub, c.app, c.plat, c.sha, c.prover)
+ if err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Fatalf("Check(%s,%s,%s,%s) = %v, want an error naming %s", c.app, c.plat, c.sha, c.prover, err, c.want)
+ }
+ }
+}
+
+func TestParseProofRejectsBadShape(t *testing.T) {
+ priv, _, _ := proverKey(t)
+ good, err := SignProof(priv, sampleProof())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for name, edit := range map[string]func(map[string]any){
+ "schema": func(m map[string]any) { m["schema"] = 2 },
+ "sha256": func(m map[string]any) { m["sha256"] = "../../etc" },
+ "prover": func(m map[string]any) { m["prover"] = "Dev Box" },
+ "app": func(m map[string]any) { m["app"] = "" },
+ "sig": func(m map[string]any) { m["sig"] = "not base64!" },
+ "proven_at": func(m map[string]any) { delete(m, "proven_at") },
+ } {
+ b, _ := json.Marshal(good)
+ var m map[string]any
+ if err := json.Unmarshal(b, &m); err != nil {
+ t.Fatal(err)
+ }
+ edit(m)
+ b, _ = json.Marshal(m)
+ if _, err := ParseProof(b); err == nil {
+ t.Fatalf("%s: a malformed proof parsed", name)
+ }
+ }
+ if _, err := ParseProof([]byte("{")); err == nil {
+ t.Fatal("torn JSON parsed")
+ }
+}
+
+func TestProofStoreRoundTrip(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub, _ := proverKey(t)
+ p, err := SignProof(priv, sampleProof())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := ProofKey("bab", "console", testSHA, "dev"), "apps/bab/console/proofs/"+testSHA+"/dev.json"; got != want {
+ t.Fatalf("ProofKey = %s, want %s", got, want)
+ }
+ if _, err := GetProof(ctx, s, "bab", "console", testSHA, "dev"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("absent proof = %v, want os.ErrNotExist", err)
+ }
+ if err := PutProof(ctx, s, "bab", p); err != nil {
+ t.Fatal(err)
+ }
+ if err := PutProof(ctx, s, "bab", p); err != nil {
+ t.Fatalf("carrying the same proof twice: %v", err)
+ }
+ got, err := GetProof(ctx, s, "bab", "console", testSHA, "dev")
+ if err != nil || got.Verify(pub) != nil {
+ t.Fatalf("GetProof = %+v, %v", got, err)
+ }
+ if _, err := GetProof(ctx, s, "bab", "console", "../x", "dev"); err == nil {
+ t.Fatal("a path in the sha256 reached the store")
+ }
+}
+
+func TestProofPendingIsCreateOnly(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ first := time.Date(2026, 10, 6, 9, 30, 30, 0, time.UTC)
+ rec := ProofPending{App: "console", Platform: "linux-arm64", SHA256: testSHA, Version: "v2", Prover: "dev", LiveSince: first, After: "15m"}
+ if err := PutProofPending(ctx, s, "bab", rec); err != nil {
+ t.Fatal(err)
+ }
+ rec.LiveSince = first.Add(time.Hour)
+ if err := PutProofPending(ctx, s, "bab", rec); err != nil {
+ t.Fatalf("a second sighting: %v", err)
+ }
+ got, err := GetProofPending(ctx, s, "bab", "console", testSHA, "dev")
+ if err != nil || !got.LiveSince.Equal(first) {
+ t.Fatalf("pending = %+v, %v; want the first sighting kept", got, err)
+ }
+}
+
+func TestFileSHA256(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "f")
+ if err := os.WriteFile(p, []byte("bad build"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ got, err := FileSHA256(p)
+ if err != nil || got != etagOf([]byte("bad build")) {
+ t.Fatalf("FileSHA256 = %q, %v", got, err)
+ }
+}
+```
+
+`etagOf` (internal/release/store.go:303) is the hex sha256 `Ship` stamps into
+manifests, so `FileSHA256` and a manifest's `sha256` compare directly.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -run 'Proof|FileSHA256' -count=1`
+Expected: FAIL, `undefined: SignProof`.
+
+- [ ] **Step 3: Implement `proof.go`**
+
+```go
+// Dev proofs — D3 of the console release defences (spec
+// 2026-10-06-console-release-defences-design.md §4.2-§4.4).
+//
+// A proving deployment (dev) signs a Proof once a build has run healthily on
+// its console for a bake period. An operator carries it to a requiring
+// deployment's bucket, and a requiring box adopts a listed app's build only
+// with a proof for those exact bytes from a prover it trusts (proofgate.go).
+// A proof binds app, platform and the artifact's sha256. version is
+// informational, because version names get reused after pruning.
+//
+// The signature covers the canonical JSON of every field except sig:
+// json.Marshal of proofBody, whose field order the struct fixes and whose
+// times are UTC. Verify re-marshals the parsed fields, so a proof whose bytes
+// were reformatted in transit still verifies and one whose fields were edited
+// does not. The proving key is its own ed25519 key, never a release key.
+package release
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "regexp"
+ "time"
+)
+
+const proofSchema = 1
+
+type Proof struct {
+ Schema int `json:"schema"`
+ App string `json:"app"`
+ Platform string `json:"platform"`
+ SHA256 string `json:"sha256"`
+ Version string `json:"version"`
+ Prover string `json:"prover"`
+ ActiveSince time.Time `json:"active_since"`
+ ProvenAt time.Time `json:"proven_at"`
+ Sig string `json:"sig"`
+}
+
+// proofBody is the signed form: every field of Proof except Sig, in order.
+type proofBody struct {
+ Schema int `json:"schema"`
+ App string `json:"app"`
+ Platform string `json:"platform"`
+ SHA256 string `json:"sha256"`
+ Version string `json:"version"`
+ Prover string `json:"prover"`
+ ActiveSince time.Time `json:"active_since"`
+ ProvenAt time.Time `json:"proven_at"`
+}
+
+func (p *Proof) canonical() ([]byte, error) {
+ return json.Marshal(proofBody{Schema: p.Schema, App: p.App, Platform: p.Platform, SHA256: p.SHA256,
+ Version: p.Version, Prover: p.Prover, ActiveSince: p.ActiveSince.UTC(), ProvenAt: p.ProvenAt.UTC()})
+}
+
+var (
+ proverRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
+ sha256Re = regexp.MustCompile(`^[0-9a-f]{64}$`)
+ platformRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`)
+)
+
+// ValidProver is a prover name: lower case, digits and dashes, at most 32.
+func ValidProver(s string) bool { return proverRe.MatchString(s) }
+
+// AdoptPlatform is the artifact key this box installs (adoptPlatform), for
+// callers outside the package that mint or describe proofs.
+func AdoptPlatform() string { return adoptPlatform }
+
+func (p *Proof) shape() error {
+ switch {
+ case p.Schema != proofSchema:
+ return fmt.Errorf("proof: schema %d, want %d", p.Schema, proofSchema)
+ case !ValidApp(p.App):
+ return fmt.Errorf("proof: invalid app %q", p.App)
+ case !platformRe.MatchString(p.Platform):
+ return fmt.Errorf("proof: invalid platform %q", p.Platform)
+ case !sha256Re.MatchString(p.SHA256):
+ return fmt.Errorf("proof: sha256 %q is not 64 lower-case hex digits", p.SHA256)
+ case !ValidVersion(p.Version):
+ return fmt.Errorf("proof: invalid version %q", p.Version)
+ case !ValidProver(p.Prover):
+ return fmt.Errorf("proof: invalid prover %q", p.Prover)
+ case p.ActiveSince.IsZero() || p.ProvenAt.IsZero():
+ return fmt.Errorf("proof: active_since and proven_at are both required")
+ }
+ return nil
+}
+
+// SignProof stamps the schema and signs p with the proving key.
+func SignProof(priv ed25519.PrivateKey, p Proof) (*Proof, error) {
+ if len(priv) != ed25519.PrivateKeySize {
+ return nil, fmt.Errorf("proof: no usable proving key")
+ }
+ p.Schema, p.Sig = proofSchema, ""
+ if err := p.shape(); err != nil {
+ return nil, err
+ }
+ body, err := p.canonical()
+ if err != nil {
+ return nil, err
+ }
+ p.Sig = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, body))
+ return &p, nil
+}
+
+// ParseProof checks shape only. Whether it verifies is the requiring box's
+// question (Check), because only the box holds the prover's public key.
+func ParseProof(b []byte) (*Proof, error) {
+ var p Proof
+ if err := json.Unmarshal(b, &p); err != nil {
+ return nil, fmt.Errorf("proof: %v", err)
+ }
+ if err := p.shape(); err != nil {
+ return nil, err
+ }
+ if sig, err := base64.StdEncoding.DecodeString(p.Sig); err != nil || len(sig) != ed25519.SignatureSize {
+ return nil, fmt.Errorf("proof: sig is not a base64 ed25519 signature")
+ }
+ return &p, nil
+}
+
+func (p *Proof) Verify(pub ed25519.PublicKey) error {
+ if len(pub) != ed25519.PublicKeySize {
+ return fmt.Errorf("proof: no usable prover public key")
+ }
+ sig, err := base64.StdEncoding.DecodeString(p.Sig)
+ if err != nil {
+ return fmt.Errorf("proof: sig: %v", err)
+ }
+ body, err := p.canonical()
+ if err != nil {
+ return err
+ }
+ if !ed25519.Verify(pub, body, sig) {
+ return fmt.Errorf("proof signature does not verify against the configured key")
+ }
+ return nil
+}
+
+// Check is Verify plus the binding: this proof is for exactly app, platform
+// and sha, from prover.
+func (p *Proof) Check(pub ed25519.PublicKey, app, platform, sha, prover string) error {
+ if err := p.Verify(pub); err != nil {
+ return err
+ }
+ switch {
+ case p.App != app:
+ return fmt.Errorf("proof is for app %s, not %s", p.App, app)
+ case p.Platform != platform:
+ return fmt.Errorf("proof is for platform %s, not %s", p.Platform, platform)
+ case p.SHA256 != sha:
+ return fmt.Errorf("proof is for sha256 %s, not %s", p.SHA256, sha)
+ case p.Prover != prover:
+ return fmt.Errorf("proof names prover %s, not %s", p.Prover, prover)
+ }
+ return nil
+}
+
+// FileSHA256 hashes the bytes at path (following a symlink), the one way D3
+// learns a sha256 (spec §4.2, codex 3).
+func FileSHA256(path string) (string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ h := sha256.New()
+ if _, err := io.Copy(h, f); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(h.Sum(nil)), nil
+}
+
+func ProofsPrefix(acct, app, sha string) string { return appPrefix(acct, app) + "proofs/" + sha + "/" }
+func ProofKey(acct, app, sha, prover string) string {
+ return ProofsPrefix(acct, app, sha) + prover + ".json"
+}
+func ProofPendingKey(acct, app, sha, prover string) string {
+ return ProofsPrefix(acct, app, sha) + prover + ".pending.json"
+}
+
+func GetProof(ctx context.Context, s Store, acct, app, sha, prover string) (*Proof, error) {
+ if !sha256Re.MatchString(sha) || !ValidProver(prover) || !ValidApp(app) {
+ return nil, fmt.Errorf("proof: invalid key %s/%s/%s", app, sha, prover)
+ }
+ raw, _, err := s.Get(ctx, ProofKey(acct, app, sha, prover))
+ if err != nil {
+ return nil, err
+ }
+ return ParseProof(raw)
+}
+
+func PutProof(ctx context.Context, s Store, acct string, p *Proof) error {
+ if err := p.shape(); err != nil {
+ return err
+ }
+ b, err := json.MarshalIndent(p, "", " ")
+ if err != nil {
+ return err
+ }
+ return putImmutable(ctx, s, ProofKey(acct, p.App, p.SHA256, p.Prover), append(b, '\n'))
+}
+
+// ProofPending is the proving box's note that a listed build is live and
+// baking, so `carlos proof get` can say how long it has to go. Advisory: no
+// box reads it.
+type ProofPending struct {
+ Schema int `json:"schema"`
+ App string `json:"app"`
+ Platform string `json:"platform"`
+ SHA256 string `json:"sha256"`
+ Version string `json:"version"`
+ Prover string `json:"prover"`
+ LiveSince time.Time `json:"live_since"`
+ After string `json:"after"`
+}
+
+func PutProofPending(ctx context.Context, s Store, acct string, p ProofPending) error {
+ if !ValidApp(p.App) || !sha256Re.MatchString(p.SHA256) || !ValidProver(p.Prover) {
+ return fmt.Errorf("proof pending: invalid record %s/%s/%s", p.App, p.SHA256, p.Prover)
+ }
+ p.Schema = proofSchema
+ b, err := json.MarshalIndent(p, "", " ")
+ if err != nil {
+ return err
+ }
+ _, err = s.Put(ctx, ProofPendingKey(acct, p.App, p.SHA256, p.Prover), append(b, '\n'), "")
+ if errors.Is(err, ErrCASConflict) {
+ return nil // the first sighting stands
+ }
+ return err
+}
+
+func GetProofPending(ctx context.Context, s Store, acct, app, sha, prover string) (*ProofPending, error) {
+ if !sha256Re.MatchString(sha) || !ValidProver(prover) || !ValidApp(app) {
+ return nil, fmt.Errorf("proof pending: invalid key %s/%s/%s", app, sha, prover)
+ }
+ raw, _, err := s.Get(ctx, ProofPendingKey(acct, app, sha, prover))
+ if err != nil {
+ return nil, err
+ }
+ var p ProofPending
+ if err := json.Unmarshal(raw, &p); err != nil {
+ return nil, fmt.Errorf("proof pending: %v", err)
+ }
+ return &p, nil
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -run 'Proof|FileSHA256' -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/release/proof.go internal/release/proof_test.go
+git commit -m "release: signed dev proofs, their bucket keys and the pending note (console defences D3)"
+```
+
+---
+
+### Task 2: The proof policy file
+
+**Files:**
+- Create: `internal/release/proofpolicy.go`
+- Test: `internal/release/proofpolicy_test.go`
+
+**Interfaces:**
+- Consumes: Task 1's `ValidProver`; `ValidApp`, `ParsePublicKey`.
+- Produces:
+ - `const ProofPolicyPath = "/etc/carlos/proof-policy.json"`, `const DefaultProveAfter = 15 * time.Minute`
+ - `type ProofPolicy struct { Schema int; Prove *ProveBlock; Require []RequireEntry }`
+ - `type ProveBlock struct { Apps []string; Prover, After, KeyFile string }` (JSON `apps`, `prover`, `after`, `key_file`)
+ - `type RequireEntry struct { App, Prover, Key string }` with `func (e RequireEntry) PublicKey() ed25519.PublicKey`
+ - `func LoadProofPolicy(path string) (*ProofPolicy, error)`: `(nil, nil)` when absent, `(nil, err)` when unreadable or invalid
+ - `func ParseProofPolicy(b []byte) (*ProofPolicy, error)`
+ - nil-safe: `func (p *ProofPolicy) Requires(app string) []RequireEntry`, `func (p *ProofPolicy) Proves(app string) bool`, `func (p *ProofPolicy) ProveAfter() time.Duration`
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package release
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestProofPolicyAbsentIsOff(t *testing.T) {
+ p, err := LoadProofPolicy(filepath.Join(t.TempDir(), "proof-policy.json"))
+ if p != nil || err != nil {
+ t.Fatalf("absent = %+v, %v; want nil, nil (D3 off)", p, err)
+ }
+ if p.Requires("console") != nil || p.Proves("console") || p.ProveAfter() != DefaultProveAfter {
+ t.Fatal("a nil policy is not inert")
+ }
+}
+
+func TestProofPolicyParses(t *testing.T) {
+ _, _, pubB64 := proverKey(t)
+ path := filepath.Join(t.TempDir(), "proof-policy.json")
+ body := fmt.Sprintf(`{"schema":1,
+ "prove":{"apps":["console"],"prover":"dev","after":"20m","key_file":"/etc/carlos/proving.key"},
+ "require":[{"app":"console","prover":"dev","key":%q}]}`, pubB64)
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ p, err := LoadProofPolicy(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !p.Proves("console") || p.Proves("other") || p.ProveAfter() != 20*time.Minute {
+ t.Fatalf("prove block = %+v", p.Prove)
+ }
+ req := p.Requires("console")
+ if len(req) != 1 || req[0].Prover != "dev" || len(req[0].PublicKey()) == 0 {
+ t.Fatalf("require = %+v", req)
+ }
+ if p.Requires("other") != nil {
+ t.Fatal("an unlisted app is required")
+ }
+}
+
+func TestProofPolicyRequireOnlyUsesTheDefaultAfter(t *testing.T) {
+ _, _, pubB64 := proverKey(t)
+ p, err := ParseProofPolicy([]byte(fmt.Sprintf(`{"schema":1,"require":[{"app":"console","prover":"dev","key":%q}]}`, pubB64)))
+ if err != nil || p.ProveAfter() != DefaultProveAfter {
+ t.Fatalf("p = %+v, %v", p, err)
+ }
+}
+
+// A typo must never open the gate: every one of these is an error, which the
+// gate turns into "on and waiting".
+func TestProofPolicyUnparseableIsAnError(t *testing.T) {
+ _, _, pubB64 := proverKey(t)
+ for name, body := range map[string]string{
+ "empty": ``,
+ "torn": `{"schema":1,"require":[`,
+ "unknown key": fmt.Sprintf(`{"schema":1,"requires":[{"app":"console","prover":"dev","key":%q}]}`, pubB64),
+ "schema 2": `{"schema":2}`,
+ "bad key": `{"schema":1,"require":[{"app":"console","prover":"dev","key":"nope"}]}`,
+ "bad prover": fmt.Sprintf(`{"schema":1,"require":[{"app":"console","prover":"Dev","key":%q}]}`, pubB64),
+ "bad after": `{"schema":1,"prove":{"apps":["console"],"prover":"dev","after":"soon","key_file":"/k"}}`,
+ "relative key": `{"schema":1,"prove":{"apps":["console"],"prover":"dev","after":"15m","key_file":"proving.key"}}`,
+ "no apps": `{"schema":1,"prove":{"apps":[],"prover":"dev","after":"15m","key_file":"/k"}}`,
+ "trailing junk": `{"schema":1} {}`,
+ } {
+ if _, err := ParseProofPolicy([]byte(body)); err == nil {
+ t.Fatalf("%s: parsed, want an error", name)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -run 'ProofPolicy' -count=1`
+Expected: FAIL, `undefined: LoadProofPolicy`.
+
+- [ ] **Step 3: Implement `proofpolicy.go`**
+
+```go
+// The proof policy file — where D3 is switched on (spec
+// 2026-10-06-console-release-defences-design.md §4.5, codex 6).
+//
+// It is not host.env. host.env is root 0600 and reaches the agent only when
+// carlos-edge.service starts, while every root pass starts fresh, so an edit
+// there would reach one process and not the other. This file is root-owned
+// 0644 and holds no secrets: app names, the prover name, public keys and a
+// duration. The agent and root both read it at the start of every pass.
+//
+// Absent: D3 is off. Anything else that does not parse, including an unknown
+// key, is an error, and the gate reads an error as "on and waiting for every
+// named-unit route": the box cannot tell which apps were listed, and a typo
+// must not open the gate.
+package release
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+const (
+ ProofPolicyPath = "/etc/carlos/proof-policy.json"
+ DefaultProveAfter = 15 * time.Minute
+)
+
+type ProofPolicy struct {
+ Schema int `json:"schema"`
+ Prove *ProveBlock `json:"prove,omitempty"`
+ Require []RequireEntry `json:"require,omitempty"`
+}
+
+// ProveBlock makes this box a prover: it mints proofs for Apps once a build
+// has run healthily for After, signed with the root-only key at KeyFile.
+type ProveBlock struct {
+ Apps []string `json:"apps"`
+ Prover string `json:"prover"`
+ After string `json:"after"`
+ KeyFile string `json:"key_file"`
+ after time.Duration
+}
+
+// RequireEntry makes App on this box adopt only builds Prover has proven.
+type RequireEntry struct {
+ App string `json:"app"`
+ Prover string `json:"prover"`
+ Key string `json:"key"`
+ pub ed25519.PublicKey
+}
+
+func (e RequireEntry) PublicKey() ed25519.PublicKey { return e.pub }
+
+func LoadProofPolicy(path string) (*ProofPolicy, error) {
+ b, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("proof policy %s: %w", path, err)
+ }
+ p, err := ParseProofPolicy(b)
+ if err != nil {
+ return nil, fmt.Errorf("proof policy %s: %w", path, err)
+ }
+ return p, nil
+}
+
+func ParseProofPolicy(b []byte) (*ProofPolicy, error) {
+ dec := json.NewDecoder(bytes.NewReader(b))
+ dec.DisallowUnknownFields()
+ var p ProofPolicy
+ if err := dec.Decode(&p); err != nil {
+ return nil, fmt.Errorf("does not parse: %v", err)
+ }
+ if dec.More() {
+ return nil, fmt.Errorf("does not parse: data after the policy object")
+ }
+ if p.Schema != 1 {
+ return nil, fmt.Errorf("schema %d, want 1", p.Schema)
+ }
+ if pr := p.Prove; pr != nil {
+ if len(pr.Apps) == 0 {
+ return nil, fmt.Errorf("prove: apps is empty")
+ }
+ for _, a := range pr.Apps {
+ if !ValidApp(a) {
+ return nil, fmt.Errorf("prove: invalid app %q", a)
+ }
+ }
+ if !ValidProver(pr.Prover) {
+ return nil, fmt.Errorf("prove: invalid prover %q", pr.Prover)
+ }
+ d, err := time.ParseDuration(pr.After)
+ if err != nil || d <= 0 {
+ return nil, fmt.Errorf("prove: after %q is not a positive duration", pr.After)
+ }
+ pr.after = d
+ if !filepath.IsAbs(pr.KeyFile) {
+ return nil, fmt.Errorf("prove: key_file %q is not an absolute path", pr.KeyFile)
+ }
+ }
+ for i := range p.Require {
+ e := &p.Require[i]
+ if !ValidApp(e.App) {
+ return nil, fmt.Errorf("require[%d]: invalid app %q", i, e.App)
+ }
+ if !ValidProver(e.Prover) {
+ return nil, fmt.Errorf("require[%d]: invalid prover %q", i, e.Prover)
+ }
+ k, err := ParsePublicKey(e.Key, fmt.Sprintf("require[%d].key", i))
+ if err != nil {
+ return nil, err
+ }
+ e.pub = k
+ }
+ return &p, nil
+}
+
+func (p *ProofPolicy) Requires(app string) []RequireEntry {
+ if p == nil {
+ return nil
+ }
+ var out []RequireEntry
+ for _, e := range p.Require {
+ if e.App == app {
+ out = append(out, e)
+ }
+ }
+ return out
+}
+
+func (p *ProofPolicy) Proves(app string) bool {
+ if p == nil || p.Prove == nil {
+ return false
+ }
+ for _, a := range p.Prove.Apps {
+ if a == app {
+ return true
+ }
+ }
+ return false
+}
+
+// ProveAfter is how long a build must run before it is proven, and before a
+// root pass records it as healthy (spec §4.5): the prove block's after, else
+// DefaultProveAfter.
+func (p *ProofPolicy) ProveAfter() time.Duration {
+ if p == nil || p.Prove == nil {
+ return DefaultProveAfter
+ }
+ return p.Prove.after
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -run 'ProofPolicy' -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/release/proofpolicy.go internal/release/proofpolicy_test.go
+git commit -m "release: the proof policy file, closed on any parse error"
+```
+
+---
+
+### Task 3: The healthy history
+
+**Files:**
+- Create: `internal/release/healthy.go`
+- Test: `internal/release/healthy_test.go`
+
+**Interfaces:**
+- Consumes: Task 1's `sha256Re`; `ValidAccount`, `ValidApp`.
+- Produces:
+ - `type HealthyBuild struct { SHA256, Version, How string; At time.Time }` (JSON `sha256`, `version`, `how`, `at`; How is `"settle"` or `"live"`)
+ - `func ReadHealthy(bakeDir, acct, app string) []HealthyBuild` (newest first; nil when absent or corrupt)
+ - `func IsHealthy(bakeDir, acct, app, sha string) bool`
+ - `func RecordHealthy(bakeDir, acct, app string, b HealthyBuild) error`
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package release
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestHealthyRecordAndRead(t *testing.T) {
+ bake := t.TempDir()
+ at := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ b := HealthyBuild{SHA256: testSHA, Version: "f4478b15", How: "settle", At: at}
+ if IsHealthy(bake, "bab", "console", testSHA) {
+ t.Fatal("healthy before anything was recorded")
+ }
+ if err := RecordHealthy(bake, "bab", "console", b); err != nil {
+ t.Fatal(err)
+ }
+ b.How, b.At = "live", at.Add(time.Hour)
+ if err := RecordHealthy(bake, "bab", "console", b); err != nil {
+ t.Fatal(err)
+ }
+ got := ReadHealthy(bake, "bab", "console")
+ if len(got) != 1 || got[0].How != "settle" || !got[0].At.Equal(at) {
+ t.Fatalf("history = %+v, want one entry, the first record kept", got)
+ }
+ if !IsHealthy(bake, "bab", "console", testSHA) || IsHealthy(bake, "bab", "other", testSHA) || IsHealthy(bake, "bab", "console", "") {
+ t.Fatal("IsHealthy answers for the wrong app or an empty sha256")
+ }
+}
+
+func TestHealthyKeepsTheNewestFifty(t *testing.T) {
+ bake := t.TempDir()
+ for i := 0; i < 55; i++ {
+ if err := RecordHealthy(bake, "bab", "console", HealthyBuild{SHA256: fmt.Sprintf("%064x", i), How: "live"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got := ReadHealthy(bake, "bab", "console")
+ if len(got) != 50 || got[0].SHA256 != fmt.Sprintf("%064x", 54) || IsHealthy(bake, "bab", "console", fmt.Sprintf("%064x", 0)) {
+ t.Fatalf("len = %d, first = %s; want the newest fifty", len(got), got[0].SHA256)
+ }
+}
+
+func TestHealthyCorruptReadsEmpty(t *testing.T) {
+ bake := t.TempDir()
+ p := filepath.Join(bake, "healthy", "bab", "console.json")
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, []byte(`{"schema":1,"builds":[`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if got := ReadHealthy(bake, "bab", "console"); got != nil {
+ t.Fatalf("corrupt history = %+v, want nil", got)
+ }
+ if err := RecordHealthy(bake, "bab", "console", HealthyBuild{SHA256: "nope"}); err == nil {
+ t.Fatal("a non-hex sha256 was recorded")
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -run 'Healthy' -count=1`
+Expected: FAIL, `undefined: RecordHealthy`.
+
+- [ ] **Step 3: Implement `healthy.go`**
+
+```go
+// The healthy history — builds this box has run well (spec
+// 2026-10-06-console-release-defences-design.md §4.5). A sha256 in an app's
+// history is adopted with no proof, which is what lets a requiring box roll
+// back to a build it ran before without waiting on the prover.
+//
+// Root writes it from two places in internal/host: when D1's settle check
+// passes for a restarted named unit (How "settle"), and when a root pass finds
+// a named-unit route's live build bound, active in one invocation for the
+// policy's after, hashed from its running executable and ready (How "live").
+// Every sha256 here was hashed from bytes, never copied from a manifest. It
+// lives in the bake directory, which the sandboxed agent can read.
+package release
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+const healthyMax = 50
+
+type HealthyBuild struct {
+ SHA256 string `json:"sha256"`
+ Version string `json:"version,omitempty"`
+ How string `json:"how"`
+ At time.Time `json:"at"`
+}
+
+type healthyFile struct {
+ Schema int `json:"schema"`
+ Builds []HealthyBuild `json:"builds"`
+}
+
+func healthyPath(bakeDir, acct, app string) (string, error) {
+ if bakeDir == "" || !ValidAccount(acct) || !ValidApp(app) {
+ return "", fmt.Errorf("healthy history: invalid %q/%q", acct, app)
+ }
+ return filepath.Join(bakeDir, "healthy", acct, app+".json"), nil
+}
+
+func ReadHealthy(bakeDir, acct, app string) []HealthyBuild {
+ p, err := healthyPath(bakeDir, acct, app)
+ if err != nil {
+ return nil
+ }
+ b, err := os.ReadFile(p)
+ if err != nil {
+ return nil
+ }
+ var f healthyFile
+ if err := json.Unmarshal(b, &f); err != nil || f.Schema != 1 {
+ log.Printf("healthy history: %s does not parse (%v), treating it as empty", p, err)
+ return nil
+ }
+ return f.Builds
+}
+
+func IsHealthy(bakeDir, acct, app, sha string) bool {
+ if sha == "" {
+ return false
+ }
+ for _, b := range ReadHealthy(bakeDir, acct, app) {
+ if b.SHA256 == sha {
+ return true
+ }
+ }
+ return false
+}
+
+// RecordHealthy adds b, newest first, keeping the first record of a sha256
+// 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 {
+ p, err := healthyPath(bakeDir, acct, app)
+ if err != nil {
+ return err
+ }
+ if !sha256Re.MatchString(b.SHA256) {
+ return fmt.Errorf("healthy history: sha256 %q is not 64 lower-case hex digits", b.SHA256)
+ }
+ have := ReadHealthy(bakeDir, acct, app)
+ for _, h := range have {
+ if h.SHA256 == b.SHA256 {
+ return nil
+ }
+ }
+ builds := append([]HealthyBuild{b}, have...)
+ if len(builds) > healthyMax {
+ builds = builds[:healthyMax]
+ }
+ body, err := json.MarshalIndent(healthyFile{Schema: 1, Builds: builds}, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(p), ".healthy-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp.Name())
+ if _, err := tmp.Write(append(body, '\n')); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Chmod(0o644); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ return os.Rename(tmp.Name(), p)
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -run 'Healthy' -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/release/healthy.go internal/release/healthy_test.go
+git commit -m "release: the healthy history a requiring box adopts from without a proof"
+```
+
+---
+
+### Task 4: The proof gate in `adoptHost`
+
+**Files:**
+- Create: `internal/release/proofgate.go`
+- Modify: `internal/release/adopt.go`: `passConfig` (34) gains `proof`; new `WithProofGate` below `WithStartUnit` (246); `PassFilter` (303) calls `BeginPass` after its options loop; `adoptHost` (901) clears a wait on the `cur == ver` return (947), runs `admit` immediately after Plan 1's refusal block (before `ensureStaged`, 964), and runs `bind` immediately after Plan 2's D2 block (before the capability converge comment, 976)
+- Test: `internal/release/proofgate_test.go`
+
+**Interfaces:**
+- Consumes: Tasks 1-3; Plan 1's `WriteRefusal`, `Refusal`, `ReadRefusal`; `ReadStagedManifest` (versions.go:243), `GetManifest`, `VersionPath`, `RouteAccount`.
+- Produces:
+ - `const ProofMissTTL = 30 * time.Second`
+ - `type ProofWait struct { Host, App, Version, SHA256, Prover, Reason string; Since time.Time }`
+ - `func NewProofGate(policyPath, bakeDir string) *ProofGate`
+ - `func (g *ProofGate) WithClock(now func() time.Time) *ProofGate`
+ - `func (g *ProofGate) BeginPass()`
+ - `func (g *ProofGate) Waits() []ProofWait` (sorted by host)
+ - `func (g *ProofGate) WaitingDue(host string) bool`: host is waiting and its 30 s miss has expired
+ - `func WithProofGate(g *ProofGate) PassOption`
+ - unexported: `admit(ctx, s, optDir, rt, ptr, cur) (proofVerdict, bool)`, `bind(optDir, rt, ptr, v, now) bool`, `clear(host)`. All `*ProofGate` methods are nil-safe, so `cfg.proof` nil is today's behaviour.
+
+**Manifest note (for the reviewer):** spec §4.5 places the cheap gate "after
+reading the new manifest and before `ensureStaged`", but `adoptHost` reads no
+manifest before `ensureStaged` today (ensureStaged reads it). `admit` reads the
+staged sibling when one exists and otherwise `GetManifest`, and caches the
+sha256 per (account, app, version, `PromotedAt`), which is immutable for one
+promotion (Plan 1 Task 1's keying note). While a route waits, its cost is one
+proof GET per 30 s.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package release
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+const proofHost = "console.example.com"
+
+// proofEnv is startUnitFixture (live "old", channel canary/x promoted to
+// "new" whose bytes are "bad build") plus a prover key, a policy path, a bake
+// directory and one gate shared by every pass, as the agent shares one.
+type proofEnv struct {
+ ctx context.Context
+ reg *registry.Registry
+ store Store
+ pub ed25519.PublicKey
+ opt, bake string
+ policy string
+ prover ed25519.PrivateKey
+ proverPub string
+ newSHA string
+ clock time.Time
+ gate *ProofGate
+}
+
+func newProofEnv(t *testing.T) *proofEnv {
+ t.Helper()
+ ctx, reg, s, pub, opt := startUnitFixture(t, proofHost)
+ prover, _, pubB64 := proverKey(t)
+ e := &proofEnv{ctx: ctx, reg: reg, store: s, pub: pub, opt: opt, bake: t.TempDir(),
+ policy: filepath.Join(t.TempDir(), "proof-policy.json"), prover: prover, proverPub: pubB64,
+ newSHA: etagOf([]byte("bad build")), clock: time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)}
+ e.gate = NewProofGate(e.policy, e.bake).WithClock(func() time.Time { return e.clock })
+ return e
+}
+
+func (e *proofEnv) writePolicy(t *testing.T, body string) {
+ t.Helper()
+ if err := os.WriteFile(e.policy, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func (e *proofEnv) requireDev(t *testing.T) {
+ e.writePolicy(t, fmt.Sprintf(`{"schema":1,"require":[{"app":"console","prover":"dev","key":%q}]}`, e.proverPub))
+}
+
+func (e *proofEnv) pass(t *testing.T, s Store) string {
+ t.Helper()
+ var out bytes.Buffer
+ withCapturedStdout(t, &out, func() {
+ if err := Pass(e.ctx, e.reg, s, e.pub, e.opt, func(string) error { return nil },
+ BakeDir(e.bake), WithProofGate(e.gate)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ })
+ return out.String()
+}
+
+func (e *proofEnv) live() string { v, _ := LiveVersion(e.opt, proofHost); return v }
+
+func (e *proofEnv) signed(t *testing.T, key ed25519.PrivateKey, app, sha, prover string) *Proof {
+ t.Helper()
+ p, err := SignProof(key, Proof{App: app, Platform: adoptPlatform, SHA256: sha, Version: "new", Prover: prover,
+ ActiveSince: e.clock.Add(-time.Hour), ProvenAt: e.clock})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+// putAt writes p at the dev key for sha, whatever p itself says.
+func (e *proofEnv) putAt(t *testing.T, sha string, p *Proof) {
+ t.Helper()
+ b, err := json.MarshalIndent(p, "", " ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := e.store.Put(e.ctx, ProofKey("bab", "console", sha, "dev"), b, ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+type proofReadCountingStore struct {
+ Store
+ mu sync.Mutex
+ gets []string
+}
+
+func (c *proofReadCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ c.mu.Lock()
+ c.gets = append(c.gets, key)
+ c.mu.Unlock()
+ return c.Store.Get(ctx, key)
+}
+
+func (c *proofReadCountingStore) proofGets() int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ n := 0
+ for _, k := range c.gets {
+ if strings.Contains(k, "/proofs/") {
+ n++
+ }
+ }
+ return n
+}
+
+func TestProofNoPolicyMakesNoProofRead(t *testing.T) {
+ e := newProofEnv(t)
+ cs := &proofReadCountingStore{Store: e.store}
+ e.pass(t, cs)
+ if e.live() != "new" {
+ t.Fatalf("live = %q, want new: with no policy file adoption is unchanged", e.live())
+ }
+ if n := cs.proofGets(); n != 0 {
+ t.Fatalf("%d proof GETs with no policy file, want 0 (spec §4.6)", n)
+ }
+}
+
+func TestProofMissingStaysAndIsNotARefusal(t *testing.T) {
+ e := newProofEnv(t)
+ e.requireDev(t)
+ out := e.pass(t, e.store)
+ if e.live() != "old" {
+ t.Fatalf("live = %q, want old", e.live())
+ }
+ if !strings.Contains(out, "is not proven on dev; staying on old") {
+ t.Fatalf("printed %q, want the not-proven line", out)
+ }
+ if r, _ := ReadRefusal(e.bake, proofHost); r != nil {
+ t.Fatalf("a missing proof wrote a refusal: %+v", r)
+ }
+ w := e.gate.Waits()
+ if len(w) != 1 || w[0].Host != proofHost || w[0].SHA256 != e.newSHA || w[0].Prover != "dev" {
+ t.Fatalf("waits = %+v", w)
+ }
+}
+
+func TestProofValidAdopts(t *testing.T) {
+ e := newProofEnv(t)
+ e.requireDev(t)
+ e.putAt(t, e.newSHA, e.signed(t, e.prover, "console", e.newSHA, "dev"))
+ e.pass(t, e.store)
+ if e.live() != "new" {
+ t.Fatalf("live = %q, want new", e.live())
+ }
+ if w := e.gate.Waits(); len(w) != 0 {
+ t.Fatalf("waits = %+v after adopting", w)
+ }
+}
+
+func TestProofRejectsBadProofs(t *testing.T) {
+ for name, mk := range map[string]func(t *testing.T, e *proofEnv) *Proof{
+ "bad signature": func(t *testing.T, e *proofEnv) *Proof {
+ other, _, _ := proverKey(t)
+ return e.signed(t, other, "console", e.newSHA, "dev")
+ },
+ "wrong sha256": func(t *testing.T, e *proofEnv) *Proof {
+ return e.signed(t, e.prover, "console", etagOf([]byte("other bytes")), "dev")
+ },
+ "wrong app": func(t *testing.T, e *proofEnv) *Proof {
+ return e.signed(t, e.prover, "other", e.newSHA, "dev")
+ },
+ "wrong prover": func(t *testing.T, e *proofEnv) *Proof {
+ return e.signed(t, e.prover, "console", e.newSHA, "staging")
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ e := newProofEnv(t)
+ e.requireDev(t)
+ e.putAt(t, e.newSHA, mk(t, e))
+ out := e.pass(t, e.store)
+ if e.live() != "old" || !strings.Contains(out, "is not proven on dev") {
+ t.Fatalf("live = %q, printed %q; want old and the not-proven line", e.live(), out)
+ }
+ })
+ }
+}
+
+// Review focus 3: rolling back to a build this box ran never waits on dev.
+func TestProofHealthyHistoryAdmitsWithoutProof(t *testing.T) {
+ e := newProofEnv(t)
+ e.requireDev(t)
+ if err := RecordHealthy(e.bake, "bab", "console", HealthyBuild{SHA256: e.newSHA, Version: "new", How: "settle", At: e.clock}); err != nil {
+ t.Fatal(err)
+ }
+ cs := &proofReadCountingStore{Store: e.store}
+ e.pass(t, cs)
+ if e.live() != "new" {
+ t.Fatalf("live = %q, want new from the healthy history", e.live())
+ }
+ if n := cs.proofGets(); n != 0 {
+ t.Fatalf("%d proof GETs for a build in the healthy history, want 0", n)
+ }
+}
+
+// Review focus 2: the manifest's sha256 is proven, but the staged file (a
+// cache hit, which ensureStaged does not re-hash) holds other bytes.
+func TestProofBindingRefusesStagedBytesThatDiffer(t *testing.T) {
+ e := newProofEnv(t)
+ e.requireDev(t)
+ stale := []byte("not the shipped bytes")
+ if err := StageArtifact(e.opt, "bab", "new", "console", stale); err != nil {
+ t.Fatal(err)
+ }
+ e.putAt(t, e.newSHA, e.signed(t, e.prover, "console", e.newSHA, "dev"))
+ out := e.pass(t, e.store)
+ if e.live() != "old" || !strings.Contains(out, "not pointed live") {
+ t.Fatalf("live = %q, printed %q; want old and the binding refusal", e.live(), out)
+ }
+ r, _ := ReadRefusal(e.bake, proofHost)
+ if r == nil || !strings.Contains(r.Reason, etagOf(stale)) || !strings.Contains(r.Reason, e.newSHA) {
+ t.Fatalf("refusal = %+v, want one naming both hashes", r)
+ }
+}
+
+func TestProofMissIsCachedThirtySeconds(t *testing.T) {
+ e := newProofEnv(t)
+ e.requireDev(t)
+ cs := &proofReadCountingStore{Store: e.store}
+ e.pass(t, cs)
+ e.clock = e.clock.Add(10 * time.Second)
+ e.pass(t, cs)
+ if n := cs.proofGets(); n != 1 {
+ t.Fatalf("%d proof GETs inside 30s, want 1", n)
+ }
+ if e.gate.WaitingDue(proofHost) {
+ t.Fatal("due inside its 30s miss")
+ }
+ e.clock = e.clock.Add(21 * time.Second)
+ if !e.gate.WaitingDue(proofHost) {
+ t.Fatal("not due after 31s")
+ }
+ e.pass(t, cs)
+ if n := cs.proofGets(); n != 2 {
+ t.Fatalf("%d proof GETs after 31s, want 2", n)
+ }
+}
+
+// Review focus 1: a typo keeps the gate closed for a named-unit route.
+func TestProofPolicyUnparseableKeepsGateClosed(t *testing.T) {
+ e := newProofEnv(t)
+ if err := e.reg.SetUnit(proofHost, "console.service"); err != nil {
+ t.Fatal(err)
+ }
+ e.writePolicy(t, `{"schema":1,"require":[`)
+ out := e.pass(t, e.store)
+ if e.live() != "old" {
+ t.Fatalf("live = %q, want old: an unparseable policy must not open the gate", e.live())
+ }
+ if !strings.Contains(out, "does not parse") {
+ t.Fatalf("printed %q, want the policy error", out)
+ }
+ if w := e.gate.Waits(); len(w) != 1 || !strings.Contains(w[0].Reason, "does not parse") {
+ t.Fatalf("waits = %+v", w)
+ }
+}
+
+// The agent's gate lives across passes: a policy edit is seen on the next
+// pass without restarting anything.
+func TestProofPolicyChangeSeenNextPass(t *testing.T) {
+ e := newProofEnv(t)
+ e.requireDev(t)
+ e.pass(t, e.store)
+ if e.live() != "old" {
+ t.Fatalf("live = %q, want old while required", e.live())
+ }
+ e.writePolicy(t, `{"schema":1}`)
+ e.pass(t, e.store)
+ if e.live() != "new" {
+ t.Fatalf("live = %q, want new once the require entry is gone", e.live())
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -run 'TestProof' -count=1`
+Expected: FAIL, `undefined: NewProofGate`.
+
+- [ ] **Step 3: Implement `proofgate.go`**
+
+```go
+// The dev-proof gate — the requiring half of D3 (spec
+// 2026-10-06-console-release-defences-design.md §4.5).
+//
+// adoptHost asks it twice. admit runs before ensureStaged, so an unproven
+// build is neither downloaded nor relayed to a root pass: it takes the
+// manifest's sha256 for this platform and admits the build when that sha256
+// is in this box's healthy history, or a configured prover's proof for it
+// verifies. bind runs after ensureStaged and before PointLive and hashes the
+// staged file, because a manifest can name bytes the box is not running (a
+// reused version name, a stale cache): the staged hash must be the proof's or
+// in the healthy history, or the build is refused with a refusal record.
+//
+// A missing proof is not a refusal. The box keeps looking, so the build adopts
+// soon after its proof arrives. A miss is cached per (route, sha256) for
+// ProofMissTTL, and the gate keeps a waiting list that the agent's fast loop
+// (fastMembers) and `carlos status --live` read.
+//
+// One gate lives for the life of its process and is shared by every pass in
+// it. BeginPass re-reads the policy file, so an edit reaches the agent and
+// root alike without a restart. An absent file is D3 off: nothing is read or
+// required. A file that does not parse is D3 on and waiting for every
+// named-unit route, because the box cannot tell which apps were listed.
+package release
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+const (
+ ProofMissTTL = 30 * time.Second
+ // proofPolicyLogEvery bounds how often a broken policy file is printed
+ // by a long-lived process. A root pass is a fresh process and prints it
+ // every pass; the agent's 2-second loop prints it once a minute.
+ proofPolicyLogEvery = time.Minute
+)
+
+// ProofWait is a host held on its current build because its channel head is
+// not proven (or the policy file does not parse).
+type ProofWait struct {
+ Host, App, Version, SHA256, Prover, Reason string
+ Since time.Time
+}
+
+type promotionKey struct {
+ acct, app, version string
+ promotedAt int64
+}
+
+type missKey struct{ host, sha string }
+
+type ProofGate struct {
+ policyPath string
+ bakeDir string
+ now func() time.Time
+
+ mu sync.Mutex
+ policy *ProofPolicy
+ policyErr error
+ lastPolicyErr string
+ lastPolicyLog time.Time
+ shas map[promotionKey]string
+ misses map[missKey]time.Time
+ waits map[string]ProofWait
+}
+
+func NewProofGate(policyPath, bakeDir string) *ProofGate {
+ return &ProofGate{policyPath: policyPath, bakeDir: bakeDir, now: time.Now,
+ shas: map[promotionKey]string{}, misses: map[missKey]time.Time{}, waits: map[string]ProofWait{}}
+}
+
+// WithClock replaces the gate's clock (tests).
+func (g *ProofGate) WithClock(now func() time.Time) *ProofGate {
+ g.now = now
+ return g
+}
+
+// BeginPass re-reads the policy file. PassFilter calls it once per pass.
+func (g *ProofGate) BeginPass() {
+ if g == nil {
+ return
+ }
+ pol, err := LoadProofPolicy(g.policyPath)
+ now := g.now()
+ say := false
+ g.mu.Lock()
+ g.policy, g.policyErr = pol, err
+ if err != nil {
+ msg := err.Error()
+ say = msg != g.lastPolicyErr || now.Sub(g.lastPolicyLog) >= proofPolicyLogEvery
+ if say {
+ g.lastPolicyErr, g.lastPolicyLog = msg, now
+ }
+ } else {
+ g.lastPolicyErr = ""
+ }
+ g.mu.Unlock()
+ if say {
+ fmt.Printf(" %v; every named-unit route waits until it is fixed\n", err)
+ }
+}
+
+func (g *ProofGate) state() (*ProofPolicy, error) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ return g.policy, g.policyErr
+}
+
+func (g *ProofGate) Waits() []ProofWait {
+ if g == nil {
+ return nil
+ }
+ g.mu.Lock()
+ out := make([]ProofWait, 0, len(g.waits))
+ for _, w := range g.waits {
+ out = append(out, w)
+ }
+ g.mu.Unlock()
+ sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
+ return out
+}
+
+// WaitingDue reports whether host is waiting and its cached miss has run
+// out, which is when the fast loop should look at it again.
+func (g *ProofGate) WaitingDue(host string) bool {
+ if g == nil {
+ return false
+ }
+ now := g.now()
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ w, ok := g.waits[host]
+ if !ok {
+ return false
+ }
+ until, cached := g.misses[missKey{host, w.SHA256}]
+ return !cached || !now.Before(until)
+}
+
+func (g *ProofGate) clear(host string) {
+ if g == nil {
+ return
+ }
+ g.mu.Lock()
+ delete(g.waits, host)
+ g.mu.Unlock()
+}
+
+// hold records host as waiting, caches the miss, and prints the line once
+// per change of version, sha256 or reason.
+func (g *ProofGate) hold(rt registry.Route, ver, sha, prover, reason, cur string) {
+ if cur == "" {
+ cur = "nothing"
+ }
+ now := g.now()
+ g.mu.Lock()
+ prev, had := g.waits[rt.Host]
+ same := had && prev.Version == ver && prev.SHA256 == sha
+ w := ProofWait{Host: rt.Host, App: rt.App, Version: ver, SHA256: sha, Prover: prover, Reason: reason, Since: now}
+ if same {
+ w.Since = prev.Since
+ }
+ g.waits[rt.Host] = w
+ g.misses[missKey{rt.Host, sha}] = now.Add(ProofMissTTL)
+ g.mu.Unlock()
+ if same && prev.Reason == reason {
+ return
+ }
+ if sha == "" {
+ fmt.Printf(" %s: %s is waiting: %s; staying on %s\n", rt.Host, ver, reason, cur)
+ return
+ }
+ fmt.Printf(" %s: %s (sha256 %s) is not proven on %s; staying on %s (%s)\n", rt.Host, ver, sha, prover, cur, reason)
+}
+
+// proofVerdict is admit's answer, carried to bind.
+type proofVerdict struct {
+ required bool // the route's app is listed in a require entry
+ sha string // the manifest's sha256 for this platform
+ proof *Proof // the verified proof; nil when admitted by the healthy history
+}
+
+// admit is the cheap gate (spec §4.5 check 1). false means adoptHost returns.
+func (g *ProofGate) admit(ctx context.Context, s Store, optDir string, rt registry.Route, ptr *ChannelPointer, cur string) (proofVerdict, bool) {
+ if g == nil {
+ return proofVerdict{}, true
+ }
+ pol, perr := g.state()
+ if perr != nil {
+ if rt.Unit == "" {
+ return proofVerdict{}, true
+ }
+ g.hold(rt, ptr.Version, "", "", "the proof policy file does not parse", cur)
+ return proofVerdict{}, false
+ }
+ reqs := pol.Requires(rt.App)
+ if len(reqs) == 0 {
+ g.clear(rt.Host)
+ return proofVerdict{}, true
+ }
+ acct := RouteAccount(rt)
+ sha, err := g.manifestSHA(ctx, s, optDir, acct, rt.App, ptr)
+ if err != nil {
+ fmt.Printf(" %s: %s: reading its manifest for the proof check: %v\n", rt.Host, ptr.Version, err)
+ return proofVerdict{}, false
+ }
+ v := proofVerdict{required: true, sha: sha}
+ if sha == "" {
+ return v, true // no build for this platform: ensureStaged says so
+ }
+ if IsHealthy(g.bakeDir, acct, rt.App, sha) {
+ g.clear(rt.Host)
+ return v, true
+ }
+ g.mu.Lock()
+ until, cached := g.misses[missKey{rt.Host, sha}]
+ g.mu.Unlock()
+ if cached && g.now().Before(until) {
+ return v, false
+ }
+ var reasons, names []string
+ for _, e := range reqs {
+ names = append(names, e.Prover)
+ p, err := GetProof(ctx, s, acct, rt.App, sha, e.Prover)
+ if errors.Is(err, os.ErrNotExist) {
+ reasons = append(reasons, "no proof from "+e.Prover)
+ continue
+ }
+ if err == nil {
+ err = p.Check(e.PublicKey(), rt.App, adoptPlatform, sha, e.Prover)
+ }
+ if err != nil {
+ reasons = append(reasons, fmt.Sprintf("proof from %s: %v", e.Prover, err))
+ continue
+ }
+ g.clear(rt.Host)
+ v.proof = p
+ return v, true
+ }
+ g.hold(rt, ptr.Version, sha, strings.Join(names, " or "), strings.Join(reasons, "; "), cur)
+ return v, false
+}
+
+// manifestSHA is the manifest's sha256 for this platform, from the staged
+// sibling when there is one and the bucket otherwise, cached per promotion.
+func (g *ProofGate) manifestSHA(ctx context.Context, s Store, optDir, acct, app string, ptr *ChannelPointer) (string, error) {
+ k := promotionKey{acct, app, ptr.Version, ptr.PromotedAt.Unix()}
+ g.mu.Lock()
+ sha, ok := g.shas[k]
+ g.mu.Unlock()
+ if ok {
+ return sha, nil
+ }
+ m, err := ReadStagedManifest(optDir, acct, ptr.Version, app)
+ if err != nil || m == nil {
+ m, err = GetManifest(ctx, s, acct, app, ptr.Version)
+ if err != nil {
+ return "", err
+ }
+ }
+ sha = m.Artifacts[adoptPlatform].SHA256
+ g.mu.Lock()
+ g.shas[k] = sha
+ g.mu.Unlock()
+ return sha, nil
+}
+
+// bind is the staged-bytes check (spec §4.5 check 2). false means adoptHost
+// returns; a mismatch is a refusal record naming both hashes.
+func (g *ProofGate) bind(optDir string, rt registry.Route, ptr *ChannelPointer, v proofVerdict, now time.Time) bool {
+ if g == nil || !v.required {
+ return true
+ }
+ acct := RouteAccount(rt)
+ got, err := FileSHA256(VersionPath(optDir, acct, ptr.Version, rt.App))
+ if err != nil {
+ fmt.Printf(" %s: %s: hashing the staged build for the proof check: %v; not pointed live\n", rt.Host, ptr.Version, err)
+ return false
+ }
+ if v.proof != nil && got == v.proof.SHA256 {
+ return true
+ }
+ if IsHealthy(g.bakeDir, acct, rt.App, got) {
+ return true
+ }
+ want := v.sha
+ if v.proof != nil {
+ want = v.proof.SHA256
+ }
+ reason := fmt.Sprintf("the staged bytes are sha256 %s, not the proven %s", got, want)
+ fmt.Printf(" %s: %s: %s; not pointed live\n", rt.Host, ptr.Version, reason)
+ if g.bakeDir != "" {
+ if err := WriteRefusal(g.bakeDir, Refusal{Host: rt.Host, App: rt.App, Version: ptr.Version, SHA256: got,
+ Platform: adoptPlatform, PointerPromotedAt: ptr.PromotedAt, RefusedAt: now.UTC(),
+ Reason: "not proven: " + reason}); err != nil {
+ fmt.Printf(" %s: writing refusal record: %v\n", rt.Host, err)
+ }
+ }
+ g.clear(rt.Host)
+ return false
+}
+```
+
+- [ ] **Step 4: Wire it into `adopt.go`**
+
+In `passConfig`, after `startUnit`:
+
+```go
+ // proof is D3's gate (WithProofGate, proofgate.go). nil, the default for
+ // every existing caller and test, means no proof is read or required.
+ proof *ProofGate
+```
+
+Below `WithStartUnit`:
+
+```go
+// WithProofGate arms D3 (proofgate.go): a build of an app the policy file
+// lists is adopted only with a verified proof for its bytes, or from this
+// box's healthy history. Pass the SAME gate to every pass of a process, so
+// its 30 s miss cache and its waiting list survive between ticks.
+func WithProofGate(g *ProofGate) PassOption {
+ return func(c *passConfig) { c.proof = g }
+}
+```
+
+In `PassFilter`, right after the `for _, opt := range opts { opt(&cfg) }` loop:
+
+```go
+ // D3: re-read the proof policy at the start of every pass, so an edit
+ // reaches the agent and root alike without a restart (spec §4.5).
+ cfg.proof.BeginPass()
+```
+
+In `adoptHost`, replace
+
+```go
+ if cur == ver {
+ return // up to date — stay quiet on the periodic tick
+ }
+```
+
+with
+
+```go
+ if cur == ver {
+ cfg.proof.clear(rt.Host) // live on its head: nothing left to wait for
+ return // up to date — stay quiet on the periodic tick
+ }
+```
+
+Immediately after Plan 1's refusal block (the `if cfg.bakeDir != "" { if r, err := ReadRefusal(...` block that prints `refused: ...`), and before `m, err := ensureStaged(...)`, insert:
+
+```go
+ // D3's cheap gate (proofgate.go, spec §4.5 check 1): before ensureStaged,
+ // so an unproven build is neither downloaded nor relayed to root.
+ verdict, admitted := cfg.proof.admit(ctx, s, optDir, rt, ptr, cur)
+ if !admitted {
+ return
+ }
+```
+
+Immediately after Plan 2's D2 block (the `if cfg.bakeDir != "" && newProg != "" { ... }` block that prints `not pointed live (ship with --program-change ...)`), and before the `// The capability converge for the NEW version` comment, insert:
+
+```go
+ // D3's binding check (spec §4.5 check 2): the staged bytes themselves
+ // must be the proven ones, or a build this box has run well.
+ if !cfg.proof.bind(optDir, rt, ptr, verdict, now) {
+ return
+ }
+```
+
+- [ ] **Step 5: Run the release suite**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -count=1`
+Expected: PASS, every new test and every existing one: no existing caller passes `WithProofGate`, and every gate method is nil-safe.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/release/proofgate.go internal/release/proofgate_test.go internal/release/adopt.go
+git commit -m "release: adoptHost requires a dev proof for listed apps, checked before staging and on the staged bytes"
+```
+
+---
+
+### Task 5: A running unit's build, from its bytes
+
+**Files:**
+- Modify: `internal/host/unitsettle.go` (Plan 1): `unitProps` gains `ActiveEnterTimestamp`; `systemdUnits.Show` asks for it; `parseShow` reads it
+- Modify: `internal/host/agent.go`: `checkedUnitRestart` (Plan 1 Task 6) gains a `settled` hook; its two agent-loop callers (adoptLoop's and fastConvergeLoop's `makeRestartRoute` calls) pass `nil`
+- Modify: `internal/host/unitsettle_test.go`: Plan 1's three `checkedUnitRestart(...)` calls (`TestNamedRestartBindingMismatchKeepsTheError`, `TestNamedRestartShowFailsFallsBack`, `TestNamedRestartStoppedUnitIsNotChecked`) gain a final `nil` argument
+- Create: `internal/host/proofobserve.go`
+- Test: `internal/host/proofobserve_test.go`
+
+**Interfaces:**
+- Consumes: Plan 1's `unitSystem`, `unitProps`, `parseShow`, `settleUnit`, `routeReady`, `fakeUnits`, `liveTo`; Tasks 1 and 3's `release.FileSHA256`, `release.RecordHealthy`, `release.ReadHealthy`.
+- Produces:
+ - `unitProps.ActiveEnterTimestamp time.Time`
+ - `func parseSystemdTime(v string) time.Time`
+ - `func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) string, settled func(rt registry.Route)) func(rt registry.Route) error` (changes Plan 1's signature: `settled` runs only when the settle check passed)
+ - `type liveBuild struct { SHA256, Version string; ActiveSince time.Time }`
+ - `type proofObserver struct { sys unitSystem; procExe func(pid int) string; optDir string; now func() time.Time; ready func(rt registry.Route) func() error }`
+ - `func (o proofObserver) observe(rt registry.Route, after time.Duration) (liveBuild, error)`
+ - `func productionProofObserver(optDir string) proofObserver`
+ - `func procExePath(pid int) string`
+ - `func recordSettled(sys unitSystem, procExe func(pid int) string, optDir, bakeDir string, now func() time.Time) func(rt registry.Route)`
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package host
+
+import (
+ "errors"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestParseShowReadsActiveEnterTimestamp(t *testing.T) {
+ p := parseShow("ActiveState=active\nActiveEnterTimestamp=Mon 2026-10-06 09:30:30 UTC\n")
+ if want := time.Date(2026, 10, 6, 9, 30, 30, 0, time.UTC); !p.ActiveEnterTimestamp.Equal(want) {
+ t.Fatalf("ActiveEnterTimestamp = %v, want %v", p.ActiveEnterTimestamp, want)
+ }
+ if !parseShow("ActiveEnterTimestamp=\n").ActiveEnterTimestamp.IsZero() {
+ t.Fatal("an empty timestamp is not zero")
+ }
+}
+
+const observedHost = "platform.example.com"
+
+// observeFixture: the route's live link names versions/v1/console, and by
+// default the unit runs that very file, bound, active for 20 minutes, ready.
+func observeFixture(t *testing.T) (proofObserver, registry.Route, string, time.Time) {
+ t.Helper()
+ opt := t.TempDir()
+ liveTo(t, opt, observedHost, "v1")
+ staged := filepath.Join(opt, "versions", "v1", "console")
+ now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
+ o := proofObserver{
+ sys: &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "i1", MainPID: 7,
+ ExecPath: release.LiveLink(opt, observedHost), ActiveEnterTimestamp: now.Add(-20 * time.Minute)}}},
+ procExe: func(int) string { return staged },
+ optDir: opt,
+ now: func() time.Time { return now },
+ ready: func(registry.Route) func() error { return nil },
+ }
+ rt := registry.Route{Host: observedHost, App: "console", Unit: "console.service"}
+ return o, rt, staged, now
+}
+
+func TestProofObserveAcceptsABakedBuild(t *testing.T) {
+ o, rt, staged, now := observeFixture(t)
+ b, err := o.observe(rt, 15*time.Minute)
+ if err != nil {
+ t.Fatalf("observe: %v", err)
+ }
+ want, _ := release.FileSHA256(staged)
+ if b.SHA256 != want || b.Version != "v1" || !b.ActiveSince.Equal(now.Add(-20*time.Minute)) {
+ t.Fatalf("build = %+v", b)
+ }
+}
+
+// Spec §7: the mint conditions, each false in turn.
+func TestProofObserveConditionsEachFalse(t *testing.T) {
+ for name, c := range map[string]struct {
+ edit func(t *testing.T, o *proofObserver, now time.Time)
+ want string
+ }{
+ "binding": {func(t *testing.T, o *proofObserver, now time.Time) {
+ o.sys.(*fakeUnits).shows[0].ExecPath = release.LiveLink(o.optDir, "console.example.com")
+ }, "binding"},
+ "not active": {func(t *testing.T, o *proofObserver, now time.Time) {
+ o.sys.(*fakeUnits).shows[0].ActiveState = "failed"
+ }, "failed"},
+ "too young": {func(t *testing.T, o *proofObserver, now time.Time) {
+ o.sys.(*fakeUnits).shows[0].ActiveEnterTimestamp = now.Add(-5 * time.Minute)
+ }, "needs 15m"},
+ "restarted during the check": {func(t *testing.T, o *proofObserver, now time.Time) {
+ f := o.sys.(*fakeUnits)
+ second := f.shows[0]
+ second.InvocationID = "i2"
+ f.shows = append(f.shows, second)
+ }, "restarted"},
+ "exe and live link differ": {func(t *testing.T, o *proofObserver, now time.Time) {
+ other := filepath.Join(t.TempDir(), "console")
+ if err := os.WriteFile(other, []byte("a different build"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ o.procExe = func(int) string { return other }
+ }, "is not the live link's build"},
+ "not ready": {func(t *testing.T, o *proofObserver, now time.Time) {
+ o.ready = func(registry.Route) func() error { return func() error { return errors.New("status 503") } }
+ }, "not ready"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ o, rt, _, now := observeFixture(t)
+ c.edit(t, &o, now)
+ if _, err := o.observe(rt, 15*time.Minute); err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Fatalf("observe = %v, want an error containing %q", err, c.want)
+ }
+ })
+ }
+}
+
+// D1's settle passing is the hook that records a healthy build, hashed from
+// the executable that passed.
+func TestSettledRestartRecordsTheRunningExe(t *testing.T) {
+ opt, bake := t.TempDir(), t.TempDir()
+ liveTo(t, opt, observedHost, "new")
+ staged, err := filepath.EvalSymlinks(filepath.Join(opt, "versions", "new", "console"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { ln.Close() })
+ rt := registry.Route{Host: observedHost, App: "console", Unit: "console.service", Addr: ln.Addr().String()}
+ f := &fakeUnits{shows: []unitProps{
+ {ActiveState: "active", InvocationID: "old", ExecPath: filepath.Join(opt, "live", observedHost)},
+ {ActiveState: "active", InvocationID: "new", MainPID: 7},
+ }, exe: map[int]string{7: staged}}
+ settled := recordSettled(f, func(int) string { return staged }, opt, bake, time.Now)
+ if err := checkedUnitRestart(f, opt, func(string) string { return "" }, settled)(rt); err != nil {
+ t.Fatalf("restart: %v", err)
+ }
+ sha, _ := release.FileSHA256(staged)
+ h := release.ReadHealthy(bake, "bab", "console")
+ if len(h) != 1 || h[0].SHA256 != sha || h[0].How != "settle" || h[0].Version != "new" {
+ t.Fatalf("history = %+v, want the settled build", h)
+ }
+}
+
+func TestUnsettledRestartRecordsNothing(t *testing.T) {
+ opt, bake := t.TempDir(), t.TempDir()
+ liveTo(t, opt, observedHost, "new")
+ rt := registry.Route{Host: observedHost, App: "console", Unit: "console.service"}
+ f := &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "old",
+ ExecPath: filepath.Join(opt, "live", "console.example.com")}}}
+ settled := recordSettled(f, func(int) string { return "/nonexistent" }, opt, bake, time.Now)
+ if err := checkedUnitRestart(f, opt, func(string) string { return "" }, settled)(rt); err != nil {
+ t.Fatalf("restart: %v", err)
+ }
+ if h := release.ReadHealthy(bake, "bab", "console"); len(h) != 0 {
+ t.Fatalf("a restart that was not checked recorded %+v", h)
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/host -run 'ActiveEnterTimestamp|ProofObserve|SettledRestart|UnsettledRestart' -count=1`
+Expected: FAIL, `unknown field ActiveEnterTimestamp in struct literal of type unitProps`.
+
+- [ ] **Step 3: Extend `unitsettle.go`**
+
+In `unitProps`, after `ExecPath`:
+
+```go
+ ActiveEnterTimestamp time.Time // when the unit last entered active; zero when unknown
+```
+
+In `systemdUnits.Show`, add `"-p", "ActiveEnterTimestamp"` after `"-p", "ExecStart"`.
+
+In `parseShow`'s switch, add:
+
+```go
+ case "ActiveEnterTimestamp":
+ p.ActiveEnterTimestamp = parseSystemdTime(v)
+```
+
+and below `parseShow`:
+
+```go
+// parseSystemdTime reads systemctl show's default timestamp form,
+// "Mon 2026-10-06 09:30:30 UTC". Boxes run in UTC. "" and "n/a" are zero,
+// which every caller reads as "unknown", never as "long ago".
+func parseSystemdTime(v string) time.Time {
+ t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", strings.TrimSpace(v))
+ if err != nil {
+ return time.Time{}
+ }
+ return t
+}
+```
+
+- [ ] **Step 4: Add the hook to `checkedUnitRestart`**
+
+Change its signature to:
+
+```go
+func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) string, settled func(rt registry.Route)) func(rt registry.Route) error {
+```
+
+and replace its success block
+
+```go
+ if err == nil {
+ log.Printf("adopt: %s: %s settled on %s", rt.Host, rt.Unit, filepath.Dir(want))
+ }
+ return err
+```
+
+with
+
+```go
+ if err == nil {
+ log.Printf("adopt: %s: %s settled on %s", rt.Host, rt.Unit, filepath.Dir(want))
+ // D3 (proofobserve.go): a build that passed the settle check is a
+ // build this box has run well.
+ if settled != nil {
+ settled(rt)
+ }
+ }
+ return err
+```
+
+In adoptLoop and fastConvergeLoop, the two `checkedUnitRestart(systemdUnits{}, optDir, os.Getenv)` calls Plan 1 Task 6 wrote become `checkedUnitRestart(systemdUnits{}, optDir, os.Getenv, nil)`: the sandboxed agent cannot hash another unit's executable, and spec §4.5 has root record the history. Add the `nil` to Plan 1's three test calls listed under Files.
+
+- [ ] **Step 5: Implement `proofobserve.go`**
+
+```go
+// What a named unit is running, established from the bytes — D3 of the
+// console release defences (spec 2026-10-06-console-release-defences-design.md
+// §4.2, §4.3, §4.5).
+//
+// observe checks the first three of spec §4.3's mint conditions: the binding
+// is verified, the unit is active in one invocation for at least `after`, the
+// sha256 of /proc/<MainPID>/exe equals the live link's bytes, and the route is
+// ready now. The root pass mints on it (proofroot.go) and records the build
+// in the healthy history on it. recordSettled is the other way into that
+// history: the hook D1's checkedUnitRestart runs when the settle check passes.
+package host
+
+import (
+ "fmt"
+ "log"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type liveBuild struct {
+ SHA256 string
+ Version string
+ ActiveSince time.Time
+}
+
+type proofObserver struct {
+ sys unitSystem
+ procExe func(pid int) string // "/proc/<pid>/exe" in production
+ optDir string
+ now func() time.Time
+ ready func(rt registry.Route) func() error // routeReady in production; nil func = no upstream
+}
+
+func productionProofObserver(optDir string) proofObserver {
+ return proofObserver{sys: systemdUnits{}, procExe: procExePath, optDir: optDir, now: time.Now,
+ ready: func(rt registry.Route) func() error { return routeReady(rt, 2*time.Second) }}
+}
+
+func procExePath(pid int) string { return fmt.Sprintf("/proc/%d/exe", pid) }
+
+func (o proofObserver) observe(rt registry.Route, after time.Duration) (liveBuild, error) {
+ link := release.LiveLink(o.optDir, rt.Host)
+ before, err := o.sys.Show(rt.Unit)
+ if err != nil {
+ return liveBuild{}, fmt.Errorf("cannot read %s: %v", rt.Unit, err)
+ }
+ if before.ExecPath != link {
+ return liveBuild{}, fmt.Errorf("binding: %s runs %s, not %s", rt.Unit, before.ExecPath, link)
+ }
+ if before.ActiveState != "active" || before.MainPID <= 0 {
+ return liveBuild{}, fmt.Errorf("%s is %s", rt.Unit, before.ActiveState)
+ }
+ if before.ActiveEnterTimestamp.IsZero() {
+ return liveBuild{}, fmt.Errorf("%s reports no ActiveEnterTimestamp", rt.Unit)
+ }
+ if up := o.now().Sub(before.ActiveEnterTimestamp); up < after {
+ return liveBuild{}, fmt.Errorf("%s has been active for %s, needs %s", rt.Unit, up.Round(time.Second), after)
+ }
+ exe, err := release.FileSHA256(o.procExe(before.MainPID))
+ if err != nil {
+ return liveBuild{}, fmt.Errorf("hashing the running executable: %v", err)
+ }
+ live, err := release.FileSHA256(link)
+ if err != nil {
+ return liveBuild{}, fmt.Errorf("hashing the live link's build: %v", err)
+ }
+ if exe != live {
+ return liveBuild{}, fmt.Errorf("the running executable (sha256 %s) is not the live link's build (sha256 %s)", exe, live)
+ }
+ if ready := o.ready(rt); ready != nil {
+ if err := ready(); err != nil {
+ return liveBuild{}, fmt.Errorf("not ready: %v", err)
+ }
+ }
+ again, err := o.sys.Show(rt.Unit)
+ if err != nil || again.ActiveState != "active" || again.InvocationID != before.InvocationID || again.MainPID != before.MainPID {
+ return liveBuild{}, fmt.Errorf("%s restarted during the check", rt.Unit)
+ }
+ ver, _ := release.LiveVersion(o.optDir, rt.Host)
+ return liveBuild{SHA256: exe, Version: ver, ActiveSince: before.ActiveEnterTimestamp}, nil
+}
+
+// recordSettled is checkedUnitRestart's settled hook on the root pass: hash
+// the executable that just passed the settle check and add it to the app's
+// healthy history. Every failure is a log line; a missed record costs only a
+// proof wait later.
+func recordSettled(sys unitSystem, procExe func(pid int) string, optDir, bakeDir string, now func() time.Time) func(rt registry.Route) {
+ return func(rt registry.Route) {
+ p, err := sys.Show(rt.Unit)
+ if err != nil || p.ActiveState != "active" || p.MainPID <= 0 {
+ log.Printf("adopt: %s: settled, but cannot read %s's process for the healthy history (%v)", rt.Host, rt.Unit, err)
+ return
+ }
+ sha, err := release.FileSHA256(procExe(p.MainPID))
+ if err != nil {
+ log.Printf("adopt: %s: settled, but hashing its executable failed: %v", rt.Host, err)
+ return
+ }
+ ver, _ := release.LiveVersion(optDir, rt.Host)
+ if err := release.RecordHealthy(bakeDir, release.RouteAccount(rt), rt.App,
+ release.HealthyBuild{SHA256: sha, Version: ver, How: "settle", At: now().UTC()}); err != nil {
+ log.Printf("adopt: %s: recording the healthy build: %v", rt.Host, err)
+ }
+ }
+}
+```
+
+- [ ] **Step 6: Run the host suite**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false nice -n 10 go test ./internal/host -count=1`
+Expected: PASS. `TestSettledRestartRecordsTheRunningExe` settles on its first poll (the TCP listener answers the connect), so it takes well under a second.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/host/unitsettle.go internal/host/unitsettle_test.go internal/host/agent.go internal/host/proofobserve.go internal/host/proofobserve_test.go
+git commit -m "host: establish a unit's running build from its bytes, and record settled builds as healthy"
+```
+
+---
+
+### Task 6: The root pass mints, records live builds, and says when the requirement is off
+
+**Files:**
+- Create: `internal/host/proofroot.go`
+- Modify: `internal/host/agent.go`: `rootRestartRoute` (3242) gains a `settled` parameter; `RunAdopt` (3456): its `rootRestartRoute` call (3536), its `passOpts` (3660), and a `proofRoot` run right after `release.Pass` (3665)
+- Modify: `internal/host/agent_test.go`: the three `rootRestartRoute(...)` calls (lines 718, 836, 963) gain a final `nil` argument
+- Test: `internal/host/proofroot_test.go`
+
+**Interfaces:**
+- Consumes: Task 5's `proofObserver`, `liveBuild`, `recordSettled`, `procExePath`, `productionProofObserver`; Tasks 1-4 (`release.SignProof`, `GetProof`, `PutProof`, `AdoptPlatform`, `LoadProofPolicy`, `ProofPolicyPath`, `RecordHealthy`, `ReadHealthy`, `NewProofGate`, `WithProofGate`); Plan 1's `release.ListRefusals`.
+- Produces:
+ - `type proofRoot struct { obs proofObserver; reg *registry.Registry; store release.Store; bakeDir, policyPath string; readKey func(path string) (ed25519.PrivateKey, error); hourly bool; logf func(format string, a ...any) }`
+ - `func (r proofRoot) run(ctx context.Context)`
+ - `func readProvingKey(path string) (ed25519.PrivateKey, error)` (refuses a key file any non-owner can read)
+ - `func rootRestartRoute(reg *registry.Registry, optDir string, settled func(rt registry.Route)) func(host string) error`
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type proofRootEnv struct {
+ root proofRoot
+ store release.Store
+ bake string
+ keyFile string
+ pub ed25519.PublicKey
+ sha string
+ now time.Time
+ logs []string
+}
+
+func provePolicy(keyFile string) string {
+ return fmt.Sprintf(`{"schema":1,"prove":{"apps":["console"],"prover":"dev","after":"15m","key_file":%q}}`, keyFile)
+}
+
+// newProofRootEnv: one named-unit console route, bound, running its live
+// build for 20 minutes, ready. policy builds the policy file from the key
+// file's path; nil writes no policy file.
+func newProofRootEnv(t *testing.T, policy func(keyFile string) string) *proofRootEnv {
+ t.Helper()
+ opt, bake, etc := t.TempDir(), t.TempDir(), t.TempDir()
+ liveTo(t, opt, observedHost, "v1")
+ reg := restartTestRegistry(t)
+ if err := reg.Upsert(registry.Route{Host: observedHost, Kind: "instance", App: "console",
+ Channel: "canary/console", Unit: "console.service", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ keyFile := filepath.Join(etc, "proving.key")
+ if err := os.WriteFile(keyFile, []byte(seed+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ policyPath := filepath.Join(etc, "proof-policy.json")
+ if policy != nil {
+ if err := os.WriteFile(policyPath, []byte(policy(keyFile)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ staged := filepath.Join(opt, "versions", "v1", "console")
+ sha, err := release.FileSHA256(staged)
+ if err != nil {
+ t.Fatal(err)
+ }
+ e := &proofRootEnv{store: release.NewDirStore(t.TempDir()), bake: bake, keyFile: keyFile, pub: pub, sha: sha,
+ now: time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)}
+ units := &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "i1", MainPID: 7,
+ ExecPath: release.LiveLink(opt, observedHost), ActiveEnterTimestamp: e.now.Add(-20 * time.Minute)}}}
+ e.root = proofRoot{
+ obs: proofObserver{sys: units, procExe: func(int) string { return staged }, optDir: opt,
+ now: func() time.Time { return e.now }, ready: func(registry.Route) func() error { return nil }},
+ reg: reg, store: e.store, bakeDir: bake, policyPath: policyPath, readKey: readProvingKey,
+ logf: func(f string, a ...any) { e.logs = append(e.logs, fmt.Sprintf(f, a...)) },
+ }
+ return e
+}
+
+func (e *proofRootEnv) proof(t *testing.T) *release.Proof {
+ t.Helper()
+ p, err := release.GetProof(context.Background(), e.store, "bab", "console", e.sha, "dev")
+ if err != nil {
+ return nil
+ }
+ return p
+}
+
+func TestProofRootMintsWhenEveryConditionHolds(t *testing.T) {
+ e := newProofRootEnv(t, provePolicy)
+ e.root.run(context.Background())
+ p := e.proof(t)
+ if p == nil {
+ t.Fatalf("no proof minted; logs: %v", e.logs)
+ }
+ if err := p.Check(e.pub, "console", release.AdoptPlatform(), e.sha, "dev"); err != nil {
+ t.Fatalf("minted proof: %v", err)
+ }
+ if p.Version != "v1" || !p.ActiveSince.Equal(e.now.Add(-20*time.Minute)) || !p.ProvenAt.Equal(e.now) {
+ t.Fatalf("proof = %+v", p)
+ }
+}
+
+func TestProofRootDoesNotMintARefusedBuild(t *testing.T) {
+ e := newProofRootEnv(t, provePolicy)
+ if err := release.WriteRefusal(e.bake, release.Refusal{Host: observedHost, App: "console", Version: "v1", SHA256: e.sha}); err != nil {
+ t.Fatal(err)
+ }
+ e.root.run(context.Background())
+ if e.proof(t) != nil {
+ t.Fatal("minted a proof for a build a refusal record names")
+ }
+}
+
+func TestProofRootNeverOverwritesAProof(t *testing.T) {
+ e := newProofRootEnv(t, provePolicy)
+ seed, _ := os.ReadFile(e.keyFile)
+ key, err := release.ParseSigningKey(string(seed), "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ first, err := release.SignProof(key, release.Proof{App: "console", Platform: release.AdoptPlatform(), SHA256: e.sha,
+ Version: "v1", Prover: "dev", ActiveSince: e.now.Add(-time.Hour), ProvenAt: e.now.Add(-time.Minute)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := release.PutProof(context.Background(), e.store, "bab", first); err != nil {
+ t.Fatal(err)
+ }
+ e.root.run(context.Background())
+ if p := e.proof(t); p == nil || !p.ProvenAt.Equal(first.ProvenAt) {
+ t.Fatalf("proof = %+v, want the first one untouched", p)
+ }
+}
+
+func TestProofRootRefusesAKeyOthersCanRead(t *testing.T) {
+ e := newProofRootEnv(t, provePolicy)
+ if err := os.Chmod(e.keyFile, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ e.root.run(context.Background())
+ if e.proof(t) != nil {
+ t.Fatal("minted with a world-readable key")
+ }
+ if !strings.Contains(strings.Join(e.logs, "\n"), "readable by root only") {
+ t.Fatalf("logs = %v, want the key-mode refusal", e.logs)
+ }
+}
+
+type noReadStore struct {
+ release.Store
+ t *testing.T
+}
+
+func (s noReadStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ s.t.Errorf("bucket GET %s with no policy file", key)
+ return s.Store.Get(ctx, key)
+}
+
+// Review focus 3's source: with no policy file at all, the build already
+// live enters the healthy history, and no proof is read or minted.
+func TestProofRootRecordsTheLiveBuildAsHealthy(t *testing.T) {
+ e := newProofRootEnv(t, nil)
+ e.root.store = noReadStore{Store: e.store, t: t}
+ e.root.run(context.Background())
+ h := release.ReadHealthy(e.bake, "bab", "console")
+ if len(h) != 1 || h[0].SHA256 != e.sha || h[0].How != "live" || h[0].Version != "v1" {
+ t.Fatalf("history = %+v, want the live build", h)
+ }
+ if e.proof(t) != nil {
+ t.Fatal("a proof was minted with no policy file")
+ }
+}
+
+// A box that never had the requirement (Tito, a self-hoster) has nothing
+// switched off and must stay quiet; one that had it and lost it logs hourly.
+func TestProofRootHourlyLineOnlyWhereTheRequirementWasOnce(t *testing.T) {
+ e := newProofRootEnv(t, func(string) string { return `{"schema":1}` })
+ e.root.hourly = true
+ e.root.run(context.Background())
+ if strings.Contains(strings.Join(e.logs, "\n"), "requirement is off") {
+ t.Fatalf("a box that never required a proof logged that the requirement is off: %v", e.logs)
+ }
+}
+
+func TestProofRootHourlyLineWhenRequirementIsOff(t *testing.T) {
+ e := newProofRootEnv(t, func(string) string { return `{"schema":1}` })
+ marker := filepath.Join(e.root.bakeDir, "proof-required", "console")
+ if err := os.MkdirAll(filepath.Dir(marker), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(marker, nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ e.root.hourly = true
+ e.root.run(context.Background())
+ if !strings.Contains(strings.Join(e.logs, "\n"), "requirement is off for console") {
+ t.Fatalf("logs = %v, want the hourly requirement-off line", e.logs)
+ }
+ e.logs = nil
+ e.root.hourly = false
+ e.root.run(context.Background())
+ if strings.Contains(strings.Join(e.logs, "\n"), "requirement is off") {
+ t.Fatalf("a stage-only pass printed the hourly line: %v", e.logs)
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/host -run 'ProofRoot' -count=1`
+Expected: FAIL, `undefined: proofRoot`.
+
+- [ ] **Step 3: Implement `proofroot.go`**
+
+```go
+// The root pass's D3 work (spec 2026-10-06-console-release-defences-design.md
+// §4.3, §4.5): after release.Pass, look at every named-unit route's live build.
+//
+// - A build meeting observe's conditions (bound, active in one invocation
+// for the policy's after, hashed from its running executable, ready)
+// enters the app's healthy history, on every box, policy file or not.
+// That is what puts the build already live when a requirement is switched
+// on into the history without a restart.
+// - On a proving box (a prove block lists the app) the same build is
+// minted a proof, unless a refusal record names it or the bucket already
+// has one. Minting needs the root-only key, which is why it is here.
+// - On the hourly pass, an app with healthy history and no require entry
+// gets one line saying the requirement is off: the break-glass trail.
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type proofRoot struct {
+ obs proofObserver
+ reg *registry.Registry
+ store release.Store
+ bakeDir 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) run(ctx context.Context) {
+ pol, perr := release.LoadProofPolicy(r.policyPath)
+ if perr != nil {
+ r.logf("proof: %v; nothing is minted until it parses", perr)
+ }
+ routes, err := r.reg.List()
+ if err != nil {
+ r.logf("proof: list routes: %v", err)
+ return
+ }
+ after := pol.ProveAfter()
+ type acctApp struct{ acct, app string }
+ seen := map[acctApp]bool{}
+ for _, rt := range routes {
+ if !release.Adoptable(rt) || rt.Unit == "" {
+ continue
+ }
+ acct := release.RouteAccount(rt)
+ seen[acctApp{acct, rt.App}] = true
+ b, err := r.obs.observe(rt, after)
+ if err != nil {
+ if pol.Proves(rt.App) {
+ r.logf("proof: %s: not minting yet: %v", rt.Host, err)
+ }
+ continue
+ }
+ if err := release.RecordHealthy(r.bakeDir, acct, 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)
+ }
+ if perr == nil && pol.Proves(rt.App) {
+ r.mint(ctx, pol, rt, acct, b)
+ }
+ }
+ if !r.hourly || perr != nil {
+ return
+ }
+ keys := make([]acctApp, 0, len(seen))
+ for k := range seen {
+ keys = append(keys, k)
+ }
+ sort.Slice(keys, func(i, j int) bool { return keys[i].acct+"/"+keys[i].app < keys[j].acct+"/"+keys[j].app })
+ for _, k := range keys {
+ // The marker says this box has required a proof for the app at some
+ // point. Only then is "off" news: a box that never required one
+ // (Tito, a self-hoster) has nothing switched off.
+ marker := filepath.Join(r.bakeDir, "proof-required", k.app)
+ if len(pol.Requires(k.app)) > 0 {
+ if err := os.MkdirAll(filepath.Dir(marker), 0o755); err == nil {
+ _ = os.WriteFile(marker, nil, 0o644)
+ }
+ continue
+ }
+ if _, err := os.Stat(marker); err != nil {
+ continue
+ }
+ if n := len(release.ReadHealthy(r.bakeDir, 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)
+ }
+ }
+}
+
+func (r proofRoot) mint(ctx context.Context, pol *release.ProofPolicy, rt registry.Route, acct string, b liveBuild) {
+ refusals, err := release.ListRefusals(r.bakeDir)
+ if err != nil {
+ r.logf("proof: %s: reading refusal records: %v; not minting", rt.Host, err)
+ return
+ }
+ for _, ref := range refusals {
+ if ref.SHA256 == b.SHA256 {
+ r.logf("proof: %s: not minting %s: this box refused it (%s)", rt.Host, b.Version, ref.Reason)
+ return
+ }
+ }
+ prover := pol.Prove.Prover
+ _, err = release.GetProof(ctx, r.store, acct, rt.App, b.SHA256, prover)
+ switch {
+ case err == nil:
+ return // already proven
+ case !errors.Is(err, os.ErrNotExist):
+ r.logf("proof: %s: reading the bucket for an existing proof: %v; not minting", rt.Host, err)
+ return
+ }
+ key, err := r.readKey(pol.Prove.KeyFile)
+ if err != nil {
+ r.logf("proof: %s: %v; not minting", rt.Host, err)
+ return
+ }
+ p, err := release.SignProof(key, release.Proof{App: rt.App, Platform: release.AdoptPlatform(), SHA256: b.SHA256,
+ Version: b.Version, Prover: prover, ActiveSince: b.ActiveSince.UTC().Truncate(time.Second),
+ ProvenAt: r.obs.now().UTC().Truncate(time.Second)})
+ if err != nil {
+ r.logf("proof: %s: signing: %v", rt.Host, err)
+ return
+ }
+ if err := release.PutProof(ctx, r.store, acct, p); err != nil {
+ r.logf("proof: %s: writing the proof: %v (the agent asks again in 2 minutes)", rt.Host, err)
+ return
+ }
+ r.logf("proof: minted %s %s (sha256 %s) as %s, live since %s", rt.App, b.Version, b.SHA256, prover,
+ b.ActiveSince.UTC().Format(time.RFC3339))
+}
+
+// readProvingKey reads the root-only proving key, refusing one any other user
+// can read (spec §4.2: the key is root 0600).
+func readProvingKey(path string) (ed25519.PrivateKey, error) {
+ fi, err := os.Stat(path)
+ if err != nil {
+ return nil, fmt.Errorf("proving key: %w", err)
+ }
+ if perm := fi.Mode().Perm(); perm&0o077 != 0 {
+ return nil, fmt.Errorf("proving key %s is mode %04o; it must be readable by root only (0600)", path, perm)
+ }
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("proving key: %w", err)
+ }
+ return release.ParseSigningKey(string(b), path)
+}
+```
+
+- [ ] **Step 4: Wire it into `RunAdopt`**
+
+Change `rootRestartRoute` (after Plan 1 it takes `optDir`) to:
+
+```go
+func rootRestartRoute(reg *registry.Registry, optDir string, settled func(rt registry.Route)) func(host string) error {
+ return makeRestartRoute(reg, systemctlTryRestart, checkedUnitRestart(systemdUnits{}, optDir, os.Getenv, settled), nil)
+}
+```
+
+In `RunAdopt`, the `restart := rootRestartRoute(...)` line becomes:
+
+```go
+ // settled: D1's settle passing records the build in the healthy history
+ // (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))
+```
+
+Append to `passOpts`, after `release.WithStartUnit(...)`:
+
+```go
+ // 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.
+ release.WithProofGate(release.NewProofGate(release.ProofPolicyPath, dataDir())),
+```
+
+Immediately after the `if err := release.Pass(...); err != nil { log.Fatalf(...) }` block, before `// Step 4: the advisories.`:
+
+```go
+ // D3 (proofroot.go): record live builds as healthy, mint on a proving
+ // box, and on the hourly pass say when a requirement is off. After the
+ // pass, so it looks at what this pass left live.
+ proofRoot{
+ obs: productionProofObserver(release.OptDir()),
+ reg: reg,
+ store: store,
+ bakeDir: dataDir(),
+ policyPath: release.ProofPolicyPath,
+ readKey: readProvingKey,
+ hourly: !*stageOnly,
+ logf: log.Printf,
+ }.run(ctx)
+```
+
+In `internal/host/agent_test.go`, add a final `nil` to the `rootRestartRoute(...)` calls at lines 718, 836 and 963.
+
+- [ ] **Step 5: Run the host suite and the build**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false nice -n 10 go test ./internal/host -count=1 && GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go build ./...`
+Expected: PASS, and the build succeeds.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/host/proofroot.go internal/host/proofroot_test.go internal/host/agent.go internal/host/agent_test.go
+git commit -m "host: the root pass mints dev proofs and records healthy builds (console defences D3)"
+```
+
+---
+
+### Task 7: The agent: one gate, a mint asker, and a waiting route in the fast loop
+
+**Files:**
+- Create: `internal/host/proofask.go`
+- Modify: `internal/host/agent.go`: `materializeHook` (776), `adoptLoop`'s `release.Pass` options (868), `fastDeps` (1420) gains `proof` and `mintLeg`, `fastMembers` (1522), `fastTick` (1805), `fastConvergeLoop` (1908)
+- Test: `internal/host/proofask_test.go`
+
+**Interfaces:**
+- Consumes: Tasks 1, 2, 4 (`release.GetProof`, `PutProofPending`, `LoadProofPolicy`, `ProofPolicyPath`, `NewProofGate`, `WithProofGate`, `(*ProofGate).WaitingDue`); Task 5's `unitProps.ActiveEnterTimestamp`; `stageRequester` (agent.go:1224).
+- Produces:
+ - `func agentProofGate() *release.ProofGate`: one gate per agent process
+ - `const mintAskEvery = 2 * time.Minute`
+ - `type mintAsker struct { reg *registry.Registry; store release.Store; sys unitSystem; optDir, policyPath string; request func(host, ver string, err error); now func() time.Time; ... }`
+ - `func (a *mintAsker) tick(ctx context.Context)`
+ - `fastDeps.proof *release.ProofGate`, `fastDeps.mintLeg func(ctx context.Context)`
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package host
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func mintAskerFixture(t *testing.T, activeFor time.Duration) (*mintAsker, *int, *time.Time, release.Store, string) {
+ t.Helper()
+ opt, etc := t.TempDir(), t.TempDir()
+ liveTo(t, opt, observedHost, "v1")
+ reg := restartTestRegistry(t)
+ if err := reg.Upsert(registry.Route{Host: observedHost, Kind: "instance", App: "console",
+ Channel: "canary/console", Unit: "console.service", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ policy := filepath.Join(etc, "proof-policy.json")
+ if err := os.WriteFile(policy, []byte(provePolicy("/etc/carlos/proving.key")), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ t0 := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
+ clock := t0
+ asked := 0
+ store := release.NewDirStore(t.TempDir())
+ a := &mintAsker{reg: reg, store: store, optDir: opt, policyPath: policy,
+ sys: &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "i1", MainPID: 7,
+ ActiveEnterTimestamp: t0.Add(-activeFor)}}},
+ request: func(string, string, error) { asked++ },
+ now: func() time.Time { return clock }}
+ sha, err := release.FileSHA256(filepath.Join(opt, "versions", "v1", "console"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return a, &asked, &clock, store, sha
+}
+
+// Review focus 5: the relay can drop a request and a mint write can fail, so
+// the agent asks every 2 minutes until it sees the proof, then stops.
+func TestMintAskerReasksUntilTheProofAppears(t *testing.T) {
+ a, asked, clock, store, sha := mintAskerFixture(t, 20*time.Minute)
+ ctx := context.Background()
+ t0 := *clock
+ at := func(d time.Duration) { *clock = t0.Add(d); a.tick(ctx) }
+ at(0)
+ if *asked != 1 {
+ t.Fatalf("asked %d times at first sight, want 1", *asked)
+ }
+ at(time.Minute)
+ if *asked != 1 {
+ t.Fatalf("asked %d times inside 2 minutes, want 1", *asked)
+ }
+ at(2 * time.Minute)
+ if *asked != 2 {
+ t.Fatalf("asked %d times after 2 minutes, want 2", *asked)
+ }
+ seed, _, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, err := release.ParseSigningKey(seed, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ p, err := release.SignProof(key, release.Proof{App: "console", Platform: release.AdoptPlatform(), SHA256: sha,
+ Version: "v1", Prover: "dev", ActiveSince: t0.Add(-20 * time.Minute), ProvenAt: t0})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := release.PutProof(ctx, store, "bab", p); err != nil {
+ t.Fatal(err)
+ }
+ at(4 * time.Minute)
+ at(6 * time.Minute)
+ if *asked != 2 {
+ t.Fatalf("asked %d times after the proof appeared, want 2", *asked)
+ }
+ pend, err := release.GetProofPending(ctx, store, "bab", "console", sha, "dev")
+ if err != nil || !pend.LiveSince.Equal(t0.Add(-20*time.Minute)) {
+ t.Fatalf("pending = %+v, %v", pend, err)
+ }
+}
+
+func TestMintAskerWaitsOutTheBake(t *testing.T) {
+ a, asked, _, store, sha := mintAskerFixture(t, 5*time.Minute)
+ a.tick(context.Background())
+ if *asked != 0 {
+ t.Fatalf("asked %d times five minutes into a fifteen-minute bake", *asked)
+ }
+ if _, err := release.GetProofPending(context.Background(), store, "bab", "console", sha, "dev"); err != nil {
+ t.Fatalf("no pending note for a baking build: %v", err)
+ }
+}
+
+// Spec §4.5, codex 14: a non-canary listed route with a live version stays in
+// the fast set while it waits (at the gate's 30 s cadence), and leaves once
+// it adopts.
+func TestFastMembersKeepsAWaitingRoute(t *testing.T) {
+ f := newFastFixture(t)
+ ctx := context.Background()
+ host := "edge.example.com"
+ f.addRoute(t, host, "edge")
+ f.stageLive(t, host, "v1")
+ plat := release.AdoptPlatform()
+ for ver, body := range map[string]string{"v1": "staged build", "v2": "build two"} {
+ m := release.Manifest{App: "fastapp", Version: ver, Kind: "binary", Artifacts: map[string]release.Artifact{plat: {}}}
+ if err := release.Ship(ctx, f.store, "bab", m, map[string][]byte{plat: []byte(body)}); err != nil {
+ t.Fatalf("Ship %s: %v", ver, err)
+ }
+ }
+ if err := release.Promote(ctx, f.store, f.priv, "bab", "fastapp", "edge", "v2"); err != nil {
+ t.Fatal(err)
+ }
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ prover, err := release.ParseSigningKey(seed, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ policy := filepath.Join(t.TempDir(), "proof-policy.json")
+ if err := os.WriteFile(policy, []byte(fmt.Sprintf(`{"schema":1,"require":[{"app":"fastapp","prover":"dev","key":%q}]}`, pubB64)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ clock := time.Now()
+ gate := release.NewProofGate(policy, f.dataDir).WithClock(func() time.Time { return clock })
+ d := f.deps(nil)
+ d.passOpts = append(d.passOpts, release.WithProofGate(gate))
+ d.proof = gate
+
+ if got := d.fastMembers(clock); len(got) != 0 {
+ t.Fatalf("members = %v before anything waits", got)
+ }
+ // The serial-triggered full pass sees v2 and finds no proof.
+ if err := release.Pass(ctx, f.reg, f.store, f.pub, f.optDir, d.restart, d.passOpts...); err != nil {
+ t.Fatal(err)
+ }
+ if v, _ := release.LiveVersion(f.optDir, host); v != "v1" {
+ t.Fatalf("live = %q, want v1 while unproven", v)
+ }
+ clock = clock.Add(31 * time.Second)
+ if got := d.fastMembers(clock); len(got) != 1 || got[0] != host {
+ t.Fatalf("members = %v, want the waiting non-canary route", got)
+ }
+ sum := sha256.Sum256([]byte("build two"))
+ p, err := release.SignProof(prover, release.Proof{App: "fastapp", Platform: plat, SHA256: hex.EncodeToString(sum[:]),
+ Version: "v2", Prover: "dev", ActiveSince: clock.Add(-time.Hour).UTC().Truncate(time.Second),
+ ProvenAt: clock.UTC().Truncate(time.Second)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := release.PutProof(ctx, f.store, "bab", p); err != nil {
+ t.Fatal(err)
+ }
+ fastTick(ctx, d, clock)
+ if v, _ := release.LiveVersion(f.optDir, host); v != "v2" {
+ t.Fatalf("live = %q, want v2 once proven", v)
+ }
+ clock = clock.Add(31 * time.Second)
+ if got := d.fastMembers(clock); len(got) != 0 {
+ t.Fatalf("members = %v after adopting, want none", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/host -run 'MintAsker|FastMembersKeepsAWaitingRoute' -count=1`
+Expected: FAIL, `undefined: mintAsker`.
+
+- [ ] **Step 3: Implement `proofask.go`**
+
+```go
+// The agent's half of D3 (spec 2026-10-06-console-release-defences-design.md
+// §4.3, §4.5).
+//
+// agentProofGate is the one release.ProofGate this process passes to every
+// pass it runs (adoptLoop, fastConvergeLoop, the cold-wake hook), so the miss
+// cache, the waiting list fastMembers reads and the status line all agree.
+//
+// mintAsker runs on the proving box's fast loop. Minting needs the root-only
+// key, and root passes run hourly or on request, so when a listed app's live
+// build has run for the policy's after with no proof in the bucket, the asker
+// requests a root pass through the stage-request relay. It asks again every
+// mintAskEvery until it sees the proof (codex 5): the relay can miss a request
+// written while carlos-stage.service is running, and a mint write can fail.
+// It also leaves a pending note the first time it sees a build live, which is
+// how `carlos proof get` can say how long the build has had.
+//
+// It cannot check everything a mint needs (it cannot read another unit's
+// /proc/<pid>/exe), and need not: the root pass checks every condition again.
+// When systemctl show is denied here, it asks anyway and root decides.
+package host
+
+import (
+ "context"
+ "errors"
+ "log"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+var (
+ proofGateOnce sync.Once
+ proofGateInst *release.ProofGate
+)
+
+func agentProofGate() *release.ProofGate {
+ proofGateOnce.Do(func() { proofGateInst = release.NewProofGate(release.ProofPolicyPath, dataDir()) })
+ return proofGateInst
+}
+
+const mintAskEvery = 2 * time.Minute
+
+type mintAsker struct {
+ reg *registry.Registry
+ store release.Store
+ sys unitSystem
+ optDir string
+ policyPath string
+ request func(host, ver string, err error) // stageRequester in production
+ now func() time.Time
+
+ last map[string]time.Time // host -> when it was last looked at
+ proven map[string]string // host -> sha256 seen proven
+ pending map[string]string // host -> sha256 whose pending note is written
+ shaByPath map[string]string // staged file -> sha256 (version dirs are immutable)
+}
+
+func (a *mintAsker) tick(ctx context.Context) {
+ pol, err := release.LoadProofPolicy(a.policyPath)
+ if err != nil || pol == nil || pol.Prove == nil {
+ return
+ }
+ routes, err := a.reg.List()
+ if err != nil {
+ return
+ }
+ if a.last == nil {
+ a.last, a.proven, a.pending, a.shaByPath = map[string]time.Time{}, map[string]string{}, map[string]string{}, map[string]string{}
+ }
+ now := a.now()
+ after := pol.ProveAfter()
+ prover := pol.Prove.Prover
+ for _, rt := range routes {
+ if !release.Adoptable(rt) || rt.Unit == "" || !pol.Proves(rt.App) {
+ continue
+ }
+ if last, ok := a.last[rt.Host]; ok && now.Sub(last) < mintAskEvery {
+ continue
+ }
+ a.last[rt.Host] = now
+ target, err := filepath.EvalSymlinks(release.LiveLink(a.optDir, rt.Host))
+ if err != nil {
+ continue
+ }
+ sha, ok := a.shaByPath[target]
+ if !ok {
+ if sha, err = release.FileSHA256(target); err != nil {
+ continue
+ }
+ a.shaByPath[target] = sha
+ }
+ if a.proven[rt.Host] == sha {
+ continue
+ }
+ ver, _ := release.LiveVersion(a.optDir, rt.Host)
+ acct := release.RouteAccount(rt)
+ if p, err := a.sys.Show(rt.Unit); err == nil {
+ if p.ActiveState != "active" {
+ continue
+ }
+ if !p.ActiveEnterTimestamp.IsZero() {
+ if a.pending[rt.Host] != sha {
+ if err := release.PutProofPending(ctx, a.store, acct, release.ProofPending{App: rt.App,
+ Platform: release.AdoptPlatform(), SHA256: sha, Version: ver, Prover: prover,
+ LiveSince: p.ActiveEnterTimestamp.UTC(), After: pol.Prove.After}); err != nil {
+ log.Printf("agent: %s: writing the proof pending note: %v", rt.Host, err)
+ } else {
+ a.pending[rt.Host] = sha
+ }
+ }
+ if now.Sub(p.ActiveEnterTimestamp) < after {
+ continue
+ }
+ }
+ }
+ _, err = release.GetProof(ctx, a.store, acct, rt.App, sha, prover)
+ switch {
+ case err == nil:
+ a.proven[rt.Host] = sha
+ continue
+ case !errors.Is(err, os.ErrNotExist):
+ log.Printf("agent: %s: reading the bucket for %s's proof: %v", rt.Host, ver, err)
+ continue
+ }
+ a.request(rt.Host, ver, nil)
+ log.Printf("agent: %s has run %s for %s with no proof in the bucket; asked for a root pass to mint one", rt.Host, ver, after)
+ }
+}
+```
+
+- [ ] **Step 4: Wire it into `agent.go`**
+
+`fastDeps`, after `systemLeg`:
+
+```go
+ proof *release.ProofGate // D3's gate (agentProofGate); nil = no proof waits to keep
+ mintLeg func(ctx context.Context) // D3's mint asker (proofask.go); nil = not armed
+```
+
+`fastMembers`, immediately after the `if isCanaryRoute(rt) { ... continue }` block:
+
+```go
+ // D3 (spec §4.5, codex 14): a listed route waiting for a dev proof
+ // stays in the sweep although it has a live version, whenever its
+ // 30 s miss has run out, so it adopts soon after the proof arrives
+ // rather than at the next serial bump or the hourly floor.
+ if d.proof.WaitingDue(rt.Host) {
+ seen[rt.Host] = true
+ members = append(members, rt.Host)
+ continue
+ }
+```
+
+`fastTick`, immediately before `members := d.fastMembers(now)`:
+
+```go
+ // D3's mint asker: on a proving box, ask for a root pass every 2 minutes
+ // while a listed build has baked with no proof. Elsewhere it reads one
+ // absent file and returns.
+ if d.mintLeg != nil {
+ d.mintLeg(ctx)
+ }
+```
+
+`fastConvergeLoop`, immediately after `opts := fastPassOpts(dd, stageRequester(dd), restart, makeStartRoute(reg, systemctlStartUnit))`:
+
+```go
+ // D3: the process's one proof gate rides every pass this loop runs, and
+ // fastMembers reads its waiting list. The mint asker has its own
+ // stage requester, so its 2-minute asks never spend the stage budget.
+ opts = append(opts, release.WithProofGate(agentProofGate()))
+ d.proof = agentProofGate()
+ d.mintLeg = (&mintAsker{reg: reg, store: store, sys: systemdUnits{}, optDir: optDir,
+ policyPath: release.ProofPolicyPath, request: stageRequester(dd), now: time.Now}).tick
+```
+
+`adoptLoop`'s `release.Pass(...)` call: add `release.WithProofGate(agentProofGate()),` after `release.WithStartUnit(makeStartRoute(reg, systemctlStartUnit))`.
+
+`materializeHook`: its `release.MaterializeHost(ctx, reg, store, pub, optDir, dd, host, release.OnStageBlocked(stageReq))` call becomes:
+
+```go
+ return release.MaterializeHost(ctx, reg, store, pub, optDir, dd, host,
+ release.OnStageBlocked(stageReq), release.WithProofGate(agentProofGate()))
+```
+
+so a cold wake cannot adopt an unproven build of a listed app.
+
+- [ ] **Step 5: Run the host suite**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false nice -n 10 go test ./internal/host -count=1`
+Expected: PASS. Existing fast-loop tests leave `proof` and `mintLeg` nil, so their behaviour is unchanged.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/host/proofask.go internal/host/proofask_test.go internal/host/agent.go
+git commit -m "host: the agent shares one proof gate, keeps a waiting route in the fast loop, and asks root to mint"
+```
+
+---
+
+### Task 8: "waiting for a dev proof" in `carlos status --live`
+
+**Files:**
+- Modify: `internal/status/snapshot.go`: new `ProofWait` type beside Plan 1's `ReleaseRefusal`; `Probes` (41) and `BoxMetrics` (213) gain `ProofWaits`; the emptiness check (700) and the copy (719)
+- Create: `internal/host/proofstatus.go`
+- Modify: `internal/host/agent.go`: the status probe block (2939), beside Plan 1's release-refusal probe
+- Modify: `cli.go` `printLiveBoxes` (3418)
+- Test: `internal/status/proofwait_test.go`, `statuslive_test.go` (append)
+
+**Interfaces:**
+- Consumes: Task 4's `(*ProofGate).Waits`, `release.ProofWait`; Task 7's `agentProofGate`.
+- Produces:
+ - `type ProofWait struct { Host, Version, SHA256, Prover, Reason string; Since time.Time }` (status package; JSON `host`, `version`, `sha256`, `prover`, `reason`, `since`)
+ - `Probes.ProofWaits []ProofWait`, `BoxMetrics.ProofWaits []ProofWait` (`proof_waits,omitempty`)
+ - `func proofWaitProbe() []status.ProofWait`
+
+- [ ] **Step 1: Write the failing tests**
+
+`internal/status/proofwait_test.go`:
+
+```go
+package status
+
+import (
+ "testing"
+ "time"
+)
+
+func TestCollectBoxCarriesProofWaits(t *testing.T) {
+ now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
+ box := CollectBox(nil, Probes{ProofWaits: []ProofWait{{Host: "console.example.com", Version: "v2",
+ SHA256: "abc", Prover: "dev", Reason: "no proof from dev", Since: now}}}, now)
+ if box.Metrics == nil || len(box.Metrics.ProofWaits) != 1 || box.Metrics.ProofWaits[0].Host != "console.example.com" {
+ t.Fatalf("metrics = %+v, want the proof wait carried", box.Metrics)
+ }
+}
+```
+
+Append to `statuslive_test.go`:
+
+```go
+func TestPrintLiveBoxesShowsAProofWait(t *testing.T) {
+ now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
+ var buf bytes.Buffer
+ printLiveBoxes(&buf, []status.Box{{GeneratedAt: now, Label: "ada-stargazing", Metrics: &status.BoxMetrics{
+ ProofWaits: []status.ProofWait{{Host: "console.example.com", Version: "v2",
+ SHA256: "e47fcc1e0123456789abcdef", Prover: "dev", Since: now.Add(-time.Hour)}},
+ }}}, now)
+ want := " waiting for a dev proof: console.example.com v2 (sha256 e47fcc1e0123) since 2026-10-06T09:00:00Z"
+ if !strings.Contains(buf.String(), want) {
+ t.Fatalf("missing %q in:\n%s", want, buf.String())
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/status . -run 'ProofWait' -count=1`
+Expected: FAIL, `unknown field ProofWaits in struct literal of type Probes`.
+
+- [ ] **Step 3: Implement**
+
+`internal/status/snapshot.go`, beside `ConfigRefusal`:
+
+```go
+// ProofWait is a host this box is holding on its current build because its
+// channel head has no dev proof yet (D3, release.ProofGate), or because the
+// box's proof policy file does not parse. PRIVATE like the rest of
+// Box.Metrics: Host is a real route name.
+type ProofWait struct {
+ Host string `json:"host"`
+ Version string `json:"version"`
+ SHA256 string `json:"sha256,omitempty"`
+ Prover string `json:"prover,omitempty"`
+ Reason string `json:"reason"`
+ Since time.Time `json:"since"`
+}
+```
+
+In `Probes`, after `ConfigGuard`:
+
+```go
+ // ProofWaits is the hosts held on their current build waiting for a dev
+ // proof (D3). Private Metrics only: they name hosts.
+ ProofWaits []ProofWait
+```
+
+In `BoxMetrics`, after `ConfigGuard`:
+
+```go
+ // ProofWaits is the hosts this box is holding on their current build
+ // until a dev proof for the new head arrives (D3). Hostname-bearing, so
+ // it lives in this private block only. Omitted when empty.
+ ProofWaits []ProofWait `json:"proof_waits,omitempty"`
+```
+
+In `CollectBox`, add `len(p.ProofWaits) > 0 ||` to the Metrics emptiness condition beside `len(p.ConfigRefusals) > 0 ||`, and in the `&BoxMetrics{...}` literal add:
+
+```go
+ ProofWaits: append([]ProofWait(nil), p.ProofWaits...),
+```
+
+`internal/host/proofstatus.go`:
+
+```go
+package host
+
+import "github.com/carlosframework/platform/internal/status"
+
+// proofWaitProbe feeds status.Probes.ProofWaits from the agent's proof gate.
+func proofWaitProbe() []status.ProofWait {
+ var out []status.ProofWait
+ for _, w := range agentProofGate().Waits() {
+ out = append(out, status.ProofWait{Host: w.Host, Version: w.Version, SHA256: w.SHA256,
+ Prover: w.Prover, Reason: w.Reason, Since: w.Since})
+ }
+ return out
+}
+```
+
+`internal/host/agent.go`, right after `p.ConfigGuard = configGuardProbe()`:
+
+```go
+ // Hosts held for a dev proof (D3): private Metrics only.
+ p.ProofWaits = proofWaitProbe()
+```
+
+`cli.go` `printLiveBoxes`, after the box's main `Fprintf` (and after Plan 1's refusal lines):
+
+```go
+ for _, pw := range m.ProofWaits {
+ sha := pw.SHA256
+ if len(sha) > 12 {
+ sha = sha[:12]
+ }
+ fmt.Fprintf(w, " waiting for a %s proof: %s %s (sha256 %s) since %s\n",
+ orDash(pw.Prover), pw.Host, pw.Version, orDash(sha), pw.Since.UTC().Format(time.RFC3339))
+ }
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/status ./internal/host . -run 'ProofWait|LiveBoxes|Snapshot|CollectBox' -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/status/snapshot.go internal/status/proofwait_test.go internal/host/proofstatus.go internal/host/agent.go cli.go statuslive_test.go
+git commit -m "status: show a host waiting for a dev proof in status --live"
+```
+
+---
+
+### Task 9: `carlos proof keygen | get | put`
+
+**Files:**
+- Create: `proof_cli.go`
+- Modify: `main.go`: `case "proof":` beside `case "release-keygen":` (133), and a usage line beside `release-keygen` (265)
+- Test: `proof_cli_test.go`
+
+**Interfaces:**
+- Consumes: Task 1 (`release.GenerateKey`, `ParseProof`, `PutProof`, `GetProof`, `GetProofPending`, `ProofsPrefix`, `ProofKey`); `openStore`, `accountFlags`, `appFlag`, `flagUsage`, `stdout` (cli.go, flags.go).
+- Produces:
+ - `func runProof(args []string)`
+ - `func proofKeygen(path string, w io.Writer) error`
+ - `func proofGet(ctx context.Context, s release.Store, acct, app, sha, prover string, w io.Writer) error`
+ - `func proofPut(ctx context.Context, s release.Store, acct, app string, raw []byte, w io.Writer) error`
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/base64"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const cliSHA = "e47fcc1e0123456789abcdef0123456789abcdef0123456789abcdef01234567"
+
+func TestProofKeygenWritesARootOnlyKey(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "proving.key")
+ var out bytes.Buffer
+ if err := proofKeygen(path, &out); err != nil {
+ t.Fatal(err)
+ }
+ fi, err := os.Stat(path)
+ if err != nil || fi.Mode().Perm() != 0o600 {
+ t.Fatalf("key file mode = %v, %v; want 0600", fi.Mode().Perm(), err)
+ }
+ seed, _ := os.ReadFile(path)
+ key, err := release.ParseSigningKey(string(seed), path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub := base64.StdEncoding.EncodeToString(key.Public().(ed25519.PublicKey))
+ if !strings.Contains(out.String(), "public key: "+pub+"\n") {
+ t.Fatalf("printed %q, want the key file's public half %s", out.String(), pub)
+ }
+ if err := proofKeygen(path, &out); err == nil || !strings.Contains(err.Error(), "already exists") {
+ t.Fatalf("second keygen = %v, want a refusal to overwrite", err)
+ }
+}
+
+func signedCLIProof(t *testing.T) *release.Proof {
+ t.Helper()
+ seed, _, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, err := release.ParseSigningKey(seed, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ p, err := release.SignProof(key, release.Proof{App: "console", Platform: "linux-arm64", SHA256: cliSHA,
+ Version: "f4478b15", Prover: "dev", ActiveSince: time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC),
+ ProvenAt: time.Date(2026, 10, 6, 9, 15, 0, 0, time.UTC)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+// get against dev, put against flagship, byte for byte.
+func TestProofGetThenPutCarriesIt(t *testing.T) {
+ ctx := context.Background()
+ dev, flagship := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
+ p := signedCLIProof(t)
+ if err := release.PutProof(ctx, dev, "bab", p); err != nil {
+ t.Fatal(err)
+ }
+ var got bytes.Buffer
+ if err := proofGet(ctx, dev, "bab", "console", cliSHA, "", &got); err != nil {
+ t.Fatalf("proof get: %v", err)
+ }
+ var out bytes.Buffer
+ if err := proofPut(ctx, flagship, "bab", "console", got.Bytes(), &out); err != nil {
+ t.Fatalf("proof put: %v", err)
+ }
+ carried, err := release.GetProof(ctx, flagship, "bab", "console", cliSHA, "dev")
+ if err != nil || carried.Sig != p.Sig {
+ t.Fatalf("carried = %+v, %v", carried, err)
+ }
+}
+
+func TestProofGetNotYetNamesTheBake(t *testing.T) {
+ ctx := context.Background()
+ dev := release.NewDirStore(t.TempDir())
+ if err := release.PutProofPending(ctx, dev, "bab", release.ProofPending{App: "console", Platform: "linux-arm64",
+ SHA256: cliSHA, Version: "f4478b15", Prover: "dev",
+ LiveSince: time.Date(2026, 10, 6, 9, 30, 30, 0, time.UTC), After: "15m"}); err != nil {
+ t.Fatal(err)
+ }
+ err := proofGet(ctx, dev, "bab", "console", cliSHA, "", &bytes.Buffer{})
+ want := "not proven yet: " + cliSHA + " has been live on dev since 2026-10-06T09:30:30Z, proven after 15m"
+ if err == nil || err.Error() != want {
+ t.Fatalf("err = %v, want %q", err, want)
+ }
+ err = proofGet(ctx, release.NewDirStore(t.TempDir()), "bab", "console", cliSHA, "", &bytes.Buffer{})
+ if err == nil || !strings.Contains(err.Error(), "has not been seen live") {
+ t.Fatalf("err = %v, want the never-seen text", err)
+ }
+}
+
+func TestProofPutRefusesAnotherApp(t *testing.T) {
+ var b bytes.Buffer
+ p := signedCLIProof(t)
+ raw, _ := json.MarshalIndent(p, "", " ")
+ if err := proofPut(context.Background(), release.NewDirStore(t.TempDir()), "bab", "other", raw, &b); err == nil {
+ t.Fatal("put a console proof under another app")
+ }
+ if err := proofPut(context.Background(), release.NewDirStore(t.TempDir()), "bab", "console", []byte(`{"schema":1}`), &b); err == nil {
+ t.Fatal("put a malformed proof")
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test . -run 'TestProof' -count=1`
+Expected: FAIL, `undefined: proofKeygen`.
+
+- [ ] **Step 3: Implement `proof_cli.go`**
+
+```go
+// carlos proof — D3 of the console release defences (spec
+// 2026-10-06-console-release-defences-design.md §4.2-§4.4).
+//
+// keygen runs as root on the proving console box. get runs against the
+// proving deployment's bucket and prints the proof; put runs against the
+// requiring deployment's bucket. Direct mode only (CARLOS_DEPLOYMENT_BUCKET or
+// _DIR): these are operator verbs, and a proof lives only in a bucket. put
+// checks shape, not the signature: only the requiring box holds the prover's
+// public key, and it checks.
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "path"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const proofUsage = "usage: carlos proof keygen [--out <file>] | get --app <a> --sha256 <hex> [--prover <name>] | put --app <a> <file>"
+
+func runProof(args []string) {
+ if len(args) == 0 {
+ log.Fatal(proofUsage)
+ }
+ ctx := context.Background()
+ switch args[0] {
+ case "keygen":
+ fs := flag.NewFlagSet("proof keygen", flag.ExitOnError)
+ out := fs.String("out", "/etc/carlos/proving.key", "where to write the private key (root only, mode 0600)")
+ fs.Usage = flagUsage(fs, "")
+ fs.Parse(args[1:])
+ if fs.NArg() != 0 {
+ log.Fatal(proofUsage)
+ }
+ if err := proofKeygen(*out, stdout); err != nil {
+ log.Fatal(err)
+ }
+ case "get":
+ fs := flag.NewFlagSet("proof get", flag.ExitOnError)
+ app := appFlag(fs, "")
+ sha := fs.String("sha256", "", "the artifact's sha256, hex")
+ prover := fs.String("prover", "", "whose proof (default: the only one)")
+ _, _, acctOf := accountFlags(fs)
+ fs.Usage = flagUsage(fs, "")
+ fs.Parse(args[1:])
+ if *app == "" || *sha == "" || fs.NArg() != 0 {
+ log.Fatal(proofUsage)
+ }
+ if err := proofGet(ctx, openStore(), acctOf(), *app, *sha, *prover, stdout); err != nil {
+ log.Fatal(err)
+ }
+ case "put":
+ fs := flag.NewFlagSet("proof put", flag.ExitOnError)
+ app := appFlag(fs, "")
+ _, _, acctOf := accountFlags(fs)
+ fs.Usage = flagUsage(fs, "")
+ fs.Parse(args[1:])
+ if *app == "" || fs.NArg() != 1 {
+ log.Fatal(proofUsage)
+ }
+ raw, err := os.ReadFile(fs.Arg(0))
+ if err != nil {
+ log.Fatal(err)
+ }
+ if err := proofPut(ctx, openStore(), acctOf(), *app, raw, stdout); err != nil {
+ log.Fatal(err)
+ }
+ default:
+ log.Fatal(proofUsage)
+ }
+}
+
+func proofKeygen(file string, w io.Writer) error {
+ seed, pub, err := release.GenerateKey()
+ if err != nil {
+ return err
+ }
+ f, err := os.OpenFile(file, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if errors.Is(err, os.ErrExist) {
+ return fmt.Errorf("%s already exists. A proving key is never overwritten: delete it first if you mean to replace it, then update every require entry that names its public key", file)
+ }
+ if err != nil {
+ return err
+ }
+ if _, err := f.WriteString(seed + "\n"); err != nil {
+ f.Close()
+ return err
+ }
+ if err := f.Close(); err != nil {
+ return err
+ }
+ fmt.Fprintf(w, "wrote the proving key to %s (root only, mode 0600)\n", file)
+ fmt.Fprintf(w, "public key: %s\n", pub)
+ fmt.Fprintf(w, "a requiring box names it in its proof policy: {\"app\":\"<app>\",\"prover\":\"<prover>\",\"key\":%q}\n", pub)
+ return nil
+}
+
+func proofGet(ctx context.Context, s release.Store, acct, app, sha, prover string, w io.Writer) error {
+ if prover == "" {
+ keys, err := s.List(ctx, release.ProofsPrefix(acct, app, sha))
+ if err != nil {
+ return err
+ }
+ var names []string
+ for _, k := range keys {
+ base := path.Base(k)
+ if strings.HasSuffix(base, ".pending.json") {
+ continue
+ }
+ if n, ok := strings.CutSuffix(base, ".json"); ok {
+ names = append(names, n)
+ }
+ }
+ switch len(names) {
+ case 0:
+ return proofNotYet(ctx, s, acct, app, sha, keys)
+ case 1:
+ prover = names[0]
+ default:
+ return fmt.Errorf("%s has proofs from %s; choose one with --prover", sha, strings.Join(names, ", "))
+ }
+ }
+ p, err := release.GetProof(ctx, s, acct, app, sha, prover)
+ if errors.Is(err, os.ErrNotExist) {
+ return proofNotYet(ctx, s, acct, app, sha, []string{release.ProofPendingKey(acct, app, sha, prover)})
+ }
+ if err != nil {
+ return err
+ }
+ b, err := json.MarshalIndent(p, "", " ")
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(w, "%s\n", b)
+ return nil
+}
+
+// proofNotYet explains a missing proof from the proving box's pending note
+// when there is one among keys.
+func proofNotYet(ctx context.Context, s release.Store, acct, app, sha string, keys []string) error {
+ for _, k := range keys {
+ prover, ok := strings.CutSuffix(path.Base(k), ".pending.json")
+ if !ok {
+ continue
+ }
+ pend, err := release.GetProofPending(ctx, s, acct, app, sha, prover)
+ if err != nil {
+ continue
+ }
+ return fmt.Errorf("not proven yet: %s has been live on %s since %s, proven after %s",
+ sha, pend.Prover, pend.LiveSince.UTC().Format(time.RFC3339), pend.After)
+ }
+ return fmt.Errorf("not proven yet: %s has not been seen live on the proving deployment", sha)
+}
+
+func proofPut(ctx context.Context, s release.Store, acct, app string, raw []byte, w io.Writer) error {
+ p, err := release.ParseProof(raw)
+ if err != nil {
+ return err
+ }
+ if p.App != app {
+ return fmt.Errorf("this proof is for %s, not %s", p.App, app)
+ }
+ if err := release.PutProof(ctx, s, acct, p); err != nil {
+ return err
+ }
+ fmt.Fprintf(w, "put %s's proof of %s %s (sha256 %s) at %s\n", p.Prover, p.App, p.Version, p.SHA256,
+ release.ProofKey(acct, p.App, p.SHA256, p.Prover))
+ return nil
+}
+```
+
+`main.go`: below `case "release-keygen":` / `runKeygen(args)`, add
+
+```go
+ case "proof":
+ runProof(args)
+```
+
+and below the `release-keygen` usage line:
+
+```
+ proof dev proofs a box requires before adopting: keygen | get | put
+```
+
+- [ ] **Step 4: Run the tests and the build**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test . -run 'TestProof' -count=1 && GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go build ./...`
+Expected: PASS, and the build succeeds.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add proof_cli.go proof_cli_test.go main.go
+git commit -m "carlos proof: keygen on the proving box, get and put to carry a proof"
+```
+
+---
+
+### Task 10: Operator note and the full gate
+
+**Files:**
+- Modify: `README.md`: the release-notes area under "Releasing the platform" that Plans 1 and 2 used
+
+- [ ] **Step 1: Add the note**
+
+```markdown
+**Dev proofs (console release defences, D3).** Off unless a box has
+`/etc/carlos/proof-policy.json` (root, mode 0644). On the proving console box
+(dev), run `carlos proof keygen` as root, then write the policy file with a
+`prove` block: the apps, the prover's name, how long a build must run
+(`after`, 15m) and `key_file`. Dev then mints a proof for each build that runs
+healthily for that long. On a requiring box (flagship), a `require` entry
+names the app, the prover and its public key. A new build of that app is then
+adopted only with a proof for those exact bytes, or if the box has already run
+that build healthily. Carry a proof with `carlos proof get` against dev and
+`carlos proof put` against flagship. `carlos status --live` shows a host that
+is waiting. A policy file that does not parse holds every named-unit route on
+its current build and says so in the journal. To switch the requirement off,
+delete the `require` entry (or the file) as root and run
+`carlos adopt --stage-only`. The box then logs once an hour that it is off.
+Write policy files over SSM, never through tofu `user_data`.
+```
+
+This is operator documentation, not member-facing copy. Keep its sentences
+plain and avoid em dashes in new prose (`[[copy-voice-no-em-dashes]]`). The
+CLI strings in Tasks 4, 6, 7 and 9 follow the same rule.
+
+- [ ] **Step 2: Run the full gate**
+
+Run, niced and serial (`[[cpu-budget-docs-t1]]`):
+
+```bash
+GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false nice -n 10 go vet ./... && \
+GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false nice -n 10 go test ./... -count=1 && \
+GOOS=darwin GOARCH=arm64 GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go build ./... && \
+GOOS=windows GOARCH=amd64 GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go build ./...
+```
+
+Expected: all pass. The new host files use `/proc` paths and `os` calls only,
+no linux-only syscalls, so they need no build tag (`[[cross-compile-gate]]`).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add README.md
+git commit -m "README: operator note for dev proofs"
+```
diff --git a/docs/superpowers/plans/2026-10-06-console-release-defences-plan-4-d4.md b/docs/superpowers/plans/2026-10-06-console-release-defences-plan-4-d4.md
index 53cd5be7c..c60289ed5 100644
--- a/docs/superpowers/plans/2026-10-06-console-release-defences-plan-4-d4.md
+++ b/docs/superpowers/plans/2026-10-06-console-release-defences-plan-4-d4.md
@@ -408,9 +408,7 @@ The policy files are not in tofu `user_data` on purpose: a whole-root apply
restarts every box.
````
-The `carlos proof keygen --out` flag name must match what Plan 3 defines.
-Check Plan 3's `proof keygen` task and use its exact flags here before
-committing.
+`--out` is Plan 3 Task 9's flag; its default is `/etc/carlos/proving.key`.
- [ ] **Step 2: Put the section through copy review**
diff --git a/docs/superpowers/specs/2026-10-06-console-release-defences-design.md b/docs/superpowers/specs/2026-10-06-console-release-defences-design.md
index d277db6aa..f331980eb 100644
--- a/docs/superpowers/specs/2026-10-06-console-release-defences-design.md
+++ b/docs/superpowers/specs/2026-10-06-console-release-defences-design.md
@@ -389,7 +389,9 @@ these hold at once:
- no refusal record names S;
- no proof for S exists yet in dev's bucket.
-It writes `proofs/<app>/<sha256>.json` to dev's own bucket.
+It writes `apps/<acct>/<app>/proofs/<sha256>/<prover>.json` to dev's own
+bucket: the same key shape the target uses (§4.4), so `proof get` and `proof
+put` move one object between two buckets under one name.
Minting needs root (the key), and root passes run hourly or when the agent
asks. So the agent asks: on its fast loop, when a listed app's route has been
@@ -492,8 +494,10 @@ private key, and only root opens it.
**Break glass:** delete the `require` entry (or the file) as root,
then `touch` the stage-request path (or run `carlos adopt --stage-only`) so a
root pass adopts at once. The agent's next tick also sees the line gone.
-While the requirement is off on a box that has a healthy history for an app,
-the box logs once an hour that the requirement is off. Nothing in the bucket
+While the requirement is off for an app on a box that has required a proof
+for it before (a marker the root pass writes when it reads a `require`
+entry), the box logs once an hour that the requirement is off. A box that
+never had the requirement, such as Tito's or a self-hoster's, stays quiet. Nothing in the bucket
or the CLI can switch it off.
### 4.6 Who sees none of this