carlos / platform Public

settings plans: Astra I3 fixed (flag-off held-wake admission, refusing hook on build-state init failure, per-route identity, marker-gated resign, requests resolved before pins, atomic require, console clearing)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 33cb11d7dd6663ff1a2789d020d5feb0d78abd54 parent 1dc1516
4 files changed, +669 −134
  • docs/superpowers/plans/2026-10-07-instance-settings-0-index.md +48 −7
  • docs/superpowers/plans/2026-10-07-instance-settings-1-pointer-digests.md +118 −19
  • docs/superpowers/plans/2026-10-07-instance-settings-4-root-applies.md +400 −99
  • docs/superpowers/plans/2026-10-07-instance-settings-5-surfaces.md +103 −9

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-07-instance-settings-0-index.md b/docs/superpowers/plans/2026-10-07-instance-settings-0-index.md
index 9c742a60e..6ced21a3e 100644
--- a/docs/superpowers/plans/2026-10-07-instance-settings-0-index.md
+++ b/docs/superpowers/plans/2026-10-07-instance-settings-0-index.md
@@ -115,6 +115,7 @@ type DigestRatchet struct{ /* unexported */ }
func NewDigestRatchet(own string, also []string, require bool) *DigestRatchet // own: the dir this process writes; also: dirs it only reads
func SharedDigestRatchet(own string, also []string, require bool) *DigestRatchet // ONE per own dir per process (I2 #1): adoption, materialization,
// held staging and the edge's static reader all use it, so an ErrRatchetUnpersisted channel stays remembered across passes; require only widens
+ // (DigestRatchet.require is an atomic.Bool: a lookup may widen it while Admit reads it, I3 #7)
func RootRatchetDir(optDir string) string // <optDir>/pointer-digests (/opt/carlos/pointer-digests)
func EdgeRatchetDir(dataDir string) string // <dataDir>/pointer-digests (/data/pointer-digests)
func (r *DigestRatchet) Admit(acct string, p *ChannelPointer) error // nil-safe: a nil ratchet admits everything
@@ -140,6 +141,8 @@ type ResignPlan struct {
}
func (sg Signer) PlanResign(ctx context.Context, s Store, acct, app, channel string) (*ResignPlan, error)
func (sg Signer) Resign(ctx context.Context, s Store, plan *ResignPlan, actor string) error // CAS on the plan's etag
+// PlanResign and Resign both require the verified emission marker (I3 #4): absent refuses with
+// TextResignNoMarker, unverifiable with TextMarkerUnreadable.
// The straggler report:
type ChannelDigestState struct {
@@ -323,7 +326,7 @@ type TargetDecision struct {
Fail uint64 // this pass completes request Fail as failed
Detail string
}
-func Target(in TargetInput) TargetDecision
+func Target(in TargetInput) TargetDecision // request resolution and failure completion run first, before every rule, pins included (I3 #6)
func WithUpdateSettings(failed func(acct, app, build, host string, served bool) bool, resolve bool) PassOption // resolve: root only
// internal/registry
@@ -344,18 +347,28 @@ type identityBackend interface {
LiveIdentity(ctx context.Context, rt registry.Route) (release.StagedIdentity, error) // ensureLiveLink (the Materialize hook) then release.ResolveLive
StartExe(ctx context.Context, rt registry.Route, id release.StagedIdentity) (Proc, string, error) // flat spawnChildFrom; nested spawnUnitFrom gen 0 with the exe
SeedExe(ctx context.Context, rt registry.Route, id release.StagedIdentity) error // flat seedChild(exe); nested Control.SeedExe
-} // on ExecBackend, forwarded by SplitBackend
+} // on ExecBackend only; SplitBackend does not implement it (I3 #3):
+func identityFor(b Backend, rt registry.Route) identityBackend // per route; SplitBackend answers through pick, nil for a unit row
+// The hook: host buildStateHook(bs, initErr) is nil only with no replica bucket; an init failure
+// installs a refusing hook and the box reports UpdateSettings 0 (I3 #1).
// internal/instancectl (reusing zdd-3 T9's StagedExe, validateStagedExe, ensureHostExeAt):
// /start admits exe for the generation-0 run role; /seed parses and validates exe (403 on refusal);
func (c *Client) StartExe(ctx context.Context, host string, role Role, memoryMax int64, x StagedExe) (string, error) // StartSidecarExe delegates to it
func (c *Client) SeedExe(ctx context.Context, host string, memoryMax int64, x StagedExe) (SeedResponse, error)
// The claimed primary start records s.Version inside startAdmitted's beforeStart, before every
// RoleLauncher.Start attempt (I2 #3).
-// Service gains: HeldTarget func(host string) (build string, held, unknown bool);
-// RequestStage func(host, build string): the held wake check (§4.3 rule 3). The check runs
-// before zdd-5's planning, never sets e.pin, waits for root's install, and the planner's
-// failure-aware decideWake chooses what boots (I2 #4); heldTarget checks the pin before a
-// pending request and is not held when root's decision for the target is failed (I2 #7).
+type HeldWake struct {
+ Target string
+ Held, Unknown, Failed bool // Failed: root's failed decision or refusal record names Target
+ Staged *release.StagedIdentity // a retained copy of Target verified against its staged manifest
+}
+// Service gains: HeldTarget func(host string) HeldWake; RequestStage func(host, build string).
+// The wake's own admission rule (§4.3 rule 3; works with CARLOS_SWAP off, I3 #2/#5, I2-4):
+// Failed: refused with no planner; with a planner, its failure-aware decideWake decides.
+// No planner and a retained Staged copy: run it directly, recorded first (unclaimed wakes).
+// Otherwise: ask root, wait within the deadline; with a planner, it chooses the boot (I2 #4).
+// heldTarget checks the pin before a pending request (I2 #7). The check never sets e.pin.
+func (a *Service) wakeCheck(ctx context.Context, host string, direct bool) (string, *release.StagedIdentity, error)
func heldIdentityOK(target string, id release.StagedIdentity, plan WakePlan, rt registry.Route) bool
// internal/status
@@ -495,6 +508,34 @@ fix. None was rejected.
| 8 | `adoptHost` carries `sel`, the selected build, into the proof gate (`admit`, `bind`), D1, D2 and the refusal records; the real head stays for the staging digest and the canary nag | 4, T6 | `TestProofGateSeesTheSelectedBuild` |
| 9 (and I1-2) | `HeldAuthorized(ctx, s, pub, r *DigestRatchet, acct, app, version)` matches the interface and caller | 2, T7 | builds; `TestHeldStageAppliesTheDigestRatchet` |
+## Astra review I3 of the plans (2026-10-08): every finding fixed
+
+`CODEX-FINDINGS-SETTINGS-PLANS-I3.md`, REJECT: I2-4 partial, 8 new. Each is
+fixed with a test that fails without the fix. None was rejected.
+
+**Structural:** settings work with `CARLOS_SWAP` off (spec rev 11 §4.2),
+where zdd-5's `Planner` is nil. The held wake therefore has its own small
+admission rule (plan 4 Task 7): a target root reports failed is never run; a
+retained, verified staged copy of the target runs directly, recorded first;
+otherwise the wake asks root and waits; when a planner exists, the wake defers
+to it.
+
+| # | Fix | Plan, task | Test |
+|---|---|---|---|
+| I2-4, 2 | With no planner, a target root reports failed (its `failed` decision, or the box's refusal record) refuses the wake (`errTargetFailed`), even at the live link; with a planner, its failure-aware decision chooses | 4, T7 | `TestWithNoPlannerAFailedTargetIsRefusedAtTheLiveLink`, `TestWithAPlannerAFailedTargetDefersToIt`, `TestARootFailedTargetIsReportedFailed` |
+| 1 | A build-state init failure installs a refusing `BeforeExec` (`buildStateHook`), never the nil hook; the box reports settings protocol 0 | 4, T4, T5, T10 | `TestAFailedBuildStateStartRefusesEveryExec`, `TestARefusingHookStartsNothing`, the extended `boxSettingsMetrics` test |
+| 3 | Identity capability per route (`identityFor`, `SplitBackend.identityFor` through `pick`); `SplitBackend` no longer forwards the identity methods | 4, T5 | `TestAUnitRouteOnASplitBackendKeepsTodaysWake` (production-shaped `New` over a `SplitBackend`) |
+| 4 | `PlanResign` and `Resign` require the verified emission marker (new draft `digests.resign-no-marker`, reviewed in plan 1 Task 1) | 1, T1, T5, T10, T11, T12, T13 (fixtures turn the marker on) | `TestResignRequiresTheVerifiedMarker` (absent, removed between plan and sign, foreign) |
+| 5 | With no planner, a retained copy of the target whose bytes match its staged manifest runs directly through `wakeIdentity`, recorded first; no repoint needed | 4, T7 | `TestWithNoPlannerARetainedCopyRunsWithoutARepoint`, `TestARetainedCopyIsOfferedOnlyWhenVerified` |
+| 6 | `Target` resolves pending requests and completes failed ones before every rule, pins included, and carries both through the pin decision; an agent pass under a pin installs it without writing the resolution | 4, T2, T6, T11 | four new pin cases in `TestTargetRules`; simulation invariant 6, mutation M6 |
+| 7 | `DigestRatchet.require` is an `atomic.Bool` | 1, T6, T8 | `TestSharedRatchetLookupAndAdmitAreRaceFree` under `-race` |
+| 8 | No settings control is disabled; a hidden `form=updates` marker says the form was sent; only changed controls are sent, so clearing never re-sends an "enabling" manual | 5, T5 | `TestClearingWorksFromThePageWhileTheGateIsClosed` (auto on, major cleared, pin cleared, gate closed), `TestAnOmittedCheckboxIsNotManual` |
+
+Left open: with a planner (`CARLOS_SWAP` on), a held wake whose retained copy
+is on the box still waits for root's repoint, because zdd-5's `decideWake`
+boots the installed build. Letting the planner take a held identity is a
+zdd-5 interface change; it is noted here rather than made from a settings plan.
+
## Spec questions found while planning
Q1, Q4, Q6, Q9 and Q10 are answered by spec rev 11 (the coordinator's
diff --git a/docs/superpowers/plans/2026-10-07-instance-settings-1-pointer-digests.md b/docs/superpowers/plans/2026-10-07-instance-settings-1-pointer-digests.md
index ba69d195a..bed3afdb7 100644
--- a/docs/superpowers/plans/2026-10-07-instance-settings-1-pointer-digests.md
+++ b/docs/superpowers/plans/2026-10-07-instance-settings-1-pointer-digests.md
@@ -120,6 +120,7 @@ Expected: two lines, both in `cli.go` (`runChannels`' API and direct-store table
{"id": "digests.marker-unreadable", "section": "Refusals", "label": "Marker unreadable", "text": "%s: the pointer digest marker for account %s could not be verified, so nothing was signed: %v", "context": "Returned by promote, rollback and fast-track when the switch that turns digests on exists but cannot be read or verified. First %s is the verb (promote, rollback, fast-track)."},
{"id": "digests.resign-changed", "section": "Refusals", "label": "Resign: channel moved", "text": "%s moved since these digests were shown. Nothing was signed. Run the command again", "context": "Returned by carlos channels resign when someone promoted or rolled back the channel between showing the digests and the confirmation. %s is the channel."},
{"id": "digests.resign-unreadable", "section": "Refusals", "label": "Resign: manifest missing", "text": "%s has no readable manifest, so its digest cannot be confirmed. Promote a newer build instead", "context": "Returned by carlos channels resign when the channel's current build has been pruned or its manifest cannot be read. %s is the version."},
+ {"id": "digests.resign-no-marker", "section": "Refusals", "label": "Resign: digests off", "text": "resign: pointer digests are not turned on for account %s. Upgrade every writer, then run carlos channels digests on --account-id %s first", "context": "Returned by carlos channels resign when the account's digest marker is absent. Resigning would arm every box's ratchet for the channel while old writers can still write pointers without digests, and those writes would then be refused. Both %s are the account."},
{"id": "digests.resign-intro", "section": "carlos channels resign", "label": "Plan heading", "text": "Re-sign %s %s with these manifest digests:", "context": "First line printed by carlos channels resign, above one line per build. App and channel."},
{"id": "digests.resign-row", "section": "carlos channels resign", "label": "One build", "text": " %s %s %s", "context": "One line per build: the version, its manifest digest, and where the digest comes from (the next two strings).", "notes": "Kept as layout; review only if you want labels here."},
{"id": "digests.resign-computed", "section": "carlos channels resign", "label": "Digest computed now", "text": "from the bucket now: check it", "context": "Third column of a build line whose digest resign reads from the bucket now, because the pointer never signed one. This is the part the operator must confirm."},
@@ -139,7 +140,7 @@ Expected: two lines, both in `cli.go` (`runChannels`' API and direct-store table
{"id": "digests.turned-on", "section": "carlos channels digests", "label": "Turned on", "text": "pointer digests on for %s. Upgraded writers sign digests from now on", "context": "Printed after carlos channels digests on. %s is the account list."},
{"id": "digests.turned-off", "section": "carlos channels digests", "label": "Turned off", "text": "pointer digests off for %s. Boxes that have already seen digests keep refusing pointers without them", "context": "Printed after carlos channels digests off. The second sentence is the warning that matters."},
{"id": "digests.all-accounts", "section": "carlos channels digests", "label": "No account named", "text": "every account", "context": "Fills %s in the on and off lines when no --account-id was given, through a console."},
- {"id": "digests.cli-heading-resign", "section": "Docs: CLI page", "label": "resign section", "text": "Re-signs a channel's current pointer with the sha256 of each build's manifest, so boxes that insist on digests accept it. It shows every digest first and signs only when you type yes. Use it on a channel nobody has promoted to since digests were turned on.", "context": "Body of the new carlos channels resign section on the CLI docs page."},
+ {"id": "digests.cli-heading-resign", "section": "Docs: CLI page", "label": "resign section", "text": "Re-signs a channel's current pointer with the sha256 of each build's manifest, so boxes that insist on digests accept it. It shows every digest first and signs only when you type yes. Use it on a channel nobody has promoted to since digests were turned on. It refuses until `carlos channels digests on` has been run for the account.", "context": "Body of the new carlos channels resign section on the CLI docs page."},
{"id": "digests.cli-heading-digests", "section": "Docs: CLI page", "label": "digests section", "text": "Shows whether channel pointers carry manifest digests, and lists the channels that still do not. on and off are for platform operators: on tells upgraded writers to sign digests, and off stops them while a writer is being fixed. Turn it on only after every console, CLI and deploy script that promotes has been upgraded.", "context": "Body of the new carlos channels digests section on the CLI docs page."},
{"id": "digests.roll-note", "section": "Docs: operator notes", "label": "README roll note", "text": "**Pointer digests (#253): writers before readers.** Once a box has verified a channel pointer that carries manifest digests, it refuses pointers for that channel without them. So digests are turned on in order. First upgrade every writer: every console, and every CLI or deploy script that promotes, rolls back or fast-tracks. Then run `carlos channels digests on`. Then watch `carlos channels digests`: a channel marked as written by an old writer means one is still in use. Rolling a writer back below this release stops it writing pointers boxes accept; `carlos channels digests off` suspends digests while it is fixed, but does not clear the boxes that have already seen them. When `carlos channels digests` lists nothing, set `CARLOS_REQUIRE_POINTER_DIGEST=on` in every box's host.env, so a new box refuses old pointers too.", "context": "The operator note added to README.md's release notes section, read before rolling the release that carries this change."}
]
@@ -158,6 +159,7 @@ Expected: two lines, both in `cli.go` (`runChannels`' API and direct-store table
{"id": "digests.marker-unreadable", "file": "internal/release/digestwords.go", "text": "<approved>"},
{"id": "digests.resign-changed", "file": "internal/release/digestwords.go", "text": "<approved>"},
{"id": "digests.resign-unreadable", "file": "internal/release/digestwords.go", "text": "<approved>"},
+ {"id": "digests.resign-no-marker", "file": "internal/release/digestwords.go", "text": "<approved>"},
{"id": "digests.resign-intro", "file": "channels_digests.go", "text": "<approved>"},
{"id": "digests.resign-row", "file": "channels_digests.go", "text": "<approved>"},
{"id": "digests.resign-computed", "file": "channels_digests.go", "text": "<approved>"},
@@ -854,7 +856,7 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
**Interfaces:**
- Consumes: Tasks 2–4; `RebindPointer` (`rebind.go:46`, unchanged: it re-marshals the verified pointer, so digests travel verbatim); `trapStore`'s shape (`scopedpromote_test.go:17`) for a counting store.
-- Produces: `func inheritedDigests(old *ChannelPointer, version string) []string`; `type ResignNeededError struct{ App, Channel, Version string }` with `Error() string`; constants `TextRollbackResign`, `TextMarkerUnreadable`, `TextResignChanged`, `TextResignUnreadable` in `digestwords.go`.
+- Produces: `func inheritedDigests(old *ChannelPointer, version string) []string`; `type ResignNeededError struct{ App, Channel, Version string }` with `Error() string`; constants `TextRollbackResign`, `TextMarkerUnreadable`, `TextResignChanged`, `TextResignUnreadable`, `TextResignNoMarker` in `digestwords.go`.
- [ ] **Step 1: Write the strings file.** Create `internal/release/digestwords.go` with the approved texts from the Task 1 record (the drafts are shown; write the approved text in their place):
@@ -873,6 +875,8 @@ const (
TextResignChanged = "%s moved since these digests were shown. Nothing was signed. Run the command again"
// digests.resign-unreadable: version.
TextResignUnreadable = "%s has no readable manifest, so its digest cannot be confirmed. Promote a newer build instead"
+ // digests.resign-no-marker: account, account.
+ TextResignNoMarker = "resign: pointer digests are not turned on for account %s. Upgrade every writer, then run carlos channels digests on --account-id %s first"
)
```
@@ -1447,6 +1451,7 @@ import (
"path/filepath"
"strings"
"sync"
+ "sync/atomic"
"time"
)
@@ -1486,9 +1491,11 @@ func EdgeRatchetDir(dataDir string) string { return filepath.Join(dataDir, "poin
// they are never cleared, and removing the emission marker does not clear
// them.
type DigestRatchet struct {
- own string
- also []string
- require bool
+ own string
+ also []string
+ // require is atomic: SharedDigestRatchet (Task 8) may widen it while
+ // another goroutine's Admit reads it (Astra I3 #7).
+ require atomic.Bool
mu sync.Mutex
seen map[string]bool // channels found marked, in any dir, by this process
@@ -1496,7 +1503,9 @@ type DigestRatchet struct {
// NewDigestRatchet reads marks from own and also, and writes them to own.
func NewDigestRatchet(own string, also []string, require bool) *DigestRatchet {
- return &DigestRatchet{own: own, also: also, require: require, seen: map[string]bool{}}
+ r := &DigestRatchet{own: own, also: also, seen: map[string]bool{}}
+ r.require.Store(require)
+ return r
}
// markPath is a channel's mark. The ".digests" suffix lets "canary" and
@@ -1519,7 +1528,7 @@ func (r *DigestRatchet) Admit(acct string, p *ChannelPointer) error {
if p.CarriesDigests() {
return r.note(acct, p.App, p.Channel)
}
- if r.require {
+ if r.require.Load() {
return fmt.Errorf("%w: %s/%s/%s, and %s is on", ErrPointerWithoutDigests, acct, p.App, p.Channel, RequirePointerDigestEnv)
}
if r.marked(acct, p.App, p.Channel) {
@@ -2019,6 +2028,28 @@ func TestSharedRatchetIsOnePerDirectory(t *testing.T) {
}
}
+// Astra I3 #7: lookups that widen require race with Admit's read on the one
+// shared object. Run under -race; with a plain bool field the detector fails it.
+func TestSharedRatchetLookupAndAdmitAreRaceFree(t *testing.T) {
+ d := t.TempDir()
+ legacy := &release.ChannelPointer{App: "km", Channel: "stable", Version: "v1"}
+ var wg sync.WaitGroup
+ for i := 0; i < 16; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ for j := 0; j < 200; j++ {
+ r := release.SharedDigestRatchet(d, nil, i%2 == 0)
+ _ = r.Admit("bab", legacy)
+ }
+ }(i)
+ }
+ wg.Wait()
+ if err := release.SharedDigestRatchet(d, nil, false).Admit("bab", legacy); !errors.Is(err, release.ErrPointerWithoutDigests) {
+ t.Fatalf("require, widened by a lookup, was lost: %v", err)
+ }
+}
+
func TestPointerRatchetSharesMarksBetweenRootAndTheAgent(t *testing.T) {
opt, data := t.TempDir(), t.TempDir()
off := func(string) string { return "" }
@@ -2047,8 +2078,8 @@ func TestPointerRatchetSharesMarksBetweenRootAndTheAgent(t *testing.T) {
`runtimePlatform()` is `runtime.GOOS + "-" + runtime.GOARCH` (add it to the test file with a `"runtime"` import if `internal/host` has no such helper).
-Run: `cd $W && TMPDIR=/tmp/k go test ./internal/host/ -run 'PointerRatchet|OneRatchetPerProcess' -count=1 && go test ./internal/release/ -run SharedRatchet -count=1`
-Expected: FAIL to compile, `undefined: pointerRatchet`, `undefined: release.SharedDigestRatchet`. (Move `TestSharedRatchetIsOnePerDirectory` into `internal/release/ratchet_test.go` as package `release`, calling `SharedDigestRatchet` unqualified.)
+Run: `cd $W && TMPDIR=/tmp/k go test ./internal/host/ -run 'PointerRatchet|OneRatchetPerProcess' -count=1 && go test -race ./internal/release/ -run SharedRatchet -count=1`
+Expected: FAIL to compile, `undefined: pointerRatchet`, `undefined: release.SharedDigestRatchet`. (Move `TestSharedRatchetIsOnePerDirectory` and `TestSharedRatchetLookupAndAdmitAreRaceFree` into `internal/release/ratchet_test.go` as package `release`, calling `SharedDigestRatchet`, `ChannelPointer` and `ErrPointerWithoutDigests` unqualified, with `sync` and `errors` imported.)
- [ ] **Step 3: Create `internal/release/ratchet_shared.go`:**
@@ -2075,9 +2106,9 @@ func SharedDigestRatchet(own string, also []string, require bool) *DigestRatchet
sharedRatchets[own] = r
return r
}
- r.mu.Lock()
- r.require = r.require || require
- r.mu.Unlock()
+ if require {
+ r.require.Store(true) // atomic: Admit may be reading it right now (Astra I3 #7)
+ }
return r
}
```
@@ -2117,8 +2148,8 @@ The limit across processes, stated: a box that cannot persist marks refuses ever
- [ ] **Step 5: Run the packages and build.**
-Run: `cd $W && go build ./... && go test ./internal/release/ -count=1 && TMPDIR=/tmp/k go test ./internal/host/ -count=1`
-Expected: PASS.
+Run: `cd $W && go build ./... && go test -race ./internal/release/ -count=1 && TMPDIR=/tmp/k go test ./internal/host/ -count=1`
+Expected: PASS, the race detector silent.
- [ ] **Step 6: Commit.**
@@ -2414,10 +2445,10 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
- Modify: `internal/release/digests.go` (`ChannelDigestState`, `DigestStateOf`, `DigestReport`)
**Interfaces:**
-- Consumes: `GetManifestRaw`, `ManifestDigest`, `ReadDigestMarker`, `signPointer`, `verifyPointer`, `TextResignChanged`, `TextResignUnreadable`.
+- Consumes: `GetManifestRaw`, `ManifestDigest`, `ReadDigestMarker`, `RemoveDigestMarker`, `signPointer`, `verifyPointer`, `TextResignChanged`, `TextResignUnreadable`, `TextResignNoMarker`, `TextMarkerUnreadable`.
- Produces: `type ResignPlan struct{ Account, App, Channel, Version, Digest string; History, HistoryDigests []string; Computed []bool; etag string }`; `func (sg Signer) PlanResign(ctx, s, acct, app, channel string) (*ResignPlan, error)`; `func (sg Signer) Resign(ctx, s, plan *ResignPlan, actor string) error`; `var ErrResignChanged error`; `type ChannelDigestState`; `func DigestStateOf(p *ChannelPointer, marker *DigestMarker) ChannelDigestState`; `func DigestReport(ctx, s, pub, acct, app string) ([]ChannelDigestState, *DigestMarker, error)`.
-Resign is the one writer that computes digests for entries it did not promote. The spec allows it only with an operator's confirmation of each digest (§4.6). So `PlanResign` computes them and returns them for display, and `Resign` signs exactly the plan the operator saw, conditional on the pointer's etag: a channel that moved in between fails with `ErrResignChanged`. A digest the current pointer already signs is copied, never recomputed. An entry whose manifest is gone stays `""`. The marker is not required: resign is an explicit operator act.
+Resign is the one writer that computes digests for entries it did not promote. The spec allows it only with an operator's confirmation of each digest (§4.6). So `PlanResign` computes them and returns them for display, and `Resign` signs exactly the plan the operator saw, conditional on the pointer's etag: a channel that moved in between fails with `ErrResignChanged`. A digest the current pointer already signs is copied, never recomputed. An entry whose manifest is gone stays `""`. **Resign requires the verified emission marker (Astra I3 #4)**, at plan time and again at signing time: a resigned pointer arms every box's ratchet for the channel, and confirming one channel's digests says nothing about whether every writer is upgraded, which is what the marker records (§4.6 writers before readers, §8). An absent marker refuses with `digests.resign-no-marker`; an unverifiable one with `digests.marker-unreadable`.
- [ ] **Step 1: Write the failing tests.** Create `internal/release/resign_test.go`:
@@ -2427,6 +2458,8 @@ package release
import (
"context"
"errors"
+ "fmt"
+ "strings"
"testing"
"time"
)
@@ -2442,6 +2475,7 @@ func TestResignSignsTheDigestsTheOperatorSaw(t *testing.T) {
t.Fatal(err)
}
}
+ markerOn(t, s, priv, "bab") // every writer upgraded; the old pointer still has no digests
sg := RootSigner(priv)
plan, err := sg.PlanResign(ctx, s, "bab", "km", "stable")
if err != nil {
@@ -2462,7 +2496,6 @@ func TestResignSignsTheDigestsTheOperatorSaw(t *testing.T) {
t.Fatalf("resigned pointer = %+v", p)
}
// Once resigned, the rollback the spec refused is allowed.
- markerOn(t, s, priv, "bab")
if err := Rollback(ctx, s, priv, "bab", "km", "stable"); err != nil {
t.Fatalf("rollback after resign: %v", err)
}
@@ -2507,6 +2540,7 @@ func TestResignRefusesAChannelThatMoved(t *testing.T) {
if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil {
t.Fatal(err)
}
+ markerOn(t, s, priv, "bab")
sg := RootSigner(priv)
plan, err := sg.PlanResign(ctx, s, "bab", "km", "stable")
if err != nil {
@@ -2531,11 +2565,47 @@ func TestResignRefusesAHeadWithNoManifest(t *testing.T) {
if err := DeleteVersion(ctx, s, "bab", "km", "v1"); err != nil {
t.Fatal(err)
}
+ markerOn(t, s, priv, "bab")
if _, err := RootSigner(priv).PlanResign(ctx, s, "bab", "km", "stable"); err == nil {
t.Fatal("a head with no manifest was planned")
}
}
+// Astra I3 #4: resign arms every box's ratchet for the channel, so it needs
+// the verified marker (every writer upgraded), at plan time and at signing.
+func TestResignRequiresTheVerifiedMarker(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("one"))
+ if err := Promote(ctx, s, priv, "bab", "km", "stable", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ sg := RootSigner(priv)
+ if _, err := sg.PlanResign(ctx, s, "bab", "km", "stable"); err == nil || err.Error() != fmt.Sprintf(TextResignNoMarker, "bab", "bab") {
+ t.Fatalf("PlanResign with no marker = %v, want the no-marker refusal", err)
+ }
+ markerOn(t, s, priv, "bab")
+ plan, err := sg.PlanResign(ctx, s, "bab", "km", "stable")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := RemoveDigestMarker(ctx, s, "bab"); err != nil { // turned off between plan and confirmation
+ t.Fatal(err)
+ }
+ if err := sg.Resign(ctx, s, plan, "ops"); err == nil {
+ t.Fatal("Resign signed with the marker gone")
+ }
+ foreign, _ := testKeys(t)
+ markerOn(t, s, foreign, "bab") // present, but not root-signed
+ if err := sg.Resign(ctx, s, plan, "ops"); err == nil || !strings.Contains(err.Error(), "marker") {
+ t.Fatalf("Resign with an unverifiable marker = %v", err)
+ }
+ if p := readPtr(t, s, pub, "stable"); p.Digest != "" {
+ t.Fatal("a refused resign wrote digests")
+ }
+}
+
func TestDigestStateOf(t *testing.T) {
at := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
m := &DigestMarker{Schema: 1, Account: "bab", At: at}
@@ -2628,6 +2698,9 @@ func (sg Signer) PlanResign(ctx context.Context, s Store, acct, app, channel str
if err := sg.allows(channel, time.Now().UTC()); err != nil {
return nil, fmt.Errorf("resign: %w", err)
}
+ if err := resignMarker(ctx, s, sg, acct); err != nil {
+ return nil, err
+ }
key := ChannelKey(acct, app, channel)
cur, etag, err := GetQualified(ctx, s, key)
if err != nil {
@@ -2663,6 +2736,11 @@ func (sg Signer) PlanResign(ctx context.Context, s Store, acct, app, channel str
// and fast-track waiver, with the plan's digests, conditional on the pointer
// being exactly the one the plan was made from.
func (sg Signer) Resign(ctx context.Context, s Store, plan *ResignPlan, actor string) error {
+ // Checked again at signing: the marker may have been turned off since
+ // the plan was shown.
+ if err := resignMarker(ctx, s, sg, plan.Account); err != nil {
+ return err
+ }
key := ChannelKey(plan.Account, plan.App, plan.Channel)
cur, etag, err := GetQualified(ctx, s, key)
if err != nil {
@@ -2695,6 +2773,22 @@ func (sg Signer) Resign(ctx context.Context, s Store, plan *ResignPlan, actor st
}
return nil
}
+
+// resignMarker is resign's gate (Astra I3 #4, spec §4.6 and §8): the
+// account's emission marker must exist and verify against the root, the
+// operator's statement that every writer is upgraded. Without it a resigned
+// pointer would arm the boxes' ratchets while old writers still write
+// pointers without digests, and every such write would then be refused.
+func resignMarker(ctx context.Context, s Store, sg Signer, acct string) error {
+ m, err := ReadDigestMarker(ctx, s, sg.verifyPub(), acct)
+ if err != nil {
+ return fmt.Errorf(TextMarkerUnreadable, "resign", acct, err)
+ }
+ if m == nil {
+ return fmt.Errorf(TextResignNoMarker, acct, acct)
+ }
+ return nil
+}
```
`signPointer` re-stamps `PromotedAt`, which is right: a resigned pointer is a new signed statement, and the bake clock keys on the box's first sighting of the version, which this does not change (the `FastTrack` precedent, `release.go` ~L1210). `actor` is accepted for the console's activity log; the pointer's audit trail is unchanged, as for a rollback.
@@ -2806,7 +2900,7 @@ import (
)
func TestResignDoorPlansThenSigns(t *testing.T) {
- fx := newDigestsFixture(t) // apisystemoperators_test.go's fixture, plus app km shipped and promoted on stable twice
+ fx := newDigestsFixture(t) // apisystemoperators_test.go's fixture, plus app km shipped and promoted on stable twice, then the account's digest marker written with the root key (resign needs it, Astra I3 #4)
st, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/km/channels/resign", map[string]string{"channel": "stable"}, fx.member)
if st != http.StatusOK {
t.Fatalf("plan: %d %s", st, out)
@@ -3181,6 +3275,11 @@ func directDigestStore(t *testing.T) (release.Store, ed25519.PublicKey) {
if err := release.Promote(context.Background(), s, priv, "bab", "km", "stable", "v1"); err != nil {
t.Fatal(err)
}
+ // Resign needs the verified marker (Astra I3 #4); written after the
+ // promotion, so the pointer still has no digests to resign.
+ if err := release.WriteDigestMarker(context.Background(), s, release.RootSigner(priv), "bab", "test"); err != nil {
+ t.Fatal(err)
+ }
return s, pub
}
@@ -3613,7 +3712,7 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
````markdown
### carlos channels resign
-Re-signs a channel's current pointer with the sha256 of each build's manifest, so boxes that insist on digests accept it. It shows every digest first and signs only when you type yes. Use it on a channel nobody has promoted to since digests were turned on.
+Re-signs a channel's current pointer with the sha256 of each build's manifest, so boxes that insist on digests accept it. It shows every digest first and signs only when you type yes. Use it on a channel nobody has promoted to since digests were turned on. It refuses until `carlos channels digests on` has been run for the account.
```sh
carlos channels resign --app hello stable
diff --git a/docs/superpowers/plans/2026-10-07-instance-settings-4-root-applies.md b/docs/superpowers/plans/2026-10-07-instance-settings-4-root-applies.md
index 566430113..0d954a5de 100644
--- a/docs/superpowers/plans/2026-10-07-instance-settings-4-root-applies.md
+++ b/docs/superpowers/plans/2026-10-07-instance-settings-4-root-applies.md
@@ -468,7 +468,17 @@ func TestTargetRules(t *testing.T) {
TargetDecision{Version: "v3", Reason: ReasonAdopt, Evidence: EvidencePointer, Bake: true}},
{"pin: the pinned build, a record, no bake", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1"}, Installed: "v3"},
TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}},
- {"pin outranks manual and a request", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, Pin: "v1", InstallRequest: 5}},
+ // Astra I3 #6: the request is still resolved at the start of the pass,
+ // pin or no pin, onto the head eligible now; the pin decides what runs.
+ {"pin outranks manual and a request, which still resolves", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, Pin: "v1", InstallRequest: 5}},
+ TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord, Resolve: &ResolvedRequest{ID: 5, Target: "v3"}}},
+ {"pin, request already resolved to T: the pin runs, T is kept for later", TargetInput{Pointer: head("v4"), Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2},
+ Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}, Completed: 1}},
+ TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}},
+ {"pin, the request's T failed: completes failed under the pin", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2},
+ Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v9"}, Completed: 1}, Failed: failedV9},
+ TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord, Fail: 2}},
+ {"pin, request waiting on bake: unresolved", TargetInput{Pointer: head("v3"), HeadHeld: "held 20h", Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2}},
TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}},
{"pending request, head eligible: resolves to the head", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2},
Build: known(true, &BuildState{LastExecuted: "v1", Completed: 1}), Installed: "v1"},
@@ -655,43 +665,60 @@ func Target(in TargetInput) TargetDecision {
state := in.Build.State
wm := Watermark(in.Consumed, state)
- // Rule 1: a pin. A person chose it, so bake does not apply.
- if u.Pin != "" {
- if failed(u.Pin) {
- return TargetDecision{Reason: ReasonFailed, Detail: u.Pin}
- }
- return TargetDecision{Version: u.Pin, Reason: ReasonPin, Evidence: EvidenceRecord, Detail: u.Pin}
- }
-
- // Rule 2: a RESOLVED request above the watermark. A completed request
- // never enters here again.
- var failedReq uint64
+ // Request bookkeeping comes first, before any selection rule, pins
+ // included (§4.3 rule 2: "resolved at the start of every pass"; Astra I3
+ // #6). A pending R is resolved onto the head eligible now; a resolved R
+ // whose T failed completes failed. A pin then decides what runs, and the
+ // resolution waits, so clearing the pin later installs this T, not a
+ // head that moved on. A completed request never enters here again.
+ var (
+ resolve *ResolvedRequest
+ failedReq uint64
+ reqTarget string // a resolved T, not failed, above the watermark
+ )
if u.InstallRequest > wm {
if res := resolvedFor(in.Consumed, state, u.InstallRequest); res != nil {
- if !failed(res.Target) {
- ev := EvidenceRecord
- if res.Target == head {
- ev = EvidencePointer
- }
- return TargetDecision{Version: res.Target, Reason: ReasonRequest, Evidence: ev, Detail: res.Target}
+ if failed(res.Target) {
+ failedReq = res.ID // completes failed, once; the rules below decide what runs
+ } else {
+ reqTarget = res.Target
}
- // T failed: the request completes as failed, once, and the next
- // rule decides what runs.
- failedReq = res.ID
} else if head != "" && (in.HeadHeld == "" || u.InstallFastTrack) && !failed(head) {
- return TargetDecision{Version: head, Reason: ReasonRequest, Evidence: EvidencePointer,
- Resolve: &ResolvedRequest{ID: u.InstallRequest, Target: head}, Detail: head}
+ resolve = &ResolvedRequest{ID: u.InstallRequest, Target: head}
+ reqTarget = head
}
// Unresolved (the head is baking here): it blocks nothing.
}
// A completed request whose target failed after it first ran (a later
// health, settle or hold failure) is recorded completed-failed, once,
- // without reopening it (Astra I1 #10, §4.3 rule 2).
+ // without reopening it (Astra I1 #10, §4.3 rule 2); also before the pin.
if failedReq == 0 && u.InstallRequest != 0 && u.InstallRequest <= wm && !completedFailed(in.Consumed, state, wm) {
if res := resolvedFor(in.Consumed, state, wm); res != nil && failed(res.Target) {
failedReq = res.ID
}
}
+ carry := func(d TargetDecision) TargetDecision {
+ d.Resolve, d.Fail = resolve, failedReq
+ return d
+ }
+
+ // Rule 1: a pin. A person chose it, so bake does not apply.
+ if u.Pin != "" {
+ if failed(u.Pin) {
+ return carry(TargetDecision{Reason: ReasonFailed, Detail: u.Pin})
+ }
+ return carry(TargetDecision{Version: u.Pin, Reason: ReasonPin, Evidence: EvidenceRecord, Detail: u.Pin})
+ }
+
+ // Rule 2: a resolved request above the watermark (this pass's resolution
+ // included).
+ if reqTarget != "" {
+ ev := EvidenceRecord
+ if reqTarget == head {
+ ev = EvidencePointer
+ }
+ return carry(TargetDecision{Version: reqTarget, Reason: ReasonRequest, Evidence: ev, Detail: reqTarget})
+ }
// Rule 3: held.
majorHeld := u.Major != nil && !tagInMajor(in.HeadTag, *u.Major)
@@ -1099,7 +1126,7 @@ func (b *buildStates) write(ctx context.Context, host string, s release.BuildSta
Which routes carry a build state: activator-managed exec instances (`Hibernate`, no `Unit`, not platform); the settings apply only to them (plan 3 refuses named units and other kinds). If Task 0 Step 4 found the claimed wake leasing a different prefix, `prefix` consults the claim holder's location for hosts with an active claim and the hibernate prefix otherwise; write that branch here with the function Task 0 named.
-- [ ] **Step 4: Refresh after reconcile.** In `agent.go`'s fast-converge leg, after the `reconcileSerialized(…)` call and its error log, add `if bs != nil { bs.refreshAll(ctx) }`, where `bs` is built once in `runAgent` with `newBuildStates(reg, release.OptDir())` (log and continue on error) and passed to the loop the way `svc` is. Also call it once after the hourly `adoptLoop`'s reconcile if that loop reconciles (it does not on main; check).
+- [ ] **Step 4: Refresh after reconcile.** In `agent.go`'s fast-converge leg, after the `reconcileSerialized(…)` call and its error log, add `if bs != nil { bs.refreshAll(ctx) }`, where `bs` is built once in `runAgent` with `bs, bsErr := newBuildStates(reg, release.OptDir())` and passed to the loop the way `svc` is. On `bsErr != nil` the agent logs it and keeps running, but **never** with the nil hook: Task 5 Step 7 installs a refusing `BeforeExec` for that case, and Task 10's report says the box does not enforce settings (Astra I3 #1). `nil, nil` (no replica bucket configured) is the only case that leaves the hook nil. Also call it once after the hourly `adoptLoop`'s reconcile if that loop reconciles (it does not on main; check).
- [ ] **Step 5: Run the package.** Run: `cd $W && go build ./... && TMPDIR=/tmp/k go test -race ./internal/host/ -count=1`. Expected: PASS.
@@ -1119,7 +1146,7 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
**Files:**
- Create: `internal/activator/beforeexec.go`, `internal/activator/beforeexec_test.go`
-- Modify: `internal/activator/activator.go` (`Service`; `doWake` between the restore (step 2) and the seed (step 3) ~L1186, the seed call, and the spawn arms), `internal/activator/claimwake.go` (`startAdmitted`'s `beforeStart`, Step 5b), `internal/activator/backend_exec.go` (`LiveIdentity`, `StartExe`, `SeedExe`; `seedChild` and `seedUnit` gain `exe`; `startRole`'s generation-0 branch), `internal/activator/backend_unit.go` (`SplitBackend` forwards), and the zdd spawn points listed in Step 6
+- Modify: `internal/activator/activator.go` (`Service`; `doWake` between the restore (step 2) and the seed (step 3) ~L1186, the seed call, and the spawn arms), `internal/activator/claimwake.go` (`startAdmitted`'s `beforeStart`, Step 5b), `internal/activator/backend_exec.go` (`LiveIdentity`, `StartExe`, `SeedExe`; `seedChild` and `seedUnit` gain `exe`; `startRole`'s generation-0 branch), `internal/activator/backend_unit.go` (`SplitBackend.identityFor`, per route through `pick`), and the zdd spawn points listed in Step 6
- Modify: `internal/instancectl/server.go` (the `/start` parser admits `exe` for the generation-0 run role; `handleSeed` parses and validates `exe`; `parseStagedExe`), `internal/instancectl/client.go` (`StartExe`, `SeedExe`), `internal/instancectl/stagedexe_test.go` (Step 4a)
- Modify: `internal/host/buildstate.go` (`beforeExec`), `internal/host/agent.go` (wire `svc.BeforeExec`)
@@ -1336,6 +1363,36 @@ type identityBackend interface {
StartExe(ctx context.Context, rt registry.Route, id release.StagedIdentity) (Proc, string, error)
SeedExe(ctx context.Context, rt registry.Route, id release.StagedIdentity) error
}
+
+// identityPicker is a composed backend that answers per route (Astra I3
+// #3): SplitBackend.identityFor goes through pick, so a unit-backed route
+// gets nil and keeps today's Start and Seed.
+type identityPicker interface {
+ identityFor(rt registry.Route) identityBackend
+}
+
+// identityFor is the backend that can pin an executable for rt, or nil.
+// Never a type assertion on the composed backend alone: production's
+// SplitBackend serves unit-backed routes too.
+func identityFor(b Backend, rt registry.Route) identityBackend {
+ if p, ok := b.(identityPicker); ok {
+ return p.identityFor(rt)
+ }
+ ib, _ := b.(identityBackend)
+ return ib
+}
+```
+
+In `internal/activator/backend_unit.go`, beside `pick`:
+
+```go
+// identityFor is the identity-pinned half for rt's backend: the exec side's
+// for an exec row, nil for a unit row (Astra I3 #3). SplitBackend does not
+// implement identityBackend itself.
+func (b *SplitBackend) identityFor(rt registry.Route) identityBackend {
+ ib, _ := b.pick(rt).(identityBackend)
+ return ib
+}
```
Add `registry` and `release` to the imports.
@@ -1349,7 +1406,7 @@ and to `Service`:
BeforeExec func(ctx context.Context, host, build string) error
```
-- [ ] **Step 4: The exec backend execs an identity.** In `internal/activator/backend_exec.go`, add to `ExecBackend` (and forward on `SplitBackend`, as zdd-4 T8 forwards `StartWorkerStaged`):
+- [ ] **Step 4: The exec backend execs an identity.** In `internal/activator/backend_exec.go`, add to `ExecBackend` (`SplitBackend` does **not** forward these; it answers `identityFor` per route, Step 3, Astra I3 #3):
- `LiveIdentity(ctx, rt) (release.StagedIdentity, error)`: today's `b.ensureLiveLink(ctx, rt.Host)` (`backend_exec.go:1106`), unchanged, so a missing link goes through the `Materialize` hook and its root-stage relay; then `release.ResolveLive(b.OptDir, rt.Host)`, once (Astra I2 #6: the identity is captured only after materializing, so a cold default instance still wakes).
- `StartExe(ctx, rt, id)`: `release.VerifyStaged(id)` first (a refusal is a failed start, never a fallback to the link). The flat path calls zdd-3's `spawnChildFrom(ctx, rt, sock, 0, id.StagedPath, tier)` then `startChildAt`'s health tail. The nested path calls `spawnUnitFrom(ctx, rt, sock, 0, &instancectl.StagedExe{Path: id.StagedPath, Version: id.Version, Digest: id.Digest}, tier)` then `startUnitAt`'s health tail; Step 4a makes the generation-0 run role honour that executable. `sock` and `tier` are what `Start` derives today (the canonical socket; `b.TierOf(rt, id.Version)`). It returns `id.Version` as the spawned version.
- `SeedExe(ctx, rt, id)`: `release.VerifyStaged(id)`, then by layout. Flat: `seedChild` with `id.StagedPath` where it passes `b.liveLink(host)` (`seedChild` gains an `exe string` parameter; `Seed` passes the live link, unchanged). Nested: `seedUnit` gains `exe *instancectl.StagedExe` and calls `b.Control.SeedExe(ctx, rt.Host, tier.MemoryMax, *exe)` when it is set (Step 4a); `Seed` passes nil.
@@ -1405,7 +1462,7 @@ func TestTheSeedUnitBindsAValidatedStagedExe(t *testing.T) {
// the lease taken in step 1), and the only file this wake runs.
// A backend that cannot pin an executable (UnitBackend) keeps today's
// calls and records nothing.
- ib, _ := a.backend.(identityBackend)
+ ib := identityFor(a.backend, rt)
var id release.StagedIdentity
if ib != nil {
var idErr error
@@ -1442,7 +1499,48 @@ func (a *Service) wakeIdentity(ctx context.Context, ib identityBackend, e *actEn
}
```
-Then: in step 3, when the backend is an `identityBackend`, replace `a.backend.Seed(ctx, rt)` with `ib.SeedExe(ctx, rt, id)`; in step 4, replace the instance arm's `proc, spawnVer, err = a.backend.Start(ctx, rt)` with `proc, spawnVer, err = ib.StartExe(ctx, rt, id)` (flat and nested alike: `StartExe` dispatches by layout as `Start` does), and the worker arm's `a.startWorker(ctx, rt, env, nil)` with `a.startWorker(ctx, rt, env, &id)`. The planned and pinned arms already exec `plan.Boot`/`*e.pin` through `startPinned`, which now skips its own write for the recorded build. A backend that is not an `identityBackend` (`UnitBackend`: systemd-unit routes, which plan 3 refuses settings on) keeps today's calls, and the wake records nothing for it, because nothing there could exec a pinned file.
+Then: in step 3, when `ib != nil`, replace `a.backend.Seed(ctx, rt)` with `ib.SeedExe(ctx, rt, id)`; in step 4, replace the instance arm's `proc, spawnVer, err = a.backend.Start(ctx, rt)` with `proc, spawnVer, err = ib.StartExe(ctx, rt, id)` (flat and nested alike: `StartExe` dispatches by layout as `Start` does), and the worker arm's `a.startWorker(ctx, rt, env, nil)` with `a.startWorker(ctx, rt, env, &id)`. The planned and pinned arms already exec `plan.Boot`/`*e.pin` through `startPinned`, which now skips its own write for the recorded build. A route whose backend cannot pin an executable (`identityFor` nil: a unit-backed route on production's `SplitBackend`, systemd-unit routes, which plan 3 refuses settings on) keeps today's calls, and the wake records nothing for it, because nothing there could exec a pinned file.
+
+Add to Step 1's tests, on the production shape (`activator.New` over a `SplitBackend`, as `agent.go:382` builds it):
+
+```go
+// Astra I3 #3: identity capability is per route. On a SplitBackend whose
+// exec half pins executables, a unit-backed route still wakes by today's
+// Start and Seed, records nothing, and never reaches StartExe; an exec row on
+// the same service takes the identity path.
+func TestAUnitRouteOnASplitBackendKeepsTodaysWake(t *testing.T) {
+ exec, unit := &fakeBackend{liveVersion: "v1"}, &fakeBackend{liveVersion: "v1"}
+ // struct{ Backend } hides the unit fake's identity methods, as UnitBackend has none.
+ split := &SplitBackend{Exec: exec, Unit: struct{ Backend }{unit}}
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := New(Config{Bucket: "test-bucket", Idle: time.Hour}, split, reg) // as agent.go builds it
+ var l execLog
+ a.BeforeExec = l.hook
+ ur := testRoute("u.example.com")
+ ur.Backing = registry.BackingUnit
+ done, err := a.beginRequest(context.Background(), ur)
+ if err != nil {
+ t.Fatalf("a unit route's wake failed on the split backend: %v", err)
+ }
+ done()
+ if unit.liveStarts() != 1 || exec.startCount() != 0 || len(l.calls) != 0 {
+ t.Fatalf("unit live starts %d, exec starts %d, records %v; want today's unit wake, nothing recorded", unit.liveStarts(), exec.startCount(), l.calls)
+ }
+ done, err = a.beginRequest(context.Background(), testRoute("e.example.com"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ if exec.lastExeVersion() != "v1" || len(l.calls) != 1 {
+ t.Fatalf("the exec row did not take the identity path: %q, %v", exec.lastExeVersion(), l.calls)
+ }
+}
+```
+
+With I2's `a.backend.(identityBackend)` (SplitBackend forwarding the methods), the unit route is sent to the exec fake's `LiveIdentity`/`StartExe` and the first check fails.
Add to Step 1's tests:
@@ -1511,7 +1609,7 @@ func TestAClaimedStartRecordsBeforeEachExec(t *testing.T) {
- zdd-3 `startPinned(ctx, rt, pin, …)`: `if err := a.beforeExec(ctx, rt.Host, pin.Build); err != nil { return nil, "", pin, err }`.
- zdd-3 `spawnSuccessor(ctx, gb, e, rt, work, g)`: `if err := a.beforeExec(ctx, rt.Host, g.Build); err != nil { return g, nil, err }`. Its caller in `swapOverlap` already maps a spawn error to the abandoned or failed result the zdd plans define; check that an `errBuildStateUnwritten` maps to `SwapAbandoned` (not a build failure, which would write a failure record) and add `errors.Is(spawnErr, errBuildStateUnwritten)` to that mapping if not.
- zdd-3 `startAdmitted`'s fallback arm (`if policy.Fallback != nil && !ready && …`): before the fallback's spawn, `if err := a.beforeExec(ctx, rt.Host, policy.Fallback.Build); err != nil { … return the same error the arm returns for a failed fallback spawn … }`.
- - zdd-4 `startWorker(ctx, rt, env, onto)`: if `onto == nil`, materialize and resolve first, the same order as `wakeIdentity` (Astra I2 #6): `if ib, ok := a.backend.(identityBackend); ok { id, err := ib.LiveIdentity(ctx, rt); if err != nil { return nil, release.StagedIdentity{}, err }; onto = &id }`; then `build := ""; if onto != nil { build = onto.Version }; if err := a.beforeExec(ctx, rt.Host, build); err != nil { return nil, release.StagedIdentity{}, err }`, and pass `onto` on. (Passing the resolution as `onto` makes the exec use exactly the build recorded.)
+ - zdd-4 `startWorker(ctx, rt, env, onto)`: if `onto == nil`, materialize and resolve first, the same order as `wakeIdentity` (Astra I2 #6): `if ib := identityFor(a.backend, rt); ib != nil { id, err := ib.LiveIdentity(ctx, rt); if err != nil { return nil, release.StagedIdentity{}, err }; onto = &id }`; then `build := ""; if onto != nil { build = onto.Version }; if err := a.beforeExec(ctx, rt.Host, build); err != nil { return nil, release.StagedIdentity{}, err }`, and pass `onto` on. (Passing the resolution as `onto` makes the exec use exactly the build recorded.)
zdd-5's `spawnPlanned`, `spawnPlannedWorker` and `ReplaceWorker` reach the exec only through `startPinned` and `startWorker`, so they need nothing of their own; the tests in Step 1 prove it.
@@ -1563,7 +1661,47 @@ func (b *buildStates) beforeExec(ctx context.Context, host, build string) error
}
```
-In `runAgent`, where the activator `Service` is built, set `svc.BeforeExec = bs.beforeExec` when `bs != nil`. A box with no replica bucket runs no hibernating instances and leaves it nil.
+and the hook's choice:
+
+```go
+// buildStateHook is Service.BeforeExec for this box (settings spec §4.2,
+// Astra I3 #1). Only a box with no replica bucket configured (bs nil, no
+// error) keeps the nil hook: it runs no hibernating instance, so it has no
+// build state. A box whose bucket is configured but whose build-state
+// service failed to start refuses every exec: the write before execution is
+// mandatory, and "could not record" never means "run unrecorded".
+func buildStateHook(bs *buildStates, initErr error) func(ctx context.Context, host, build string) error {
+ if initErr != nil {
+ return func(context.Context, string, string) error {
+ return fmt.Errorf("the build state service did not start on this box (%v); restart the agent once it is fixed", initErr)
+ }
+ }
+ if bs == nil {
+ return nil
+ }
+ return bs.beforeExec
+}
+```
+
+In `runAgent`, where the activator `Service` is built, set `svc.BeforeExec = buildStateHook(bs, bsErr)` (log `bsErr` once, as an operator line). Add to `internal/host/buildstate_test.go`:
+
+```go
+// Astra I3 #1: an initialization failure never leaves the nil hook.
+func TestAFailedBuildStateStartRefusesEveryExec(t *testing.T) {
+ hook := buildStateHook(nil, errors.New("s3: no credentials"))
+ if hook == nil {
+ t.Fatal("an initialization failure installed the nil hook: execs would run unrecorded")
+ }
+ if err := hook(context.Background(), "a.example.com", "v1"); err == nil {
+ t.Fatal("the refusing hook let an exec through")
+ }
+ if buildStateHook(nil, nil) != nil {
+ t.Fatal("a box with no replica bucket must keep today's nil hook")
+ }
+}
+```
+
+and to `internal/activator/beforeexec_test.go`, `TestARefusingHookStartsNothing`: a `newTestService` with `a.BeforeExec` returning an error; a wake fails with `errBuildStateUnwritten` and `b.startCount()` stays 0. With I2's `if bs != nil` wiring, the first test fails (the hook is nil).
- [ ] **Step 8: Run the packages.** Run: `cd $W && go build ./... && TMPDIR=/tmp/k go test -race ./internal/activator/ ./internal/host/ ./internal/instancectl/ -count=1`. Expected: PASS, every zdd test included, with Step 4a's one changed assertion in `TestSidecarStartMountsTheValidatedStagedExe`.
@@ -2044,9 +2182,11 @@ func settingsTarget(ctx context.Context, s Store, optDir string, bake *FirstSeen
fmt.Printf(" %s: recording the update decision: %v; nothing installed\n", rt.Host, err)
return "", dec, true
}
- } else if dec.Resolve != nil {
+ } else if dec.Resolve != nil && dec.Reason == ReasonRequest {
return "", dec, true // only root resolves; the next root pass will
}
+ // (An agent pass under a pin carries a Resolve it does not write; the
+ // pin's install does not depend on it, so it goes ahead.)
if dec.Version == "" || dec.Version == cur {
if dec.Version == "" {
fmt.Printf(" %s: %s: nothing installed\n", rt.Host, dec.Reason)
@@ -2161,13 +2301,20 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
**Files:**
- Create: `internal/activator/wakecheck.go`, `internal/activator/wakecheck_test.go`
-- Modify: `internal/activator/activator.go` (`Service`; `doWake` and `claimwake.go` `doClaimWake` before Task 5's `beforeExec` block), `internal/host/buildstate.go` (`heldTarget`), `internal/host/agent.go`
+- Modify: `internal/activator/activator.go` (`Service`; `doWake` and `claimwake.go` `doClaimWake` before Task 5's `beforeExec` block), `internal/host/buildstate.go` (`heldTarget`, `retained`), `internal/host/agent.go`
**Interfaces:**
-- Consumes: `release.ReadConsumed`, `ParseBuildStateCopy`, `ParseUpdateSettings`, `Watermark`, `ReasonFailed`; `Service.liveVer`, `Service.Routes`; zdd-3 `actEntry.pin` (read only: the check never sets it); zdd-5 `Planner`, `WakePlan`, `planActivator`; Task 5's `LiveIdentity` and step 2b; `stageRequester` (`agent.go:1357`).
-- Produces: `Service.HeldTarget func(host string) (build string, held bool, unknown bool)`; `Service.RequestStage func(host, build string)`; `func (a *Service) wakeCheck(ctx context.Context, host string) (string, error)`; `func heldIdentityOK(target string, id release.StagedIdentity, plan WakePlan, rt registry.Route) bool`; `var errBuildUnknown, errStaleLink error`; host `func (b *buildStates) heldTarget(host string) (string, bool, bool)`.
+- Consumes: `release.ReadConsumed`, `ReadRefusal`, `ReadStagedManifest`, `VersionPath`, `FileSHA256`, `RouteAccount`, `ParseBuildStateCopy`, `ParseUpdateSettings`, `Watermark`, `ReasonFailed`; `Service.liveVer`, `Service.Routes`; zdd-3 `actEntry.pin` (read only: the check never sets it); zdd-5 `Planner`, `WakePlan`, `planActivator`; Task 5's `LiveIdentity`, `wakeIdentity` and step 2b; `stageRequester` (`agent.go:1357`).
+- Produces: `type HeldWake struct{ Target string; Held, Unknown, Failed bool; Staged *release.StagedIdentity }`; `Service.HeldTarget func(host string) HeldWake`; `Service.RequestStage func(host, build string)`; `func (a *Service) wakeCheck(ctx context.Context, host string, direct bool) (string, *release.StagedIdentity, error)`; `func heldIdentityOK(target string, id release.StagedIdentity, plan WakePlan, rt registry.Route) bool`; `var errBuildUnknown, errStaleLink, errTargetFailed error`; host `func (b *buildStates) heldTarget(host string) activator.HeldWake`, `func (b *buildStates) retained(rt registry.Route, v string) *release.StagedIdentity`; `buildStates.bakeDir`.
-§4.3 rule 3: before it spawns a held instance, the agent compares the installed build with its target, in root's rule order (a pin, then a pending request's T, then `last_executed`; Astra I2 #7). If they differ it never runs the installed build: it touches the stage relay and waits, within the wake's own deadline, for root to install the target. It never pins the target itself (Astra I2 #4): root's `Target` applies the failure book before it installs anything, and the wake then goes through the scheduler's failure-aware decision (zdd-5's `Planner`, `decideWake`: `HeldDown` refuses, `Blocks` boots the good build or refuses) exactly as any other wake does. When root's own decision for the target is `failed`, the agent does not wait for an install root will not make: the wake is not held and the failure-aware planner decides. After the identity is chosen (Task 5), the wake refuses unless it is the target or the planner's recovery boot. An unknown build fails the wake with `settings.box.reason.unknown`.
+§4.3 rule 3: before it spawns a held instance, the agent compares the installed build with its target, in root's rule order (a pin, then a pending request's T, then `last_executed`; Astra I2 #7), and never runs a different installed build. **Settings work with `CARLOS_SWAP` off (spec rev 11 §4.2), where zdd-5's `Planner` is nil, so the wake check has its own small admission rule that needs no planner (Astra I3 #2, #5, I2-4):**
+
+1. **A target root reports failed is never run.** Root's last decision for the host is `failed` naming the target, or the box's refusal record (`release.ReadRefusal(bakeDir, host)`, the record root writes when a build did not come up) names it. With no planner the wake is refused (`errTargetFailed`), even when the failed target is still the live link. With a planner the wake is not held and zdd-5's failure-aware `decideWake` chooses: recovery onto the good build, or a refusal.
+2. **A retained, verified staged copy of the target is run directly** when there is no planner: the agent finds the target's staged executable on this box whose sha256 equals its staged manifest's (the recovery check `stageAuthorized` uses), and Task 5's wake records that identity first and then execs exactly that file (`wakeIdentity` takes it ahead of `LiveIdentity`). No root repoint is needed. The claimed path (`doClaimWake`) cannot inject an executable (its launcher execs the claim inputs' file), so it uses rule 3.
+3. **Otherwise ask root and wait**: touch the stage relay and wait, within the wake's own deadline, for root to install the target.
+4. **When a planner exists, defer to it** for what boots after root's install (I2 #4): the check never sets `e.pin`, and the identity must be the target or the planner's recovery boot (`heldIdentityOK`).
+
+An unknown build fails the wake with `settings.box.reason.unknown`.
- [ ] **Step 1: Write the failing tests.** Create `internal/activator/wakecheck_test.go`, on `newTestService`:
@@ -2181,11 +2328,15 @@ import (
"time"
)
+func heldOn(v string) func(string) HeldWake {
+ return func(string) HeldWake { return HeldWake{Target: v, Held: true} }
+}
+
// Spec §8 "Returning residence": the wake never runs a stale link.
func TestAHeldWakeNeverRunsAStaleLink(t *testing.T) {
a, b := newTestService(t)
b.liveVersion = "v3" // an old live link left on this box
- a.HeldTarget = func(string) (string, bool, bool) { return "v1", true, false }
+ a.HeldTarget = heldOn("v1")
var asked []string
a.RequestStage = func(host, build string) {
asked = append(asked, build)
@@ -2204,7 +2355,7 @@ func TestAHeldWakeNeverRunsAStaleLink(t *testing.T) {
func TestAHeldWakeWaitsOnlyWithinItsBound(t *testing.T) {
a, b := newTestService(t)
b.liveVersion = "v3"
- a.HeldTarget = func(string) (string, bool, bool) { return "v1", true, false }
+ a.HeldTarget = heldOn("v1")
a.RequestStage = func(string, string) {}
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
@@ -2218,7 +2369,7 @@ func TestAHeldWakeWaitsOnlyWithinItsBound(t *testing.T) {
func TestAnUnknownBuildNeverWakes(t *testing.T) {
a, b := newTestService(t)
- a.HeldTarget = func(string) (string, bool, bool) { return "", true, true }
+ a.HeldTarget = func(string) HeldWake { return HeldWake{Held: true, Unknown: true} }
if _, err := a.beginRequest(context.Background(), testRoute("one.example.com")); !errors.Is(err, errBuildUnknown) {
t.Fatalf("err = %v", err)
}
@@ -2229,7 +2380,7 @@ func TestAnUnknownBuildNeverWakes(t *testing.T) {
func TestAnAutoInstanceIsNotChecked(t *testing.T) {
a, b := newTestService(t)
- a.HeldTarget = func(string) (string, bool, bool) { return "", false, false }
+ a.HeldTarget = func(string) HeldWake { return HeldWake{} }
done, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
if err != nil {
t.Fatal(err)
@@ -2240,6 +2391,59 @@ func TestAnAutoInstanceIsNotChecked(t *testing.T) {
}
}
+// Astra I3 #2: with CARLOS_SWAP off there is no planner. A target root
+// reports failed is never run, even while it is still the live link.
+func TestWithNoPlannerAFailedTargetIsRefusedAtTheLiveLink(t *testing.T) {
+ a, b := newTestService(t) // Planner nil, as with CARLOS_SWAP unset
+ b.liveVersion = "v9" // the failed build is still installed
+ a.HeldTarget = func(string) HeldWake { return HeldWake{Target: "v9", Held: true, Failed: true} }
+ if _, err := a.beginRequest(context.Background(), testRoute("one.example.com")); !errors.Is(err, errTargetFailed) {
+ t.Fatalf("err = %v, want errTargetFailed", err)
+ }
+ if b.startCount() != 0 {
+ t.Fatal("a build root reported failed ran again")
+ }
+}
+
+// Astra I3 #5: with no planner, a retained, verified staged copy of the
+// target runs directly, recorded first; root's repoint is not needed.
+func TestWithNoPlannerARetainedCopyRunsWithoutARepoint(t *testing.T) {
+ a, b := newTestService(t)
+ b.liveVersion = "v3" // stale B installed
+ retained := release.StagedIdentity{Version: "v1", StagedPath: "/opt/carlos/versions/bab/v1/blog", Digest: "sha-v1"}
+ a.HeldTarget = func(string) HeldWake { return HeldWake{Target: "v1", Held: true, Staged: &retained} }
+ a.RequestStage = func(string, string) { t.Error("asked root to repoint, with the retained copy on the box") }
+ var l execLog
+ a.BeforeExec = l.hook
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ done, err := a.beginRequest(ctx, testRoute("one.example.com"))
+ if err != nil {
+ t.Fatalf("the wake waited for root instead of running the retained copy: %v", err)
+ }
+ done()
+ if b.lastExe() != retained || len(l.calls) != 1 || l.calls[0] != "one.example.com@v1" {
+ t.Fatalf("ran %+v, recorded %v; want the retained v1, recorded first", b.lastExe(), l.calls)
+ }
+}
+
+// With a planner, a failed target is the planner's call (I2 #4): the wake is
+// not refused by the settings rule, and the planner's recovery boots.
+func TestWithAPlannerAFailedTargetDefersToIt(t *testing.T) {
+ good := release.StagedIdentity{Version: "v0", StagedPath: "/opt/carlos/versions/v0/blog", Digest: "sha-v0"}
+ a, b, _ := planActivator(t, WakePlan{Boot: good})
+ b.liveVersion = "v9"
+ a.HeldTarget = func(string) HeldWake { return HeldWake{Target: "v9", Held: true, Failed: true} }
+ done, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ if b.lastStartedVersion() != "v0" {
+ t.Fatalf("started %q, want the planner's recovery v0", b.lastStartedVersion())
+ }
+}
+
// stampRoutes is the registry row the wake re-reads (RouteLookup); root's
// repoint stamps Version, as adoption does.
type stampRoutes struct {
@@ -2273,7 +2477,7 @@ func TestAHeldWakeGoesThroughTheFailureAwarePlan(t *testing.T) {
planned = append(planned, rt.Version)
return WakePlan{Boot: good} // decideWake's recovery when Blocks(v1)
}
- a.HeldTarget = func(string) (string, bool, bool) { return "v1", true, false }
+ a.HeldTarget = heldOn("v1")
a.RequestStage = func(host, build string) { b.liveVersion = build; routes.stamp(build) }
done, err := a.beginRequest(context.Background(), routes.rt)
if err != nil {
@@ -2290,7 +2494,7 @@ func TestAHeldWakeGoesThroughTheFailureAwarePlan(t *testing.T) {
func TestAHeldWakeRefusesAnIdentityThatIsNotItsTarget(t *testing.T) {
a, b := newTestService(t)
b.liveVersion = "v3"
- a.HeldTarget = func(string) (string, bool, bool) { return "v1", true, false }
+ a.HeldTarget = heldOn("v1")
a.RequestStage = func(string, string) { b.liveVersion = "v1" }
b.onLiveIdentity = func() { b.liveVersion = "v2" } // a repoint after the wait
if _, err := a.beginRequest(context.Background(), testRoute("one.example.com")); !errors.Is(err, errStaleLink) {
@@ -2302,9 +2506,9 @@ func TestAHeldWakeRefusesAnIdentityThatIsNotItsTarget(t *testing.T) {
}
```
-`lastStartedVersion()` is Task 5's `lastExeVersion()` (the version of the last `StartExe` or `StartStaged`). Add to `fakeBackend`: `stage(v string)` (records v as staged; `StartStaged` and the I1 shortcut's existence check see it) and `onLiveIdentity func()`, run inside `LiveIdentity` before it resolves. Add `sync`, `registry` and `release` to the imports.
+`lastStartedVersion()` is Task 5's `lastExeVersion()` (the version of the last `StartExe` or `StartStaged`); `lastExe()` is the whole last identity `StartExe` was given. Add to `fakeBackend`: `stage(v string)` (records v as staged; `StartStaged` sees it) and `onLiveIdentity func()`, run inside `LiveIdentity` before it resolves. Add `sync`, `registry` and `release` to the imports. Without rule 1 the failed-target test starts v9 from the live link; without rule 2 the retained-copy test calls `RequestStage` and times out.
-- [ ] **Step 2: Run them, see them fail.** Run: `cd $W && TMPDIR=/tmp/k go test ./internal/activator/ -run 'HeldWake|UnknownBuildNeverWakes|AutoInstanceIsNotChecked' -count=1`. Expected: FAIL to compile. (`TestAHeldWakeGoesThroughTheFailureAwarePlan` needs zdd-5 T9's `planActivator`, which Task 0 confirms is on the base.)
+- [ ] **Step 2: Run them, see them fail.** Run: `cd $W && TMPDIR=/tmp/k go test ./internal/activator/ -run 'HeldWake|UnknownBuildNeverWakes|AutoInstanceIsNotChecked|NoPlanner|WithAPlanner' -count=1`. Expected: FAIL to compile. (`TestAHeldWakeGoesThroughTheFailureAwarePlan` needs zdd-5 T9's `planActivator`, which Task 0 confirms is on the base.)
- [ ] **Step 3: Create `internal/activator/wakecheck.go`:**
@@ -2323,42 +2527,64 @@ import (
var (
errBuildUnknown = errors.New("build unknown: use Update now or set a pin")
errStaleLink = errors.New("the installed build is not this instance's build, and root has not installed it yet")
+ errTargetFailed = errors.New("this instance's build failed on this box, so it is not started again")
)
-// wakeCheck is settings spec §4.3 rule 3's wake half. For a held instance it
-// returns the target (a pin, else a pending request's T, else last_executed)
-// once root has installed it: it asks root and waits within the wake's own
-// deadline. It never pins the target (Astra I2 #4): what boots is still
-// chosen by the failure-aware planner, or by the live identity Task 5
-// captures, and checked against the target by heldIdentityOK. "" means the
-// instance is not held. An unknown build never wakes.
-func (a *Service) wakeCheck(ctx context.Context, host string) (string, error) {
+// HeldWake is the agent's answer for a held instance's wake (settings spec
+// §4.3 rule 3).
+type HeldWake struct {
+ Target string // the build the instance must run
+ Held bool // false: root follows the channel for it; today's wake
+ Unknown bool // rule 3's "build unknown"
+ Failed bool // root reports Target failed on this box (its decision, or a refusal record)
+ // Staged is a retained copy of Target on this box whose bytes match its
+ // staged manifest; nil when there is none.
+ Staged *release.StagedIdentity
+}
+
+// wakeCheck is settings spec §4.3 rule 3's wake half, with its own admission
+// rule so it holds with CARLOS_SWAP off and no planner (Astra I3 #2, #5):
+// a failed target is refused (or, with a planner, left to its failure-aware
+// decision); with no planner and direct set, a retained verified copy of the
+// target is returned to run as is; otherwise root is asked to install the
+// target and the wake waits within its own deadline. It never sets e.pin
+// (I2 #4). The string is the target ("" when the wake is not held).
+func (a *Service) wakeCheck(ctx context.Context, host string, direct bool) (string, *release.StagedIdentity, error) {
if a.HeldTarget == nil {
- return "", nil
+ return "", nil, nil
}
- target, held, unknown := a.HeldTarget(host)
- if !held {
- return "", nil
+ h := a.HeldTarget(host)
+ if !h.Held {
+ return "", nil, nil
}
- if unknown || target == "" {
- return "", errBuildUnknown
+ if h.Unknown || h.Target == "" {
+ return "", nil, errBuildUnknown
+ }
+ if h.Failed {
+ if a.Planner != nil {
+ return "", nil, nil // zdd-5's decideWake: recovery onto the good build, or a refusal
+ }
+ return "", nil, fmt.Errorf("%w: %s on %s", errTargetFailed, h.Target, host)
}
installed, _ := a.liveVer(host)
- if installed == target {
- return target, nil
+ if installed == h.Target {
+ return h.Target, nil, nil
+ }
+ if direct && a.Planner == nil && h.Staged != nil && h.Staged.Version == h.Target {
+ return h.Target, h.Staged, nil // run the retained copy; Task 5 records it first
}
if a.RequestStage != nil {
- a.RequestStage(host, target)
+ a.RequestStage(host, h.Target)
}
tick := time.NewTicker(250 * time.Millisecond)
defer tick.Stop()
for {
- if v, _ := a.liveVer(host); v == target {
- return target, nil
+ if v, _ := a.liveVer(host); v == h.Target {
+ return h.Target, nil, nil
}
select {
case <-ctx.Done():
- return "", fmt.Errorf("%w: %s is on %s, wants %s", errStaleLink, host, installed, target)
+ return "", nil, fmt.Errorf("%w: %s is on %s, wants %s", errStaleLink, host, installed, h.Target)
case <-tick.C:
}
}
@@ -2378,41 +2604,45 @@ func heldIdentityOK(target string, id release.StagedIdentity, plan WakePlan, rt
(add `registry` to the imports). `a.liveVer` is the `LiveVersion` reader `New` already wires (`activator.go` ~L707); the fakes set it through `fakeBackend.LiveVersion`. Add `HeldTarget` and `RequestStage` to `Service` with doc comments.
Call sites in `doWake` (Astra I2 #4):
- 1. After the layout refusal (step 0.5) and **before** zdd-5's planning block, when `e.pin == nil` (a hold swap's pinned wake was chosen by the swap from root's install): `held, err := a.wakeCheck(ctx, host); if err != nil { return w, fmt.Errorf("%s: %w", host, err) }`. No lease is held yet. When `held != "" && a.Routes != nil`, re-read the row exactly as step 0 does (`fresh, err := a.Routes.Get(host)`, `rt = *fresh`, `e.route = rt` under `a.mu`) so the planner sees the build root just installed.
- 2. In Task 5's step 2b, after `wakeIdentity` succeeds: `if !heldIdentityOK(held, id, e.plan, rt) { idErr = fmt.Errorf("%w: %s would run %s, wants %s", errStaleLink, host, id.Version, held) }`, before `beforeExec`, so the refusal releases the lease the way 2b's other errors do and records nothing.
+ 1. After the layout refusal (step 0.5) and **before** zdd-5's planning block, when `e.pin == nil` (a hold swap's pinned wake was chosen by the swap from root's install): `held, heldID, err := a.wakeCheck(ctx, host, true); if err != nil { return w, fmt.Errorf("%s: %w", host, err) }`. No lease is held yet. When `held != "" && heldID == nil && a.Routes != nil`, re-read the row exactly as step 0 does (`fresh, err := a.Routes.Get(host)`, `rt = *fresh`, `e.route = rt` under `a.mu`) so the planner sees the build root just installed.
+ 2. Task 5's `wakeIdentity` gains a `heldID *release.StagedIdentity` parameter, checked after the planned and pinned cases and before `LiveIdentity`: `case heldID != nil: return *heldID, nil`. Step 2b passes it. `StartExe` and `SeedExe` run `release.VerifyStaged` on it before any exec, as for every identity.
+ 3. In Task 5's step 2b, after `wakeIdentity` succeeds: `if !heldIdentityOK(held, id, e.plan, rt) { idErr = fmt.Errorf("%w: %s would run %s, wants %s", errStaleLink, host, id.Version, held) }`, before `beforeExec`, so the refusal releases the lease the way 2b's other errors do and records nothing.
-In `doClaimWake`, the same: the check before zdd-5's claimed planning block, the route re-read, and in Task 5's Step 5b `beforeStart`, before `a.beforeExec`: `if held != "" && s.Version != held && !(e.plan.planned() && !e.plan.FirstWake && s.Version == rt.GoodVersion) { return fmt.Errorf("%w: %s would run %s, wants %s", errStaleLink, e.host, s.Version, held) }`.
+In `doClaimWake`, the same with `direct` false (`held, _, err := a.wakeCheck(ctx, rt.Host, false)`: its launcher execs the claim inputs' file, so a retained copy cannot be injected and the wake waits for root): the check before zdd-5's claimed planning block, the route re-read, and in Task 5's Step 5b `beforeStart`, before `a.beforeExec`: `if held != "" && s.Version != held && !(e.plan.planned() && !e.plan.FirstWake && s.Version == rt.GoodVersion) { return fmt.Errorf("%w: %s would run %s, wants %s", errStaleLink, e.host, s.Version, held) }`.
- [ ] **Step 4: The agent's answer.** Add to `internal/host/buildstate.go`:
```go
// heldTarget is Service.HeldTarget: the target a held instance must wake
-// onto. held is false for an instance root follows the channel for; unknown
-// is rule 3's "build unknown".
-func (b *buildStates) heldTarget(host string) (string, bool, bool) {
+// onto, in root's rule order. Held is false for an instance root follows the
+// channel for. Failed and Staged feed the wake's own admission rule, which
+// holds with no planner (Astra I3 #2, #5).
+func (b *buildStates) heldTarget(host string) activator.HeldWake {
rt, err := b.reg.Get(host)
if err != nil || rt == nil {
- return "", false, false
+ return activator.HeldWake{}
}
u, err := release.ParseUpdateSettings(rt.UpdateSettings)
if err != nil || u.Default() {
- return "", false, false
+ return activator.HeldWake{}
}
bs, _ := release.ParseBuildStateCopy(rt.BuildState)
c, _ := release.ReadConsumed(b.optDir, host)
- // Root's order (Target, §4.3): a pin outranks a pending request (Astra
- // I2 #7). When root's own decision found the target failed, root will
- // not install it: the wake is not held, and the failure-aware planner
- // decides what runs (Astra I2 #4).
+ refusal, _ := release.ReadRefusal(b.bakeDir, host)
+ // rootFailed: root's own decision found v failed, or root's refusal
+ // record (a build that did not come up here) names it.
rootFailed := func(v string) bool {
- return c != nil && c.Reason == string(release.ReasonFailed) && c.Detail == v
+ return (c != nil && c.Reason == string(release.ReasonFailed) && c.Detail == v) ||
+ (refusal != nil && refusal.Version == v)
}
- held := func(v string) (string, bool, bool) {
- if rootFailed(v) {
- return "", false, false
+ held := func(v string) activator.HeldWake {
+ h := activator.HeldWake{Target: v, Held: true, Failed: rootFailed(v)}
+ if !h.Failed {
+ h.Staged = b.retained(*rt, v)
}
- return v, true, false
+ return h
}
+ // Root's order (Target, §4.3): a pin outranks a pending request (I2 #7).
if u.Pin != "" {
return held(u.Pin)
}
@@ -2426,19 +2656,36 @@ func (b *buildStates) heldTarget(host string) (string, bool, bool) {
}
heldByRoot := c != nil && (c.Reason == string(release.ReasonManual) || c.Reason == string(release.ReasonMajor) || c.Reason == string(release.ReasonUnknown))
if u.Updates != release.UpdatesManual && !heldByRoot {
- return "", false, false
+ return activator.HeldWake{}
}
if c != nil && c.Reason == string(release.ReasonUnknown) {
- return "", true, true
+ return activator.HeldWake{Held: true, Unknown: true}
}
if bs.State != nil && bs.State.LastExecuted != "" {
return held(bs.State.LastExecuted)
}
- return "", false, false // no build state: whatever root installed is the instance's
+ return activator.HeldWake{} // no build state: whatever root installed is the instance's
+}
+
+// retained is v's staged executable on this box when its bytes match its
+// staged manifest's sha256 for this platform (the check stageAuthorized's
+// recovery uses), else nil (Astra I3 #5).
+func (b *buildStates) retained(rt registry.Route, v string) *release.StagedIdentity {
+ acct := release.RouteAccount(rt)
+ m, err := release.ReadStagedManifest(b.optDir, acct, v, rt.App)
+ if err != nil || m == nil {
+ return nil
+ }
+ path := release.VersionPath(b.optDir, acct, v, rt.App)
+ sum, err := release.FileSHA256(path)
+ if err != nil || sum != m.Artifacts[runtime.GOOS+"-"+runtime.GOARCH].SHA256 {
+ return nil
+ }
+ return &release.StagedIdentity{Version: v, StagedPath: path, Digest: sum}
}
```
-Root's consumed reason decides whether a `major` hold is in force (the agent does not read tags). In `runAgent`, set `svc.HeldTarget = bs.heldTarget` and `svc.RequestStage = func(host, build string) { stageRequester(dataDir())(host, build, nil) }` when `bs != nil`. Before every wake the copy is refreshed: add `_ = bs.refresh(ctx, host)` at the top of the `HeldTarget` call path by wrapping: `svc.HeldTarget = func(h string) (string, bool, bool) { _ = bs.refresh(context.Background(), h); return bs.heldTarget(h) }` (§4.2: "refreshed at every reconcile and before every wake").
+`buildStates` gains `bakeDir string`, set in `newBuildStates` to `dataDir()` (the `BakeDir` every agent pass uses, `agent.go:995`); add `runtime` and `internal/activator` to the imports. Root's consumed reason decides whether a `major` hold is in force (the agent does not read tags). In `runAgent`, set `svc.RequestStage = func(host, build string) { stageRequester(dataDir())(host, build, nil) }` when `bs != nil`, and refresh the copy before every wake (§4.2: "refreshed at every reconcile and before every wake"): `svc.HeldTarget = func(h string) activator.HeldWake { _ = bs.refresh(context.Background(), h); return bs.heldTarget(h) }`.
Add to `internal/host/buildstate_test.go`:
@@ -2458,8 +2705,8 @@ func TestAMajorHoldStaysHeldWhileARequestBakes(t *testing.T) {
if err := release.WriteConsumed(b.optDir, release.Consumed{Host: "a.example.com", Reason: string(release.ReasonMajor), Desired: "v2", Baking: "v3"}); err != nil {
t.Fatal(err)
}
- if target, held, _ := b.heldTarget("a.example.com"); !held || target != "v2" {
- t.Fatalf("heldTarget = %q, %v; want v2, held", target, held)
+ if h := b.heldTarget("a.example.com"); !h.Held || h.Target != "v2" {
+ t.Fatalf("heldTarget = %+v; want v2, held", h)
}
}
```
@@ -2480,14 +2727,14 @@ func TestAPinOutranksAPendingRequestAtTheWake(t *testing.T) {
if err := release.WriteConsumed(b.optDir, release.Consumed{Host: "a.example.com", Completed: 1, Resolved: &release.ResolvedRequest{ID: 2, Target: "v4"}}); err != nil {
t.Fatal(err)
}
- if target, held, _ := b.heldTarget("a.example.com"); !held || target != "v5" {
- t.Fatalf("heldTarget = %q, %v; want the pin v5", target, held)
+ if h := b.heldTarget("a.example.com"); !h.Held || h.Target != "v5" {
+ t.Fatalf("heldTarget = %+v; want the pin v5", h)
}
}
-// Astra I2 #4: root found the pin failed and will not install it; the wake
-// does not wait for it, and the failure-aware planner decides.
-func TestARootFailedTargetIsNotHeld(t *testing.T) {
+// Astra I3 #2: root's failed decision, or its refusal record, marks the
+// target failed, so the wake's own rule refuses it with no planner.
+func TestARootFailedTargetIsReportedFailed(t *testing.T) {
b, _, reg := testBuildStates(t)
if err := reg.SetUpdateSettings("a.example.com", release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", Pin: "v5"}.Column()); err != nil {
t.Fatal(err)
@@ -2495,13 +2742,45 @@ func TestARootFailedTargetIsNotHeld(t *testing.T) {
if err := release.WriteConsumed(b.optDir, release.Consumed{Host: "a.example.com", Reason: string(release.ReasonFailed), Detail: "v5"}); err != nil {
t.Fatal(err)
}
- if _, held, _ := b.heldTarget("a.example.com"); held {
- t.Fatal("a pin root refused as failed held the wake for an install that never comes")
+ if h := b.heldTarget("a.example.com"); !h.Held || !h.Failed || h.Target != "v5" || h.Staged != nil {
+ t.Fatalf("heldTarget = %+v; want v5 held and failed, no retained copy offered", h)
+ }
+ if err := release.WriteConsumed(b.optDir, release.Consumed{Host: "a.example.com", Reason: string(release.ReasonPin), Detail: "v5"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := release.WriteRefusal(b.bakeDir, release.Refusal{Host: "a.example.com", App: "km", Version: "v5", Reason: "not up"}); err != nil {
+ t.Fatal(err)
+ }
+ if h := b.heldTarget("a.example.com"); !h.Failed {
+ t.Fatalf("a refusal record for the pin = %+v; want failed", h)
+ }
+}
+
+// Astra I3 #5: a retained copy whose bytes match its staged manifest is
+// offered; one whose bytes differ is not.
+func TestARetainedCopyIsOfferedOnlyWhenVerified(t *testing.T) {
+ b, _, reg := testBuildStates(t)
+ if err := reg.SetUpdateSettings("a.example.com", release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable"}.Column()); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetBuildState("a.example.com", release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "v2"}}.Column()); err != nil {
+ t.Fatal(err)
+ }
+ stageWithManifest(t, b.optDir, "bab", "km", "v2", []byte("two")) // the artifact and its manifest sibling, sha256 matching
+ h := b.heldTarget("a.example.com")
+ if h.Staged == nil || h.Staged.Version != "v2" || h.Staged.Digest != fileSHA(t, h.Staged.StagedPath) {
+ t.Fatalf("heldTarget = %+v; want the verified retained v2", h)
+ }
+ if err := os.WriteFile(release.VersionPath(b.optDir, "bab", "v2", "km"), []byte("tampered"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if h := b.heldTarget("a.example.com"); h.Staged != nil {
+ t.Fatal("a staged copy whose bytes differ from its manifest was offered")
}
}
```
-The first fails against the I1 order (the resolved request's v4 came first); the second fails without `rootFailed` (the pin returned held).
+The pin test fails against the I1 order (the resolved request's v4 came first). `stageWithManifest` writes the artifact with `release.StageArtifact` and its manifest sibling with the artifact's sha256 under `runtime.GOOS+"-"+runtime.GOARCH` (copy the sibling writer `ensureStaged` uses); `fileSHA(t, path)` is a test helper in `buildstate_test.go` that returns `release.FileSHA256(path)` and fails the test on an error. `testBuildStates` sets `bakeDir` to a temp dir. Add `os` and `runtime` to the test imports.
- [ ] **Step 5: Run the packages.** Run: `cd $W && go build ./... && TMPDIR=/tmp/k go test -race ./internal/activator/ ./internal/host/ -count=1`. Expected: PASS.
@@ -3109,9 +3388,18 @@ func TestEveryBoxOfThisBinaryReportsTheProtocol(t *testing.T) {
if m := boxSettingsMetrics(); m != status.UpdateSettingsProtocol {
t.Fatalf("this binary reports %d", m)
}
+ // Astra I3 #1: a box whose build-state service failed does not claim to
+ // enforce settings, so the console keeps refusing them.
+ buildStatesFailed.Store(true)
+ t.Cleanup(func() { buildStatesFailed.Store(false) })
+ if m := boxSettingsMetrics(); m != 0 {
+ t.Fatalf("a box with no build-state service reports %d", m)
+ }
}
```
+In `runAgent`, right after `bs, bsErr := newBuildStates(…)`: `if bsErr != nil { buildStatesFailed.Store(true) }`. Add `sync/atomic` to `updatesreport.go`'s imports.
+
- [ ] **Step 2: Run, see it fail, then implement.** In `snapshot.go`, add `HostUpdates` (the index's shape plus `Detail`, `Baking string` and `Major *int`, the instance's effective major hold, so a list can name an inherited hold: Astra I1 #8, #12) and `Updates map[string]HostUpdates \`json:"updates,omitempty"\`` on `BoxMetrics`, with a doc comment saying it is hostname-keyed and private like `RouteVersions`. Create `internal/host/updatesreport.go`:
```go
@@ -3126,8 +3414,19 @@ import (
// boxSettingsMetrics is this binary's settings protocol: it applies pin,
// manual and major, and its retention protects the builds they name
-// (settings spec §4.4). It lifts the console's refusal.
-func boxSettingsMetrics() int { return status.UpdateSettingsProtocol }
+// (settings spec §4.4). It lifts the console's refusal. A box whose
+// build-state service failed to start (buildStatesFailed, set by runAgent
+// from bsErr) reports 0: it refuses every exec and enforces nothing
+// (Astra I3 #1).
+func boxSettingsMetrics() int {
+ if buildStatesFailed.Load() {
+ return 0
+ }
+ return status.UpdateSettingsProtocol
+}
+
+// buildStatesFailed is set once by runAgent when newBuildStates errs.
+var buildStatesFailed atomic.Bool
// updatesReport is root's last decision per host with settings, for the
// console's list and instance page.
@@ -3221,6 +3520,7 @@ Invariants, checked after every step:
3. **No install lacks evidence**: every repoint is the head (pointer) or a build with a promotion record in the model, or the box's own staged copy.
4. **Failure records outrank**: no box repoints onto a build it marked failed for an instance that has not served it.
5. **Unresolved requests block nothing**: a held instance with an unresolved request whose head is baking still recovers onto `last_executed` on a fresh box.
+6. **A request resolves at its first eligible pass, pin or not** (Astra I3 #6): the world records `firstEligible[R]`, the head at the first root pass where R is pending and that head is not baking on the box; every resolution of R any box records names exactly that build.
- [ ] **Step 1: Write the simulation** with these pieces, each a small function in the test file: `type simBox struct{ consumed *Consumed; live string; failed map[string]bool; staged map[string]bool }`, `type simWorld struct{ boxes []*simBox; at int; state BuildState; settings UpdateSettings; head string; baking bool; promoted map[string]bool; lastRan string; rng *rand.Rand }`, `func (w *simWorld) rootPass(b *simBox)`, `func (w *simWorld) wake(b *simBox)`, `func (w *simWorld) check(t *testing.T, step int, action string)`, and the `for seed := …` loop. `rootPass` builds a `TargetInput` from the box and world exactly as `settingsTarget` does (copy its body's input construction) and applies the decision; `wake` applies the merge of `buildStates.beforeExec` (copy its body against the in-memory state). A failed invariant prints the seed, the step and the action trace.
@@ -3248,15 +3548,16 @@ For each mutation below: apply it with the Edit tool (or `sed -i` inside the cop
| M3 | the wake merge completes R at install (in `rootPass`) instead of at the wake | 1 or 2 |
| M4 | `Target` drops the `failed(u.Pin)` check | 4 |
| M5 | `Target` rule 3 returns the head when `!in.Build.Known` | 5 (and Review Focus 4's unit test) |
+| M6 | `Target` returns the pin before the request bookkeeping (the I2 order) | 6 (and Task 2's pin cases) |
-Record each result in the commit message. Delete the copy: `rm -rf $TMPDIR/settings-4/mut`.
+Add `firstEligible map[uint64]string` to `simWorld`. Record each result in the commit message. Delete the copy: `rm -rf $TMPDIR/settings-4/mut`.
- [ ] **Step 4: Commit.**
```bash
cd $W && gofmt -l internal/release && go vet ./internal/release/
git add internal/release/updatesim_test.go
-git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "settings-4 task 11: request lifecycle simulation; 3000 seeds clean, M1-M5 each caught
+git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "settings-4 task 11: request lifecycle simulation; 3000 seeds clean, M1-M6 each caught
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
diff --git a/docs/superpowers/plans/2026-10-07-instance-settings-5-surfaces.md b/docs/superpowers/plans/2026-10-07-instance-settings-5-surfaces.md
index 36fe4b988..14bd6c841 100644
--- a/docs/superpowers/plans/2026-10-07-instance-settings-5-surfaces.md
+++ b/docs/superpowers/plans/2026-10-07-instance-settings-5-surfaces.md
@@ -151,7 +151,7 @@ func init() {
Draft{ID: "settings.page.status", Text: "Installed %s. Serving %s. Wants %s: %s", Context: "Status line. Installed, serving and desired builds, then the reason sentence from the reasons above."},
Draft{ID: "settings.page.kept", Text: "kept by the app", Context: "Small note beside a setting the app's policy does not let the instance change itself. Members can still change it here."},
Draft{ID: "settings.page.not-enforced", Text: "Settings not enforced: this instance's box runs a release that ignores them, so it follows its channel", Context: "Warning shown when the box does not apply the settings yet."},
- Draft{ID: "settings.page.refused-until-boxes", Text: "Manual updates, major holds and pins can be turned on once every box runs a release that applies them", Context: "Note shown under the form while the console refuses those settings."},
+ Draft{ID: "settings.page.refused-until-boxes", Text: "Manual updates, major holds and pins can be turned on once every box runs a release that applies them", Context: "Note shown under the form while the console refuses those settings. The controls stay usable: turning auto updates back on and clearing a major hold or a pin always work; only switching these on is refused."},
Draft{ID: "settings.page.saved", Text: "Update settings saved.", Context: "Green notice after Save."},
Draft{ID: "settings.page.requested", Text: "Update requested. The box installs the build and it runs on the next start.", Context: "Green notice after Update now."},
Draft{ID: "settings.page.does-not-apply", Text: "Update settings do not apply to this instance: its process is run by its own systemd unit and always takes its channel's builds.", Context: "Shown instead of the form for a named-unit route."},
@@ -999,7 +999,7 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
- Consumes: plan 3's `updatesTarget`, `refuseShape`, `updatesViewOf`, `updatesEnforced`; the browser form gate in `browserrestart.go` (`s.account`, CSRF compare, `HasAppIn` 404, `requireFreshAuth`); zdd-6 Task 12's `heldFixture`/`signInPaul`/`postForm`/`sessionToken` test helpers.
- Produces: `func (s *Server) applyUpdates(ctx, acct *Account, app, principal string, rec *provision.Record, etag string, policy *provision.UpdatePolicy, req map[string]any) (updatesView, int, string)`; `func (s *Server) requestInstall(ctx, acct *Account, app, principal, host string, fast bool) (uint64, int, string)`; `POST /a/{sqid}/apps/{app}/instances/{host}/updates` and `…/updates/install`; `instanceDetail.Updates *updatesPageView`.
-**The section, Eleven-style:** a heading, a status line ("Installed v4. Serving v4. Wants v4: following its channel"), and a small form: the auto-update checkbox, the channel (shown, with a link to the channel control, since a move may need approvals), the major field (only on stable), the pin field, and Save; beside it an "Update now" button (and, for owners, "Skip the safety delay"). A setting outside the app's `instance_control` carries the "kept by the app" note (members may still change it here; the note tells them the instance cannot). While the console refuses `manual`, major and pin (plan 3's gate), those controls are disabled and `settings.page.refused-until-boxes` explains why. A named-unit route shows `settings.page.does-not-apply` and no form. An instance whose box ignores settings shows `settings.page.not-enforced`.
+**The section, Eleven-style:** a heading, a status line ("Installed v4. Serving v4. Wants v4: following its channel"), and a small form: the auto-update checkbox, the channel (shown, with a link to the channel control, since a move may need approvals), the major field (only on stable), the pin field, and Save; beside it an "Update now" button (and, for owners, "Skip the safety delay"). A setting outside the app's `instance_control` carries the "kept by the app" note (members may still change it here; the note tells them the instance cannot). While the console refuses `manual`, major and pin (plan 3's gate), `settings.page.refused-until-boxes` explains that switching them on waits for the boxes; the controls stay live, because turning auto back on and clearing a major or a pin are never refused (Astra I3 #8). A named-unit route shows `settings.page.does-not-apply` and no form. An instance whose box ignores settings shows `settings.page.not-enforced`.
- [ ] **Step 1: Extract the core.** Move the body of plan 3's `handleAPIUpdatesPut` after `updatesTarget`/`refuseShape` into `applyUpdates`, which returns the view on success, or a status and the reviewed message on refusal; and the retry loop of `handleAPIUpdatesInstall` into `requestInstall`. The API handlers keep their gates and become: gate, read, `applyUpdates`, answer. Run plan 3's tests: `go test ./internal/console/ -run 'Updates|Install|Policy' -count=1` must stay green before anything else changes.
@@ -1090,11 +1090,62 @@ func TestFastTrackNeedsFreshAuth(t *testing.T) {
t.Fatalf("fast-track without fresh auth = %d %s", code, loc)
}
}
+
+// Astra I3 #8: with the fleet gate closed, a member can still switch auto
+// back on, clear a major hold and clear a pin from the page, each posted
+// exactly as the browser posts the whole form.
+func TestClearingWorksFromThePageWhileTheGateIsClosed(t *testing.T) {
+ fx := heldFixture(t)
+ fx.boxesEnforce(false)
+ fx.setChannel(heldHost, "stable")
+ two := 2
+ fx.setRecordSettings(heldHost, func(r *provision.Record) { r.Updates, r.Major, r.Pin = "manual", &two, "v1" })
+ c := signInPaul(t, fx)
+ _, page := getPage(t, c, fx.u("/apps/kass/instances/"+heldHost))
+ if strings.Contains(page, "disabled") {
+ t.Fatal("a settings control is disabled while the gate is closed")
+ }
+ csrf := sessionToken(t, fx, c)
+ post := func(v url.Values) {
+ t.Helper()
+ v.Set("csrf", csrf)
+ v.Set("form", "updates")
+ if code, _, _ := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/updates"), v); code != http.StatusSeeOther {
+ t.Fatalf("save = %d", code)
+ }
+ }
+ post(url.Values{"major": {"2"}, "pin": {""}}) // clear the pin; still manual (box unchecked), major unchanged
+ if r := fx.record(heldHost); r.Pin != "" || r.Updates != "manual" {
+ t.Fatalf("clearing the pin: %+v", r)
+ }
+ post(url.Values{"major": {""}, "pin": {""}}) // clear the major hold
+ if r := fx.record(heldHost); r.Major != nil {
+ t.Fatalf("clearing major: %+v", r)
+ }
+ post(url.Values{"auto": {"1"}, "major": {""}, "pin": {""}}) // auto back on
+ if r := fx.record(heldHost); r.Updates != "auto" {
+ t.Fatalf("switching auto on: %+v", r)
+ }
+}
+
+func TestAnOmittedCheckboxIsNotManual(t *testing.T) {
+ v := updatesView{}
+ v.Settings.Updates = "auto"
+ if req := updatesFormRequest(url.Values{"csrf": {"x"}}, v); len(req) != 0 {
+ t.Fatalf("a post without the form marker sent %v", req)
+ }
+ if req := updatesFormRequest(url.Values{"form": {"updates"}, "auto": {"1"}, "pin": {""}}, v); len(req) != 0 {
+ t.Fatalf("an unchanged form sent %v", req)
+ }
+ if req := updatesFormRequest(url.Values{"form": {"updates"}}, v); req["updates"] != "manual" {
+ t.Fatalf("unchecking auto sent %v", req)
+ }
+}
```
-Add to the fixture (beside zdd-6's `heldFixture`) the helpers these use, each a few lines over the store: `getPage`, `fx.boxesEnforce`, `fx.record`, `fx.pin`, `fx.makeNamedUnit`, `fx.setRecordUpdates`, `fx.ageSession` (the last as the console's step-up tests age a session; read `stepup_test.go`).
+Add `"github.com/carlosframework/platform/internal/provision"` to the imports. Add to the fixture (beside zdd-6's `heldFixture`) the helpers these use, each a few lines over the store: `getPage`, `fx.boxesEnforce`, `fx.record`, `fx.pin`, `fx.makeNamedUnit`, `fx.setRecordUpdates`, `fx.setRecordSettings` (read-modify-write of the record, bypassing the door), `fx.setChannel`, `fx.ageSession` (the last as the console's step-up tests age a session; read `stepup_test.go`). With the I2 template (`disabled` on the three inputs) the first test fails on the page check, and each disabled field would have posted nothing; with the handler reading a re-sent "manual", clearing the pin is refused at plan 3's gate.
-- [ ] **Step 3: Run them, see them fail.** Run: `cd $W && go test ./internal/console/ -run 'UpdatesSection|PageSave|PageUpdateNow|NotEnforcedIsShown|FastTrackNeedsFreshAuth' -count=1`. Expected: FAIL.
+- [ ] **Step 3: Run them, see them fail.** Run: `cd $W && go test ./internal/console/ -run 'UpdatesSection|PageSave|PageUpdateNow|NotEnforcedIsShown|FastTrackNeedsFreshAuth|ClearingWorksFromThePage|OmittedCheckboxIsNotManual' -count=1`. Expected: FAIL.
- [ ] **Step 4: The page data.** Add to `instanceDetail`:
@@ -1128,10 +1179,11 @@ and fill it in `handleAppInstance` from `updatesTarget`'s record and policy, `up
{{if .StatusLine}}<p class="small">{{.StatusLine}}</p>{{end}}
<form method="post" action="{{$.Base}}/apps/{{$.AppName}}/instances/{{$.InstanceDetail.Row.Host}}/updates">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
- <label><input type="checkbox" name="auto" value="1" {{if eq .View.Settings.Updates "auto"}}checked{{end}} {{if .Refusing}}disabled{{end}}> {{call $.Text "settings.page.auto-label"}}</label>{{if index .Kept "updates"}} <span class="small">{{call $.Text "settings.page.kept"}}</span>{{end}}
+ <input type="hidden" name="form" value="updates">
+ <label><input type="checkbox" name="auto" value="1" {{if eq .View.Settings.Updates "auto"}}checked{{end}}> {{call $.Text "settings.page.auto-label"}}</label>{{if index .Kept "updates"}} <span class="small">{{call $.Text "settings.page.kept"}}</span>{{end}}
<div>{{call $.Text "settings.page.channel-label"}}: <code>{{.View.Settings.Channel}}</code></div>
- {{if eq .View.Settings.Channel "stable"}}<label>{{call $.Text "settings.page.major-label"}} <input type="number" min="0" name="major" value="{{with .View.Explicit.Major}}{{.}}{{end}}" {{if .Refusing}}disabled{{end}}></label>{{if index .Kept "major"}} <span class="small">{{call $.Text "settings.page.kept"}}</span>{{end}}{{end}}
- <label>{{call $.Text "settings.page.pin-label"}} <input type="text" name="pin" value="{{.View.Explicit.Pin}}" {{if .Refusing}}disabled{{end}}></label>{{if index .Kept "pin"}} <span class="small">{{call $.Text "settings.page.kept"}}</span>{{end}}
+ {{if eq .View.Settings.Channel "stable"}}<label>{{call $.Text "settings.page.major-label"}} <input type="number" min="0" name="major" value="{{with .View.Explicit.Major}}{{.}}{{end}}"></label>{{if index .Kept "major"}} <span class="small">{{call $.Text "settings.page.kept"}}</span>{{end}}{{end}}
+ <label>{{call $.Text "settings.page.pin-label"}} <input type="text" name="pin" value="{{.View.Explicit.Pin}}"></label>{{if index .Kept "pin"}} <span class="small">{{call $.Text "settings.page.kept"}}</span>{{end}}
<button type="submit" class="btn-sm">{{call $.Text "settings.page.save"}}</button>
</form>
<form method="post" action="{{$.Base}}/apps/{{$.AppName}}/instances/{{$.InstanceDetail.Row.Host}}/updates/install">
@@ -1145,10 +1197,52 @@ and fill it in `handleAppInstance` from `updatesTarget`'s record and policy, `up
{{end}}
```
-`$.Text` is a template function returning `updatewords.Text`; add it to the page data (or to the template's `FuncMap` as `text`, whichever the console's templates already use for copy: read how `$.InstanceCopy` and `$.SwapCopy` are provided and follow that shape, adding an `UpdatesCopy` struct of the `settings.page.*` texts if the console uses copy structs rather than functions). The checkbox's unchecked state posts nothing, so the handler reads `auto` absent as `manual` only when the form was submitted (`r.Form.Has("csrf")`), never from a GET.
+`$.Text` is a template function returning `updatewords.Text`; add it to the page data (or to the template's `FuncMap` as `text`, whichever the console's templates already use for copy: read how `$.InstanceCopy` and `$.SwapCopy` are provided and follow that shape, adding an `UpdatesCopy` struct of the `settings.page.*` texts if the console uses copy structs rather than functions). **No control is ever disabled (Astra I3 #8).** Plan 3's gate refuses only *enabling* (`manual`, a major hold, a pin) while some box runs an old binary; turning auto back on and clearing a major or a pin are always allowed (plan 3 Review Focus 5). A disabled input posts nothing, and the handler could not tell "unchecked" from "not sent". So while `.Refusing`, every control stays live and `settings.page.refused-until-boxes` explains that only switching these on waits for the boxes; a refused enabling change comes back as plan 3's reviewed refusal in the error flash. The unchecked checkbox posts nothing, so the hidden `form=updates` field is what says the form was submitted: without it, nothing is read as `manual`.
- [ ] **Step 6: The two form handlers.** Create `internal/console/browserupdates.go` with `handleBrowserUpdates` and `handleBrowserUpdateNow`, each following `handleBrowserRestart`'s gate exactly (session via `s.account`, CSRF compare with "Cross-site request blocked.", `HasAppIn` 404, record 404), then:
- - `handleBrowserUpdates`: build `req` from the form (`updates`: `"auto"` if `auto=1` else `"manual"`; `major` and `pin` only if their inputs were present and not disabled: a disabled input posts nothing, so absent means "not sent"), call `applyUpdates`, set the flash (`settings.page.saved`, or the refusal message as an error flash), redirect `303` to the instance page.
+ - `handleBrowserUpdates`: build `req` with `updatesFormRequest(r.Form, updatesViewOf(…))` (below), call `applyUpdates` (an empty `req` is a no-op save), set the flash (`settings.page.saved`, or the refusal message as an error flash), redirect `303` to the instance page.
+
+```go
+// updatesFormRequest is the browser form as plan 3's PUT body: only the
+// controls the member changed (Astra I3 #8). Re-sending an unchanged
+// "manual" would count as enabling at plan 3's gate and refuse a save that
+// only clears a pin; and a form without the hidden marker sends nothing, so
+// an omitted checkbox is never read as manual.
+func updatesFormRequest(f url.Values, v updatesView) map[string]any {
+ req := map[string]any{}
+ if f.Get("form") != "updates" {
+ return req
+ }
+ want := release.UpdatesManual
+ if f.Get("auto") == "1" {
+ want = release.UpdatesAuto
+ }
+ if want != v.Settings.Updates {
+ req["updates"] = want
+ }
+ if f.Has("major") {
+ cur := ""
+ if v.Explicit.Major != nil {
+ cur = strconv.Itoa(*v.Explicit.Major)
+ }
+ if m := strings.TrimSpace(f.Get("major")); m != cur {
+ if n, err := strconv.Atoi(m); err == nil {
+ req["major"] = n
+ } else {
+ req["major"] = m // "" clears; anything else gets plan 3's bad-major refusal
+ }
+ }
+ }
+ if f.Has("pin") {
+ if p := strings.TrimSpace(f.Get("pin")); p != v.Explicit.Pin {
+ req["pin"] = p
+ }
+ }
+ return req
+}
+```
+
+(imports: `net/url`, `strconv`, `strings`, `internal/release`.)
- `handleBrowserUpdateNow`: if `fast_track=1`, require `role == RoleOwner` (else 404, the fast-track door's answer) and `s.requireFreshAuth(w, r, addr, raw)` last; then `requestInstall`, flash `settings.page.requested` or the refusal, redirect.
Register in `server.go` beside the restart route: