D3-held fix round 1: bind inside the publication with a re-hash at the swap; move-scoped held refusals
Finding 1: publishHeld runs bind inside guard (after the authority's re-check, under its freeze), swaps the link, and binds again on the bytes the link now names; a refusal there puts the link back (or removes it) and is ErrHeldNotProven. heldBeforeSwap is the test seam for a replacement between bind and the swap. Finding 2: a held binding refusal carries its move (Refusal.MoveOp, from rt.HoldOp, now required on a held stage) and is never written over an adoption refusal. A successful held stage retires its move's record; the target's moves pass retires records of moves no longer preparing or ready (with evictArchives); root's held stage pass retires those whose claim no longer names the move into this box. Proof minting skips move-scoped records. bind is bindRecording with WriteRefusal, so adoption's behaviour and records are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 files changed,
+464
−13
internal/host/heldstage.go+31 −0internal/host/heldstage_test.go+1 −1internal/host/movespass.go+1 −0internal/host/movesreport.go+20 −0internal/host/movesstage.go+1 −1internal/host/movesstage_proof_test.go+95 −0internal/host/proofroot.go+3 −1internal/host/proofroot_test.go+15 −0internal/release/materializeheld.go+114 −7internal/release/materializeheld_proof_test.go+169 −2internal/release/proofgate.go+8 −1internal/release/refusal.go+6 −0
diff --git a/internal/host/heldstage.go b/internal/host/heldstage.go| index dfe5a201a..5bd1ca769 100644 |
| --- a/internal/host/heldstage.go |
| +++ b/internal/host/heldstage.go |
| @@ -191,4 +191,35 @@ func heldStagePass(ctx context.Context, dataDir, optDir string, reg *registry.Re |
| if err := release.ServeHeldStageRequests(ctx, store, optDir, dataDir, proof, heldStageAuthority(reg, store, box)); err != nil { |
| log.Printf("adopt: held stage requests: %v", err) |
| } |
| + retireEndedHeldRefusals(ctx, store, dataDir, box) |
| +} |
| + |
| +// retireEndedHeldRefusals is root's half of a held refusal's lifecycle |
| +// (D3-held): every refusal record a move's held stage left (MoveOp set) |
| +// whose move the host claim no longer names as moving or landing into this |
| +// box is removed, so an aborted or abandoned move leaves nothing behind. |
| +// The agent retires the ones it can (movesWorker.retireHeldRefusals), but |
| +// not a record root wrote. A claim that cannot be read keeps the record for |
| +// the next pass. Adoption refusals are never touched, and no claim is read |
| +// unless a held refusal exists. |
| +func retireEndedHeldRefusals(ctx context.Context, store release.Store, dataDir, box string) { |
| + retired, err := release.RetireHeldRefusals(dataDir, func(r release.Refusal) bool { |
| + rctx, cancel := context.WithTimeout(ctx, heldStageReadTimeout) |
| + defer cancel() |
| + obs, err := hostclaim.Read(rctx, store, r.Host) |
| + if err != nil { |
| + return true |
| + } |
| + if obs.Absent || obs.Claim == nil { |
| + return false |
| + } |
| + op := obs.Claim.Op |
| + return op.ID == r.MoveOp && (op.Kind == hostclaim.KindMoving || op.Kind == hostclaim.KindLanding) && op.Intent["target"] == box |
| + }) |
| + for _, host := range retired { |
| + log.Printf("adopt: %s: retired the held refusal of a move that is over", host) |
| + } |
| + if err != nil { |
| + log.Printf("adopt: held refusals: %v", err) |
| + } |
| } |
diff --git a/internal/host/heldstage_test.go b/internal/host/heldstage_test.go| index c1744adde..e8a6b5eff 100644 |
| --- a/internal/host/heldstage_test.go |
| +++ b/internal/host/heldstage_test.go |
| @@ -355,7 +355,7 @@ func TestHeldPublicationIsGuardedByTheAuthority(t *testing.T) { |
| if err := auth(req, nil); err != nil { |
| t.Fatalf("authority before staging: %v", err) |
| } |
| - rt := registry.Route{Host: req.Host, Account: req.Account, App: req.App} |
| + rt := registry.Route{Host: req.Host, Account: req.Account, App: req.App, HoldOp: req.Op} |
| if _, err := release.StageHeld(ctx, s, opt, rt, req.Version); err != nil { |
| t.Fatalf("StageHeld: %v", err) |
| } |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index a8bbd7bc3..6a548d652 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -529,6 +529,7 @@ func movesPass(ctx context.Context, s release.Store, w *movesWorker) error { |
| } |
| default: |
| w.evictArchives(nil) // no move into this box: no verdict to keep |
| + w.retireHeldRefusals(nil) |
| } |
| w.liftFences(recs) |
| return nil |
diff --git a/internal/host/movesreport.go b/internal/host/movesreport.go| index 7ddde5b17..21c763f5c 100644 |
| --- a/internal/host/movesreport.go |
| +++ b/internal/host/movesreport.go |
| @@ -7,6 +7,7 @@ import ( |
| "errors" |
| "fmt" |
| "io" |
| + "io/fs" |
| "log" |
| "os" |
| "path/filepath" |
| @@ -122,6 +123,7 @@ func loadMoveFleet(ctx context.Context, s release.Store) (moveFleet, error) { |
| func (w *movesWorker) targetReportPass(ctx context.Context, s release.Store, recs []provision.Record, fleet moveFleet) { |
| live := map[string]bool{} |
| defer w.evictArchives(live) |
| + defer w.retireHeldRefusals(live) |
| for _, rec := range recs { |
| m := rec.Move |
| if m == nil || m.Legacy() || m.To != w.cfg.Region || (m.State != provision.MovePreparing && m.State != provision.MoveReady) { |
| @@ -148,6 +150,24 @@ func (w *movesWorker) evictArchives(live map[string]bool) { |
| } |
| } |
| +// retireHeldRefusals retires the held-stage refusal (D3-held) of every |
| +// move no longer preparing or ready into this box, with the rest of its |
| +// staging state: live is the same set evictArchives keeps. A record root |
| +// wrote cannot be removed here (the agent does not own it), which is not |
| +// worth a line: the root stage pass retires those (retireEndedHeldRefusals). |
| +func (w *movesWorker) retireHeldRefusals(live map[string]bool) { |
| + if w.cfg.DataDir == "" { |
| + return |
| + } |
| + retired, err := release.RetireHeldRefusals(w.cfg.DataDir, func(r release.Refusal) bool { return live[r.MoveOp] }) |
| + for _, host := range retired { |
| + log.Printf("moves: %s: retired the held refusal of a move that is over", host) |
| + } |
| + if err != nil && !errors.Is(err, fs.ErrPermission) { |
| + w.logTrouble("held refusals", err) |
| + } |
| +} |
| + |
| // verifiedArchive is what T5 learned from one published archive, kept per |
| // exact version so a pass does not open the same archive again. |
| type verifiedArchive struct { |
diff --git a/internal/host/movesstage.go b/internal/host/movesstage.go| index 9d826bba7..c666f13b4 100644 |
| --- a/internal/host/movesstage.go |
| +++ b/internal/host/movesstage.go |
| @@ -46,7 +46,7 @@ func heldReleaseReady(reg *registry.Registry, optDir, dataDir, box string, proof |
| logMoveStage(rec.Host, version, err) |
| return moves.Check{Reason: t7Failed} |
| } |
| - rt := registry.Route{Host: rec.Host, Kind: "instance", App: rec.App, Account: rec.Account} |
| + rt := registry.Route{Host: rec.Host, Kind: "instance", App: rec.App, Account: rec.Account, HoldOp: rec.Move.ID} |
| _, err := heldMaterialize(ctx, s, optDir, rt, version, proof, func(publish func() error) error { return auth(req, publish) }) |
| switch { |
| case errors.Is(err, release.ErrHeldNotProven): |
diff --git a/internal/host/movesstage_proof_test.go b/internal/host/movesstage_proof_test.go| index b751659fe..9bc7186e0 100644 |
| --- a/internal/host/movesstage_proof_test.go |
| +++ b/internal/host/movesstage_proof_test.go |
| @@ -5,13 +5,16 @@ import ( |
| "crypto/ed25519" |
| "crypto/sha256" |
| "encoding/hex" |
| + "errors" |
| "fmt" |
| "os" |
| "path/filepath" |
| "runtime" |
| + "strings" |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -143,3 +146,95 @@ func TestHeldReleaseReadyAppliesTheProofGate(t *testing.T) { |
| } |
| }) |
| } |
| + |
| +// heldRefusalFixture writes three refusal records under bake: a held one |
| +// for hsOp at hsHost, a held one for another move at another host, and an |
| +// adoption refusal (no MoveOp) at a third host. |
| +func heldRefusalFixture(t *testing.T, bake string) { |
| + t.Helper() |
| + at := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) |
| + for _, r := range []release.Refusal{ |
| + {Host: hsHost, App: "titogo", Version: "v7", RefusedAt: at, Kind: release.RefusalNotProven, MoveOp: hsOp}, |
| + {Host: "other.example.com", App: "titogo", Version: "v7", RefusedAt: at, Kind: release.RefusalNotProven, MoveOp: hsOther}, |
| + {Host: "adopted.example.com", App: "titogo", Version: "v7", RefusedAt: at, Kind: release.RefusalNotProven}, |
| + } { |
| + if err := release.WriteRefusal(bake, r); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +func refusalHosts(t *testing.T, bake string) string { |
| + t.Helper() |
| + recs, err := release.ListRefusals(bake) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var hosts []string |
| + for _, r := range recs { |
| + hosts = append(hosts, r.Host) |
| + } |
| + return strings.Join(hosts, ",") |
| +} |
| + |
| +// Fix round 1, finding 2, the agent's half: when a move stops being one |
| +// this box prepares (aborted, abandoned, or past ready), the target's pass |
| +// retires its held refusal with the rest of the move's staging state. A |
| +// live move's refusal and an adoption refusal stay. |
| +func TestTargetPassRetiresAnEndedMovesHeldRefusal(t *testing.T) { |
| + data := t.TempDir() |
| + heldRefusalFixture(t, data) |
| + w := &movesWorker{cfg: moveCfg{DataDir: data}} |
| + w.retireHeldRefusals(map[string]bool{hsOp: true}) |
| + if got := refusalHosts(t, data); got != "adopted.example.com,"+hsHost { |
| + t.Fatalf("left %q, want the adoption refusal and the live move's", got) |
| + } |
| + w.retireHeldRefusals(nil) // no move into this box any more |
| + if got := refusalHosts(t, data); got != "adopted.example.com" { |
| + t.Fatalf("left %q, want only the adoption refusal", got) |
| + } |
| +} |
| + |
| +// Fix round 1, finding 2, root's half: the agent cannot remove a record |
| +// root wrote, so the root stage pass retires every held refusal whose move |
| +// the host claim no longer names as moving or landing into this box. A |
| +// claim it cannot read keeps the record; an adoption refusal is never read |
| +// past its MoveOp. |
| +func TestRootRetiresAnAbandonedMovesHeldRefusal(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + data := t.TempDir() |
| + heldRefusalFixture(t, data) |
| + hsClaim(t, s, movingOp()) // hsHost: still moving into hsBox under hsOp |
| + // other.example.com has no claim at all: its move is over. |
| + retireEndedHeldRefusals(ctx, s, data, hsBox) |
| + if got := refusalHosts(t, data); got != "adopted.example.com,"+hsHost { |
| + t.Fatalf("left %q, want the adoption refusal and the live move's", got) |
| + } |
| + |
| + // The move is abandoned: the claim goes back to serving. |
| + obs, err := hostclaim.Read(ctx, s, hsHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := hostclaim.Update(ctx, s, obs, hostclaim.ReleaseMove(hsOp, "ie-box")); err != nil { |
| + t.Fatal(err) |
| + } |
| + retireEndedHeldRefusals(ctx, s, data, hsBox) |
| + if got := refusalHosts(t, data); got != "adopted.example.com" { |
| + t.Fatalf("left %q, want only the adoption refusal", got) |
| + } |
| + |
| + // A claim read that fails keeps the record. |
| + heldRefusalFixture(t, data) |
| + retireEndedHeldRefusals(ctx, failingClaimStore{s}, data, hsBox) |
| + if got := refusalHosts(t, data); got != "adopted.example.com,"+hsHost+",other.example.com" { |
| + t.Fatalf("left %q after failed claim reads, want all three", got) |
| + } |
| +} |
| + |
| +type failingClaimStore struct{ release.Store } |
| + |
| +func (failingClaimStore) Get(context.Context, string) ([]byte, string, error) { |
| + return nil, "", errors.New("bucket unreachable") |
| +} |
diff --git a/internal/host/proofroot.go b/internal/host/proofroot.go| index 5f2c1fb1e..263bd08fd 100644 |
| --- a/internal/host/proofroot.go |
| +++ b/internal/host/proofroot.go |
| @@ -232,7 +232,9 @@ func (r proofRoot) mint(ctx context.Context, pol *release.ProofPolicy, rt regist |
| return |
| } |
| for _, ref := range refusals { |
| - if ref.SHA256 == b.SHA256 { |
| + // A move's held refusal (MoveOp set, D3-held) is about that move's |
| + // held stage, and is retired with it; it never stops a mint. |
| + if ref.MoveOp == "" && ref.SHA256 == b.SHA256 { |
| r.logf("proof: %s: not minting %s: this box refused it (%s)", rt.Host, b.Version, ref.Reason) |
| return |
| } |
diff --git a/internal/host/proofroot_test.go b/internal/host/proofroot_test.go| index 03826398e..d950c1f2d 100644 |
| --- a/internal/host/proofroot_test.go |
| +++ b/internal/host/proofroot_test.go |
| @@ -134,6 +134,21 @@ func TestProofRootDoesNotMintARefusedBuild(t *testing.T) { |
| } |
| } |
| +// D3-held fix round 1: a move's held refusal (MoveOp set) is scoped to |
| +// that move's held stage and never stops this box minting a proof for a |
| +// build it runs. |
| +func TestProofRootMintsPastAMovesHeldRefusal(t *testing.T) { |
| + e := newProofRootEnv(t, provePolicy) |
| + if err := release.WriteRefusal(e.bake, release.Refusal{Host: "held.example.com", App: "console", Version: "v1", SHA256: e.sha, |
| + Kind: release.RefusalNotProven, MoveOp: "mv_abcdefghijklmnopqrstuvwxyz"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + e.root.run(context.Background()) |
| + if e.proof(t) == nil { |
| + t.Fatalf("a move's held refusal stopped the mint; logs: %v", e.logs) |
| + } |
| +} |
| + |
| func TestProofRootNeverOverwritesAProof(t *testing.T) { |
| e := newProofRootEnv(t, provePolicy) |
| first := e.signed(t, e.now.Add(-time.Minute)) |
diff --git a/internal/release/materializeheld.go b/internal/release/materializeheld.go| index 1c4d5bdd6..219f13c99 100644 |
| --- a/internal/release/materializeheld.go |
| +++ b/internal/release/materializeheld.go |
| @@ -51,6 +51,10 @@ var ( |
| // a variable so a test can make it fail the way a read-only mount does. |
| var materializeHeldStage = ensureStaged |
| +// heldBeforeSwap runs inside the publication between bind and the link |
| +// swap. A test seam: the window a replaced staged file lands in. |
| +var heldBeforeSwap = func() {} |
| + |
| // MaterializeHeld is StageHeld then PublishHeld: version of rt's app is |
| // staged, and live/<host> is pointed at it inside guard. A held row runs |
| // exactly the pinned release, so there is no channel resolution, no |
| @@ -135,7 +139,19 @@ func PublishHeld(ctx context.Context, s Store, optDir string, rt registry.Route, |
| return publishHeld(optDir, rt, version, proof, ptr, v, guard) |
| } |
| -// publishHeld is PublishHeld after admit: bind, then guard and PointLive. |
| +// publishHeld is PublishHeld after admit. Everything runs inside guard, |
| +// after the authority's re-check and under whatever it holds (for the root |
| +// relay, the registry's holds frozen): bind hashes the staged bytes, the |
| +// link is swapped, and bind runs again on the bytes the link now names. The |
| +// guard's re-check reads the bucket and waits for the freeze, and the |
| +// staged file can be replaced meanwhile or after the first hash, so only a |
| +// hash taken after the swap vouches for what the link points at. When that |
| +// second bind refuses, the link goes back to the version it named before |
| +// (or away, when there was none) and the stage is ErrHeldNotProven. |
| +// |
| +// A binding refusal is recorded scoped to the move (rt.HoldOp, Refusal's |
| +// MoveOp), never over an adoption refusal for the host; a publication that |
| +// succeeds retires its move's earlier refusal. |
| func publishHeld(optDir string, rt registry.Route, version string, proof *ProofGate, ptr *ChannelPointer, v proofVerdict, guard func(publish func() error) error) error { |
| if err := heldStageable(rt, version); err != nil { |
| return err |
| @@ -143,19 +159,107 @@ func publishHeld(optDir string, rt registry.Route, version string, proof *ProofG |
| if guard == nil { |
| return errHeldNoGuard |
| } |
| - cur, _ := LiveVersion(optDir, rt.Host) |
| - if !proof.bind(optDir, rt, ptr, v, cur, proof.now()) { |
| - return fmt.Errorf("%w: %s at %s: its staged bytes are not the proven ones", ErrHeldNotProven, rt.Host, version) |
| - } |
| - return guard(func() error { |
| + record := heldRefusalWriter(proof.bakeDir, rt.HoldOp) |
| + notProven := fmt.Errorf("%w: %s at %s: its staged bytes are not the proven ones", ErrHeldNotProven, rt.Host, version) |
| + err := guard(func() error { |
| + cur, err := LiveVersion(optDir, rt.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if !proof.bindRecording(optDir, rt, ptr, v, cur, proof.now(), record) { |
| + return notProven |
| + } |
| + heldBeforeSwap() |
| if err := PointLive(optDir, rt.Host, RouteAccount(rt), version, rt.App); err != nil { |
| if StageBlocked(err) { |
| return fmt.Errorf("%w: %w", ErrStageBlocked, err) |
| } |
| return err |
| } |
| + if !proof.bindRecording(optDir, rt, ptr, v, cur, proof.now(), record) { |
| + if rerr := restoreLive(optDir, rt, cur); rerr != nil { |
| + return errors.Join(notProven, fmt.Errorf("putting live/%s back on %q: %w", rt.Host, cur, rerr)) |
| + } |
| + return notProven |
| + } |
| return nil |
| }) |
| + if err != nil { |
| + return err |
| + } |
| + if proof.bakeDir != "" { |
| + if err := RetireHeldRefusal(proof.bakeDir, rt.Host, rt.HoldOp); err != nil { |
| + fmt.Printf(" %s: retiring move %s's held refusal: %v\n", rt.Host, rt.HoldOp, err) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// restoreLive puts live/<host> back on cur, or removes it when cur is "". |
| +func restoreLive(optDir string, rt registry.Route, cur string) error { |
| + if cur == "" { |
| + if err := os.Remove(LiveLink(optDir, rt.Host)); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + return nil |
| + } |
| + return PointLive(optDir, rt.Host, RouteAccount(rt), cur, rt.App) |
| +} |
| + |
| +// heldRefusalWriter is a held stage's refusal writer for bindRecording: the |
| +// record carries the move's op, and an adoption refusal standing for the |
| +// host (MoveOp "") is left as it is rather than overwritten. |
| +func heldRefusalWriter(bakeDir, op string) func(Refusal) error { |
| + return func(r Refusal) error { |
| + old, err := ReadRefusal(bakeDir, r.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if old != nil && old.MoveOp == "" { |
| + return fmt.Errorf("an adoption refusal for %s stands, so move %s's is not recorded over it", r.Host, op) |
| + } |
| + r.MoveOp = op |
| + return WriteRefusal(bakeDir, r) |
| + } |
| +} |
| + |
| +// RetireHeldRefusal removes host's refusal record when it is move op's held |
| +// refusal, and leaves any other record (another move's, or adoption's). |
| +func RetireHeldRefusal(bakeDir, host, op string) error { |
| + if op == "" { |
| + return nil |
| + } |
| + r, err := ReadRefusal(bakeDir, host) |
| + if err != nil || r == nil || r.MoveOp != op { |
| + return err |
| + } |
| + return ClearRefusal(bakeDir, host) |
| +} |
| + |
| +// RetireHeldRefusals removes every held refusal (MoveOp set) whose move |
| +// live says is over, and returns the hosts it removed. Adoption refusals |
| +// are never read past their MoveOp. With no refusal directory it reads |
| +// nothing. Errors from individual removals are joined; the rest go ahead. |
| +func RetireHeldRefusals(bakeDir string, live func(Refusal) bool) ([]string, error) { |
| + recs, err := ListRefusals(bakeDir) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var ( |
| + retired []string |
| + errs []error |
| + ) |
| + for _, r := range recs { |
| + if r.MoveOp == "" || live(r) { |
| + continue |
| + } |
| + if err := RetireHeldRefusal(bakeDir, r.Host, r.MoveOp); err != nil { |
| + errs = append(errs, fmt.Errorf("%s: %w", r.Host, err)) |
| + continue |
| + } |
| + retired = append(retired, r.Host) |
| + } |
| + return retired, errors.Join(errs...) |
| } |
| // heldProofAdmit is D3's cheap check (ProofGate.admit, unchanged) for a |
| @@ -192,6 +296,9 @@ func heldStageable(rt registry.Route, version string) error { |
| if !controlname.ValidHostShape(rt.Host) || !ValidApp(rt.App) || !ValidVersion(version) { |
| return fmt.Errorf("release: held stage of %q/%q at %q: malformed", rt.Host, rt.App, version) |
| } |
| + if rt.HoldOp == "" { |
| + return fmt.Errorf("release: held stage of %q at %q names no move", rt.Host, version) |
| + } |
| return nil |
| } |
| @@ -298,7 +405,7 @@ func serveHeldStageRequest(ctx context.Context, s Store, optDir, dataDir, reqDir |
| if err := authorize(req, nil); err != nil { |
| return drop("refused: %v", err) |
| } |
| - rt := registry.Route{Host: req.Host, Account: req.Account, App: req.App} |
| + rt := registry.Route{Host: req.Host, Account: req.Account, App: req.App, HoldOp: req.Op} |
| guard := func(publish func() error) error { return authorize(req, publish) } |
| if _, err := MaterializeHeld(ctx, s, optDir, rt, req.Version, proof, guard); err != nil { |
| if errors.Is(err, ErrStaleHeldStage) { |
diff --git a/internal/release/materializeheld_proof_test.go b/internal/release/materializeheld_proof_test.go| index 426ce872b..5b5ce10b5 100644 |
| --- a/internal/release/materializeheld_proof_test.go |
| +++ b/internal/release/materializeheld_proof_test.go |
| @@ -215,8 +215,12 @@ func TestHeldStageProofBindingRefusesOtherStagedBytes(t *testing.T) { |
| if err := StageArtifact(e.opt, "bab", "v1", "console", stale); err != nil { |
| t.Fatal(err) |
| } |
| - err, guarded, out := e.materialize(t, e.store) |
| - e.refusedUntouched(t, err, guarded) |
| + // bind runs inside the publication (fix round 1), so the guard is asked |
| + // and the refusal comes from within it. |
| + err, _, out := e.materialize(t, e.store) |
| + if !errors.Is(err, ErrHeldNotProven) || e.live() != "v0" { |
| + t.Fatalf("MaterializeHeld: %v, live %q; want ErrHeldNotProven and v0", err, e.live()) |
| + } |
| if !strings.Contains(out, "not pointed live") { |
| t.Fatalf("printed %q, want the binding refusal", out) |
| } |
| @@ -294,3 +298,166 @@ func TestServeHeldStageRequestsAppliesTheProofGate(t *testing.T) { |
| t.Fatalf("live = %q, want the proven v1", e.live()) |
| } |
| } |
| + |
| +// materializeWith is materialize with the caller's guard. |
| +func (e *heldProofEnv) materializeWith(t *testing.T, guard func(publish func() error) error) (error, string) { |
| + t.Helper() |
| + var ( |
| + err error |
| + out bytes.Buffer |
| + ) |
| + withCapturedStdout(t, &out, func() { |
| + _, err = MaterializeHeld(e.ctx, e.store, e.opt, heldRoute(), "v1", e.gate, guard) |
| + }) |
| + return err, out.String() |
| +} |
| + |
| +// Fix round 1, finding 1: the authority's re-check (bucket reads, the |
| +// registry freeze) runs between staging and publication, and the staged |
| +// bytes can be replaced meanwhile. bind runs inside the publication, after |
| +// the re-check, so bytes replaced while the guard ran are refused. |
| +func TestHeldStageProofBindsInsideThePublication(t *testing.T) { |
| + e := newHeldProofEnv(t) |
| + e.require(t, "console") |
| + e.prove(t, e.pinnedSHA) |
| + stale := []byte("replaced while the guard re-checked") |
| + err, _ := e.materializeWith(t, func(publish func() error) error { |
| + if err := StageArtifact(e.opt, "bab", "v1", "console", stale); err != nil { |
| + t.Fatal(err) |
| + } |
| + return publish() |
| + }) |
| + if !errors.Is(err, ErrHeldNotProven) || e.live() != "v0" { |
| + t.Fatalf("MaterializeHeld: %v, live %q; want ErrHeldNotProven and v0", err, e.live()) |
| + } |
| +} |
| + |
| +// Fix round 1, finding 1: bytes replaced between bind and the link swap |
| +// are caught by a re-hash at the swap, and the link goes back to what it |
| +// was (or away, when there was nothing). |
| +func TestHeldStageProofRehashesAtTheSwap(t *testing.T) { |
| + for name, hadLive := range map[string]bool{"was on v0": true, "had no link": false} { |
| + t.Run(name, func(t *testing.T) { |
| + e := newHeldProofEnv(t) |
| + if !hadLive { |
| + if err := os.Remove(LiveLink(e.opt, heldTestHost)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + e.require(t, "console") |
| + e.prove(t, e.pinnedSHA) |
| + prev := heldBeforeSwap |
| + t.Cleanup(func() { heldBeforeSwap = prev }) |
| + swapped := false |
| + heldBeforeSwap = func() { |
| + swapped = true |
| + if err := StageArtifact(e.opt, "bab", "v1", "console", []byte("replaced after bind")); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + err, guarded, _ := e.materialize(t, e.store) |
| + if !swapped || !guarded { |
| + t.Fatalf("the seam ran %v, the guard ran %v", swapped, guarded) |
| + } |
| + want := "v0" |
| + if !hadLive { |
| + want = "" |
| + } |
| + if !errors.Is(err, ErrHeldNotProven) || e.live() != want { |
| + t.Fatalf("MaterializeHeld: %v, live %q; want ErrHeldNotProven and %q", err, e.live(), want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Fix round 1, finding 2: a held binding refusal carries its move's op. |
| +func TestHeldStageProofRefusalCarriesTheMove(t *testing.T) { |
| + e := newHeldProofEnv(t) |
| + e.require(t, "console") |
| + e.prove(t, e.pinnedSHA) |
| + if err := StageArtifact(e.opt, "bab", "v1", "console", []byte("not the shipped bytes")); err != nil { |
| + t.Fatal(err) |
| + } |
| + e.materialize(t, e.store) |
| + r, _ := ReadRefusal(e.bake, heldTestHost) |
| + if r == nil || r.MoveOp != heldTestOp || r.Kind != RefusalNotProven { |
| + t.Fatalf("refusal = %+v, want a not-proven record naming op %s", r, heldTestOp) |
| + } |
| +} |
| + |
| +// Fix round 1, finding 2: an adoption refusal already standing for the host |
| +// is never overwritten by a held one. |
| +func TestHeldStageProofLeavesAnAdoptionRefusal(t *testing.T) { |
| + e := newHeldProofEnv(t) |
| + e.require(t, "console") |
| + e.prove(t, e.pinnedSHA) |
| + adoption := Refusal{Host: heldTestHost, App: "console", Version: "v0", SHA256: "aa", Reason: "did not come up", |
| + PointerPromotedAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), RefusedAt: time.Date(2026, 10, 1, 0, 1, 0, 0, time.UTC)} |
| + if err := WriteRefusal(e.bake, adoption); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, _ := os.ReadFile(filepath.Join(RefusalDir(e.bake), heldTestHost+".json")) |
| + if err := StageArtifact(e.opt, "bab", "v1", "console", []byte("not the shipped bytes")); err != nil { |
| + t.Fatal(err) |
| + } |
| + err, _, _ := e.materialize(t, e.store) |
| + if !errors.Is(err, ErrHeldNotProven) { |
| + t.Fatalf("MaterializeHeld: %v, want ErrHeldNotProven", err) |
| + } |
| + after, _ := os.ReadFile(filepath.Join(RefusalDir(e.bake), heldTestHost+".json")) |
| + if !bytes.Equal(before, after) { |
| + t.Fatalf("the adoption refusal changed:\n%s\nwant\n%s", after, before) |
| + } |
| +} |
| + |
| +// Fix round 1, finding 2: a later held stage of the same move that |
| +// succeeds retires that move's refusal. |
| +func TestHeldStageProofSuccessRetiresTheMovesRefusal(t *testing.T) { |
| + e := newHeldProofEnv(t) |
| + e.require(t, "console") |
| + e.prove(t, e.pinnedSHA) |
| + if err := StageArtifact(e.opt, "bab", "v1", "console", []byte("not the shipped bytes")); err != nil { |
| + t.Fatal(err) |
| + } |
| + e.materialize(t, e.store) |
| + if r, _ := ReadRefusal(e.bake, heldTestHost); r == nil { |
| + t.Fatal("no refusal to retire") |
| + } |
| + if err := StageArtifact(e.opt, "bab", "v1", "console", []byte("pinned build")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err, _, _ := e.materialize(t, e.store); err != nil || e.live() != "v1" { |
| + t.Fatalf("retry: %v, live %q", err, e.live()) |
| + } |
| + if r, _ := ReadRefusal(e.bake, heldTestHost); r != nil { |
| + t.Fatalf("the move's refusal outlived its successful retry: %+v", r) |
| + } |
| +} |
| + |
| +// RetireHeldRefusals removes a held refusal whose move is over and nothing |
| +// else: not a live move's, never an adoption refusal. |
| +func TestRetireHeldRefusals(t *testing.T) { |
| + bake := t.TempDir() |
| + at := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) |
| + for _, r := range []Refusal{ |
| + {Host: "live.example.com", App: "a", Version: "v1", RefusedAt: at, Kind: RefusalNotProven, MoveOp: "mv_live"}, |
| + {Host: "over.example.com", App: "a", Version: "v1", RefusedAt: at, Kind: RefusalNotProven, MoveOp: "mv_over"}, |
| + {Host: "adopted.example.com", App: "a", Version: "v1", RefusedAt: at, Kind: RefusalNotProven}, |
| + } { |
| + if err := WriteRefusal(bake, r); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + retired, err := RetireHeldRefusals(bake, func(r Refusal) bool { return r.MoveOp == "mv_live" }) |
| + if err != nil || len(retired) != 1 || retired[0] != "over.example.com" { |
| + t.Fatalf("retired %v, %v; want over.example.com", retired, err) |
| + } |
| + left, _ := ListRefusals(bake) |
| + if len(left) != 2 || left[0].Host != "adopted.example.com" || left[1].Host != "live.example.com" { |
| + t.Fatalf("left %+v", left) |
| + } |
| + // With no refusal directory it reads nothing and fails nothing. |
| + if retired, err := RetireHeldRefusals(t.TempDir(), func(Refusal) bool { return false }); err != nil || len(retired) != 0 { |
| + t.Fatalf("empty: %v, %v", retired, err) |
| + } |
| +} |
diff --git a/internal/release/proofgate.go b/internal/release/proofgate.go| index 2245770c4..470b411ef 100644 |
| --- a/internal/release/proofgate.go |
| +++ b/internal/release/proofgate.go |
| @@ -323,6 +323,13 @@ func (g *ProofGate) manifestSHA(ctx context.Context, s Store, optDir, acct, app |
| // 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 |
| } |
| @@ -344,7 +351,7 @@ func (g *ProofGate) bind(optDir string, rt registry.Route, ptr *ChannelPointer, |
| } |
| 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 := WriteRefusal(g.bakeDir, Refusal{Host: rt.Host, App: rt.App, Version: ptr.Version, SHA256: got, |
| + 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 { |
diff --git a/internal/release/refusal.go b/internal/release/refusal.go| index 2dc4691b8..87c06b394 100644 |
| --- a/internal/release/refusal.go |
| +++ b/internal/release/refusal.go |
| @@ -66,6 +66,12 @@ type Refusal struct { |
| RestoredVersion string `json:"restored_version,omitempty"` |
| Recovered bool `json:"recovered"` |
| RecoveryError string `json:"recovery_error,omitempty"` |
| + // MoveOp is set only on a move's held-stage refusal (materializeheld.go, |
| + // ruling D3-held): the op of the move whose held stage was refused. It |
| + // scopes the record to that move, which retires it (a later held stage |
| + // of the move that succeeds, or the move ending), and it is never set on |
| + // an adoption refusal, so adoption's records are byte-identical. |
| + MoveOp string `json:"move_op,omitempty"` |
| } |
| func RefusalDir(bakeDir string) string { return filepath.Join(bakeDir, "refusals") } |