| 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 |