// 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 of // kind RefusalNotProven. // // 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. A host leaves the list as // soon as the gate stops being asked about it (adoptHost clears it on every // early return before admit), so the list never holds a head the channel has // moved off. // // 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. Any other failure to load it (it does not parse, or cannot be // read) 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 cannot be loaded). 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 healthyDir 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, healthyDir: bakeDir, now: time.Now, shas: map[promotionKey]string{}, misses: map[missKey]time.Time{}, waits: map[string]ProofWait{}} } // NewRootProofGate keeps diagnostic output in bakeDir. Health authority is // always read from the root-owned history, including in the agent's gate. // Do not enable a D3 requirement until both current and previous builds have // root history. Service-owned history is not imported; enabling it earlier // can block an otherwise authorized promote-previous recovery without proof. func NewRootProofGate(policyPath, bakeDir string) *ProofGate { g := NewProofGate(policyPath, bakeDir) g.healthyDir = RootHealthyDir return g } // 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 } // Requires reports what this pass's policy says about app: required when a // require entry lists it, and policyErr when the policy failed to load, which // the gate reads as required (closed) because the box cannot tell which apps // were listed. A nil gate requires nothing. // // adoptHost's refusal gate asks it about a RefusalNotProven record: that // record stands only while the box still requires a proof for the app, so // removing the require entry (or the policy file) is the break glass it is // meant to be (final review H3). func (g *ProofGate) Requires(app string) (required bool, policyErr bool) { if g == nil { return false, false } pol, err := g.state() if err != nil { return true, true } return len(pol.Requires(app)) > 0, false } // Waits is the waiting list, sorted by host. 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) } // clear takes host off the waiting list and forgets its cached misses. A // miss that outlived its wait would hold the host silently, off the list, // if its channel came back to the same build inside ProofMissTTL. func (g *ProofGate) clear(host string) { if g == nil { return } g.mu.Lock() delete(g.waits, host) for k := range g.misses { if k.host == host { delete(g.misses, k) } } 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) { cur = orNothing(cur) 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) } // orNothing names the build a host is on for a "staying on" or "still on" // line: a first adoption has none. func orNothing(ver string) string { if ver == "" { return "nothing" } return ver } // 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 { // Any load error is D3 on, never "no policy" (spec §4.5): every // named-unit route waits, since the box cannot tell which apps were // listed. The reason is the load error itself, which names the file // and says whether it did not parse or could not be read. if rt.Unit == "" { return proofVerdict{}, true } g.hold(rt, ptr.Version, "", "", perr.Error(), 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.healthyDir, 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 RefusalNotProven record naming both hashes: // nothing was restarted, so it is Recovered and still on cur, the same shape // as D2's wrong-program record. func (g *ProofGate) bind(optDir string, rt registry.Route, ptr *ChannelPointer, v proofVerdict, cur string, now time.Time) bool { return g.bindRecording(optDir, rt, ptr, v, cur, now, func(r Refusal) error { return WriteRefusal(g.bakeDir, r) }) } // bindRecording is bind with the refusal record's writer as a parameter: // adoption's bind writes it with WriteRefusal, and a move's held stage // (materializeheld.go) scopes it to the move first. func (g *ProofGate) bindRecording(optDir string, rt registry.Route, ptr *ChannelPointer, v proofVerdict, cur string, now time.Time, record func(Refusal) error) 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.healthyDir, acct, rt.App, got) { return true } reason := fmt.Sprintf("staged sha256 %s, manifest sha256 %s", got, v.sha) if v.proof != nil { reason = fmt.Sprintf("staged sha256 %s, %s proved %s", got, v.proof.Prover, v.proof.SHA256) } fmt.Printf(" %s: %s: the staged bytes do not match the proof (%s); not pointed live\n", rt.Host, ptr.Version, reason) if g.bakeDir != "" { if err := record(Refusal{Host: rt.Host, App: rt.App, Version: ptr.Version, SHA256: got, Platform: adoptPlatform, PointerPromotedAt: ptr.PromotedAt, RefusedAt: now.UTC(), Reason: "not proven: " + reason, Kind: RefusalNotProven, Recovered: true, RestoredVersion: cur}); err != nil { fmt.Printf(" %s: writing refusal record: %v\n", rt.Host, err) } } g.clear(rt.Host) return false }