settings plans: all 12 Astra I1 findings fixed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+710
−213
docs/superpowers/plans/2026-10-07-instance-settings-0-index.md+43 −8docs/superpowers/plans/2026-10-07-instance-settings-1-pointer-digests.md+32 −20docs/superpowers/plans/2026-10-07-instance-settings-2-promotion-records.md+108 −64docs/superpowers/plans/2026-10-07-instance-settings-3-console-settings.md+40 −3docs/superpowers/plans/2026-10-07-instance-settings-4-root-applies.md+415 −85docs/superpowers/plans/2026-10-07-instance-settings-5-surfaces.md+72 −33
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 b4223cf88..59bacb26c 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 |
| @@ -110,6 +110,7 @@ func ReadDigestMarker(ctx context.Context, s Store, pub ed25519.PublicKey, acct |
| // The per-box ratchet: |
| const RequirePointerDigestEnv = "CARLOS_REQUIRE_POINTER_DIGEST" |
| var ErrPointerWithoutDigests = errors.New("release: pointer carries no digests") |
| +var ErrRatchetUnpersisted error // a pointer with digests whose mark could not be written: refused (I1 #4); the channel is remembered in memory at once |
| 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 RootRatchetDir(optDir string) string // <optDir>/pointer-digests (/opt/carlos/pointer-digests) |
| @@ -174,9 +175,11 @@ func PlanAuthorize(ctx context.Context, s Store, acct, app, version string) (*Au |
| func (sg Signer) Authorize(ctx context.Context, s Store, plan *AuthorizePlan, actor string) (already bool, err error) // root only; re-reads, refuses if the digest moved |
| func PromotedPrefix(acct, app string) string // every record of an app; PurgeApp sweeps it |
| var ErrHeldNotAuthorized error |
| -func HeldAuthorized(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string) (want string, err error) // rev 11 §4.3a |
| -// StageHeld, MaterializeHeld and ServeHeldStageRequests each gain `pub ed25519.PublicKey` after `s Store`; |
| -// internal/host heldStagePass gains `pub` (runAdopt passes the parsed release key). |
| +func HeldAuthorized(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, acct, app, version string) (want string, err error) // rev 11 §4.3a; pointer evidence passes r.Admit (I1 #2) |
| +// StageHeld, MaterializeHeld and ServeHeldStageRequests each gain `pub ed25519.PublicKey, r *DigestRatchet` after `s Store`; |
| +// internal/host heldStagePass gains `pub, r` (runAdopt passes the parsed release key and pointerRatchet(true, …)). |
| +// sg.promote writes a record only for a NEW head, after the retry guard and the authorize callback, |
| +// before the conditional Put (I1 #1); repeats and renewals never create one (I1 #3). |
| ``` |
| ### Plan 3 defines |
| @@ -275,6 +278,7 @@ type BuildStateCopy struct { |
| Replica bool `json:"replica,omitempty"` |
| State *BuildState `json:"state,omitempty"` |
| ReadAt time.Time `json:"read_at"` |
| + ReadErr string `json:"read_err,omitempty"` // latest refresh failed: last state kept, report incomplete (I1 #6) |
| } |
| func ParseBuildStateCopy(col string) (BuildStateCopy, error) // "" = never read: Known false |
| func (c BuildStateCopy) Column() string |
| @@ -287,6 +291,7 @@ type Consumed struct { // /opt/carlos/consumed/<host>, root-owned |
| Desired string `json:"desired,omitempty"` // root's last decision, for reports |
| Reason string `json:"reason,omitempty"` // a TargetReason |
| Detail string `json:"detail,omitempty"` // the build or number the reason names |
| + Baking string `json:"baking,omitempty"` // a pending request's baking head, BESIDE Reason (I1 #8) |
| At time.Time `json:"at"` |
| } |
| func ConsumedPath(optDir, host string) string |
| @@ -329,6 +334,13 @@ func (r *Registry) SetBuildState(host, col string) error |
| // (after Restore, before the seed), startPinned, spawnSuccessor, startAdmitted's claimed |
| // fallback, startWorker (which resolves the live link first when onto is nil). A non-nil |
| // error refuses the spawn: a wake fails as a wake fails today; a swap is abandoned. |
| +// One executable identity per wake (I1 #5): doWake/doClaimWake settle a release.StagedIdentity |
| +// (plan.Boot, *e.pin, or release.ResolveLive once), record it before the seed and spawn |
| +// (withRecordedBuild(ctx, build) so the chokepoints do not write twice), and run only that file: |
| +type identityBackend interface { |
| + StartExe(ctx context.Context, rt registry.Route, id release.StagedIdentity) (Proc, string, error) // zdd-3 spawnChildFrom/spawnUnitFrom + the health tail |
| + SeedExe(ctx context.Context, rt registry.Route, id release.StagedIdentity) error // seedChild with the staged path |
| +} // on ExecBackend, forwarded by SplitBackend |
| // Service gains: HeldTarget func(host string) (build string, held, unknown bool); |
| // RequestStage func(host, build string): the held wake check (§4.3 rule 3). |
| @@ -337,12 +349,14 @@ type HostUpdates struct { |
| Desired string `json:"desired,omitempty"` |
| Reason string `json:"reason,omitempty"` |
| Detail string `json:"detail,omitempty"` |
| + Baking string `json:"baking,omitempty"` |
| + Major *int `json:"major,omitempty"` // the effective major hold, so a list names an inherited one (I1 #12) |
| Pending uint64 `json:"pending,omitempty"` // an install request above the watermark |
| Completed uint64 `json:"completed,omitempty"` |
| CompletedFailed bool `json:"completed_failed,omitempty"` |
| } |
| // BoxMetrics gains: Updates map[string]HostUpdates `json:"updates,omitempty"` (host-keyed, private) |
| -type BuildStatePins struct { Account, App string; Builds []string } // rev 11 §4.4 |
| +type BuildStatePins struct { Account, App string; Builds []string; Complete bool } // rev 11 §4.4; every keeping instance listed, Complete=false when unread (I1 #6) |
| // BoxMetrics gains: BuildStates map[string]BuildStatePins `json:"build_states,omitempty"` (host-keyed, private) |
| // internal/moves |
| @@ -424,6 +438,27 @@ printed. Plan 1's strings are reviewed in plan 1. |
| Expected: the console refuses `pin`, `manual` and `major`; the housekeeper |
| prunes nothing it cannot prove unneeded. Plans 3 and 4 own it. |
| +## Astra review I1 of the plans (2026-10-07): every finding fixed |
| + |
| +`CODEX-FINDINGS-SETTINGS-PLANS-I1.md`, REJECT, 12 findings. Each is fixed in |
| +the plan and task named, with a test that fails without the fix. None was |
| +rejected. |
| + |
| +| # | Fix | Plan, task | Test | |
| +|---|---|---|---| |
| +| 1 | The record is written after the retry guard and the authoritative `authorize` callback, before the conditional Put | 2, T3 | `TestAuthorizeSeesAPipelineInstalledBeforeTheAuthoritativeRead` (extended: no record after a refusal) | |
| +| 2 | Held staging's pointer evidence passes the box's ratchet and `CARLOS_REQUIRE_POINTER_DIGEST`; a nil ratchet admits nothing | 2, T7 | `TestHeldStageAppliesTheDigestRatchet` | |
| +| 3 | `ensurePromotionRecord` removed: a repeat or renewal never creates a record; legacy heads need `releases authorize` | 2, T3 | `TestARepeatOrRetryNeverCreatesARecord` | |
| +| 4 | The ratchet remembers in memory at once and refuses a pointer whose mark cannot be persisted (`ErrRatchetUnpersisted`) | 1, T6 | `TestRatchetFailsClosedWhenTheMarkCannotBePersisted` | |
| +| 5 | One executable identity per wake, recorded before the seed and the spawn, and the only file run, with swapping on or off (`StartExe`, `SeedExe`, zdd-3's spawn-only `spawnChildFrom`/`spawnUnitFrom`) | 4, T5 | `TestTheWakeExecsTheIdentityItRecorded`, `TestTheSeedRunsTheRecordedIdentity`, `TestAPlannedWakeRecordsBeforeTheSeed` | |
| +| 6 | Failed refreshes set `BuildStateCopy.ReadErr`; every keeping instance is reported with `Complete`; the housekeeper refuses while any is incomplete | 4, T4 and T9 | `TestAFailedRefreshIsRecorded`, `TestAnUnreadBuildStateIsReportedIncomplete`, `TestAnIncompleteBuildStatePrunesNothing` | |
| +| 7 | The mint waits until the app policy is known; absence (known) stays distinct from a read failure (unknown) | 3, T4 | `TestMintWaitsForAReadablePolicy` | |
| +| 8 | A baking request goes in `Consumed.Baking` beside the decision, never replacing the held reason | 4, T1, T6, T7, T10 | `TestABakingRequestKeepsTheHeldReason`, `TestAMajorHoldStaysHeldWhileARequestBakes` | |
| +| 9 | `adoptHost` evaluates settings before the head-only guard; `Target` needs no head for rules 1 to 3 | 4, T2, T6 | `TestIndependentTargetsNeedNoPointer`, `TestAPinInstallsWithoutAChannelPointer` | |
| +| 10 | A completed request whose T fails later is marked completed-failed (equal-watermark update), never reopened; root and the agent both merge it | 4, T2, T5, T6 | `TestAFailureAfterCompletionMarksTheRequestFailed` | |
| +| 11 | The CLI decodes the report from `update_report` and the record's setting from `updates` | 5, T4 | `TestTheListRecordDecodesBothUpdatesFields` | |
| +| 12 | The list renders the effective reported reason, with the effective major from the report (`HostUpdates.Major`); *settings not enforced* uses the effective settings (`settings_default`) | 4, T10; 5, T4 | `TestUpdatesReason`, `TestTheReportCarriesRootsDecision` | |
| + |
| ## Spec questions found while planning |
| Q1, Q4, Q6, Q9 and Q10 are answered by spec rev 11 (the coordinator's |
| @@ -486,10 +521,10 @@ Q2, Q3, Q5, Q7 and Q8 stand. |
| - **Q10 (resolved, rev 11 §4.2). The flag-off wake.** Rev 11 states that the |
| legacy wake writes the build state before it execs, which plan 4 Task 5 |
| does (`TestTheBuildStateIsWrittenBeforeTheWake`, |
| - `TestAFailedWriteBlocksTheSpawn`). The residual the plan already handles |
| - stays recorded: the flag-off wake execs `live/<host>`, so a repoint between |
| - the write and the exec is caught by comparing the build that ran and |
| - re-recording it before the wake completes (`TestAWakeThatRanAnotherBuildRecordsIt`). |
| + `TestAFailedWriteBlocksTheSpawn`). Since Astra I1 #5 the flag-off wake no |
| + longer execs `live/<host>`: it resolves the link once, records that |
| + identity, and seeds and execs the staged file by path, so the window is |
| + closed (`TestTheWakeExecsTheIdentityItRecorded`). |
| - **Q8. Who may authorize and resign.** Both sign with the deployment's |
| key. Plan 2 and plan 1 make them root-key operations (the console signs |
| with its key for an app member, as it does for promote; a direct-store CLI |
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 77c089641..72da4c500 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 |
| @@ -38,7 +38,7 @@ |
| 1. **A pointer whose `history_digests` is shorter than `history`** (hand-written, or truncated by a buggy writer). Expected: refused at verification as malformed, never read as "the missing ones have no digest". Pinned in Task 3 (`TestValidatePointerRefusesMisalignedHistoryDigests`). |
| 2. **Promoting while the marker exists but cannot be verified** (written with a scoped key, or corrupted). Expected: the promotion is refused, naming the marker, and the channel is unchanged. Pinned in Task 5 (`TestPromoteRefusesAnUnverifiableMarker`). |
| 3. **A channel named `canary` and another named `canary/x` on the same box.** Expected: both can be marked, and marking one never refuses the other. Pinned in Task 6 (`TestRatchetMarksNestedAndFlatChannels`). |
| -4. **A box whose own ratchet directory is read-only** (the agent before `/data/pointer-digests` exists, a full disk). Expected: the pointer is admitted, the failure is logged, and a mark another process wrote still refuses. Pinned in Task 6 (`TestRatchetWriteFailureStillHonoursOtherMarks`). |
| +4. **A box whose own ratchet directory is read-only** (a full disk, a permissions mistake). Expected: the process remembers the channel at once, the pointer with digests is refused until its mark is durable (nothing installs or extracts on it), and a mark another process wrote still refuses (Astra I1 #4). Pinned in Task 6 (`TestRatchetFailsClosedWhenTheMarkCannotBePersisted`). |
| 5. **A static site already cached under its bare version name** (every box before this plan). Expected: the first request after the upgrade re-extracts under the digest-keyed name and never serves the old tree as the verified one. Pinned in Task 9 (`TestStaticNeverReusesALegacyCacheDir`). |
| --- |
| @@ -1299,7 +1299,7 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| **Interfaces:** |
| - Consumes: `CarriesDigests` (Task 3), `WriteFileAtomic` (`versions.go:110`). |
| -- Produces: `const RequirePointerDigestEnv = "CARLOS_REQUIRE_POINTER_DIGEST"`; `var ErrPointerWithoutDigests error`; `type DigestRatchet`; `func NewDigestRatchet(own string, also []string, require bool) *DigestRatchet`; `func RootRatchetDir(optDir string) string`; `func EdgeRatchetDir(dataDir string) string`; `func RequirePointerDigest(getenv func(string) string) bool`; `func (r *DigestRatchet) Admit(acct string, p *ChannelPointer) error`. |
| +- Produces: `const RequirePointerDigestEnv = "CARLOS_REQUIRE_POINTER_DIGEST"`; `var ErrPointerWithoutDigests error`; `var ErrRatchetUnpersisted error`; `type DigestRatchet`; `func NewDigestRatchet(own string, also []string, require bool) *DigestRatchet`; `func RootRatchetDir(optDir string) string`; `func EdgeRatchetDir(dataDir string) string`; `func RequirePointerDigest(getenv func(string) string) bool`; `func (r *DigestRatchet) Admit(acct string, p *ChannelPointer) error`. |
| - [ ] **Step 1: Write the failing tests.** Create `internal/release/ratchet_test.go`: |
| @@ -1387,8 +1387,11 @@ func TestRatchetMarksNestedAndFlatChannels(t *testing.T) { |
| } |
| } |
| -// Review Focus 4. |
| -func TestRatchetWriteFailureStillHonoursOtherMarks(t *testing.T) { |
| +// Review Focus 4, Astra I1 #4: a mark that cannot be persisted fails closed. |
| +// The pointer that carried digests is refused (nothing installs or extracts |
| +// on it), the process remembers the channel at once, and a mark another |
| +// process wrote is still honoured. |
| +func TestRatchetFailsClosedWhenTheMarkCannotBePersisted(t *testing.T) { |
| ro := filepath.Join(t.TempDir(), "ro") |
| if err := os.MkdirAll(ro, 0o555); err != nil { |
| t.Fatal(err) |
| @@ -1401,8 +1404,11 @@ func TestRatchetWriteFailureStillHonoursOtherMarks(t *testing.T) { |
| t.Fatal(err) |
| } |
| r := NewDigestRatchet(ro, []string{other}, false) |
| - if err := r.Admit("bab", ptr("beta", dA)); err != nil { |
| - t.Fatalf("an unwritable mark refused a pointer with digests: %v", err) |
| + if err := r.Admit("bab", ptr("beta", dA)); !errors.Is(err, ErrRatchetUnpersisted) { |
| + t.Fatalf("an unpersisted mark admitted the pointer: %v", err) |
| + } |
| + if err := r.Admit("bab", ptr("beta", "")); !errors.Is(err, ErrPointerWithoutDigests) { |
| + t.Fatalf("the same process forgot the channel it just saw with digests: %v", err) |
| } |
| if err := r.Admit("bab", ptr("stable", "")); !errors.Is(err, ErrPointerWithoutDigests) { |
| t.Fatalf("the other process's mark was not honoured: %v", err) |
| @@ -1453,6 +1459,11 @@ const RequirePointerDigestEnv = "CARLOS_REQUIRE_POINTER_DIGEST" |
| // ErrPointerWithoutDigests is a pointer the ratchet or the flag refuses. |
| var ErrPointerWithoutDigests = errors.New("release: pointer carries no digests") |
| +// ErrRatchetUnpersisted is a pointer with digests whose channel mark could |
| +// not be written: nothing installs or extracts on it until the mark is |
| +// durable, because a restart would otherwise forget the channel (Astra I1 #4). |
| +var ErrRatchetUnpersisted = errors.New("release: the pointer digest mark could not be persisted") |
| + |
| // RequirePointerDigest reads the flag: 1, true, on or yes in any case. |
| func RequirePointerDigest(getenv func(string) string) bool { |
| switch strings.ToLower(strings.TrimSpace(getenv(RequirePointerDigestEnv))) { |
| @@ -1496,17 +1507,17 @@ func markPath(dir, acct, app, channel string) string { |
| // Admit is the ratchet's verdict on a verified pointer. A nil ratchet admits |
| // everything, which is today's behaviour for any caller that does not arm |
| -// one. A pointer with digests is admitted and marks its channel; a mark this |
| -// process cannot write is logged and the pointer still admitted, because the |
| -// pointer itself is fine and refusing it would stop a channel that has done |
| -// nothing wrong. |
| +// one. A pointer with digests marks its channel: in memory at once, so this |
| +// process refuses a digestless replay from that moment, and on disk before |
| +// the pointer is admitted. A mark that cannot be persisted refuses the |
| +// pointer (ErrRatchetUnpersisted): installing or extracting on it would leave |
| +// the box unprotected after a restart. |
| func (r *DigestRatchet) Admit(acct string, p *ChannelPointer) error { |
| if r == nil || p == nil { |
| return nil |
| } |
| if p.CarriesDigests() { |
| - r.note(acct, p.App, p.Channel) |
| - return nil |
| + return r.note(acct, p.App, p.Channel) |
| } |
| if r.require { |
| return fmt.Errorf("%w: %s/%s/%s, and %s is on", ErrPointerWithoutDigests, acct, p.App, p.Channel, RequirePointerDigestEnv) |
| @@ -1537,31 +1548,32 @@ func (r *DigestRatchet) marked(acct, app, channel string) bool { |
| return false |
| } |
| -func (r *DigestRatchet) note(acct, app, channel string) { |
| +func (r *DigestRatchet) note(acct, app, channel string) error { |
| k := acct + "|" + app + "|" + channel |
| r.mu.Lock() |
| defer r.mu.Unlock() |
| + r.seen[k] = true // remembered at once, whatever the disk says |
| if r.own == "" { |
| - r.seen[k] = true |
| - return |
| + return nil |
| } |
| path := markPath(r.own, acct, app, channel) |
| if _, err := os.Stat(path); err == nil { |
| - r.seen[k] = true |
| - return |
| + return nil |
| } |
| if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| log.Printf("release: pointer digest ratchet: %v", err) |
| - return |
| + return fmt.Errorf("%w: %v", ErrRatchetUnpersisted, err) |
| } |
| if err := WriteFileAtomic(path, []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644); err != nil { |
| log.Printf("release: pointer digest ratchet: %v", err) |
| - return |
| + return fmt.Errorf("%w: %v", ErrRatchetUnpersisted, err) |
| } |
| - r.seen[k] = true |
| + return nil |
| } |
| ``` |
| +`Admit`'s callers already treat any error as a refusal: the adoption pass drops the group's pointer (Task 7) and the static reader keeps serving its cached tree (Task 9), so nothing installs or extracts on a pointer whose mark is not durable. The next pass retries the write. |
| + |
| - [ ] **Step 4: Run the package.** |
| Run: `cd $W && go test ./internal/release/ -count=1` |
diff --git a/docs/superpowers/plans/2026-10-07-instance-settings-2-promotion-records.md b/docs/superpowers/plans/2026-10-07-instance-settings-2-promotion-records.md| index cc0d798fc..79ace34c7 100644 |
| --- a/docs/superpowers/plans/2026-10-07-instance-settings-2-promotion-records.md |
| +++ b/docs/superpowers/plans/2026-10-07-instance-settings-2-promotion-records.md |
| @@ -45,7 +45,7 @@ |
| | `internal/updatewords/words.go`, `words_test.go` (new) | `Draft`, `add`, `Text`, `All`; the no-duplicate, no-em-dash checks | 1 | |
| | `internal/updatewords/words_authorize.go` (new) | this plan's drafts | 1 | |
| | `internal/release/promotion.go` (new) | `PromotionRecordKey`, `PromotionRecord`, `ErrNoPromotionRecord`, `WritePromotionRecord`, `ReadPromotionRecord`, `AuthorizedManifest` | 2 | |
| -| `internal/release/release.go` | `sg.promote` and the NoOp repeat in `PromoteOnLadder` write the record | 3 | |
| +| `internal/release/release.go`, `authorize_test.go` | `sg.promote` writes a new head's record after authorization; repeats and renewals write none | 3 | |
| | `internal/release/authorize_record.go` (new) | `AuthorizePlan`, `PlanAuthorize`, `Authorize`, `ErrAuthorizeChanged` | 4 | |
| | `internal/release/prune.go`, `trash.go` | the record goes with a pruned version and a purged app | 5 | |
| | `internal/console/apiauthorize.go` (new), `authcli.go` | `GET`/`POST …/releases/{version}/authorize` | 6 | |
| @@ -62,11 +62,12 @@ |
| cd /home/paulca/amadan.net/carlos/platform && git fetch origin |
| git worktree add -b settings-2-promotion-records .claude/worktrees/settings-2-promotion-records origin/main |
| cd .claude/worktrees/settings-2-promotion-records |
| -git grep -n 'func ManifestDigest(raw \[\]byte) string\|func CheckManifestDigest(\|func GetManifestRaw(\|ErrManifestDigest = ' internal/release |
| +git grep -n 'func ManifestDigest(raw \[\]byte) string\|func CheckManifestDigest(\|func GetManifestRaw(\|ErrManifestDigest = \|func NewDigestRatchet(' internal/release |
| +git grep -n 'func pointerRatchet(' internal/host |
| git grep -n '_, shippedRaw, err := GetManifestRaw(ctx, s, acct, app, version)' internal/release/release.go |
| ``` |
| -Expected: four definitions and the one `shippedRaw` line. If any is missing, plan 1 has not merged: stop. |
| +Expected: five definitions in `internal/release`, `pointerRatchet` in `internal/host`, and the one `shippedRaw` line. If any is missing, plan 1 has not merged: stop. |
| - [ ] **Step 2: Baseline.** |
| @@ -627,14 +628,17 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| ### Task 3: Every promotion writes its record |
| **Files:** |
| -- Modify: `internal/release/release.go` (`sg.promote`, after plan 1's marker read; `PromoteOnLadder`'s NoOp branch ~L1040) |
| +- Modify: `internal/release/release.go` (`sg.promote`: after the `if authorize != nil { … }` block ~L783, before `next := &ChannelPointer{…}`) |
| +- Modify: `internal/release/authorize_test.go` (`TestAuthorizeSeesAPipelineInstalledBeforeTheAuthoritativeRead`, L167) |
| - Create: `internal/release/promote_record_test.go` |
| **Interfaces:** |
| -- Consumes: `shippedRaw` (plan 1 Task 5, in `sg.promote`), `WritePromotionRecord`, `ReadPromotionRecord`, `GetManifestRaw`, `countingStore`-style wrapper (`writers_test.go`, plan 1). |
| -- Produces: `func (sg Signer) ensurePromotionRecord(ctx context.Context, s Store, acct, app, channel, version, actor string) error`. |
| +- Consumes: `shippedRaw` (plan 1 Task 5, in `sg.promote`), `WritePromotionRecord`, `ReadPromotionRecord`, `refusingStore`-style wrapper below. |
| +- Produces: nothing new outside `sg.promote`. |
| -The record is written **before** the pointer's conditional write, from the bytes `sg.promote` already read. A pointer then never names a version without a record. If the conditional write loses its race afterwards, a record exists for bytes the signer did authorize, which grants nothing a later promotion of those bytes would not. |
| +The record is written **after every check that can refuse the promotion** (the retry guard and its fresh-stamp wait, and the authoritative `authorize` callback that `sg.promote` runs between the pointer read and the conditional write) and **before** the pointer's conditional write, from the bytes `sg.promote` already read. A refused promotion therefore leaves no record behind (Astra I1 #1: a record written before the callback would make a refused build pinnable). A pointer never names a newly promoted version without a record. If the conditional write then loses its race, a record exists for bytes the signer did authorize, which grants nothing a later promotion of those bytes would not. |
| + |
| +Only a write that **moves the head to a new version** writes a record. A repeat (`PromoteOnLadder`'s no-op) and a renewal (`--retry`, which keeps the head) never create one: the head they leave in place may be a legacy build whose bytes were swapped since it was promoted, and signing them now would be the automatic backfill §4.1 forbids (Astra I1 #3). A legacy head is authorized only by `carlos releases authorize` (Task 4). |
| - [ ] **Step 1: Write the failing tests.** Create `internal/release/promote_record_test.go`: |
| @@ -702,7 +706,9 @@ func TestOneRecordPerVersionAcrossChannels(t *testing.T) { |
| } |
| } |
| -func TestARepeatPromotionHealsAMissingRecord(t *testing.T) { |
| +// Astra I1 #3: a repeat or a renewal of a legacy head never signs its |
| +// current bytes. Only `carlos releases authorize` does. |
| +func TestARepeatOrRetryNeverCreatesARecord(t *testing.T) { |
| s := NewDirStore(t.TempDir()) |
| ctx := context.Background() |
| priv, pub := testKeys(t) |
| @@ -710,15 +716,22 @@ func TestARepeatPromotionHealsAMissingRecord(t *testing.T) { |
| if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| t.Fatal(err) |
| } |
| - if err := s.Delete(ctx, PromotionRecordKey("bab", "km", "v1")); err != nil { |
| + if err := s.Delete(ctx, PromotionRecordKey("bab", "km", "v1")); err != nil { // a head promoted before records |
| t.Fatal(err) |
| } |
| res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "canary/x", "v1", PromoteOptions{}) |
| if err != nil || !res.NoOp { |
| t.Fatalf("repeat = %+v, %v", res, err) |
| } |
| - if _, err := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); err != nil { |
| - t.Fatalf("the repeat did not heal the record: %v", err) |
| + if _, err := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); !errors.Is(err, ErrNoPromotionRecord) { |
| + t.Fatalf("a no-op repeat created a record: %v", err) |
| + } |
| + time.Sleep(1100 * time.Millisecond) |
| + if _, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "canary/x", "v1", PromoteOptions{Retry: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := ReadPromotionRecord(ctx, s, pub, "bab", "km", "v1"); !errors.Is(err, ErrNoPromotionRecord) { |
| + t.Fatalf("a retry created a record: %v", err) |
| } |
| } |
| @@ -751,69 +764,57 @@ func TestPromotionFailsBeforeThePointerWhenTheRecordCannotBeWritten(t *testing.T |
| } |
| ``` |
| -- [ ] **Step 2: Run them and see them fail.** |
| - |
| -Run: `cd $W && go test ./internal/release/ -run 'PromoteWritesTheRecord|OutOfHistory|OneRecordPerVersion|HealsAMissingRecord|RecordCannotBeWritten' -count=1` |
| -Expected: FAIL (no record is written). |
| - |
| -- [ ] **Step 3: Write the record in `sg.promote`.** Directly after plan 1's marker read (`marker, merr := ReadDigestMarker(ctx, s, pub, acct)` and its `if merr != nil { … }`), add: |
| +Add `"time"` to the imports. Then extend the existing `TestAuthorizeSeesAPipelineInstalledBeforeTheAuthoritativeRead` (`authorize_test.go:167`): after its `edgeVersion` check, add |
| ```go |
| - // The durable record of this promotion (spec §4.1), from the bytes the |
| - // publisher shipped, BEFORE the pointer moves: a pointer must never name |
| - // a version without one. A renewal (--retry) writes nothing new: the |
| - // record for these bytes already exists, and WritePromotionRecord keeps |
| - // it. |
| - actor := "" |
| - if len(rec) > 0 { |
| - actor = rec[0].Actor |
| - } |
| - if err := sg.WritePromotionRecord(ctx, s, PromotionRecord{Account: acct, App: app, Version: version, |
| - ManifestDigest: ManifestDigest(shippedRaw), Channel: channel, Actor: actor}); err != nil { |
| - return fmt.Errorf("promote: write the promotion record for %s: %w", version, err) |
| + // Astra I1 #1: the authoritative callback refused, so nothing may vouch |
| + // for v1 either. |
| + if _, err := ReadPromotionRecord(ctx, inner, pub, "bab", "myapp", "v1"); !errors.Is(err, ErrNoPromotionRecord) { |
| + t.Fatalf("a refused promotion left a record: %v", err) |
| } |
| ``` |
| -- [ ] **Step 4: Heal on a repeat.** Add to `promotion.go`: |
| +- [ ] **Step 2: Run them and see them fail.** |
| -```go |
| -// ensurePromotionRecord writes a missing record for a version a channel |
| -// already names: PromoteOnLadder's repeat, which writes no pointer. Like the |
| -// tag stableTag cuts on a repeat, re-running the same command heals it. |
| -func (sg Signer) ensurePromotionRecord(ctx context.Context, s Store, acct, app, channel, version, actor string) error { |
| - if _, err := ReadPromotionRecord(ctx, s, sg.verifyPub(), acct, app, version); err == nil { |
| - return nil |
| - } |
| - _, raw, err := GetManifestRaw(ctx, s, acct, app, version) |
| - if err != nil { |
| - return err |
| - } |
| - return sg.WritePromotionRecord(ctx, s, PromotionRecord{Account: acct, App: app, Version: version, |
| - ManifestDigest: ManifestDigest(raw), Channel: channel, Actor: actor}) |
| -} |
| -``` |
| +Run: `cd $W && go test ./internal/release/ -run 'PromoteWritesTheRecord|OutOfHistory|OneRecordPerVersion|RecordCannotBeWritten|AuthorizeSeesAPipelineInstalledBeforeTheAuthoritativeRead' -count=1` |
| +Expected: FAIL (no record is written; once Step 3 is in, writing it any earlier than shown makes the authorize test fail). |
| -In `PromoteOnLadder`'s repeat branch, directly before `res.NoOp = true`, add: |
| +- [ ] **Step 3: Write the record in `sg.promote`.** Directly after the `if authorize != nil { … }` block (which follows the `expectVersion` guard and `waitPastPromotedAt`) and before `next := &ChannelPointer{…}`, add: |
| ```go |
| - if err := sg.ensurePromotionRecord(ctx, s, acct, app, channel, version, opt.Actor); err != nil { |
| - return res, fmt.Errorf("promote: write the promotion record for %s: %w", version, err) |
| - } |
| + // The durable record of this promotion (spec §4.1), from the bytes the |
| + // publisher shipped: after every check that can refuse the write (the |
| + // retry guard, the authoritative authorize callback), and BEFORE the |
| + // pointer moves, so a refused promotion leaves nothing behind and a |
| + // published head never lacks one. Only a NEW head is recorded: a renewal |
| + // (--retry) keeps the head it found, and signing that head's current |
| + // bytes here would be the automatic backfill §4.1 forbids. A legacy head |
| + // is authorized only by `carlos releases authorize`. |
| + if old.Version != version { |
| + actor := "" |
| + if len(rec) > 0 { |
| + actor = rec[0].Actor |
| + } |
| + if err := sg.WritePromotionRecord(ctx, s, PromotionRecord{Account: acct, App: app, Version: version, |
| + ManifestDigest: ManifestDigest(shippedRaw), Channel: channel, Actor: actor}); err != nil { |
| + return fmt.Errorf("promote: write the promotion record for %s: %w", version, err) |
| + } |
| + } |
| ``` |
| -The `Retry` branch above it calls `sg.promote`, which writes the record (Step 3). |
| +`PromoteOnLadder`'s repeat branch writes no pointer and is left as it is: it creates no record. |
| -- [ ] **Step 5: Run the package, then everything that promotes.** |
| +- [ ] **Step 4: Run the package, then everything that promotes.** |
| Run: `cd $W && go test ./internal/release/ -count=1 && go test . ./internal/console/ -count=1 -run 'Promote|Deploy|Pending|Canary'` |
| Expected: PASS. A test whose store refuses unexpected keys (a `trapStore`) and that promotes would fail here; `scopedpromote_test.go`'s trap refuses before any write by design, so it is unaffected. |
| -- [ ] **Step 6: Commit.** |
| +- [ ] **Step 5: Commit.** |
| ```bash |
| cd $W && gofmt -l internal/release && go vet ./internal/release/ |
| -git add internal/release/release.go internal/release/promotion.go internal/release/promote_record_test.go |
| -git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "settings-2 task 3: every promotion writes its signed record before the pointer moves |
| +git add internal/release/release.go internal/release/promote_record_test.go internal/release/authorize_test.go |
| +git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "settings-2 task 3: a new head's promotion writes its signed record after authorization, before the pointer moves |
| Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| ``` |
| @@ -1303,7 +1304,9 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| **Interfaces:** |
| - Consumes: `ReadPromotionRecord`, `ErrNoPromotionRecord` (Task 2); plan 1's `ensureStagedDigest`, `(*ChannelPointer).DigestOf`, `ErrManifestDigest`; `Channel`, `ChannelsPrefix`. |
| -- Produces: `var ErrHeldNotAuthorized error`; `func HeldAuthorized(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string) (want string, err error)`; `StageHeld`, `MaterializeHeld`, `ServeHeldStageRequests` each gain a `pub ed25519.PublicKey` parameter after `s Store`. |
| +- Produces: `var ErrHeldNotAuthorized error`; `func HeldAuthorized(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, acct, app, version string) (want string, err error)`; `StageHeld`, `MaterializeHeld`, `ServeHeldStageRequests` each gain `pub ed25519.PublicKey, r *DigestRatchet` after `s Store`; `heldStagePass` passes root's ratchet (plan 1's `pointerRatchet(true, …)`). |
| + |
| +Pointer evidence goes through the same per-box ratchet and `CARLOS_REQUIRE_POINTER_DIGEST` as ordinary adoption (Astra I1 #2, spec §4.6): a pointer the ratchet refuses (no digests on a channel this box has seen with them, or any digestless pointer under the flag) is not evidence, so a replayed legacy pointer cannot install substituted bytes through a move. |
| Spec rev 11 §4.3a: "A move that installs a build on the destination (the held stage) is held to §4.1: it installs only a build that a promotion record or a signed pointer authorizes." Today `StageHeld` stages any version a move's request names, checked only by D3's proof gate. `HeldAuthorized` answers first with the digest staging must bind: the promotion record's digest; else, for a build some verified pointer of the app names (head or history), that entry's signed digest, or `""` for a pointer entry from before digests (today's pointer trust, which §4.1 keeps for pointer evidence). No record and no pointer naming it: `ErrHeldNotAuthorized`, and the request is dropped, never retried. |
| @@ -1325,13 +1328,45 @@ func heldRoute(host string) registry.Route { |
| return registry.Route{Host: host, Account: "bab", App: "km", HoldOp: "op-1"} |
| } |
| +// nr is a fresh, unarmed ratchet: today's admission. |
| +func nr(t *testing.T) *DigestRatchet { return NewDigestRatchet(t.TempDir(), nil, false) } |
| + |
| +// Astra I1 #2: a pointer the box's ratchet refuses is not held-stage evidence. |
| +func TestHeldStageAppliesTheDigestRatchet(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { // a legacy, digestless pointer |
| + t.Fatal(err) |
| + } |
| + if err := s.Delete(ctx, PromotionRecordKey("bab", "km", "v1")); err != nil { // so the pointer is the only evidence |
| + t.Fatal(err) |
| + } |
| + armed := NewDigestRatchet(t.TempDir(), nil, false) |
| + if err := armed.Admit("bab", &ChannelPointer{App: "km", Channel: "canary/x", Version: "v0", Digest: ManifestDigest([]byte("m"))}); err != nil { |
| + t.Fatal(err) // this box has seen canary/x with digests |
| + } |
| + if _, err := StageHeld(ctx, s, pub, armed, opt, heldRoute("a.example.com"), "v1"); !errors.Is(err, ErrHeldNotAuthorized) { |
| + t.Fatalf("a ratcheted channel's legacy pointer authorized a held stage: %v", err) |
| + } |
| + required := NewDigestRatchet(t.TempDir(), nil, true) |
| + if _, err := StageHeld(ctx, s, pub, required, opt, heldRoute("a.example.com"), "v1"); !errors.Is(err, ErrHeldNotAuthorized) { |
| + t.Fatalf("CARLOS_REQUIRE_POINTER_DIGEST did not apply to a held stage: %v", err) |
| + } |
| + if _, err := StageHeld(ctx, s, pub, nil, opt, heldRoute("a.example.com"), "v1"); !errors.Is(err, ErrHeldNotAuthorized) { |
| + t.Fatalf("a nil ratchet admitted pointer evidence: %v", err) |
| + } |
| +} |
| + |
| func TestHeldStageRefusesABuildNothingAuthorizes(t *testing.T) { |
| s := NewDirStore(t.TempDir()) |
| ctx := context.Background() |
| _, pub := testKeys(t) |
| opt := t.TempDir() |
| shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("shipped, never promoted")) |
| - if _, err := StageHeld(ctx, s, pub, opt, heldRoute("a.example.com"), "v1"); !errors.Is(err, ErrHeldNotAuthorized) { |
| + if _, err := StageHeld(ctx, s, pub, nr(t), opt, heldRoute("a.example.com"), "v1"); !errors.Is(err, ErrHeldNotAuthorized) { |
| t.Fatalf("StageHeld = %v; want ErrHeldNotAuthorized", err) |
| } |
| if _, err := os.Stat(VersionPath(opt, "bab", "v1", "km")); err == nil { |
| @@ -1351,7 +1386,7 @@ func TestHeldStageAcceptsARecordedBuildOutOfHistory(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| - if _, err := StageHeld(ctx, s, pub, opt, heldRoute("a.example.com"), "va"); err != nil { |
| + if _, err := StageHeld(ctx, s, pub, nr(t), opt, heldRoute("a.example.com"), "va"); err != nil { |
| t.Fatalf("a recorded build out of the pointer's history: %v", err) |
| } |
| } |
| @@ -1368,7 +1403,7 @@ func TestHeldStageAcceptsAPointerNamedBuildWithoutARecord(t *testing.T) { |
| if err := s.Delete(ctx, PromotionRecordKey("bab", "km", "v1")); err != nil { // promoted before records |
| t.Fatal(err) |
| } |
| - if _, err := StageHeld(ctx, s, pub, opt, heldRoute("a.example.com"), "v1"); err != nil { |
| + if _, err := StageHeld(ctx, s, pub, nr(t), opt, heldRoute("a.example.com"), "v1"); err != nil { |
| t.Fatalf("a build the signed pointer names: %v", err) |
| } |
| } |
| @@ -1397,7 +1432,7 @@ func TestHeldStageBindsTheRecordDigest(t *testing.T) { |
| if _, err := s.Put(ctx, PromotionRecordKey("bab", "km", "v1"), saved, ""); err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := StageHeld(ctx, s, pub, opt, heldRoute("a.example.com"), "v1"); !errors.Is(err, ErrManifestDigest) && !errors.Is(err, ErrHeldNotAuthorized) { |
| + if _, err := StageHeld(ctx, s, pub, nr(t), opt, heldRoute("a.example.com"), "v1"); !errors.Is(err, ErrManifestDigest) && !errors.Is(err, ErrHeldNotAuthorized) { |
| t.Fatalf("a reused name under a replayed record = %v", err) |
| } |
| if _, err := os.Stat(VersionPath(opt, "bab", "v1", "km")); err == nil { |
| @@ -1406,7 +1441,7 @@ func TestHeldStageBindsTheRecordDigest(t *testing.T) { |
| } |
| ``` |
| -- [ ] **Step 2: Run them, see them fail.** Run: `cd $W && go test ./internal/release/ -run HeldStage -count=1`. Expected: FAIL to compile (`StageHeld` takes no `pub`, `undefined: ErrHeldNotAuthorized`). |
| +- [ ] **Step 2: Run them, see them fail.** Run: `cd $W && go test ./internal/release/ -run HeldStage -count=1`. Expected: FAIL to compile (`StageHeld` takes no `pub` or ratchet, `undefined: ErrHeldNotAuthorized`). |
| - [ ] **Step 3: Implement.** Add to `materializeheld.go`: |
| @@ -1441,6 +1476,15 @@ func HeldAuthorized(ctx context.Context, s Store, pub ed25519.PublicKey, acct, a |
| if err != nil { |
| continue |
| } |
| + // The box's ratchet and CARLOS_REQUIRE_POINTER_DIGEST apply here as |
| + // they do to adoption (§4.6): a pointer the ratchet refuses is not |
| + // evidence. A nil ratchet would admit everything, so it is refused. |
| + if r == nil { |
| + return "", fmt.Errorf("%w: no pointer digest ratchet", ErrHeldNotAuthorized) |
| + } |
| + if rerr := r.Admit(acct, p); rerr != nil { |
| + continue |
| + } |
| if p.Version == version || slices.Contains(p.History, version) { |
| return p.DigestOf(version), nil |
| } |
| @@ -1449,16 +1493,16 @@ func HeldAuthorized(ctx context.Context, s Store, pub ed25519.PublicKey, acct, a |
| } |
| ``` |
| -Change `StageHeld`'s signature to `StageHeld(ctx context.Context, s Store, pub ed25519.PublicKey, optDir string, rt registry.Route, version string)`, and before `materializeHeldStage`: |
| +Change `StageHeld`'s signature to `StageHeld(ctx context.Context, s Store, pub ed25519.PublicKey, r *DigestRatchet, optDir string, rt registry.Route, version string)`, and before `materializeHeldStage`: |
| ```go |
| - want, err := HeldAuthorized(ctx, s, pub, RouteAccount(rt), rt.App, version) |
| + want, err := HeldAuthorized(ctx, s, pub, r, RouteAccount(rt), rt.App, version) |
| if err != nil { |
| return nil, err |
| } |
| ``` |
| -and stage with `ensureStagedDigest(ctx, s, optDir, RouteAccount(rt), rt.App, version, rt.Host, want)` instead of `materializeHeldStage(…)` when `want != ""` (keep `materializeHeldStage` for `want == ""`, so the read-only-mount test seam still works). Thread `pub` through `MaterializeHeld` (to its `StageHeld` call), `ServeHeldStageRequests` and `serveHeldStageRequest`; in `serveHeldStageRequest`, add `ErrHeldNotAuthorized` and `ErrManifestDigest` to the `drop("refused: %v", err)` branch beside `ErrHeldNotProven`. In `internal/host/heldstage.go`, `heldStagePass` gains `pub ed25519.PublicKey` and passes it; in `runAdopt`, call `heldStagePass(ctx, dataDir(), release.OptDir(), pub, reg, store, BoxLabel())` (the release key is already parsed above that line). |
| +and stage with `ensureStagedDigest(ctx, s, optDir, RouteAccount(rt), rt.App, version, rt.Host, want)` instead of `materializeHeldStage(…)` when `want != ""` (keep `materializeHeldStage` for `want == ""`, so the read-only-mount test seam still works). Thread `pub` and `r` through `MaterializeHeld` (to its `StageHeld` call), `ServeHeldStageRequests` and `serveHeldStageRequest`; in `serveHeldStageRequest`, add `ErrHeldNotAuthorized` and `ErrManifestDigest` to the `drop("refused: %v", err)` branch beside `ErrHeldNotProven`. In `internal/host/heldstage.go`, `heldStagePass` gains `pub ed25519.PublicKey, r *release.DigestRatchet` and passes them; in `runAdopt`, call `heldStagePass(ctx, dataDir(), release.OptDir(), pub, pointerRatchet(true, release.OptDir(), dataDir(), os.Getenv), reg, store, BoxLabel())` (the release key is already parsed above that line; `pointerRatchet` is plan 1 Task 8's). |
| Existing `materializeheld_test.go` and `internal/host` held-stage tests stage versions their fixtures ship but never promote. Promote each fixture version to `canary/x` in the fixture helper they share (find it by its `Ship`/`shipRelease` call) so they keep testing what they tested; none of them may be weakened. |
diff --git a/docs/superpowers/plans/2026-10-07-instance-settings-3-console-settings.md b/docs/superpowers/plans/2026-10-07-instance-settings-3-console-settings.md| index 3f349e098..f4c140777 100644 |
| --- a/docs/superpowers/plans/2026-10-07-instance-settings-3-console-settings.md |
| +++ b/docs/superpowers/plans/2026-10-07-instance-settings-3-console-settings.md |
| @@ -681,11 +681,34 @@ func TestUnreadablePolicyLeavesTheColumnAlone(t *testing.T) { |
| t.Fatal("an unreadable policy reset the instance to auto") |
| } |
| } |
| + |
| +// Astra I1 #7: an instance that inherits manual from its app, minted (on a |
| +// fresh box, or after a move) while the app's manifest cannot be read, must |
| +// not be minted as auto: it would adopt the head instead of recovering |
| +// last_executed. The mint waits until the policy reads; a genuinely absent |
| +// manifest is known (no policy) and mints at once. |
| +func TestMintWaitsForAReadablePolicy(t *testing.T) { |
| + fx := reconcileFixture(t) |
| + fx.putManifest(Manifest{Schema: 1, App: "km", Domain: "example.com", Channel: "stable", |
| + Updates: &UpdatePolicy{DefaultUpdates: release.UpdatesManual}}) |
| + fx.corruptManifest("bab", "km") |
| + fx.putRecord(Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable"}) |
| + fx.run() |
| + if fx.hasRow("a.example.com") { |
| + t.Fatal("minted while the app's update policy was unreadable") |
| + } |
| + fx.putManifest(Manifest{Schema: 1, App: "km", Domain: "example.com", Channel: "stable", |
| + Updates: &UpdatePolicy{DefaultUpdates: release.UpdatesManual}}) |
| + fx.run() |
| + if u, _ := release.ParseUpdateSettings(fx.row("a.example.com").UpdateSettings); u.Updates != release.UpdatesManual { |
| + t.Fatalf("minted with %q once the policy read", fx.row("a.example.com").UpdateSettings) |
| + } |
| +} |
| ``` |
| -If `reconcile_test.go` has no `corruptManifest`, write it in this file: `s.Put(ctx, ManifestKey(acct, app), []byte("{"), etag)` after a `Get` for the etag. |
| +`fx.hasRow` is `reg.Get(host)` returning a row; add it beside the fixture if it lacks one. If `reconcile_test.go` has no `corruptManifest`, write it in this file: `s.Put(ctx, ManifestKey(acct, app), []byte("{"), etag)` after a `Get` for the etag. |
| -- [ ] **Step 2: Run them and see them fail.** Run: `cd $W && go test ./internal/provision/ -run 'CarriesSettings|UntouchedFleet|UnreadablePolicy' -count=1`. Expected: FAIL (the column is never written). |
| +- [ ] **Step 2: Run them and see them fail.** Run: `cd $W && go test ./internal/provision/ -run 'CarriesSettings|UntouchedFleet|UnreadablePolicy|MintWaitsForAReadablePolicy' -count=1`. Expected: FAIL (the column is never written). |
| - [ ] **Step 3: Cache the policy beside the seed.** Add `policy *UpdatePolicy` to `manifestRead`, and in the prefetch set it from the manifest: `r := manifestRead{err: merr}; if merr == nil { r.seed = seedJSON(m); r.policy = m.Updates }`. Beside `seedFor`, add: |
| @@ -713,7 +736,21 @@ If `reconcile_test.go` has no `corruptManifest`, write it in this file: `s.Put(c |
| } |
| ``` |
| -- [ ] **Step 4: Mint.** `routeFor` gains a trailing `policy *UpdatePolicy` and sets `UpdateSettings: EffectiveUpdates(rec, policy).Column(),` in the `registry.Route` literal. Its three callers pass `policy` from `p, _ := policyFor(rec.Account, rec.App)` (at mint an unknown policy mints the default, and the next pass converges it once the manifest reads). |
| +- [ ] **Step 4: Mint.** `routeFor` gains a trailing `policy *UpdatePolicy` and sets `UpdateSettings: EffectiveUpdates(rec, policy).Column(),` in the `registry.Route` literal. At the **mint** call (`nr := routeFor(rec, cfg.DataDir, seed, nested)` ~L1083), read the policy first and defer the mint while it is unknown (Astra I1 #7): |
| + |
| +```go |
| + p, known := policyFor(rec.Account, rec.App) |
| + if !known { |
| + // A read failure is not "no policy": minting now would mint |
| + // an instance that inherits manual as auto, and root would |
| + // adopt the head over its data's build. Absent is known. |
| + log.Printf("provision: %s: not minted this pass: the app's update policy could not be read", rec.Host) |
| + continue |
| + } |
| + nr := routeFor(rec, cfg.DataDir, seed, nested, p) |
| +``` |
| + |
| +The two other callers (~L1185, ~L1212) build a `want` row only for `shapeMatches`/`shapeMismatch`, which never compare `update_settings`; they pass the policy `policyFor` returns, known or not. `policyFor` keeps absence (`os.ErrNotExist`: known, no policy) apart from a read failure (unknown). |
| - [ ] **Step 5: Converge.** `convergeOwned` gains `policy *UpdatePolicy, policyKnown bool` before `dryRun`, and at its end: |
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 75d1740ce..d9904217b 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 |
| @@ -27,7 +27,8 @@ |
| - The build state is written by the agent under the instance's lease, before the process (or its seed) runs; a failed write refuses the spawn. `build-state.json` sits at the same prefix as the lease's `lock.json`. |
| - Retention (rev 11 §4.4): a version named by any provision record's `pin`, or by any box report's build-state builds for the app (`BoxMetrics.BuildStates`: `last_executed` and a resolved T), is protected, with its promotion record. The housekeeper prunes nothing for an app if any of its provision records is unreadable, or if the newest report naming any of its hosts is more than 6 hours old. |
| - Moves (rev 11 §4.3a): a move copies the build state with the replica (legacy round: already, pinned by a test; phase-1 lane: a second, one-object mirror call). Until a landed move's staging is promoted, the agent reads the staged build state as the instance's. The held stage's §4.1 check is plan 2 Task 7. |
| -- The legacy (flag-off) wake writes the build state before it execs (rev 11 §4.2), as Task 5 does. |
| +- The legacy (flag-off) wake writes the build state before it execs (rev 11 §4.2), as Task 5 does: every wake settles one executable identity, records it before the seed and the spawn, and runs only that file (Astra I1 #5). |
| +- The ratchet fails closed (plan 1, Astra I1 #4); held staging applies it (plan 2, I1 #2); retention refuses while any host's build state is unread (Task 9, I1 #6). |
| - Box report: `BoxMetrics.UpdateSettings = status.UpdateSettingsProtocol` only from this binary on, and only once moves carry the build state (Task 8) and retention protects it (Task 9). |
| - Member-facing strings: `internal/updatewords` drafts in `words_box.go` (`settings.box.*`); box journal lines are not drafts. No em dash. |
| - Concurrency: `-race` on every activator and host test run. The last functional task is an invariant simulation, run serially at `nice -n 19` with one 3000-seed sweep, plus mutation checks (CPU budget note: never deeper without Paul's say-so). |
| @@ -36,7 +37,7 @@ |
| ## Review Focus |
| -1. **A wake racing root's repoint with `CARLOS_SWAP` off.** The flag-off wake execs `live/<host>`, so root can repoint between the build-state write and the exec. Expected: the agent compares the build the process reports it ran (`spawnVer`) with the one it recorded and, if they differ, records the one that ran before the wake completes; if that second write fails, the process is stopped and the wake fails. Pinned in Task 5 (`TestAWakeThatRanAnotherBuildRecordsIt`). |
| +1. **A wake racing root's repoint with `CARLOS_SWAP` off.** Root can repoint `live/<host>` while the build state is being written. Expected: the wake resolved the link once, recorded that identity, and seeds and execs that staged file by path, never the link; the repoint takes effect at the next wake (Astra I1 #5). Pinned in Task 5 (`TestTheWakeExecsTheIdentityItRecorded`, `TestTheSeedRunsTheRecordedIdentity`, `TestAPlannedWakeRecordsBeforeTheSeed`). |
| 2. **A consumed file that names a request newer than the record's** (the record was edited back by hand, or restored from a backup). Expected: the watermark never decreases; a request id at or below it is complete and is never replayed. Pinned in Task 2 (`TestARequestBelowTheWatermarkNeverRuns`). |
| 3. **A pin naming a build this box has marked failed.** Expected: nothing is installed, the reason is `failed`, and the instance keeps what it has (spec: failure records outrank every rule, the pin included). Pinned in Task 2 (`TestFailureRecordsOutrankAPin`). |
| 4. **An instance with a replica but whose build state the agent could not read** (a bucket blip at reconcile). Expected: root treats it as unknown for a held instance and installs nothing, never as "new". Pinned in Task 2 (`TestAnUnreadBuildStateIsNeverNew`). |
| @@ -274,6 +275,10 @@ type BuildStateCopy struct { |
| Replica bool `json:"replica,omitempty"` |
| State *BuildState `json:"state,omitempty"` |
| ReadAt time.Time `json:"read_at"` |
| + // ReadErr is set when the latest refresh could not read the object; the |
| + // copy keeps its last known state, and the box reports the host as |
| + // incomplete for retention (Astra I1 #6). Cleared by a good read. |
| + ReadErr string `json:"read_err,omitempty"` |
| } |
| func ParseBuildStateCopy(col string) (BuildStateCopy, error) { |
| @@ -305,7 +310,11 @@ type Consumed struct { |
| Desired string `json:"desired,omitempty"` |
| Reason string `json:"reason,omitempty"` |
| Detail string `json:"detail,omitempty"` |
| - At time.Time `json:"at"` |
| + // Baking is the head a pending, unresolved request waits on (this box's |
| + // bake window). Reported beside Reason, never in place of it: Reason is |
| + // what the held wake check reads (Astra I1 #8). |
| + Baking string `json:"baking,omitempty"` |
| + At time.Time `json:"at"` |
| } |
| func ConsumedPath(optDir, host string) string { return filepath.Join(optDir, "consumed", host) } |
| @@ -530,6 +539,39 @@ func TestAResolutionInTheBuildStateIsReused(t *testing.T) { |
| TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidenceRecord}}}) |
| } |
| +// Astra I1 #10: T ran (the request completed at the pre-exec write), then |
| +// failed its health check. The completed request is marked failed once, |
| +// and is not reopened. |
| +func TestAFailureAfterCompletionMarksTheRequestFailed(t *testing.T) { |
| + failedV3 := func(v string) bool { return v == "v3" } |
| + runTargetCases(t, []tcase{ |
| + {"completed, then T failed", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| + Build: known(true, &BuildState{LastExecuted: "v1", Resolved: &ResolvedRequest{ID: 2, Target: "v3"}, Completed: 2}), |
| + Installed: "v1", Failed: failedV3}, |
| + TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord, Fail: 2}}, |
| + {"already recorded as failed: not again", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| + Build: known(true, &BuildState{LastExecuted: "v1", Resolved: &ResolvedRequest{ID: 2, Target: "v3"}, Completed: 2, CompletedFailed: true}), |
| + Installed: "v1", Failed: failedV3}, |
| + TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord}}, |
| + }) |
| +} |
| + |
| +// Astra I1 #9: rules 1-3 do not need a readable head. |
| +func TestIndependentTargetsNeedNoPointer(t *testing.T) { |
| + runTargetCases(t, []tcase{ |
| + {"pin, no pointer", TargetInput{Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1"}}, |
| + TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}}, |
| + {"resolved request, no pointer", TargetInput{Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| + Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}}}, |
| + TargetDecision{Version: "v3", Reason: ReasonRequest, Evidence: EvidenceRecord}}, |
| + {"held recovery, no pointer", TargetInput{Settings: manual(), Build: known(true, &BuildState{LastExecuted: "v1"})}, |
| + TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord}}, |
| + {"new held instance, no pointer: nothing yet", TargetInput{Settings: manual(), Build: known(false, nil)}, |
| + TargetDecision{Reason: ReasonNew}}, |
| + {"auto, no pointer: nothing", TargetInput{Settings: auto()}, TargetDecision{}}, |
| + }) |
| +} |
| + |
| // Spec §8: a held sleeper with a pending request wakes onto T, which the |
| // wake check reads from this same decision. |
| func TestAPendingTOutranksLastExecuted(t *testing.T) { |
| @@ -541,7 +583,7 @@ func TestAPendingTOutranksLastExecuted(t *testing.T) { |
| } |
| ``` |
| -- [ ] **Step 3: Run them and see them fail.** Run: `cd $W && go test ./internal/release/ -run 'TargetRules|OutrankAPin|BelowTheWatermark|UnreadBuildState|ResolutionInTheBuildState|PendingTOutranks' -count=1`. Expected: FAIL to compile, `undefined: Target`. |
| +- [ ] **Step 3: Run them and see them fail.** Run: `cd $W && go test ./internal/release/ -run 'TargetRules|OutrankAPin|BelowTheWatermark|UnreadBuildState|ResolutionInTheBuildState|PendingTOutranks|FailureAfterCompletion|IndependentTargetsNeedNoPointer' -count=1`. Expected: FAIL to compile, `undefined: Target`. |
| - [ ] **Step 4: Create `internal/release/updatetarget.go`:** |
| @@ -642,6 +684,14 @@ func Target(in TargetInput) TargetDecision { |
| } |
| // 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). |
| + 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 |
| + } |
| + } |
| // Rule 3: held. |
| majorHeld := u.Major != nil && !tagInMajor(in.HeadTag, *u.Major) |
| @@ -662,6 +712,8 @@ func Target(in TargetInput) TargetDecision { |
| d = TargetDecision{Reason: ReasonUnknown} |
| case head != "" && !failed(head): |
| d = TargetDecision{Version: head, Reason: ReasonNew, Evidence: EvidencePointer, Bake: true, Detail: head} |
| + case head == "": |
| + d = TargetDecision{Reason: ReasonNew} // new, and no readable head to give it: nothing yet |
| default: |
| d = TargetDecision{Reason: ReasonFailed, Detail: head} |
| } |
| @@ -682,6 +734,12 @@ func Target(in TargetInput) TargetDecision { |
| return TargetDecision{Version: head, Reason: ReasonAdopt, Evidence: EvidencePointer, Bake: true, Detail: head, Fail: failedReq} |
| } |
| +// completedFailed reports whether either source already records request id |
| +// as completed, failed. |
| +func completedFailed(c *Consumed, b *BuildState, id uint64) bool { |
| + return (c != nil && c.Completed == id && c.CompletedFailed) || (b != nil && b.Completed == id && b.CompletedFailed) |
| +} |
| + |
| // resolvedFor is R's T from either source (§4.3 rule 2: "locally or in the |
| // build state"), or nil. |
| func resolvedFor(c *Consumed, b *BuildState, id uint64) *ResolvedRequest { |
| @@ -856,6 +914,37 @@ func TestRefreshSeesAReplicaAndAState(t *testing.T) { |
| } |
| } |
| +// Astra I1 #6: a failed read keeps the last state and is recorded. |
| +func TestAFailedRefreshIsRecorded(t *testing.T) { |
| + b, s, reg := testBuildStates(t) |
| + ctx := context.Background() |
| + if err := b.write(ctx, "a.example.com", release.BuildState{LastExecuted: "v1"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := b.refresh(ctx, "a.example.com"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, "inst/a.example.com/build-state.json", []byte("{"), mustEtag(t, s, "inst/a.example.com/build-state.json")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := b.refresh(ctx, "a.example.com"); err == nil { |
| + t.Fatal("an unparseable build state refreshed cleanly") |
| + } |
| + c := copyOf(t, reg) |
| + if c.ReadErr == "" || !c.Known || c.State == nil || c.State.LastExecuted != "v1" { |
| + t.Fatalf("copy = %+v; want the last state kept and the failure recorded", c) |
| + } |
| +} |
| + |
| +func mustEtag(t *testing.T, s release.Store, key string) string { |
| + t.Helper() |
| + _, etag, err := s.Get(context.Background(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return etag |
| +} |
| + |
| func TestALockAloneIsNotAReplica(t *testing.T) { |
| b, s, reg := testBuildStates(t) |
| ctx := context.Background() |
| @@ -948,11 +1037,23 @@ func (b *buildStates) read(ctx context.Context, host string) (*release.BuildStat |
| return st, replica, nil |
| } |
| -// refresh stores what read found. A failed read leaves the copy as it was: |
| -// an unread copy stays unknown, and a known one is not wiped by a blip. |
| +// refresh stores what read found. A failed read keeps the last known state |
| +// for root (a known copy is not wiped by a blip; an unread one stays |
| +// unknown) and records the failure in ReadErr, so the box's report says this |
| +// host's build state is incomplete and the housekeeper prunes nothing for its |
| +// app (Astra I1 #6). |
| func (b *buildStates) refresh(ctx context.Context, host string) error { |
| st, replica, err := b.read(ctx, host) |
| if err != nil { |
| + rt, gerr := b.reg.Get(host) |
| + prev := release.BuildStateCopy{} |
| + if gerr == nil && rt != nil { |
| + prev, _ = release.ParseBuildStateCopy(rt.BuildState) |
| + } |
| + prev.ReadErr = err.Error() |
| + if serr := b.reg.SetBuildState(host, prev.Column()); serr != nil { |
| + return serr |
| + } |
| return err |
| } |
| c := release.BuildStateCopy{Known: true, Replica: replica, State: st, ReadAt: b.now().UTC().Truncate(time.Second)} |
| @@ -1018,26 +1119,34 @@ 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 post-spawn check after `proc, spawnVer, err = a.backend.Start(ctx, rt)`), `internal/activator/claimwake.go` (`doClaimWake`, the same point), and the zdd spawn points listed in Step 5 |
| +- 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` (`doClaimWake`, the same points), `internal/activator/backend_exec.go` (`StartExe`, `SeedExe`; `seedChild` gains `exe`), `internal/activator/backend_unit.go` (`SplitBackend` forwards), and the zdd spawn points listed in Step 6 |
| - Modify: `internal/host/buildstate.go` (`beforeExec`), `internal/host/agent.go` (wire `svc.BeforeExec`) |
| **Interfaces:** |
| - Consumes: Task 4's `buildStates`; `release.ReadConsumed`; zdd-3 `startPinned`, `spawnSuccessor`, `startAdmitted`'s claimed fallback; zdd-4 `startWorker`, `release.ResolveLive`; zdd-5 `spawnPlanned`'s fallback, `persistGenerations`. |
| - Produces: `Service.BeforeExec func(ctx context.Context, host, build string) error`; `func (a *Service) beforeExec(ctx context.Context, host, build string) error`; `var errBuildStateUnwritten error`; host `func (b *buildStates) beforeExec(ctx context.Context, host, build string) error`. |
| -**Where it runs.** Every process that runs a build against an instance's database starts in one of these functions, each called with the instance's lease held. `BeforeExec` runs at the top of each, with the build that function is about to exec; a non-nil error returns before the exec, which the callers already treat as a failed spawn (a wake fails as a wake fails today; a swap is abandoned): |
| +**One executable identity per spawn (Astra I1 #5, spec §4.2).** A wake settles on **one** `release.StagedIdentity` (version, staged path, sha256) before anything runs, records it, and then seeds **and** spawns that same file by path, with `CARLOS_SWAP` on or off. Nothing execs `live/<host>` after the record, so a repoint by root between the record and the exec cannot make a different build touch the data, and a crash can never leave the build state naming the wrong build. The identity comes from: |
| + |
| +| Wake | Identity | Exec | |
| +|---|---|---| |
| +| planned boot (zdd-5 T9) | `plan.Boot` | `startPinned` (zdd-3 T6) | |
| +| pinned / hold wake (zdd-3 T6) | `*e.pin` | `startPinned` | |
| +| worker wake | `release.ResolveLive(optDir, host)`, once | `startWorker(…, &id)` (zdd-4 T9 execs `onto` by path) | |
| +| flag-off instance wake, claimed or not | `release.ResolveLive(optDir, host)`, once | `ExecBackend.StartExe(ctx, rt, id)` (new, below): zdd-3's spawn-only `spawnChildFrom` (flat) or `spawnUnitFrom` with a `StagedExe` (nested) on the canonical socket with generation 0, plus `startChildAt`'s existing health tail | |
| +| the seed, whichever wake | the wake's identity | `ExecBackend.SeedExe(ctx, rt, id)` (new): `seedChild` with the staged path in place of `b.liveLink(host)` | |
| + |
| +`doWake` and `doClaimWake` record that identity **once**, after `Restore` and **before the seed** (so a planned or pinned wake records before seeding too, which the first plan skipped). The spawn functions below still call `beforeExec` for spawns that do not come from a wake (a swap's successor, a fallback, a recycle), and skip the write when the wake already recorded the same build (`recordedBuild(ctx)`): |
| -| Path | Function (plan) | Build | |
| +| Spawn | Function (plan) | Build | |
| |---|---|---| |
| -| flag-off wake (and its seed) | `doWake`, after `Restore`, before the seed (main) | `release.LiveVersion(optDir, host)` | |
| -| flag-off claimed wake | `doClaimWake`, the same point (main) | the same | |
| | pinned wake, hold swap, planned boot and fallback | `startPinned` (zdd-3 T6, zdd-5 T9 adds `healthBy`) | `pin.Build` | |
| | overlap successor | `spawnSuccessor` (zdd-3 T10) | `g.Build` | |
| | claimed first-wake fallback | `startAdmitted`'s fallback branch (zdd-3 T10, the `policy.Fallback != nil && !ready` arm) | `policy.Fallback.Build` | |
| -| every worker spawn: wake, recycle, respawn, `ReplaceWorker` | `startWorker` (zdd-4 T9) | `onto.Version`, or, when `onto == nil`, `release.ResolveLive(optDir, host)`'s version, passed on as `onto` so the exec uses that same resolution | |
| +| every worker spawn: wake, recycle, respawn, `ReplaceWorker` | `startWorker` (zdd-4 T9) | `onto.Version`, or, when `onto == nil`, `release.ResolveLive`'s version, passed on as `onto` so the exec uses that same file | |
| | legacy sidecar | runs the serving generation's build (zdd-3 T7), already recorded by its generation's spawn | none of its own | |
| -The flag-off wake execs the live link, so root can repoint between the write and the exec. The backend reports the build it actually ran (`spawnVer`); if it differs, the agent records that build before the wake completes, and stops the process and fails the wake if that write fails (Review Focus 1). With `CARLOS_SWAP` on every instance spawn is a staged path, and the race does not exist. |
| +A fallback is a different build touching the data, so it records again before its exec. |
| - [ ] **Step 1: Write the failing tests.** Create `internal/activator/beforeexec_test.go`, using the activator's fake backend (`fakeBackend` in `activator_test.go`; read it for how a test drives `beginRequest` and counts spawns, and the #231 trap: assert spawns from the backend's count, never `Owns`): |
| @@ -1096,20 +1205,63 @@ func TestAFailedWriteBlocksTheSpawn(t *testing.T) { |
| } |
| } |
| -// Review Focus 1. |
| -func TestAWakeThatRanAnotherBuildRecordsIt(t *testing.T) { |
| +// Review Focus 1, Astra I1 #5: root repoints the live link while the build |
| +// state is being written. The wake still execs exactly the file it recorded, |
| +// never the link, and records once. |
| +func TestTheWakeExecsTheIdentityItRecorded(t *testing.T) { |
| a, b := newTestService(t) |
| b.liveVersion = "v1" |
| - b.spawnVersion = "v2" // root repointed between the write and the exec |
| var l execLog |
| - a.BeforeExec = l.hook |
| + a.BeforeExec = func(ctx context.Context, host, build string) error { |
| + b.liveVersion = "v2" // root's repoint lands mid-write |
| + return l.hook(ctx, host, build) |
| + } |
| done, err := a.beginRequest(context.Background(), testRoute("one.example.com")) |
| if err != nil { |
| t.Fatal(err) |
| } |
| done() |
| - if len(l.calls) != 2 || l.calls[1] != "one.example.com@v2" { |
| - t.Fatalf("calls = %v; want the build that ran recorded second", l.calls) |
| + if len(l.calls) != 1 || l.calls[0] != "one.example.com@v1" { |
| + t.Fatalf("calls = %v; want one record of v1", l.calls) |
| + } |
| + if b.liveStarts() != 0 || b.lastExeVersion() != "v1" { |
| + t.Fatalf("live-link starts %d, exe started %q; want only the recorded v1 by path", b.liveStarts(), b.lastExeVersion()) |
| + } |
| +} |
| + |
| +// Astra I1 #5: the seed runs the recorded identity, after the record. |
| +func TestTheSeedRunsTheRecordedIdentity(t *testing.T) { |
| + a, b := newTestService(t) |
| + b.liveVersion, b.fresh = "v1", true |
| + rt := testRoute("one.example.com") |
| + rt.Seed = `["seed"]` |
| + var l execLog |
| + a.BeforeExec = l.hook |
| + done, err := a.beginRequest(context.Background(), rt) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + done() |
| + if got := b.events(); len(got) < 3 || got[0] != "record v1" || got[1] != "seed v1" || got[2] != "start v1" { |
| + t.Fatalf("events = %v; want record, seed and start, all v1, in that order", got) |
| + } |
| +} |
| + |
| +// Astra I1 #5: a planned (or pinned) wake records before the seed too. |
| +func TestAPlannedWakeRecordsBeforeTheSeed(t *testing.T) { |
| + a, b := newPlannedTestService(t, "v3") // zdd-5 T9's planActivator with Boot v3, fresh, seeded |
| + var l execLog |
| + a.BeforeExec = l.hook |
| + done, err := a.beginRequest(context.Background(), seededRoute("one.example.com")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + done() |
| + if got := b.events(); len(got) < 2 || got[0] != "record v3" || got[1] != "seed v3" { |
| + t.Fatalf("events = %v; want the planned build recorded before the seed", got) |
| + } |
| + if len(l.calls) != 1 { |
| + t.Fatalf("calls = %v; startPinned re-recorded the build the wake already recorded", l.calls) |
| } |
| } |
| @@ -1126,9 +1278,9 @@ func TestANilHookIsToday(t *testing.T) { |
| } |
| ``` |
| -Add to `fakeBackend` (if it lacks them) a `liveVersion` it returns from `LiveVersion(host)`, a `spawnVersion` it returns from `Start` (default: `liveVersion`), and `startCount()`/`seedCount()` accessors. Add one more test per zdd chokepoint, each driving that path with the fixture zdd's own tests use for it and asserting `BeforeExec` saw the path's build before the backend's spawn count rose: `TestBeforeExecRunsBeforeAHoldSwap` (`Swap` with `ModeHold`, zdd-3 T6's fixture), `TestBeforeExecRunsBeforeAnOverlapSuccessor` (zdd-3 T4/T10's `swapFixture`), `TestBeforeExecRunsBeforeAWorkerRecycle` (zdd-4 T9's `workerFixture`, `recycleWorkerLocked`), `TestBeforeExecRunsBeforeAPlannedFallback` (zdd-5 T9's `planActivator` with `FirstWake` and a `Fallback`), and in each, a failing hook leaves the spawn count unchanged and the result a failure or `SwapAbandoned`. |
| +Add to `fakeBackend` (if it lacks them): a `liveVersion` it returns from `LiveVersion(host)` and resolves for `ResolveLive` (a staged path ending in the version); `fresh` for `Restore`; `StartExe`/`SeedExe` that append `"start <version>"`/`"seed <version>"` to an event log and remember the last identity; `Start` and `Seed` (the live-link forms) that count into `liveStarts()`; `startCount()` (all starts), `seedCount()`, `lastExeVersion()` and `events()`. The test hook in each test appends `"record <build>"` to the same log (wrap `l.hook`). `newPlannedTestService` and `seededRoute` wrap zdd-5 T9's `planActivator` and `testRoute` with a seed. Add one more test per zdd chokepoint, each driving that path with the fixture zdd's own tests use for it and asserting `BeforeExec` saw the path's build before the backend's spawn count rose: `TestBeforeExecRunsBeforeAHoldSwap` (`Swap` with `ModeHold`, zdd-3 T6's fixture), `TestBeforeExecRunsBeforeAnOverlapSuccessor` (zdd-3 T4/T10's `swapFixture`), `TestBeforeExecRunsBeforeAWorkerRecycle` (zdd-4 T9's `workerFixture`, `recycleWorkerLocked`), `TestBeforeExecRunsBeforeAPlannedFallback` (zdd-5 T9's `planActivator` with `FirstWake` and a `Fallback`), and in each, a failing hook leaves the spawn count unchanged and the result a failure or `SwapAbandoned`. |
| -- [ ] **Step 2: Run them and see them fail.** Run: `cd $W && TMPDIR=/tmp/k go test ./internal/activator/ -run 'BuildStateIsWritten|FailedWriteBlocks|RanAnotherBuild|NilHookIsToday|BeforeExecRuns' -count=1`. Expected: FAIL to compile, `a.BeforeExec undefined`. |
| +- [ ] **Step 2: Run them and see them fail.** Run: `cd $W && TMPDIR=/tmp/k go test ./internal/activator/ -run 'BuildStateIsWritten|FailedWriteBlocks|ExecsTheIdentityItRecorded|SeedRunsTheRecordedIdentity|PlannedWakeRecordsBeforeTheSeed|NilHookIsToday|BeforeExecRuns' -count=1`. Expected: FAIL to compile, `a.BeforeExec undefined`. |
| - [ ] **Step 3: Create `internal/activator/beforeexec.go`:** |
| @@ -1148,9 +1300,10 @@ var errBuildStateUnwritten = errors.New("the build state could not be written, s |
| // beforeExec runs BeforeExec, if set, for a spawn about to run build against |
| // host's database. Callers hold the instance's lease and call it before the |
| -// exec (and before a seed). nil BeforeExec is today's behaviour. |
| +// exec (and before a seed). nil BeforeExec is today's behaviour. A wake that |
| +// already recorded this build (withRecordedBuild) is not written twice. |
| func (a *Service) beforeExec(ctx context.Context, host, build string) error { |
| - if a.BeforeExec == nil { |
| + if a.BeforeExec == nil || recordedBuild(ctx) == build && build != "" { |
| return nil |
| } |
| if err := a.BeforeExec(ctx, host, build); err != nil { |
| @@ -1158,8 +1311,30 @@ func (a *Service) beforeExec(ctx context.Context, host, build string) error { |
| } |
| return nil |
| } |
| + |
| +type recordedKey struct{} |
| + |
| +// withRecordedBuild marks ctx as carrying a wake whose build is recorded. |
| +func withRecordedBuild(ctx context.Context, build string) context.Context { |
| + return context.WithValue(ctx, recordedKey{}, build) |
| +} |
| + |
| +func recordedBuild(ctx context.Context) string { |
| + s, _ := ctx.Value(recordedKey{}).(string) |
| + return s |
| +} |
| + |
| +// identityBackend is the exec backend's identity-pinned spawn and seed |
| +// (Astra I1 #5): exec exactly id.StagedPath, verified against id.Digest, |
| +// never live/<host>. |
| +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 |
| +} |
| ``` |
| +Add `registry` and `release` to the imports. |
| + |
| and to `Service`: |
| ```go |
| @@ -1169,36 +1344,51 @@ and to `Service`: |
| BeforeExec func(ctx context.Context, host, build string) error |
| ``` |
| -- [ ] **Step 4: The flag-off wake.** In `doWake`, directly before the comment `// 3. First-boot seed, strictly between the restore and the spawn`, add: |
| +- [ ] **Step 4: The exec backend execs an identity.** In `internal/activator/backend_exec.go`, add `StartExe` and `SeedExe` to `ExecBackend` (and forward them on `SplitBackend`, as zdd-4 T8 forwards `StartWorkerStaged`): |
| + - `StartExe(ctx, rt, id)`: `release.VerifyStaged(id)` first (a refusal is a failed start, never a fallback to the link); then the flat path calls zdd-3's `spawnChildFrom(ctx, rt, sock, 0, id.StagedPath, tier)` and the nested path `spawnUnitFrom(ctx, rt, sock, 0, &instancectl.StagedExe{…from id…}, tier)`, with `sock` and `tier` exactly as `Start` derives them today (the canonical socket; `b.TierOf(rt, id.Version)`), followed by the health tail `startChildAt`/`startUnitAt` already run after their spawn (zdd-3 split those functions at the health wait, so the tail is a function to call, not code to copy). It returns `id.Version` as the spawned version. |
| + - `SeedExe(ctx, rt, id)`: `release.VerifyStaged(id)`, then `seedChild`'s body with `id.StagedPath` where it passes `b.liveLink(host)` (`backend_exec.go` ~L770: `seedChild` gains an `exe string` parameter; `Seed` calls it with the live link, unchanged). For a nested row, pass the staged executable the way zdd-3 T9 passes one to `startRole` (`exe *instancectl.StagedExe`), which root validates. |
| + |
| +- [ ] **Step 5: The wake records one identity, then seeds and spawns it.** In `doWake`, directly before the comment `// 3. First-boot seed, strictly between the restore and the spawn`, add: |
| ```go |
| - // 2b. The build state (settings spec §4.2): before the seed and the |
| - // spawn, both of which run a build against the database. Under the |
| - // lease taken in step 1. A failed write fails the wake as a failed |
| - // restore does. |
| - execBuild, _ := a.liveBuild(host) |
| - if err := a.beforeExec(ctx, host, execBuild); err != nil { |
| + // 2b. One executable identity for this wake (settings spec §4.2, Astra |
| + // I1 #5): chosen now, recorded now (before the seed and the spawn, under |
| + // the lease taken in step 1), and the only file this wake runs. |
| + id, idErr := a.wakeIdentity(e, host) |
| + if idErr == nil { |
| + idErr = a.beforeExec(ctx, host, id.Version) |
| + } |
| + if idErr != nil { |
| w.stopOutcome.leaseReleased = lease.Release(ctx) == nil |
| - return w, fmt.Errorf("%s: %w", host, err) |
| + return w, fmt.Errorf("%s: %w", host, idErr) |
| } |
| + ctx = withRecordedBuild(ctx, id.Version) |
| ``` |
| -where `liveBuild` uses the optional `LiveVersion(host string) (string, error)` interface the activator already asserts at `activator.go:708`. Skip this block when a planned or pinned wake is about to call `startPinned` or `startWorker` with an `onto` (those record their own build in Step 5); the flag-off path is the `else` arm that reaches `a.backend.Start` or `a.startWorker(…, nil)`. After `proc, spawnVer, err = a.backend.Start(ctx, rt)` succeeds, add: |
| +with, in `beforeexec.go`: |
| ```go |
| - if err == nil && spawnVer != "" && spawnVer != execBuild { |
| - // Root repointed between the write and the exec: record the build |
| - // that actually ran before this wake completes. |
| - if rerr := a.beforeExec(ctx, host, spawnVer); rerr != nil { |
| - a.cleanupFailedWake(ctx, w, lease) |
| - return w, fmt.Errorf("start %s: %w", host, rerr) |
| - } |
| +// wakeIdentity is the one file a wake will run: a planned boot's or a pinned |
| +// wake's staged build, else the live link resolved once (release.ResolveLive). |
| +func (a *Service) wakeIdentity(e *actEntry, host string) (release.StagedIdentity, error) { |
| + a.mu.Lock() |
| + pin, plan := e.pin, e.plan |
| + a.mu.Unlock() |
| + switch { |
| + case plan.planned(): |
| + return plan.Boot, nil |
| + case pin != nil: |
| + return release.StagedIdentity{Version: pin.Build, StagedPath: pin.StagedPath, Digest: pin.Digest, Demand: pin.Demand}, nil |
| } |
| + return release.ResolveLive(a.optDir(), host) |
| +} |
| ``` |
| -Make the same two edits in `doClaimWake` at its restore-then-spawn point. |
| +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 flag-off instance arm's `proc, spawnVer, err = a.backend.Start(ctx, rt)` with `proc, spawnVer, err = ib.StartExe(ctx, rt, id)`, 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 (`wakeIdentity` is skipped when `ib` is nil), because nothing there could exec a pinned file. |
| + |
| +Make the same edits in `doClaimWake` at its restore-then-seed-then-spawn point. |
| -- [ ] **Step 5: The zdd chokepoints.** Add, as the first statement after each function's argument checks: |
| +- [ ] **Step 6: The zdd chokepoints.** Add, as the first statement after each function's argument checks (each skips the write when the wake already recorded the same build): |
| - 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 … }`. |
| @@ -1206,7 +1396,7 @@ Make the same two edits in `doClaimWake` at its restore-then-spawn point. |
| 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. |
| -- [ ] **Step 6: The agent's hook.** Add to `internal/host/buildstate.go`: |
| +- [ ] **Step 7: The agent's hook.** Add to `internal/host/buildstate.go`: |
| ```go |
| // beforeExec is Service.BeforeExec (settings spec §4.2): before build runs |
| @@ -1234,8 +1424,13 @@ func (b *buildStates) beforeExec(ctx context.Context, host, build string) error |
| if c.Resolved != nil && c.Resolved.ID > release.Watermark(nil, &next) { |
| next.Resolved = c.Resolved |
| } |
| - if c.Completed > next.Completed { |
| + switch { |
| + case c.Completed > next.Completed: |
| next.Completed, next.CompletedFailed = c.Completed, c.CompletedFailed |
| + case c.Completed == next.Completed && c.CompletedFailed: |
| + // Root learned the completed request's T failed after it ran |
| + // (Astra I1 #10): the outcome changes, the request stays closed. |
| + next.CompletedFailed = true |
| } |
| } |
| next.LastExecuted = build |
| @@ -1251,9 +1446,9 @@ 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. |
| -- [ ] **Step 7: Run the packages.** Run: `cd $W && go build ./... && TMPDIR=/tmp/k go test -race ./internal/activator/ ./internal/host/ -count=1`. Expected: PASS, every zdd test included. |
| +- [ ] **Step 8: Run the packages.** Run: `cd $W && go build ./... && TMPDIR=/tmp/k go test -race ./internal/activator/ ./internal/host/ -count=1`. Expected: PASS, every zdd test included. |
| -- [ ] **Step 8: Commit.** |
| +- [ ] **Step 9: Commit.** |
| ```bash |
| cd $W && gofmt -l internal/activator internal/host && go vet ./internal/activator/ ./internal/host/ |
| @@ -1450,6 +1645,44 @@ func TestAnInstallRequestResolvesOnceAndWaitsOnBake(t *testing.T) { |
| } |
| } |
| +// Astra I1 #9: a record-authorized pin installs on a box that cannot read |
| +// the channel pointer. |
| +func TestAPinInstallsWithoutAChannelPointer(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt, reg := t.TempDir(), testRegistry(t) |
| + shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("v1")) |
| + if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.Delete(ctx, ChannelKey("bab", "km", "canary/x")); err != nil { // the pointer is gone |
| + t.Fatal(err) |
| + } |
| + settingsRoute(t, reg, "a.example.com", UpdateSettings{Updates: UpdatesAuto, Channel: "canary/x", Pin: "v1"}, BuildStateCopy{Known: true}) |
| + passWithSettings(t, reg, s, pub, opt) |
| + if v, _ := LiveVersion(opt, "a.example.com"); v != "v1" { |
| + t.Fatalf("live %q, want the pin v1 with no pointer", v) |
| + } |
| +} |
| + |
| +// Astra I1 #8: a request waiting on bake is reported beside a major hold, |
| +// and never replaces it. |
| +func TestABakingRequestKeepsTheHeldReason(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt, reg := t.TempDir(), testRegistry(t) |
| + ratchetedProductionApp(t, s, ctx, priv, pub, opt, reg) // adopt_test.go: a stable head inside its bake window on this box |
| + settingsRoute(t, reg, "held.example.com", UpdateSettings{Updates: UpdatesManual, Channel: "stable", InstallRequest: 1}, |
| + BuildStateCopy{Known: true, Replica: true, State: &BuildState{LastExecuted: "v0"}}) |
| + passWithSettings(t, reg, s, pub, opt) |
| + c, _ := ReadConsumed(opt, "held.example.com") |
| + if c == nil || c.Reason != string(ReasonManual) || c.Baking == "" || c.Resolved != nil { |
| + t.Fatalf("consumed = %+v; want reason manual, the baking head beside it, nothing resolved", c) |
| + } |
| +} |
| + |
| func TestDefaultSettingsAreTodaysAdoption(t *testing.T) { |
| s := NewDirStore(t.TempDir()) |
| ctx := context.Background() |
| @@ -1470,9 +1703,9 @@ func TestDefaultSettingsAreTodaysAdoption(t *testing.T) { |
| } |
| ``` |
| -The baking half of `TestAnInstallRequestResolvesOnceAndWaitsOnBake` uses a `stable` channel and `ratchetedProductionApp` (`adopt_test.go:3112`) to keep the head inside a bake window; add that sub-case with a second host, asserting `c.Resolved == nil` and `c.Reason == "baking"` while held, and `{1, head}` after the window (advance the bake clock as `adopt_test.go`'s bake tests do). |
| +The baking half of `TestAnInstallRequestResolvesOnceAndWaitsOnBake` uses a `stable` channel and `ratchetedProductionApp` (`adopt_test.go:3112`) to keep the head inside a bake window; add that sub-case with a second host, asserting `c.Resolved == nil`, `c.Baking` naming the head and `c.Reason` still the held reason while held, and `{1, head}` after the window (advance the bake clock as `adopt_test.go`'s bake tests do). |
| -- [ ] **Step 2: Run them and see them fail.** Run: `cd $W && go test ./internal/release/ -run 'ManualInstanceOnAFreshBox|UnknownBuildInstallsNothing|PinNeedsAMatching|PreFeatureBuild|InstallRequestResolvesOnce|DefaultSettingsAreToday' -count=1`. Expected: FAIL to compile, `undefined: WithUpdateSettings`. |
| +- [ ] **Step 2: Run them and see them fail.** Run: `cd $W && go test ./internal/release/ -run 'ManualInstanceOnAFreshBox|UnknownBuildInstallsNothing|PinNeedsAMatching|PreFeatureBuild|InstallRequestResolvesOnce|DefaultSettingsAreToday|PinInstallsWithoutAChannelPointer|BakingRequestKeepsTheHeldReason' -count=1`. Expected: FAIL to compile, `undefined: WithUpdateSettings`. |
| - [ ] **Step 3: The option.** Add to `passConfig`: |
| @@ -1524,22 +1757,45 @@ In `PassFilter`, set `cfg.pub = pub` after the options loop. After the pointer l |
| An unreadable tag list leaves the map empty: every head reads as untagged, so a major-held instance stays held, the safe direction. |
| -- [ ] **Step 4: `adoptHost` asks `Target`.** In `adoptHost`, directly after `channel := routeChannel(rt)` and `ver := ptr.Version` and the `LiveVersion` read (`cur`), insert: |
| +- [ ] **Step 4: `adoptHost` asks `Target`.** `adoptHost` opens with `if ptr == nil { return }` (`adopt.go:956`). §4.3 rules 1 to 3 (a pin, a resolved request, a held recovery) need no channel head, so a fresh box must still install them when the pointer is unreadable (Astra I1 #9). Replace that guard and everything up to the `cur` read with: |
| ```go |
| + settings := cfg.settings && rt.Kind == "instance" && rt.Unit == "" |
| + if ptr == nil && !settings { |
| + return |
| + } |
| + channel := routeChannel(rt) |
| + ver := "" |
| + if ptr != nil { |
| + ver = ptr.Version |
| + // (the canary nag block, unchanged, inside this branch) |
| + } |
| + cur, err := LiveVersion(optDir, rt.Host) |
| + if err != nil { |
| + fmt.Printf(" %s: read live link: %v — skipped\n", rt.Host, err) |
| + return |
| + } |
| // Instance update settings (settings spec §4): one decision per route, |
| // root's. Everything below (proof, refusals, D2, caps, restart) still |
| - // applies to whatever it chooses. |
| + // applies to whatever it chooses. Rules 1-3 need no head (I1 #9). |
| var dec TargetDecision |
| - if cfg.settings && rt.Kind == "instance" && rt.Unit == "" { |
| + if settings { |
| var stop bool |
| ver, dec, stop = settingsTarget(ctx, s, optDir, bake, rt, ptr, cur, now, cfg, holds, ignore) |
| if stop { |
| return |
| } |
| + if ptr == nil { |
| + // The gates below read the pointer for its version and promoted |
| + // time; a head-less decision stands in the way heldProofAdmit's |
| + // does (materializeheld.go). |
| + ptr = &ChannelPointer{Version: ver} |
| + } |
| } |
| ``` |
| +The `read live link` line is today's, kept verbatim. Every later use of `ptr` then sees either the real head or that stand-in. The D1 refusal check, `Hold` and `ptr.FastTrack` already only apply when the decision is the head (next paragraph). Also, in `PassFilter`'s two loops, call `adoptHost` for a route even when its group's pointer is nil (they already do: they look the pointer up and pass it, nil or not). |
| + |
| and add the helper at the end of the file: |
| ```go |
| @@ -1561,10 +1817,14 @@ func settingsTarget(ctx context.Context, s Store, optDir string, bake *FirstSeen |
| fmt.Printf(" %s: consumed file unreadable (%v): nothing installed\n", rt.Host, cerr) |
| return "", TargetDecision{}, true |
| } |
| - in := TargetInput{Route: rt, Pointer: ptr, Settings: u, Build: bs, Consumed: consumed, Installed: cur, |
| - HeadHeld: Hold(bake, acct, rt.App, routeChannel(rt), ptr.Version, holds, ignore || ptr.FastTrack, now)} |
| - if cfg.tags != nil { |
| - in.HeadTag = cfg.tags[acct+"/"+rt.App][ptr.Version] |
| + in := TargetInput{Route: rt, Pointer: ptr, Settings: u, Build: bs, Consumed: consumed, Installed: cur} |
| + headVersion, fastTrack := "", false |
| + if ptr != nil { |
| + headVersion, fastTrack = ptr.Version, ptr.FastTrack |
| + in.HeadHeld = Hold(bake, acct, rt.App, routeChannel(rt), ptr.Version, holds, ignore || ptr.FastTrack, now) |
| + if cfg.tags != nil { |
| + in.HeadTag = cfg.tags[acct+"/"+rt.App][ptr.Version] |
| + } |
| } |
| if cfg.failed != nil { |
| served := func(v string) bool { return v == rt.GoodVersion } |
| @@ -1572,20 +1832,27 @@ func settingsTarget(ctx context.Context, s Store, optDir string, bake *FirstSeen |
| } |
| dec := Target(in) |
| if u.Default() && consumed == nil && dec.Reason == ReasonAdopt { |
| - return ptr.Version, dec, false // today's adoption: write nothing new |
| + return headVersion, dec, false // today's adoption: write nothing new |
| } |
| next := Consumed{Host: rt.Host} |
| if consumed != nil { |
| next = *consumed |
| } |
| - if dec.Fail > next.Completed { |
| + switch { |
| + case dec.Fail > next.Completed: |
| next.Completed, next.CompletedFailed = dec.Fail, true |
| + case dec.Fail != 0 && dec.Fail == next.Completed: |
| + next.CompletedFailed = true // T failed after the request completed (I1 #10); not reopened |
| } |
| if dec.Resolve != nil { |
| next.Resolved = dec.Resolve |
| } |
| - if u.InstallRequest > Watermark(&next, bs.State) && next.Resolved == nil && dec.Resolve == nil && in.HeadHeld != "" { |
| - dec.Reason, dec.Detail = ReasonBaking, ptr.Version // reported; rule 3 or 4 still decided |
| + // A pending request waiting on this box's bake is reported BESIDE the |
| + // decision, never in place of it (Astra I1 #8): the held reason is what |
| + // the wake check reads (Task 7). |
| + next.Baking = "" |
| + if u.InstallRequest > Watermark(&next, bs.State) && next.Resolved == nil && dec.Resolve == nil && in.HeadHeld != "" && !u.InstallFastTrack { |
| + next.Baking = headVersion |
| } |
| next.Desired, next.Reason, next.Detail, next.At = dec.Version, string(dec.Reason), dec.Detail, time.Time{} |
| if cfg.resolve { |
| @@ -1603,7 +1870,7 @@ func settingsTarget(ctx context.Context, s Store, optDir string, bake *FirstSeen |
| return "", dec, true |
| } |
| if dec.Bake { |
| - if hold := Hold(bake, acct, rt.App, routeChannel(rt), dec.Version, holds, ignore || ptr.FastTrack, now); hold != "" { |
| + if hold := Hold(bake, acct, rt.App, routeChannel(rt), dec.Version, holds, ignore || fastTrack, now); hold != "" { |
| fmt.Printf(" %s: %s (%s) is %s\n", rt.Host, dec.Version, routeChannel(rt), hold) |
| return "", dec, true |
| } |
| @@ -1786,7 +2053,7 @@ func TestAnAutoInstanceIsNotChecked(t *testing.T) { |
| } |
| ``` |
| -Add `lastStartedVersion()` to `fakeBackend` if it lacks one (the `spawnVersion` of the last `Start`). |
| +`lastStartedVersion()` is Task 5's `lastExeVersion()` (the version of the last `StartExe` or `StartStaged`). |
| - [ ] **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. |
| @@ -1902,6 +2169,32 @@ func (b *buildStates) heldTarget(host string) (string, bool, bool) { |
| 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"). |
| +Add to `internal/host/buildstate_test.go`: |
| + |
| +```go |
| +// Astra I1 #8, the agent's half: root reports a major hold with a request |
| +// baking beside it (Consumed.Baking); the wake check still treats the |
| +// instance as held, on last_executed. |
| +func TestAMajorHoldStaysHeldWhileARequestBakes(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + two := 2 |
| + if err := reg.SetUpdateSettings("a.example.com", release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", Major: &two, InstallRequest: 1}.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) |
| + } |
| + 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) |
| + } |
| +} |
| +``` |
| + |
| +With Task 6's fix (the baking head goes in `Baking`, `Reason` stays `major`) this passes; with the first plan's overwrite to `baking`, `heldByRoot` was false and the instance woke on its stale link. |
| + |
| - [ ] **Step 5: Run the packages.** Run: `cd $W && go build ./... && TMPDIR=/tmp/k go test -race ./internal/activator/ ./internal/host/ -count=1`. Expected: PASS. |
| - [ ] **Step 6: Commit.** |
| @@ -2091,7 +2384,32 @@ func report(label string, at time.Time, pins map[string]status.BuildStatePins) s |
| } |
| func kmPins(builds ...string) status.BuildStatePins { |
| - return status.BuildStatePins{Account: "bab", App: "km", Builds: builds} |
| + return status.BuildStatePins{Account: "bab", App: "km", Builds: builds, Complete: true} |
| +} |
| + |
| +// Astra I1 #6: a host whose build state its box could not read stops |
| +// pruning for the app, even in a fresh, readable report. |
| +func TestAnIncompleteBuildStatePrunesNothing(t *testing.T) { |
| + s := recordsStore(t, provision.Record{Schema: 1, Host: "b.example.com", App: "km", Account: "bab", Channel: "stable"}) |
| + inc := kmPins() |
| + inc.Complete = false |
| + boxes := []status.Box{report("uk-1", now0.Add(-time.Minute), map[string]status.BuildStatePins{"b.example.com": inc})} |
| + if _, err := settingsProtected(context.Background(), s, boxes, true, now0, "bab", "km"); err == nil { |
| + t.Fatal("an unreadable build state did not stop pruning") |
| + } |
| +} |
| + |
| +func TestAnUnreadBuildStateIsReportedIncomplete(t *testing.T) { |
| + _, _, reg := testBuildStates(t) // a.example.com, never refreshed: copy unknown |
| + if p, ok := buildStatePins(reg)["a.example.com"]; !ok || p.Complete { |
| + t.Fatalf("report = %+v, %v; want the host listed and incomplete", p, ok) |
| + } |
| + if err := reg.SetBuildState("a.example.com", release.BuildStateCopy{Known: true, ReadErr: "timeout"}.Column()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p := buildStatePins(reg)["a.example.com"]; p.Complete { |
| + t.Fatal("a failed refresh was reported complete") |
| + } |
| } |
| func recordsStore(t *testing.T, recs ...provision.Record) release.Store { |
| @@ -2184,7 +2502,7 @@ Add `"time"` and `"github.com/carlosframework/platform/internal/status"` to the |
| If `provision.CreateRecord` validates more than these fields (a slug, a domain), fill them from `provision_test.go`'s record fixture. |
| -- [ ] **Step 2: Run them, see them fail.** Run: `cd $W && TMPDIR=/tmp/k go test ./internal/host/ -run 'ReportedBuildStates|SilentForSixHours|NewerReportForTheHost|OtherAppsReports|UnreadableRecordOrReport|ReportsItsBuildStates' -count=1`. Expected: FAIL to compile (`status.BuildStatePins` undefined). |
| +- [ ] **Step 2: Run them, see them fail.** Run: `cd $W && TMPDIR=/tmp/k go test ./internal/host/ -run 'ReportedBuildStates|SilentForSixHours|NewerReportForTheHost|OtherAppsReports|UnreadableRecordOrReport|ReportsItsBuildStates|IncompleteBuildState|ReportedIncomplete' -count=1`. Expected: FAIL to compile (`status.BuildStatePins` undefined). |
| - [ ] **Step 3: The report.** In `internal/status/snapshot.go`, beside `HostUpdates`' neighbours in `BoxMetrics`: |
| @@ -2199,10 +2517,14 @@ If `provision.CreateRecord` validates more than these fields (a slug, a domain), |
| ```go |
| // BuildStatePins is one instance's build-state builds in a box report. |
| +// Complete is false when this box could not read the instance's build state |
| +// (never read, a failed refresh, an unparseable copy): the housekeeper then |
| +// prunes nothing for the app (Astra I1 #6). |
| type BuildStatePins struct { |
| - Account string `json:"account"` |
| - App string `json:"app"` |
| - Builds []string `json:"builds"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Builds []string `json:"builds"` |
| + Complete bool `json:"complete"` |
| } |
| ``` |
| @@ -2219,20 +2541,24 @@ func buildStatePins(reg *registry.Registry) map[string]status.BuildStatePins { |
| } |
| out := map[string]status.BuildStatePins{} |
| for _, rt := range routes { |
| - c, err := release.ParseBuildStateCopy(rt.BuildState) |
| - if err != nil || c.State == nil { |
| + // Every instance that keeps a build state is reported, with or |
| + // without builds, so a missing or unreadable one shows as incomplete |
| + // rather than as nothing to protect. |
| + if rt.Kind != "instance" || !rt.Hibernate || rt.Unit != "" || rt.Platform { |
| continue |
| } |
| - var builds []string |
| - if c.State.LastExecuted != "" { |
| - builds = append(builds, c.State.LastExecuted) |
| - } |
| - if r := c.State.Resolved; r != nil && r.Target != "" && r.Target != c.State.LastExecuted { |
| - builds = append(builds, r.Target) |
| - } |
| - if len(builds) > 0 { |
| - out[rt.Host] = status.BuildStatePins{Account: release.RouteAccount(rt), App: rt.App, Builds: builds} |
| + p := status.BuildStatePins{Account: release.RouteAccount(rt), App: rt.App, Builds: []string{}} |
| + c, err := release.ParseBuildStateCopy(rt.BuildState) |
| + p.Complete = err == nil && c.Known && c.ReadErr == "" |
| + if err == nil && c.State != nil { |
| + if c.State.LastExecuted != "" { |
| + p.Builds = append(p.Builds, c.State.LastExecuted) |
| + } |
| + if r := c.State.Resolved; r != nil && r.Target != "" && r.Target != c.State.LastExecuted { |
| + p.Builds = append(p.Builds, r.Target) |
| + } |
| } |
| + out[rt.Host] = p |
| } |
| if len(out) == 0 { |
| return nil |
| @@ -2318,6 +2644,9 @@ func settingsProtected(ctx context.Context, store release.Store, boxes []status. |
| if now.Sub(n.at) > buildStateReportMaxAge { |
| return nil, fmt.Errorf("%s, which hosts %s, has not reported since %s", n.box, host, n.at.UTC().Format(time.RFC3339)) |
| } |
| + if !n.pins.Complete { |
| + return nil, fmt.Errorf("%s could not read the build state of %s", n.box, host) |
| + } |
| for _, v := range n.pins.Builds { |
| out[v] = true |
| } |
| @@ -2383,19 +2712,20 @@ import ( |
| func TestTheReportCarriesRootsDecision(t *testing.T) { |
| b, _, reg := testBuildStates(t) |
| - if err := release.WriteConsumed(b.optDir, release.Consumed{Host: "a.example.com", Desired: "v1", Reason: "manual", Detail: "v1", Completed: 2}); err != nil { |
| + if err := release.WriteConsumed(b.optDir, release.Consumed{Host: "a.example.com", Desired: "v1", Reason: "major", Detail: "v1", Completed: 2, Baking: "v4"}); err != nil { |
| t.Fatal(err) |
| } |
| - if err := reg.SetUpdateSettings("a.example.com", release.UpdateSettings{Updates: release.UpdatesManual, InstallRequest: 3}.Column()); err != nil { |
| + two := 2 |
| + if err := reg.SetUpdateSettings("a.example.com", release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", Major: &two, InstallRequest: 3}.Column()); err != nil { |
| t.Fatal(err) |
| } |
| got := updatesReport(reg, b.optDir)["a.example.com"] |
| - want := status.HostUpdates{Desired: "v1", Reason: "manual", Detail: "v1", Pending: 3, Completed: 2} |
| - if got != want { |
| - t.Fatalf("report = %+v, want %+v", got, want) |
| + if got.Desired != "v1" || got.Reason != "major" || got.Detail != "v1" || got.Pending != 3 || got.Completed != 2 || |
| + got.Baking != "v4" || got.Major == nil || *got.Major != 2 { |
| + t.Fatalf("report = %+v; want root's decision, the baking head beside it, and the effective major", got) |
| } |
| rep, err := updatesReader(reg, b.optDir)("a.example.com") |
| - if err != nil || rep.Desired != "v1" || rep.Reason != "manual" || rep.Settings.Updates != release.UpdatesManual { |
| + if err != nil || rep.Desired != "v1" || rep.Reason != "major" || rep.Settings.Major == nil { |
| t.Fatalf("socket report = %+v, %v", rep, err) |
| } |
| } |
| @@ -2407,7 +2737,7 @@ func TestEveryBoxOfThisBinaryReportsTheProtocol(t *testing.T) { |
| } |
| ``` |
| -- [ ] **Step 2: Run, see it fail, then implement.** In `snapshot.go`, add `HostUpdates` (the index's shape plus `Detail`) 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`: |
| +- [ ] **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 |
| package host |
| @@ -2442,9 +2772,9 @@ func updatesReport(reg *registry.Registry, optDir string) map[string]status.Host |
| continue |
| } |
| bs, _ := release.ParseBuildStateCopy(rt.BuildState) |
| - h := status.HostUpdates{} |
| + h := status.HostUpdates{Major: u.Major} |
| if c != nil { |
| - h.Desired, h.Reason, h.Detail = c.Desired, c.Reason, c.Detail |
| + h.Desired, h.Reason, h.Detail, h.Baking = c.Desired, c.Reason, c.Detail, c.Baking |
| } |
| h.Completed = release.Watermark(c, bs.State) |
| if c != nil && c.Completed == h.Completed { |
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 c2afe8b03..36fe4b988 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 |
| @@ -799,9 +799,9 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| **Interfaces:** |
| - Consumes: zdd-6's `joinBoxes`, `boxJoin`, `rolloutView` and its cross-tenant rule; `status.BoxMetrics.Updates`, `UpdateSettings`; `status.UpdateSettingsProtocol`. |
| -- Produces: `type updatesJoinView struct{ Desired, Reason, Detail string; Pending, Completed uint64; CompletedFailed, Enforced bool; account, app string }`; `boxJoin.updates map[string]updatesJoinView`; `func (j boxJoin) update(host, acct, app string) *updatesJoinView`; `instanceListRecord.UpdateReport *updatesJoinView` (`json:"update_report,omitempty"`); `apiInstanceRecord.Updates *apiUpdatesJoin` (`json:"update_report"`), `UpdatesSetting string` (`json:"updates"`), `Major *int` (`json:"major"`), `Pin string` (`json:"pin"`); `func updatesReason(u *apiUpdatesJoin, major *int, hasSettings bool) string`. |
| +- Produces: `type updatesJoinView struct{ Desired, Reason, Detail, Baking string; Major *int; Pending, Completed uint64; CompletedFailed, Enforced bool; account, app string }`; `boxJoin.updates map[string]updatesJoinView`; `func (j boxJoin) update(host, acct, app string) *updatesJoinView`; `instanceListRecord.UpdateReport *updatesJoinView` (`json:"update_report,omitempty"`), `instanceListRecord.SettingsDefault bool` (`json:"settings_default"`); `apiInstanceRecord.Updates *apiUpdatesJoin` (`json:"update_report"`), `UpdatesSetting string` (`json:"updates"`), `SettingsDefault bool` (`json:"settings_default"`); `func updatesReason(u *apiUpdatesJoin, settingsDefault bool) string`. |
| -The REASON cell keeps zdd-6's rollout reasons first (a failure hold outranks a preference), then the settings reason. The list record is the provision record's view (`recordView`), which carries plan 3's `updates`, `major` and `pin` keys, so the join is named `update_report` to keep `updates` for the record's own setting. `hasSettings` is `UpdatesSetting != "" || Major != nil || Pin != ""`. |
| +The REASON cell keeps zdd-6's rollout reasons first (a failure hold outranks a preference), then the settings reason. The list record is the provision record's view (`recordView`), which carries plan 3's `updates` key, so the join is named `update_report` to keep `updates` for the record's own setting (Astra I1 #11). The reason shown is the effective one the box reports, inherited or not (I1 #12). |
| - [ ] **Step 1: Write the failing tests.** Create `internal/console/updatesjoin_test.go`: |
| @@ -864,78 +864,117 @@ import ( |
| "github.com/carlosframework/platform/internal/updatewords" |
| ) |
| +// Astra I1 #12: the reason is the EFFECTIVE one the box reports, whether the |
| +// record set it or the app's default did; an inherited major hold names its |
| +// number from the report. |
| func TestUpdatesReason(t *testing.T) { |
| two := 2 |
| tx := updatewords.Text |
| for _, c := range []struct { |
| - u *apiUpdatesJoin |
| - major *int |
| - has bool |
| - want string |
| + u *apiUpdatesJoin |
| + settingsDefault bool |
| + want string |
| }{ |
| - {nil, nil, false, ""}, |
| - {&apiUpdatesJoin{Reason: "adopt", Enforced: true}, nil, true, ""}, |
| - {&apiUpdatesJoin{Reason: "manual", Detail: "v1", Enforced: true}, nil, false, ""}, |
| - {&apiUpdatesJoin{Reason: "manual", Detail: "v1", Enforced: true}, nil, true, fmt.Sprintf(tx("settings.box.reason.manual"), "v1")}, |
| - {&apiUpdatesJoin{Reason: "pin", Detail: "v3", Enforced: true}, nil, true, fmt.Sprintf(tx("settings.box.reason.pin"), "v3")}, |
| - {&apiUpdatesJoin{Reason: "major", Detail: "v2", Enforced: true}, &two, true, fmt.Sprintf(tx("settings.box.reason.major"), 2, "v2")}, |
| - {&apiUpdatesJoin{Enforced: false}, nil, true, tx("settings.cli.not-enforced-reason")}, |
| - {&apiUpdatesJoin{Reason: "unknown", Enforced: true}, nil, true, tx("settings.box.reason.unknown")}, |
| + {nil, true, ""}, |
| + {&apiUpdatesJoin{Reason: "adopt", Enforced: true}, true, ""}, |
| + {&apiUpdatesJoin{Reason: "manual", Detail: "v1", Enforced: true}, false, fmt.Sprintf(tx("settings.box.reason.manual"), "v1")}, |
| + {&apiUpdatesJoin{Reason: "pin", Detail: "v3", Enforced: true}, false, fmt.Sprintf(tx("settings.box.reason.pin"), "v3")}, |
| + {&apiUpdatesJoin{Reason: "major", Detail: "v2", Major: &two, Enforced: true}, false, fmt.Sprintf(tx("settings.box.reason.major"), 2, "v2")}, |
| + {&apiUpdatesJoin{Reason: "major", Detail: "v2", Major: &two, Baking: "v4", Enforced: true}, false, fmt.Sprintf(tx("settings.box.reason.major"), 2, "v2") + "; " + fmt.Sprintf(tx("settings.box.reason.baking"), "v4")}, |
| + {&apiUpdatesJoin{Enforced: false}, false, tx("settings.cli.not-enforced-reason")}, |
| + {&apiUpdatesJoin{Enforced: false}, true, ""}, |
| + {&apiUpdatesJoin{Reason: "unknown", Enforced: true}, false, tx("settings.box.reason.unknown")}, |
| } { |
| - if got := updatesReason(c.u, c.major, c.has); got != c.want { |
| - t.Errorf("updatesReason(%+v, %v) = %q, want %q", c.u, c.has, got, c.want) |
| + if got := updatesReason(c.u, c.settingsDefault); got != c.want { |
| + t.Errorf("updatesReason(%+v, %v) = %q, want %q", c.u, c.settingsDefault, got, c.want) |
| } |
| } |
| } |
| + |
| +// Astra I1 #11: the list record carries the report under update_report and |
| +// the record's own setting under updates; both decode. |
| +func TestTheListRecordDecodesBothUpdatesFields(t *testing.T) { |
| + var r apiInstanceRecord |
| + body := `{"host":"a.shop.example.com","updates":"manual","settings_default":false, |
| + "update_report":{"reason":"manual","detail":"v1","enforced":true}}` |
| + if err := json.Unmarshal([]byte(body), &r); err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + if r.UpdatesSetting != "manual" || r.Updates == nil || r.Updates.Reason != "manual" { |
| + t.Fatalf("decoded %+v / %+v", r.UpdatesSetting, r.Updates) |
| + } |
| +} |
| ``` |
| -Import `"fmt"`. |
| +Import `"encoding/json"` and `"fmt"`. |
| -- [ ] **Step 2: Run them, see them fail.** Run: `cd $W && go test ./internal/console/ -run UpdatesJoin -count=1 && go test . -run UpdatesReason -count=1`. Expected: FAIL to compile. |
| +- [ ] **Step 2: Run them, see them fail.** Run: `cd $W && go test ./internal/console/ -run 'UpdatesJoin|AnOldBoxIsNotEnforced' -count=1 && go test . -run 'UpdatesReason|DecodesBothUpdatesFields' -count=1`. Expected: FAIL to compile. |
| - [ ] **Step 3: The join.** In `apiinstances.go`, add the view type and the map beside zdd-6's `rolloutView` and `rollouts`; in `joinBoxes`, for every fresh box, record `enforced := b.Metrics.UpdateSettings >= status.UpdateSettingsProtocol`, then for every host in `RouteVersions` set a view with `Enforced: enforced`, and overlay `b.Metrics.Updates[host]`'s fields. Add `update(host, acct, app)` with the tenant rule above. In the list handler, set `rec.UpdateReport = j.update(host, acct.Sqid, app)` beside zdd-6's `rec.Rollout = …`. |
| -- [ ] **Step 4: The REASON cell.** In `instances_cli.go`, add to `apiInstanceRecord` `Updates *apiUpdatesJoin \`json:"updates"\`` and `UpdateSettingsSet bool \`json:"update_settings_set"\``, and: |
| +- [ ] **Step 4: The REASON cell.** In `instances_cli.go`, add to `apiInstanceRecord` (Astra I1 #11: the report is `update_report`; `updates` is the record's own setting string): |
| + |
| +```go |
| + Updates *apiUpdatesJoin `json:"update_report"` |
| + UpdatesSetting string `json:"updates"` |
| + SettingsDefault bool `json:"settings_default"` |
| +``` |
| + |
| +The console's list record gains `SettingsDefault bool \`json:"settings_default"\``: `provision.EffectiveUpdates(rec, manifest.Updates).Default()`, computed from the record and the app policy the list handler already reads, so an instance on a box that ignores settings is flagged even when its hold is inherited. Then: |
| ```go |
| type apiUpdatesJoin struct { |
| Desired string `json:"desired"` |
| Reason string `json:"reason"` |
| Detail string `json:"detail"` |
| + Major *int `json:"major"` |
| + Baking string `json:"baking"` |
| Pending uint64 `json:"pending"` |
| CompletedFailed bool `json:"completed_failed"` |
| Enforced bool `json:"enforced"` |
| } |
| -// updatesReason is the settings half of REASON (spec §5): manual, pinned, |
| -// held at major N, and the other reasons root reports. major is the record's |
| -// own hold; Detail is the build root names. |
| -func updatesReason(u *apiUpdatesJoin, major *int, hasSettings bool) string { |
| - if u == nil || !hasSettings { |
| +// updatesReason is the settings half of REASON (spec §5): the EFFECTIVE |
| +// reason the instance's box reports (Astra I1 #12), whether the record or the |
| +// app's default set it: manual, pinned, held at major N (the number from the |
| +// report), and the other reasons root reports, with a request waiting on bake |
| +// named beside the reason, never instead of it. |
| +func updatesReason(u *apiUpdatesJoin, settingsDefault bool) string { |
| + if u == nil { |
| return "" |
| } |
| if !u.Enforced { |
| + if settingsDefault { |
| + return "" |
| + } |
| return updatewords.Text("settings.cli.not-enforced-reason") |
| } |
| + var r string |
| switch u.Reason { |
| case "", "adopt": |
| - return "" |
| case "major": |
| n := 0 |
| - if major != nil { |
| - n = *major |
| + if u.Major != nil { |
| + n = *u.Major |
| } |
| - return fmt.Sprintf(updatewords.Text("settings.box.reason.major"), n, u.Detail) |
| + r = fmt.Sprintf(updatewords.Text("settings.box.reason.major"), n, u.Detail) |
| case "unknown", "new": |
| - return updatewords.Text("settings.box.reason." + u.Reason) |
| - case "pin", "request", "manual", "baking", "failed": |
| - return fmt.Sprintf(updatewords.Text("settings.box.reason."+u.Reason), u.Detail) |
| + r = updatewords.Text("settings.box.reason." + u.Reason) |
| + case "pin", "request", "manual", "failed": |
| + r = fmt.Sprintf(updatewords.Text("settings.box.reason."+u.Reason), u.Detail) |
| + } |
| + if u.Baking != "" { |
| + b := fmt.Sprintf(updatewords.Text("settings.box.reason.baking"), u.Baking) |
| + if r == "" { |
| + return b |
| + } |
| + return r + "; " + b |
| } |
| - return "" |
| + return r |
| } |
| ``` |
| -An unknown reason word (from a newer box) renders nothing rather than panicking in `Text`. In zdd-6's row loop, the REASON cell becomes `r := rolloutReason(rec.Rollout); if r == "" { r = updatesReason(rec.Updates, rec.Major, rec.UpdatesSetting != "" || rec.Major != nil || rec.Pin != "") }`. A record whose app default (not the record) sets `manual` shows no settings reason in the list; the instance page shows the effective settings. |
| +An unknown reason word (from a newer box) renders nothing rather than panicking in `Text`. In zdd-6's row loop, the REASON cell becomes `r := rolloutReason(rec.Rollout); if r == "" { r = updatesReason(rec.Updates, rec.SettingsDefault) }`. The console's `updatesJoinView` gains `Major *int` and `Baking string`, copied from `BoxMetrics.Updates` (plan 4 Task 10). |
| - [ ] **Step 5: Run the packages.** Run: `cd $W && go test ./internal/console/ . -count=1 -run 'Updates|Instances|Rollout'`. Expected: PASS. |