carlos / platform Public

moves: park.Local always asks the claim's local authorizer (M9, Q10)

The install's and the rollback's park.Local authorizers fell back to their
own rule alone when ParkLocalAuthorize was nil, the two named exceptions
kept until claim Plan 12. Plan 12's MoveParks local authorizer has landed,
so both fallbacks go. One helper, movesLocalAuthorize, now builds every
park.Local authorizer a moves arm uses (install, rollback, finalization):
the arm's own check, then the claim's; with no claim authorizer it refuses.

landingArmed and rollbackTargetArmed now require ParkLocalAuthorize, as
sourceFinalArmed already did; recoverySourceArmed inherits it through
landingArmed. armMovesParks sets it from MoveParks only once the
wake-source gate opens, so production stays unarmed.

No B8 census unit changed.

Test: TestNoArmRunsParkLocalWithoutTheClaimsLocalAuthorizer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev ca2732660c84d8803b930b9a68fff742dd36e818 parent d98a345
6 files changed, +111 −61
  • internal/host/movesfinal.go +7 −8
  • internal/host/movesinstall.go +4 −15
  • internal/host/movesland.go +6 −5
  • internal/host/moveslandpark.go +24 −12
  • internal/host/movesrollback.go +14 −21
  • internal/host/movessources_test.go +56 −0
