host: root refuses publication at a revert's probe and a clean return's probe (Task 7b fix round 2, P2-1)
Neither row journals a render under a rule root checks, and the journal is agent-written, so a missing journal was the only refusal and a compromised agent could plant one. publishVersion now refuses KindReverting outright and a restoring probe when the return is not dirty, until those probes journal their own render (RenderHeldUnderBefore at an equivalent point) under a rule root checks. Regression with a planted journal for each row; the round-1 test now expects root's refusal (the agent's adapter still stops at not-yet).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+77
−16
internal/host/movesheldrelease_agent_test.go+8 −5internal/host/movesheldrelease_root.go+15 −11internal/host/movesheldrelease_test.go+54 −0
diff --git a/internal/host/movesheldrelease_agent_test.go b/internal/host/movesheldrelease_agent_test.go| index da744acb3..96795b7bf 100644 |
| --- a/internal/host/movesheldrelease_agent_test.go |
| +++ b/internal/host/movesheldrelease_agent_test.go |
| @@ -325,8 +325,8 @@ func TestHeldReleaseAgentVerifiesRootsStage(t *testing.T) { |
| // run configured, so nothing journals a render hash under their hold op. |
| // With the recovery release missing from live/<host>, each fails closed: the |
| // probe's release adapter gets a typed not-yet and asks root nothing, root |
| -// asked directly answers not-yet before any selection, and nothing is |
| -// published. A known limitation: such a probe would need to journal its own |
| +// asked directly refuses the row outright (fix round 2, P2-1), and nothing |
| +// is published. A known limitation: such a probe would need to journal its own |
| // render hash through RenderHeldUnderBefore at an equivalent point. |
| func TestHeldReleaseRecoveryProbesFailClosed(t *testing.T) { |
| ctx := context.Background() |
| @@ -372,13 +372,16 @@ func TestHeldReleaseRecoveryProbesFailClosed(t *testing.T) { |
| if len(seen) != 0 { |
| t.Fatalf("root was asked %+v", seen) |
| } |
| - // Root asked directly, as a request carrying a render hash would. |
| + // Root asked directly, as a request carrying a render hash would: |
| + // since fix round 2 (P2-1) root refuses these rows outright, |
| + // before any journal read (TestHeldReleaseRootRefusesUnjournalledRecoveryProbes |
| + // covers a planted journal). |
| q := f.request(heldReleasePublish) |
| if q.HoldOp != tc.hold { |
| t.Fatalf("request hold op %q, want %q", q.HoldOp, tc.hold) |
| } |
| - if _, err := f.root.Serve(ctx, hrPeer, q); !errors.Is(err, errHeldReleaseNotYet) { |
| - t.Fatalf("root: %v, want errHeldReleaseNotYet", err) |
| + if _, err := f.root.Serve(ctx, hrPeer, q); !errors.Is(err, release.ErrStaleHeldStage) { |
| + t.Fatalf("root: %v, want a refusal", err) |
| } |
| f.untouched(t) |
| }) |
diff --git a/internal/host/movesheldrelease_root.go b/internal/host/movesheldrelease_root.go| index 508d22b6c..82932d408 100644 |
| --- a/internal/host/movesheldrelease_root.go |
| +++ b/internal/host/movesheldrelease_root.go |
| @@ -316,10 +316,17 @@ func heldReturnRelease(m *provision.MoveState) string { |
| // kind and phase, and "" with the reason when the claim grants none: a |
| // landing's configured or probing step, on this box as the executor, with |
| // the record at that landing phase (the pinned release); a dirty return's |
| -// configured step (claimed) or a return's probe (its recovery release, the |
| -// pin at configured when none is recorded yet); a revert's probe (its |
| -// recovery release). Anything else, a moving op included, publishes |
| -// nothing: the policy is never widened. |
| +// configured step (claimed) or its probe (its recovery release, the pin at |
| +// configured when none is recorded yet). Anything else, a moving op |
| +// included, publishes nothing: the policy is never widened. |
| +// |
| +// A revert's probe and a clean return's probe are refused outright (fix |
| +// round 2, P2-1, platform-1e's authority review). Neither runs configured, |
| +// so no render is journalled under a rule root checks, and the journal is |
| +// agent-written: its absence cannot be what refuses them, since a |
| +// compromised agent could plant one. They stay refused until those probes |
| +// journal their own render (RenderHeldUnderBefore at an equivalent point) |
| +// under a rule root checks. |
| func publishVersion(c *hostclaim.Claim, m *provision.MoveState, box string) (string, error) { |
| if c.Op.Exec != box { |
| return "", staleHeldStage("this box is not the executor") |
| @@ -341,6 +348,9 @@ func publishVersion(c *hostclaim.Claim, m *provision.MoveState, box string) (str |
| switch { |
| case c.Op.Phase == hostclaim.PhaseClaimed && rb.SourcePhase == provision.RbSourceConfigured && rb.Dirty: |
| case c.Op.Phase == hostclaim.PhaseProbing && rb.SourcePhase == provision.RbSourceProbing: |
| + if !rb.Dirty { |
| + return "", staleHeldStage("a clean return's probe journals no render root checks, so root publishes nothing for it") |
| + } |
| if rb.RecoveryInputs == nil || rb.RecoveryInputs.Release == "" { |
| return "", staleHeldStage("no recorded recovery release") |
| } |
| @@ -352,13 +362,7 @@ func publishVersion(c *hostclaim.Claim, m *provision.MoveState, box string) (str |
| } |
| return "", staleHeldStage("no recorded recovery release") |
| case hostclaim.KindReverting: |
| - if c.Op.Phase != hostclaim.PhaseProbing || c.Op.Journal[hostclaim.JournalRevPhase] != hostclaim.PhaseProbing || m.State != provision.MoveReverting { |
| - return "", staleHeldStage("a revert at %q publishes nothing", c.Op.Phase) |
| - } |
| - if m.RecoveryInputs == nil || m.RecoveryInputs.Release == "" { |
| - return "", staleHeldStage("no recorded recovery release") |
| - } |
| - return m.RecoveryInputs.Release, nil |
| + return "", staleHeldStage("a revert's probe journals no render root checks, so root publishes nothing for it") |
| } |
| return "", staleHeldStage("a %s op publishes nothing", c.Op.Kind) |
| } |
diff --git a/internal/host/movesheldrelease_test.go b/internal/host/movesheldrelease_test.go| index b74852277..49cad8df6 100644 |
| --- a/internal/host/movesheldrelease_test.go |
| +++ b/internal/host/movesheldrelease_test.go |
| @@ -727,3 +727,57 @@ func TestHeldReleaseRouteIsMovesOnly(t *testing.T) { |
| t.Fatalf("moves off wired %#v", got) |
| } |
| } |
| + |
| +// TestHeldReleaseRootRefusesUnjournalledRecoveryProbes (fix round 2, P2-1, |
| +// platform-1e's authority review): a revert's probe and a clean return's |
| +// probe journal no render under any rule root checks, so their absent |
| +// journal cannot be what refuses them: the journal is agent-written. Root |
| +// refuses both rows outright, even with a planted journal and a matching |
| +// request, and publishes nothing. A dirty return's probe, whose configured |
| +// step journalled its render, still publishes. |
| +func TestHeldReleaseRootRefusesUnjournalledRecoveryProbes(t *testing.T) { |
| + ctx := context.Background() |
| + recovery := func(f *hrFixture) { |
| + f.claim.Op.Exec, f.claim.Op.Participants = "uk-box", []string{"uk-box", "ie-box"} |
| + f.claim.Op.Intent = map[string]string{"source": "uk-box", "target": "ie-box", "to": "ie"} |
| + f.claim.Op.Phase = hostclaim.PhaseProbing |
| + f.rec.Move.From, f.rec.Move.To, f.rec.Move.LandingPhase = "uk", "ie", "" |
| + } |
| + for _, tc := range []struct { |
| + name string |
| + set func(f *hrFixture) |
| + hold string |
| + }{ |
| + {"a revert's probe", func(f *hrFixture) { |
| + recovery(f) |
| + f.claim.Op.Kind = hostclaim.KindReverting |
| + f.claim.Op.Journal = map[string]string{hostclaim.JournalRevPhase: hostclaim.PhaseProbing} |
| + f.rec.Move.State = provision.MoveReverting |
| + f.rec.Move.RecoveryInputs = &provision.MoveInputs{Release: "v7"} |
| + }, p1ID}, |
| + {"a clean return's probe", func(f *hrFixture) { |
| + recovery(f) |
| + f.claim.Op.Kind = hostclaim.KindRestoring |
| + f.claim.Op.Intent["rollback"] = hrRB |
| + f.rec.Move.State = provision.MoveRollingBack |
| + f.rec.Move.Rollback = &provision.MoveRollback{ID: hrRB, SourcePhase: provision.RbSourceProbing, RecoveryInputs: &provision.MoveInputs{Release: "v7"}} |
| + f.row.HoldOp = hrRB |
| + }, hrRB}, |
| + } { |
| + t.Run(tc.name+" with a planted journal", func(t *testing.T) { |
| + f := newHRFixture(t) |
| + f.set(tc.set) |
| + // A compromised agent plants the render it then sends. |
| + f.journal(t, tc.hold, hrRendered) |
| + q := f.request(heldReleasePublish) |
| + if q.HoldOp != tc.hold { |
| + t.Fatalf("request hold op %q, want %q", q.HoldOp, tc.hold) |
| + } |
| + _, err := f.root.Serve(ctx, hrPeer, q) |
| + if !errors.Is(err, release.ErrStaleHeldStage) { |
| + t.Fatalf("publish: %v, want a refusal", err) |
| + } |
| + f.untouched(t) |
| + }) |
| + } |
| +} |