| 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" |
| +``` |