diff --git a/internal/host/movesfinal.go b/internal/host/movesfinal.go
index 0689533fe..b357e43cc 100644
--- a/internal/host/movesfinal.go
+++ b/internal/host/movesfinal.go
@@ -491,20 +491,19 @@ func (f *sourceFinal) installation(ctx context.Context) (string, error) {
local.Box = cfg.Box
}
if local.Authorize == nil {
- // park.Local asks claim Plan 12's local authorizer (M9), behind
- // the finalization's own check over the claim it read under the
- // guard. The claim's engine authorizer refuses a park-local, and
- // nothing stands in: sourceFinalArmed requires it.
- authorize := cfg.ParkLocalAuthorize
- local.Authorize = func(ctx context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error {
+ // park.Local asks the claim's MoveParks local authorizer (M9),
+ // behind the finalization's own check over the claim it read
+ // under the guard. The claim's engine authorizer refuses a
+ // park-local, and nothing stands in: sourceFinalArmed requires it.
+ local.Authorize = movesLocalAuthorize(cfg.ParkLocalAuthorize, func(_ context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error {
if err := f.sourceOf(hostclaim.Observed{Host: host, Claim: c}); err != nil {
return err
}
if o.Op != id || o.Kind != "park-local" || o.Box != cfg.Box {
return fmt.Errorf("%w: not this finalization's local chore", park.ErrUnsafe)
}
- return authorize(ctx, c, o)
- }
+ return nil
+ })
}
if err := local.RetireInstallation(ctx, host, hostguard.Captured{Incarnation: captured.Incarnation, Stamp: stamp}, chore.ID, id); err != nil {
return "", fmt.Errorf("retire the source's installation: %w", err)
diff --git a/internal/host/movesinstall.go b/internal/host/movesinstall.go
index 2280f5071..320517610 100644
--- a/internal/host/movesinstall.go
+++ b/internal/host/movesinstall.go
@@ -222,22 +222,11 @@ func newInstallRun(s release.Store, w *movesWorker, rec provision.Record, src in
ir.local.Box = cfg.Box
}
if ir.local.Authorize == nil {
- // park.Local's authorizer: claim Plan 12's local authorizer
+ // park.Local's authorizer: the claim's MoveParks local authorizer
// (ParkLocalAuthorize) behind this install's own check; the claim's
- // engine authorizer refuses a park-local (M9). Until claim Plan 12
- // lands this install's own rule stands alone: a named exception, the
- // first of the two. claim Plan 12 F: drop the fallback once
- // ParkLocalAuthorize is always wired.
- plan12 := cfg.ParkLocalAuthorize
- ir.local.Authorize = func(ctx context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error {
- if err := ir.authorizeChore(ctx, c, o); err != nil {
- return err
- }
- if plan12 != nil {
- return plan12(ctx, c, o)
- }
- return nil
- }
+ // engine authorizer refuses a park-local (M9). Without it every local
+ // chore refuses (landingArmed requires it).
+ ir.local.Authorize = movesLocalAuthorize(cfg.ParkLocalAuthorize, ir.authorizeChore)
}
return ir
}
diff --git a/internal/host/movesland.go b/internal/host/movesland.go
index c9ec9548b..6a54e44be 100644
--- a/internal/host/movesland.go
+++ b/internal/host/movesland.go
@@ -140,12 +140,13 @@ func parkHooksArmed(cfg moveCfg) bool {
}
// landingArmed reports whether this box can run the landing: every hook
-// the phases call is wired, the claim's three park hooks and claim Plan 12's
-// RunHeld (M5: the residue parks run under the landing's own lease) among
-// them. In production armMovesParks sets those only with claim Plan 12, so
-// this answers false there until it lands.
+// the phases call is wired, the claim's three park hooks, RunHeld (M5: the
+// residue parks run under the landing's own lease) and the claim's local
+// authorizer (M9: the install's park.Local) among them. In production
+// armMovesParks sets those only once the wake-source gate opens
+// (movesWakeSourceRegistered, Plan 5 Task 9b), so this answers false there.
func landingArmed(cfg moveCfg) bool {
- return cfg.Box != "" && cfg.Guard.DataDir != "" && cfg.Local.DataDir != "" && parkHooksArmed(cfg) && cfg.ParkRunHeld != nil &&
+ return cfg.Box != "" && cfg.Guard.DataDir != "" && cfg.Local.DataDir != "" && parkHooksArmed(cfg) && cfg.ParkRunHeld != nil && cfg.ParkLocalAuthorize != nil &&
cfg.OwnLease != nil && cfg.RenderHeld != nil && cfg.ReplaceTimers != nil && cfg.SetReplication != nil &&
cfg.SetNextDue != nil && cfg.RemoveVerifyEntry != nil && cfg.WriteGen != nil && cfg.Route != nil &&
cfg.FilesDir != nil && cfg.Versioned != nil && cfg.ReleaseReady != nil && cfg.SecretsPriv != nil
diff --git a/internal/host/moveslandpark.go b/internal/host/moveslandpark.go
index 0c6890a52..ffa876ba0 100644
--- a/internal/host/moveslandpark.go
+++ b/internal/host/moveslandpark.go
@@ -22,13 +22,12 @@ import (
// the own-location lease the build holds from checking through published.
// The phases are in movesinstall.go, the landing's pass in movesland.go.
//
-// Nothing stands in for the claim's view or authorizer (Plan 5 Task 9a): an
-// arm without ParkView and ParkAuthorize is not armed, and the tests inject
-// fakes. Each arm keeps its own operation check around the claim's
-// authorizer (M17, movesArmAuthorize). The one exception is park.Local's
-// authorizer for the install's own local chores (M9): until claim Plan 12's
-// local authorizer is wired as ParkLocalAuthorize, the install keeps its own
-// rule (installRun.authorizeChore).
+// Nothing stands in for the claim's view or authorizers (Plan 5 Task 9a,
+// Q10): an arm without ParkView and ParkAuthorize is not armed, nor one that
+// runs a park.Local without ParkLocalAuthorize, and the tests inject fakes.
+// Each arm keeps its own operation check in front of the claim's authorizer
+// (M17): movesArmAuthorize for the engine's chores, movesLocalAuthorize for
+// park.Local's (M9).
// movesArmAuthorize is an arm's park authorizer (M17): the arm's own
// operation check, then the claim's authorizer, ParkAuthorize from the
@@ -45,6 +44,22 @@ func movesArmAuthorize(claim, own func(context.Context, hostclaim.Observed, host
}
}
+// movesLocalAuthorize is an arm's park.Local authorizer (M9): the arm's own
+// operation check, then the claim's MoveParks local authorizer
+// (ParkLocalAuthorize). A missing claim authorizer refuses; the arm's own
+// rule never stands alone.
+func movesLocalAuthorize(claim, own func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error) func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error {
+ return func(ctx context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error {
+ if claim == nil || own == nil {
+ return fmt.Errorf("%w: no local park authorizer", park.ErrUnsafe)
+ }
+ if err := own(ctx, c, o); err != nil {
+ return err
+ }
+ return claim(ctx, c, o)
+ }
+}
+
// authorizeMoveChore is an install's own operation check: a chore is run
// only when it is the op's own and the claim still names the op, at the
// install's kind, with this box executing.
@@ -55,11 +70,8 @@ func authorizeMoveChore(c *hostclaim.Claim, o hostclaim.Obligation, opID, kind,
return nil
}
-// authorizeChore is park.Local.Authorize for this install's own local
-// chores while claim Plan 12's local authorizer is missing: a named
-// exception (M9), the only park stand-in left in production.
-// claim Plan 12 F: replace it with ParkLocalAuthorize once the MoveParks
-// capability carries the local authorizer.
+// authorizeChore is the install's own check for its local chores, which
+// park.Local's authorizer runs before the claim's (movesLocalAuthorize, M9).
func (ir *installRun) authorizeChore(_ context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error {
return authorizeMoveChore(c, o, ir.g.ID, ir.src.kind(), ir.cfg().Box)
}
diff --git a/internal/host/movesrollback.go b/internal/host/movesrollback.go
index 2093e8405..ed7bf3121 100644
--- a/internal/host/movesrollback.go
+++ b/internal/host/movesrollback.go
@@ -209,11 +209,13 @@ func (w *movesWorker) rollbackClaim(ctx context.Context, s release.Store, rec pr
}
// rollbackTargetArmed reports whether this box can run the target's half of
-// a rollback: every hook it calls is wired, the claim's three park hooks
-// included. In production armMovesParks sets them only once claim Plan 12
-// lands, so this answers false there (J18).
+// a rollback: every hook it calls is wired, the claim's three park hooks and
+// its local authorizer (M9: the installation's park.Local) included. In
+// production armMovesParks sets them only once the wake-source gate opens
+// (movesWakeSourceRegistered, Plan 5 Task 9b), so this answers false there
+// (J18).
func rollbackTargetArmed(cfg moveCfg) bool {
- return cfg.Box != "" && cfg.DataDir != "" && cfg.Guard.DataDir != "" && cfg.Local.DataDir != "" && parkHooksArmed(cfg) &&
+ return cfg.Box != "" && cfg.DataDir != "" && cfg.Guard.DataDir != "" && cfg.Local.DataDir != "" && parkHooksArmed(cfg) && cfg.ParkLocalAuthorize != nil &&
cfg.HandHold != nil && cfg.Fence != nil && cfg.TimersDrained != nil && cfg.Evidence != nil && cfg.FinalCheckpoint != nil &&
cfg.LocalPosition != nil && cfg.Timers != nil && cfg.FilesDir != nil && cfg.Route != nil && cfg.Versioned != nil &&
cfg.StagingLease != nil && cfg.Staging != nil && cfg.DeleteHeld != nil && cfg.SecretsPub != nil
@@ -1165,8 +1167,8 @@ func (r *rollbackRun) engine(vs lease.VersionedStore) park.Engine {
// authorizeChore is this rollback's own operation check (M17): a chore is
// run only when it is this op's, the claim is still this rollback's with this
// box no longer executing, and the chore is this region's or this box's. The
-// engine runs it before the claim's authorizer; park.Local runs it alone
-// while claim Plan 12's local authorizer is missing (installation).
+// engine runs it before the claim's authorizer, and park.Local before the
+// claim's local authorizer (installation, movesLocalAuthorize).
func (r *rollbackRun) authorizeChore(c *hostclaim.Claim, o hostclaim.Obligation) error {
cfg := r.cfg()
if r.ours(c) != nil || o.Op != r.g.ID || c.Op.Exec == cfg.Box {
@@ -1243,23 +1245,14 @@ func (r *rollbackRun) installation(ctx context.Context, ours bool) (string, erro
local.Box = cfg.Box
}
if local.Authorize == nil {
- // park.Local's authorizer: claim Plan 12's local authorizer
+ // park.Local's authorizer: the claim's MoveParks local authorizer
// (ParkLocalAuthorize) behind this rollback's own check. The
// claim's engine authorizer refuses a park-local (M9), so it is
- // never asked here. Until claim Plan 12 lands this rollback's own
- // rule stands alone: a named exception, the second of the two.
- // claim Plan 12 F: drop the fallback once ParkLocalAuthorize is
- // always wired.
- plan12 := cfg.ParkLocalAuthorize
- local.Authorize = func(ctx context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error {
- if err := r.authorizeChore(c, o); err != nil {
- return err
- }
- if plan12 != nil {
- return plan12(ctx, c, o)
- }
- return nil
- }
+ // never asked here. Without it the chore refuses
+ // (rollbackTargetArmed requires it).
+ local.Authorize = movesLocalAuthorize(cfg.ParkLocalAuthorize, func(_ context.Context, c *hostclaim.Claim, o hostclaim.Obligation) error {
+ return r.authorizeChore(c, o)
+ })
}
if err := r.fault("before-rb-local-retire"); err != nil {
return "", err
diff --git a/internal/host/movessources_test.go b/internal/host/movessources_test.go
index 5f2de290a..1813d7b2d 100644
--- a/internal/host/movessources_test.go
+++ b/internal/host/movessources_test.go
@@ -965,3 +965,59 @@ func TestNoArmRunsWithoutTheThreeParkHooks(t *testing.T) {
})
}
}
+
+// TestNoArmRunsParkLocalWithoutTheClaimsLocalAuthorizer (M9, Q10): every arm
+// that runs a park.Local (the landing's install, the target's half of a
+// rollback, the source's recovery through the install, the source's
+// finalization) requires the claim's MoveParks local authorizer, and the
+// park.Local authorizer an install builds asks its own check and then the
+// claim's: there is no fallback to its own rule alone.
+func TestNoArmRunsParkLocalWithoutTheClaimsLocalAuthorizer(t *testing.T) {
+ arms := []struct {
+ name string
+ cfg func(t *testing.T) moveCfg
+ armed func(moveCfg) bool
+ }{
+ {"landing", func(t *testing.T) moveCfg { return newLandingHarness(t).lcfg }, landingArmed},
+ {"rollback target", func(t *testing.T) moveCfg { h := newRollbackHarness(t); return h.rollbackCfg(t) }, rollbackTargetArmed},
+ {"recovery", func(t *testing.T) moveCfg { h := newRecoverHarness(t, true); return h.sourceCfg(t) }, recoverySourceArmed},
+ {"source finalization", func(t *testing.T) moveCfg { return newFinalHarness(t).cfg }, sourceFinalArmed},
+ }
+ for _, a := range arms {
+ t.Run(a.name, func(t *testing.T) {
+ c := a.cfg(t)
+ if !a.armed(c) {
+ t.Fatal("fixture: the arm is not armed with every hook")
+ }
+ c.ParkLocalAuthorize = nil
+ if a.armed(c) {
+ t.Fatal("armed without the claim's local authorizer")
+ }
+ })
+ }
+ t.Run("the install's park.Local authorizer", func(t *testing.T) {
+ h := newLandingHarness(t)
+ ctx := t.Context()
+ build := func(local func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error) *installRun {
+ c := h.lcfg
+ c.ParkLocalAuthorize = local
+ rec := provision.Record{Host: p1Host, Move: &provision.MoveState{ID: p1ID, Incarnation: h.inc}}
+ return newInstallRun(h.s, &movesWorker{cfg: c}, rec, installSource{OpID: p1ID, Host: p1Host, Role: roleTarget})
+ }
+ box := h.lcfg.Box
+ ours := &hostclaim.Claim{Host: p1Host, Op: hostclaim.Op{ID: p1ID, Kind: hostclaim.KindLanding, Exec: box}}
+ chore := hostclaim.Obligation{ID: "local", Op: p1ID, Kind: "park-local", Box: box}
+ if err := build(nil).local.Authorize(ctx, ours, chore); err == nil {
+ t.Fatal("the install's own rule stood alone without the claim's local authorizer")
+ }
+ asked := 0
+ claim := func(context.Context, *hostclaim.Claim, hostclaim.Obligation) error { asked++; return nil }
+ if err := build(claim).local.Authorize(ctx, ours, chore); err != nil || asked != 1 {
+ t.Fatalf("the claim's local authorizer was not asked: %v (%d)", err, asked)
+ }
+ other := &hostclaim.Claim{Host: p1Host, Op: hostclaim.Op{ID: "mv_other", Kind: hostclaim.KindLanding, Exec: box}}
+ if err := build(claim).local.Authorize(ctx, other, chore); err == nil || asked != 1 {
+ t.Fatalf("the install's own check did not run first: %v (%d)", err, asked)
+ }
+ })
+}