carlos / platform Public

WIP: settings plans partly updated for spec rev 11 (writer hit the usage limit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 24604dbc2c4f7c4bd62bce57f55622be2c213877 parent 1eb734b
5 files changed, +245 −13
  • docs/superpowers/plans/2026-10-07-instance-settings-1-pointer-digests.md +1 −1
  • docs/superpowers/plans/2026-10-07-instance-settings-2-promotion-records.md +190 −6
  • docs/superpowers/plans/2026-10-07-instance-settings-3-console-settings.md +52 −4
  • docs/superpowers/plans/2026-10-07-instance-settings-4-root-applies.md +1 −1
  • docs/superpowers/plans/2026-10-07-instance-settings-5-surfaces.md +1 −1

CI passed — run details

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 e00316138..77c089641 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
@@ -8,7 +8,7 @@
**Tech Stack:** Go (stdlib), `internal/release`, `internal/edge`, `internal/host`, `internal/console`, and the root `carlos` CLI package. No new dependencies.
-**Spec:** `docs/superpowers/specs/2026-10-07-instance-update-settings-design.md` (rev 10), §4.6 and the "Pointer digests (#253)" bullets of §8. Read §4.1's last three paragraphs too. Plan set index: `docs/superpowers/plans/2026-10-07-instance-settings-0-index.md` ("Plan 1 defines" fixes every name below).
+**Spec:** `docs/superpowers/specs/2026-10-07-instance-update-settings-design.md` (rev 11), §4.6 and the "Pointer digests (#253)" bullets of §8. Read §4.1's last three paragraphs too. Plan set index: `docs/superpowers/plans/2026-10-07-instance-settings-0-index.md` ("Plan 1 defines" fixes every name below).
**Branch:** `settings-1-pointer-digests`, cut from amadan `main`. Worktree: `/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-1-pointer-digests`, written `$W` below. Shell state does not persist between an agent's commands, so start each one with `export W=/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-1-pointer-digests GOFLAGS=-mod=mod GOPROXY=off`.
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 8d354248f..cc0d798fc 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
@@ -4,11 +4,11 @@
**Goal:** Every promotion leaves a durable, signed record binding (account, app, version, manifest digest), so a build stays provably authorized after it falls out of the pointer's ten-entry history, and an app member can explicitly authorize a build promoted before records existed.
-**Architecture:** A new signed object, `releases/<acct>/<app>/promoted/<version>`, in the pointer's wire format (`signedWrapper`, with the grant attached for a scoped signer). `sg.promote` writes it from the manifest bytes it already reads, before the pointer's conditional write. `AuthorizedManifest` reads the record and checks the manifest against its digest; plan 4 is its only caller that installs. `carlos releases authorize` shows a build's manifest and artifact digests and, on confirmation, signs a record for them: an explicit act, never automatic. Pruning a version removes its record; purging an app removes all of them. This plan also creates `internal/updatewords`, the home of every member-facing draft string plans 2–5 add.
+**Architecture:** A new signed object, `releases/<acct>/<app>/promoted/<version>`, in the pointer's wire format (`signedWrapper`, with the grant attached for a scoped signer). `sg.promote` writes it from the manifest bytes it already reads, before the pointer's conditional write. `AuthorizedManifest` reads the record and checks the manifest against its digest; plan 4 is its only caller that installs. `carlos releases authorize` shows a build's manifest and artifact digests and, on confirmation, signs a record for them: an explicit act, never automatic. Pruning a version removes its record; purging an app removes all of them. A move's held stage installs only a build a promotion record or a signed pointer authorizes (spec rev 11 §4.3a). This plan also creates `internal/updatewords`, the home of every member-facing draft string plans 2–5 add.
**Tech Stack:** Go (stdlib), `internal/release`, `internal/console`, the root `carlos` CLI package, and the new leaf `internal/updatewords`.
-**Spec:** `docs/superpowers/specs/2026-10-07-instance-update-settings-design.md` (rev 10): §4.1 in full, §4.4's "keeps the signed promotion record of every protected build", and §8's "Authorization", "Promotion binding" and "Evidence" bullets. Plan set index: `docs/superpowers/plans/2026-10-07-instance-settings-0-index.md` ("Plan 2 defines", "Words").
+**Spec:** `docs/superpowers/specs/2026-10-07-instance-update-settings-design.md` (rev 11): §4.1 in full, §4.3a's held-stage rule, §4.4's "keeps the signed promotion record of every protected build", and §8's "Authorization", "Promotion binding" and "Evidence" bullets. Plan set index: `docs/superpowers/plans/2026-10-07-instance-settings-0-index.md` ("Plan 2 defines", "Words").
**Branch:** `settings-2-promotion-records`, cut from amadan `main` after `settings-1-pointer-digests` has merged. Worktree: `/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-2-promotion-records`, written `$W`. Start every agent command with `export W=/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-2-promotion-records GOFLAGS=-mod=mod GOPROXY=off`.
@@ -23,6 +23,7 @@
- The digest is plan 1's `ManifestDigest` of the canonical manifest bytes. A record never vouches for anything else; it carries no artifact list, because the manifest carries each artifact's sha256 and the digest binds the manifest.
- Nothing is backfilled automatically (§4.1). The only writers are a promotion and `carlos releases authorize`.
- Every promotion writes a record, whether or not plan 1's digest marker is on.
+- A move's held stage (`release.StageHeld`, reached through `MaterializeHeld` and `ServeHeldStageRequests`) installs only a §4.1-authorized build: one with a promotion record whose digest the manifest matches, or one a verified pointer of the app names as head or history (digest-checked when the entry carries one). Anything else is refused before a byte is downloaded (spec rev 11 §4.3a).
- Every member-facing string is an `internal/updatewords` draft with an ID `settings.authorize.*` or `settings.evidence.*`. Plan 5's Task 1 reviews them with Paul. No em dash in any new string.
- Go: default build cache; never `GOCACHE`/`GOMODCACHE`/`GOTMPDIR` under `/tmp`. `GOFLAGS=-mod=mod GOPROXY=off`; on "read-only file system" rerun that command with the sandbox off. Scratch under `$TMPDIR/settings-2/`, deleted in the last task.
- Commits: `git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit`, one per task, `settings-2 task N: <what>`, ending `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Never push to `main`.
@@ -48,7 +49,8 @@
| `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 |
-| `releases_authorize.go` (new), `main.go`, `apimode.go`, `docs/site/cli.md` | `carlos releases authorize` | 7 |
+| `internal/release/materializeheld.go`, `heldauth_test.go` (new); `internal/host/heldstage.go`, `agent.go` | the held stage installs only authorized builds | 7 |
+| `releases_authorize.go` (new), `main.go`, `apimode.go`, `docs/site/cli.md` | `carlos releases authorize` | 8 |
---
@@ -1266,7 +1268,7 @@ func (s *Server) handleAPIAuthorize(w http.ResponseWriter, r *http.Request) {
}
```
-The 409 body text is `ErrAuthorizeChanged`'s; the CLI prints the reviewed `settings.authorize.changed` for a 409 instead (Task 7), so the API's sentinel is not member copy.
+The 409 body text is `ErrAuthorizeChanged`'s; the CLI prints the reviewed `settings.authorize.changed` for a 409 instead (Task 8), so the API's sentinel is not member copy.
- [ ] **Step 4: Register** in `authcli.go`, beside the rollback door:
@@ -1292,7 +1294,189 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
---
-### Task 7: `carlos releases authorize`, docs, gate and hand-off
+### Task 7: A move's held stage installs only authorized builds
+
+**Files:**
+- Modify: `internal/release/materializeheld.go` (`MaterializeHeld` ~L135, `StageHeld` ~L164, `ServeHeldStageRequests` ~L448, `serveHeldStageRequest` ~L471)
+- Create: `internal/release/heldauth_test.go`
+- Modify: `internal/host/heldstage.go` (`heldStagePass` ~L196), `internal/host/agent.go` (`runAdopt`'s `heldStagePass(` call ~L4121)
+
+**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`.
+
+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.
+
+- [ ] **Step 1: Write the failing tests.** Create `internal/release/heldauth_test.go`:
+
+```go
+package release
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func heldRoute(host string) registry.Route {
+ return registry.Route{Host: host, Account: "bab", App: "km", HoldOp: "op-1"}
+}
+
+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) {
+ t.Fatalf("StageHeld = %v; want ErrHeldNotAuthorized", err)
+ }
+ if _, err := os.Stat(VersionPath(opt, "bab", "v1", "km")); err == nil {
+ t.Fatal("an unauthorized build was staged")
+ }
+}
+
+func TestHeldStageAcceptsARecordedBuildOutOfHistory(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ for i := 0; i < historyMax+2; i++ {
+ v := "v" + string(rune('a'+i))
+ shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := StageHeld(ctx, s, pub, opt, heldRoute("a.example.com"), "va"); err != nil {
+ t.Fatalf("a recorded build out of the pointer's history: %v", err)
+ }
+}
+
+func TestHeldStageAcceptsAPointerNamedBuildWithoutARecord(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 {
+ t.Fatal(err)
+ }
+ 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 {
+ t.Fatalf("a build the signed pointer names: %v", err)
+ }
+}
+
+func TestHeldStageBindsTheRecordDigest(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("promoted"))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ saved, _, _ := s.Get(ctx, PromotionRecordKey("bab", "km", "v1"))
+ for i := 0; i <= historyMax; i++ { // push v1 out of the pointer, so the record is the only evidence
+ v := "w" + string(rune('a'+i))
+ shipRelease(t, s, "bab", "km", v, adoptPlatform, []byte(v))
+ if err := Promote(ctx, s, priv, "bab", "km", "canary/x", v); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := DeleteVersion(ctx, s, "bab", "km", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ shipRelease(t, s, "bab", "km", "v1", adoptPlatform, []byte("nobody promoted these"))
+ 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) {
+ t.Fatalf("a reused name under a replayed record = %v", err)
+ }
+ if _, err := os.Stat(VersionPath(opt, "bab", "v1", "km")); err == nil {
+ t.Fatal("the reused name's bytes were staged")
+ }
+}
+```
+
+- [ ] **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 3: Implement.** Add to `materializeheld.go`:
+
+```go
+// ErrHeldNotAuthorized is a held stage of a build §4.1 does not authorize:
+// no promotion record, and no verified pointer of the app names it (settings
+// spec rev 11 §4.3a). The request is dropped, never retried.
+var ErrHeldNotAuthorized = errors.New("release: the held release is not authorized: no promotion record and no signed pointer names it")
+
+// HeldAuthorized is §4.1 for a move's held stage. want is the digest staging
+// must bind: the record's, else the naming pointer entry's ("" for an entry
+// signed before digests, today's pointer trust).
+func HeldAuthorized(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string) (string, error) {
+ rec, err := ReadPromotionRecord(ctx, s, pub, acct, app, version)
+ if err == nil {
+ return rec.ManifestDigest, nil
+ }
+ if !errors.Is(err, ErrNoPromotionRecord) {
+ return "", err
+ }
+ prefix := ChannelsPrefix(acct, app)
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return "", err
+ }
+ for _, k := range keys {
+ name, ok := strings.CutSuffix(strings.TrimPrefix(k, prefix), ".json")
+ if !ok || name == "" {
+ continue
+ }
+ p, err := Channel(ctx, s, pub, acct, app, name)
+ if err != nil {
+ continue
+ }
+ if p.Version == version || slices.Contains(p.History, version) {
+ return p.DigestOf(version), nil
+ }
+ }
+ return "", fmt.Errorf("%w: %s/%s at %s", ErrHeldNotAuthorized, acct, app, version)
+}
+```
+
+Change `StageHeld`'s signature to `StageHeld(ctx context.Context, s Store, pub ed25519.PublicKey, optDir string, rt registry.Route, version string)`, and before `materializeHeldStage`:
+
+```go
+ want, err := HeldAuthorized(ctx, s, pub, 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).
+
+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.
+
+- [ ] **Step 4: Run the packages.** Run: `cd $W && go build ./... && go test ./internal/release/ -count=1 && TMPDIR=/tmp/k go test ./internal/host/ -count=1`. Expected: PASS.
+
+- [ ] **Step 5: Commit.**
+
+```bash
+cd $W && gofmt -l internal/release internal/host && go vet ./internal/release/ ./internal/host/
+git add internal/release/materializeheld.go internal/release/heldauth_test.go internal/release/materializeheld_test.go internal/host/
+git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "settings-2 task 7: a move's held stage installs only §4.1-authorized builds
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 8: `carlos releases authorize`, docs, gate and hand-off
**Files:**
- Create: `releases_authorize.go`, `releases_authorize_test.go`
@@ -1556,7 +1740,7 @@ Expected: every package `ok`, including `internal/docsite`; the cross-builds sil
```bash
cd $W && git add releases_authorize.go releases_authorize_test.go main.go apimode.go docs/site/cli.md internal/updatewords/words_authorize.go
-git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "settings-2 task 7: carlos releases authorize
+git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "settings-2 task 8: carlos releases authorize
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git push amadan settings-2-promotion-records 2>/dev/null || git push origin settings-2-promotion-records
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 cff3f8d7d..3f349e098 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
@@ -8,7 +8,7 @@
**Tech Stack:** Go (stdlib), `internal/release`, `internal/provision`, `internal/registry`, `internal/status`, `internal/console`, `internal/activator`, a new leaf `internal/instanceupdates`, and `internal/updatewords`.
-**Spec:** `docs/superpowers/specs/2026-10-07-instance-update-settings-design.md` (rev 10): §3, §4 (opening), §4.2's `install_request` row, §4.3's last line, §4.4 "Order of the roll", §4.5 "Named-unit routes", §5.1, and the §8 "Console" and "Retention" bullets about the console. Plan set index: `docs/superpowers/plans/2026-10-07-instance-settings-0-index.md` ("Plan 3 defines", Q3, Q4, Q7).
+**Spec:** `docs/superpowers/specs/2026-10-07-instance-update-settings-design.md` (rev 11): §3 (§3.2 as rev 11 states it: no default pin), §4 (opening), §4.2's `install_request` row, §4.3's last line, §4.4 "Order of the roll", §4.5 "Named-unit routes", §5.1, and the §8 "Console" and "Retention" bullets about the console. Plan set index: `docs/superpowers/plans/2026-10-07-instance-settings-0-index.md` ("Plan 3 defines", Q3, Q4, Q7).
**Branch:** `settings-3-console-settings`, cut from amadan `main` after `settings-2-promotion-records` has merged. Worktree: `/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-3-console-settings`, written `$W`. Start every agent command with `export W=/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-3-console-settings GOFLAGS=-mod=mod GOPROXY=off`.
@@ -17,7 +17,7 @@
## Global Constraints
- Setting values (§3.1): `updates` is `auto` or `manual`; `major` is empty or an integer ≥ 0 and is accepted only when the instance's channel is `stable`; `pin` is empty or a version that has a promotion record (plan 2, `release.ReadPromotionRecord`). An empty value clears a setting back to "never set", which then reads the app default (§5).
-- App policy (§3.2): `instance_control` ⊆ {`updates`, `channel`, `major`, `pin`}, default empty; defaults `auto`, the app's channel (`instances.json` `channel`, index Q4), no major. The defaults apply wherever an instance never set the value.
+- App policy (§3.2, rev 11): `instance_control` ⊆ {`updates`, `channel`, `major`, `pin`}, default empty; defaults `updates` (auto) and `major` (none), which apply wherever an instance never set them, and the channel (`instances.json` `channel`), which applies only when an instance is created. **There is no default pin**; the policy door refuses any key but its four.
- `channel` stays where it is today: `Record.Channel`, changed through the existing `POST …/instances/{host}/channel` door with its pipeline rules (passkeys, approvals). The `/updates` door reports it and does not change it. Plan 5's `carlos instances set --channel` calls the channel door.
- Wire shapes are additive. Every new record and manifest field is `omitempty`; a record written by an older console decodes with no settings. The registry column defaults to `''` and is never in `Fingerprint()`: it changes what root installs, not what the edge serves.
- The console never chooses a build (§4, §8). No door writes a version anywhere except `pin`, which is the member's own choice, checked only for having been promoted.
@@ -945,6 +945,8 @@ func init() {
Context: "Returned when the console could not write the record."},
Draft{ID: "settings.console.no-manifest", Text: "this app does not create instances yet. Run carlos instances enable first",
Context: "Returned by carlos apps set's update flags for an app with no instance provisioning."},
+ Draft{ID: "settings.console.unknown-policy-key", Text: "%s is not an app update setting. The app sets instance_control, default_updates, default_channel and default_major; there is no default pin",
+ Context: "Returned by the app policy door for any other key, for example a default pin. %s is the key."},
Draft{ID: "settings.console.bad-control", Text: "instance_control may name updates, channel, major and pin, each once",
Context: "Returned by carlos apps set --instance-control for anything else."},
Draft{ID: "settings.console.audit-set", Text: "changed the update settings of %s: %s",
@@ -1525,9 +1527,44 @@ func TestPolicyDefaultsAreGatedLikeSettings(t *testing.T) {
}
```
-`fx.manifest()` reads `provision.GetManifest` for `bab/km`; add it beside the fixture.
+```go
+// Spec rev 11 §3.2: there is no default pin.
+func TestPolicyDoorHasNoDefaultPin(t *testing.T) {
+ fx := instanceFixture(t)
+ fx.boxesEnforce(true)
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/bab/km/updates", map[string]any{"default_pin": "v1"}, fx.member)
+ if st != http.StatusBadRequest || !strings.Contains(out, "default_pin") {
+ t.Fatalf("a default pin: %d %s", st, out)
+ }
+ if m := fx.manifest(); m.Updates != nil {
+ t.Fatalf("the refused request wrote a policy: %+v", m.Updates)
+ }
+}
+
+// Spec rev 11 §3.2: the channel default applies only when an instance is
+// created; every record carries its channel from creation.
+func TestDefaultChannelReachesOnlyNewInstances(t *testing.T) {
+ fx := instanceFixture(t) // a.example.com exists on stable
+ st, _ := sendJSON(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/bab/km/updates", map[string]any{"default_channel": "beta"}, fx.member)
+ if st != http.StatusOK {
+ t.Fatal(st)
+ }
+ if ch := fx.record("a.example.com").Channel; ch != "stable" {
+ t.Fatalf("an existing instance moved to %s", ch)
+ }
+ if u := provision.EffectiveUpdates(*fx.record("a.example.com"), fx.manifest().Updates); u.Channel != "stable" {
+ t.Fatalf("effective channel %s", u.Channel)
+ }
+ fx.createInstance(t, "b.example.com") // through the console's create door, no channel given
+ if ch := fx.record("b.example.com").Channel; ch != "beta" {
+ t.Fatalf("a new instance got %s, want the default beta", ch)
+ }
+}
+```
-- [ ] **Step 2: Run them and see them fail.** Run: `cd $W && go test ./internal/console/ -run Policy -count=1`. Expected: FAIL with 404.
+`fx.manifest()` reads `provision.GetManifest` for `bab/km`; `fx.createInstance` posts to `POST /api/cli/apps/{account}/{app}/instances` as `apiinstances_test.go`'s create tests do. Add both beside the fixture. Without the key check above, `TestPolicyDoorHasNoDefaultPin` gets 200.
+
+- [ ] **Step 2: Run them and see them fail.** Run: `cd $W && go test ./internal/console/ -run 'Policy|DefaultChannel' -count=1`. Expected: FAIL with 404.
- [ ] **Step 3: The handler.** Append:
@@ -1544,6 +1581,17 @@ func (s *Server) handleAPIUpdatesPolicy(w http.ResponseWriter, r *http.Request)
if !readJSON(w, r, &req) {
return
}
+ // The policy has exactly these four keys (spec rev 11 §3.2): there is
+ // no default pin, and an unknown key is refused rather than ignored, so
+ // a caller who sends one learns it did nothing.
+ for k := range req {
+ switch k {
+ case "instance_control", "default_updates", "default_channel", "default_major":
+ default:
+ jsonError(w, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.unknown-policy-key"), k))
+ return
+ }
+ }
ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
defer cancel()
m, etag, err := provision.GetManifestETag(ctx, s.cfg.Releases, acct.Sqid, app)
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 629889f90..3c8f97f37 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
@@ -8,7 +8,7 @@
**Tech Stack:** Go (stdlib), `internal/release`, `internal/activator`, `internal/host`, `internal/registry`, `internal/status`, `internal/instanceupdates`, `internal/updatewords`; the zero-downtime plans' `internal/rollout` failure book.
-**Spec:** `docs/superpowers/specs/2026-10-07-instance-update-settings-design.md` (rev 10): §4 (all but §4.6), §6, and every §8 bullet from "`release.Target`" to "Root pass" and "Rollback". The zero-downtime spec (`2026-10-07-zero-downtime-deploys-design.md`) §4.2 (retained identity), §8.5 (failure records). Plan set index: `docs/superpowers/plans/2026-10-07-instance-settings-0-index.md` ("Plan 4 defines", the zdd table, Q1, Q2, Q9). Zero-downtime index: `docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md`, its ledger and "Fixes for Astra's P1 review".
+**Spec:** `docs/superpowers/specs/2026-10-07-instance-update-settings-design.md` (rev 11): §4 (all but §4.6), including rev 11's legacy-wake sentence in §4.2, §4.3a (moves carry the build state; the held stage itself is plan 2 Task 7) and §4.4's box-report retention; §6; and every §8 bullet from "`release.Target`" to "Root pass" and "Rollback". The zero-downtime spec (`2026-10-07-zero-downtime-deploys-design.md`) §4.2 (retained identity), §8.5 (failure records). Plan set index: `docs/superpowers/plans/2026-10-07-instance-settings-0-index.md` ("Plan 4 defines", the zdd table, Q2, Q10). Zero-downtime index: `docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md`, its ledger and "Fixes for Astra's P1 review".
**Branch:** `settings-4-root-applies`, cut from amadan `main` after settings plans 1, 2 and 3 **and** zero-downtime plans 1 to 5 have merged. Worktree: `/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-4-root-applies`, written `$W`. Start every agent command with `export W=/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-4-root-applies GOFLAGS=-mod=mod GOPROXY=off`.
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 8e9b58e5d..c2afe8b03 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
@@ -8,7 +8,7 @@
**Tech Stack:** Go (stdlib `net/http`, `html/template`, `encoding/json`), the root `carlos` CLI package, `internal/console`, `internal/updatewords`, `internal/status`.
-**Spec:** `docs/superpowers/specs/2026-10-07-instance-update-settings-design.md` (rev 10): §5 (surfaces), §3.3's last bullet (a refusal names the setting), §4.3's last line, §4.4's roll order, §4.5 (*settings not enforced*, rollback), and §8's "Console" and "Rollback" bullets. Plan set index: `docs/superpowers/plans/2026-10-07-instance-settings-0-index.md`. Zero-downtime plan 6 (`…-zero-downtime-deploys-6-surfaces.md`), which this plan extends: Tasks 1 (its copy review is this plan's pattern), 5 (`carlos apps set`), 8 (`joinBoxes`, `rolloutView`), 9 (the list's REASON column), 12 (the instance page), 15 (docs), 16 (approved-copy pin), 17 (roll notes).
+**Spec:** `docs/superpowers/specs/2026-10-07-instance-update-settings-design.md` (rev 11): §5 (surfaces), §3.3's last bullet (a refusal names the setting), §4.3's last line, §4.4's roll order, §4.5 (*settings not enforced*, rollback), and §8's "Console" and "Rollback" bullets. Plan set index: `docs/superpowers/plans/2026-10-07-instance-settings-0-index.md`. Zero-downtime plan 6 (`…-zero-downtime-deploys-6-surfaces.md`), which this plan extends: Tasks 1 (its copy review is this plan's pattern), 5 (`carlos apps set`), 8 (`joinBoxes`, `rolloutView`), 9 (the list's REASON column), 12 (the instance page), 15 (docs), 16 (approved-copy pin), 17 (roll notes).
**Branch:** `settings-5-surfaces`, cut from amadan `main` after settings plans 3 and 4 and zero-downtime plan 6 have merged. Worktree: `/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-5-surfaces`, written `$W`. Start every agent command with `export W=/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-5-surfaces GOFLAGS=-mod=mod GOPROXY=off`.