carlos / platform Public

spec: deploy credential rev 2, answering Astra round 1

Summary

All six round-1 findings checked against the code and accepted. The
scope check now runs inside the release primitive on the pipeline read it
executes against. Bare `canary` is refused. A deploy promote is signed by
a request-local scoped key, so boxes still verify the rung limit. The
whoami claim is withdrawn. Stable and hotfix claims corrected.
Agent notes
Findings committed unedited as CODEX-FINDINGS-DEPLOY-CREDENTIAL-R1.md
(the duplicated tail is codex's own output). Rev 2 removes the trade-off
rev 1 flagged to Paul in §7.4. Brief gains a round 2 section.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 38dfb2405063b905cea6eef46ccf94ce6dbc9693 parent d30a74c
3 files changed, +232 −42
  • docs/superpowers/specs/2026-10-05-deploy-credential-design.md +169 −42
  • docs/superpowers/specs/CODEX-FINDINGS-DEPLOY-CREDENTIAL-R1.md +43 −0
  • docs/superpowers/specs/CODEX-REVIEW-BRIEF-DEPLOY-CREDENTIAL.md +20 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-10-05-deploy-credential-design.md b/docs/superpowers/specs/2026-10-05-deploy-credential-design.md
index 8c872d0ca..e27a71607 100644
--- a/docs/superpowers/specs/2026-10-05-deploy-credential-design.md
+++ b/docs/superpowers/specs/2026-10-05-deploy-credential-design.md
@@ -1,7 +1,17 @@
# A deploy credential for CI: promote through the console, limited to rungs
**Date:** 2026-10-05
-**Revision:** 1.
+**Revision:** 2.
+
+- Rev 1 (d30a74c2) went to Astra (via codex). The findings are in
+ `CODEX-FINDINGS-DEPLOY-CREDENTIAL-R1.md`, unedited. All six were checked
+ against the code and accepted. Changes are marked "(codex N)" and §15
+ records each disposition.
+- **Rev 2 changes one thing Paul was told about rev 1.** Rev 1 moved CI's
+ rung limit from a grant every box verifies to a check in the console, and
+ flagged that as the trade-off. Rev 2 keeps the box-verified limit: the
+ console signs a `deploy` credential's pointer with a request-local scoped
+ key (§7.4, codex 6).
**Discussion:** carlos/platform#229.
**Code read at:** main `8d8e4bae`. Every line reference below is at that commit.
@@ -29,10 +39,14 @@ CI cannot move to the console today, because the narrowest credential it
could hold there is too wide. A `publish` service credential bound to one
app reaches (`internal/console/authcli.go:476-739`):
-- promote and rollback on every channel but stable. For an app with no
- pipeline that includes `beta`, and it includes a hotfix that skips the
- ladder order (`apicli.go:802-811`: only the stable rung is refused when
- `pipe == nil`);
+- promote and rollback on `beta`, and a hotfix that skips the ladder order,
+ for an app with no pipeline (`apicli.go:802-811`: only the stable rung is
+ refused when `pipe == nil`). Promote refuses the stable rung when the app
+ has no pipeline or its pipeline does not name `stable`. A pipeline that
+ does name `stable` is governed by that channel's own rules and nothing
+ else (`apicli.go:813`), and a pipeline may name it without a passkey rule
+ (`internal/release/pipeline.go:255`). Rollback refuses stable
+ unconditionally (codex 4);
- trash and restore of the app, retention, version-target, domains.
Today's CI key is a scoped release key for `canary,edge`. It cannot sign
@@ -109,10 +123,11 @@ is a separate spec. Neither is part of this one.
`carlos services create --role deploy --app titogo --rungs canary,edge ci`
mints a service credential. CI exports it as `CARLOS_SERVICE_TOKEN` with
`CARLOS_CONSOLE`, and runs the same `carlos ship` and `carlos promote` it
-runs today. The console checks the role at the door, then checks the target
-channel against the credential's rungs before it signs anything. The promote
-is audited and bumps the serial, so the box adopts in seconds. The runner
-holds no AWS role and no signing key.
+runs today. The console checks the role at the door and the target channel
+against the credential's rungs. It then signs the pointer with a scoped key
+minted for that one request and limited to the same rungs, so every box
+still verifies the limit. The promote is audited and bumps the serial, so
+the box adopts in seconds. The runner holds no AWS role and no signing key.
## 5. The credential
@@ -127,8 +142,8 @@ catalogue. `Allow()` keeps adding `Admin` to every set, unchanged.
`RungEdge`, `RungBeta`, `internal/release/ladder.go:30-32`). `canary`
covers every `canary/<branch>` channel, as it does in a key grant.
- `stable` is refused at mint and again at the door, mirroring
- `internal/release/grant.go:103` and `:219`. A stable promote needs a
- passkey and always has.
+ `internal/release/grant.go:103` and `:219`. No delegated key has ever
+ been able to reach stable, and no `deploy` credential can (codex 4).
- Stored canonically: deduplicated, in ladder order, comma-joined.
### 5.3 Storage
@@ -215,42 +230,117 @@ A pure function decides, so it can be table-tested without a server:
For any principal whose role is not `deploy` it returns nil. For a `deploy`
principal it refuses, in this order:
-1. `hotfix` is set. A scoped release key cannot fast-track either.
+1. `hotfix` is set. This is a deliberate restriction beyond what a scoped
+ release key carries: a scoped signer may skip ladder order with `Hotfix`
+ (`internal/release/release.go:945`). What a scoped key cannot do is
+ `FastTrack`, which waives a bake window and is a separate field
+ (`release.go:383`). A `deploy` credential gets neither. The console's
+ promote door has no fast-track input, and the plan pins that with a test
+ (codex 5).
2. `release.Rung(channel)` (`ladder.go:55`) says the channel is not a rung.
3. The rung is `stable`.
-4. The rung is not in the credential's rungs.
-5. The app has a pipeline and the rung is not `canary`.
-
-### 7.2 Where it runs
-
-- **`handleAPIPromote`** (`apicli.go:701`): after `GetPipeline` has answered
- (it fails closed already) and before the rules switch at `:803`. That is
- before any proposal is created and before `release.PromoteOnLadder` at
- `:880`.
-- **`handleAPIRollback`** (`apicli.go:970`): after the pipeline read and
- before any pointer or proposal write.
-
-Everything after the check is unchanged. The ladder order, the pipeline
-rules, the audit line and the serial bump all still run for a `deploy`
-credential, because it takes the same path as any other caller from that
-point on.
+4. The rung is `canary` and the channel is not `canary/<name>`.
+ `release.Rung("canary")` answers `canary`, so the bare name would
+ otherwise pass. It is refused for every app, pipelined or not (codex 2).
+5. The rung is not in the credential's rungs.
+6. The app has a pipeline and the rung is not `canary`.
+
+### 7.2 Where it runs: on the snapshot that executes (codex 1)
+
+Rev 1 ran the check in the handler, against the handler's own pipeline
+read (`apicli.go:772`). `PromoteOnLadder` reads the pipeline again
+(`release.go:930`) and acts on that second read. A pipeline installed
+between the two would be judged as absent and executed as present, which
+is an outcome no serial order of the two operations produces.
+
+So the authoritative check runs inside the release primitive, on the
+pipeline read the primitive itself uses:
+
+- `release.PromoteOptions` gains an authorization hook that
+ `PromoteOnLadder` calls immediately after its own `GetPipeline` and
+ before the order check. The console passes
+ `deployScopeAllows` bound to the principal, the channel and the hotfix
+ flag. A refusal from the hook is returned as a typed error the handler
+ maps to §7.3's 403.
+- `Signer.Rollback` (`release.go:1031`) gains the same hook. When it is
+ set, the primitive reads the pipeline itself and calls it before it
+ signs.
+- The handlers also call `deployScopeAllows` on their own read, before the
+ rules switch at `apicli.go:803` and before any proposal is created. This
+ is the early refusal that gives CI a clear message. It is not the
+ authority.
+
+**The contract.** For a `deploy` credential, the scope decision and the
+pointer write use one pipeline snapshot. A pipeline installed after that
+read and before the write is not seen, and the result is exactly the state
+that "promote, then install the pipeline" produces. Nothing a `deploy`
+credential writes depends on which of the two landed first. If the two
+reads disagree the other way (the handler saw a pipeline and the primitive
+did not), the handler has already refused.
+
+Serializing pipeline writes against every pointer write is not attempted
+here. It is a property of every promote today, and direct-bucket writers
+are outside the console's reach whatever the console locks (§14).
+
+The plan adds a test that installs a pipeline between the handler's read
+and the primitive's read, and asserts the refusal.
+
+Everything after the check is unchanged. The ladder order, the audit line
+and the serial bump all still run for a `deploy` credential.
### 7.3 The refusal
A 403 with a JSON `error` that names the credential and the rungs it holds,
so a CI log says what to fix. The wording goes through copy review (§11).
-### 7.4 What this replaces, stated plainly
+### 7.4 The pointer is signed by a request-local scoped key (codex 6)
Today the limit on titogo's CI is cryptographic: every box verifies the key
-grant, and a pointer signed outside the grant is rejected wherever it lands.
-With a `deploy` credential the console signs with its own key
-(`s.cfg.Key`), so the limit is the console's check in §7.1. A bug in that
-check would not be caught by a box.
-
-This is accepted under R3. It is why §7.1 is one small pure function with a
-table test, and why `stable` keeps its unconditional refusal independent of
-that function.
+grant, and a pointer signed outside the grant is rejected wherever it
+lands. Rev 1 gave that up, because the console signs with its own key
+(`s.cfg.Key`). It does not have to.
+
+For a `deploy` principal, promote and rollback sign like this:
+
+1. The console mints a scoped keypair and a root-signed grant with
+ `release.MintKeyGrant` (`grant.go:88`), for exactly the credential's
+ rungs, with a lifetime of five minutes. The grant's name identifies the
+ service credential and satisfies `grantNameRe` (`grant.go:81`).
+2. It builds a `release.ScopedSigner` (`signer.go:42`) from them and passes
+ that signer to `PromoteOnLadder` or `Rollback`.
+3. The key is never stored and never leaves the request.
+
+What this buys:
+
+- A box verifies the pointer against the grant, as it does for titogo's CI
+ today. If §7.1 were wrong about a rung, the signer refuses
+ (`Signer.allows`, `signer.go:71`; `Grant.allows`, `grant.go:254`), and a
+ pointer that somehow got written would still fail verification on every
+ box.
+- Stable gets a refusal that does not depend on §7.1: no grant can name it
+ (`grant.go:219`) and no grant can sign for it (`grant.go:263`) (codex 4).
+- The pointer itself records which credential moved it.
+
+What it does not buy, stated plainly:
+
+- It does not defend against a compromised console. The console holds the
+ root key and could sign anything.
+- It does not enforce R2's pipeline rule. A grant knows rungs, not
+ pipelines, so a pipeline that names `edge` is `edge` to a grant. That
+ rule rests on §7.1 and §7.2.
+- It does not refuse bare `canary`, which a grant accepts. That rests on
+ §7.1.
+
+Failure is closed. If the grant cannot be minted or the signer cannot be
+built (for instance, the console's key is not a root key), the request is
+refused. A `deploy` request never falls back to the root signer.
+
+A grant's lifetime is checked against the pointer's own `promoted_at`
+(`release.go:396-441`), so a five-minute grant does not expire a pointer
+it has already signed. A pointer dated before its grant was minted is
+refused, so the grant is minted before the pointer is stamped.
+
+Ship is unchanged and uses the console's own signer. It moves no pointer.
## 8. The CLI in CI
@@ -272,7 +362,12 @@ a delivery hint and read by nothing in the CLI.
unchanged.
- The token is never accepted as a flag, so it never lands in a process
list or a CI log's command echo.
-- `carlos auth whoami` reports the service identity when the token is set.
+- `carlos auth whoami` is unchanged and stays person-only. It reads the
+ credentials file and never calls `resolveMode` (`auth.go:785`), and the
+ console's whoami door refuses every service token
+ (`authcli.go:1531`, `authstore.go:515`). Rev 1 promised a service
+ identity here with no door behind it. CI does not need one, so the
+ promise is withdrawn rather than built (codex 3).
**To check in the plan.** `CARLOS_SERVICE_TOKEN` is also the name an app
receives its own service credential under at run time
@@ -317,11 +412,21 @@ credentials already are, and the mint line records the rungs.
## 12. Testing
- **Scope function.** A table over role, pipelined, channel and hotfix,
- covering every refusal in §7.1 and each allowed case.
+ covering every refusal in §7.1 and each allowed case, including bare
+ `canary` for both a pipelined and an unpipelined app.
- **Handlers.** For promote and rollback: an in-scope rung succeeds, writes
- the audit line and bumps the serial; an out-of-scope rung, `stable`, a
- hotfix, and a non-canary channel on a pipelined app each give a 403 and
- write nothing.
+ the audit line and bumps the serial; an out-of-scope rung, `stable`, bare
+ `canary`, a hotfix, and a non-canary channel on a pipelined app each give
+ a 403 and write nothing. A pipeline that names `stable` with no passkey
+ rule still refuses a `deploy` credential.
+- **One snapshot.** A pipeline installed between the handler's read and
+ the primitive's read is refused, for promote and for rollback (§7.2).
+- **Signer.** A pointer written by a `deploy` promote carries a grant for
+ exactly the credential's rungs and verifies with `verifyPointer`. With
+ the scope function stubbed to allow everything, an out-of-rung promote
+ and a stable promote are still refused by the signer. A grant that
+ cannot be minted refuses the request, and the root signer is not used.
+- **Hotfix and fast-track.** Each is refused, tested separately.
- **Doors.** A test walks every registered `cliRoute` and asserts `deploy`
is permitted on exactly the §6 list. A route added later is refused for
`deploy` unless someone adds it to the list on purpose.
@@ -365,3 +470,25 @@ credentials already are, and the mint line records the rungs.
change `publish`.
- Pipeline channel scopes (R2). If a customer with a pipeline needs CI to
propose onto a pipeline channel, that is a follow-on spec.
+- For every caller, the promote handler judges pipeline rules (approvals,
+ passkey) on its own pipeline read, and `PromoteOnLadder` acts on a second
+ one. A pipeline changed between the two is judged on the old rules. §7.2
+ closes this for `deploy` and leaves it as found for people and for
+ `publish`. Closing it for everyone means binding pipeline writes and
+ pointer writes to one generation, which is its own piece of work.
+- A pipeline may name `stable` with no passkey rule, and promote then
+ applies only that channel's rules (§1). Existing behaviour, unchanged.
+
+## 15. Review dispositions
+
+Astra round 1, `CODEX-FINDINGS-DEPLOY-CREDENTIAL-R1.md`, against d30a74c2.
+Every citation was checked at main `8d8e4bae`.
+
+| Finding | Severity | Disposition |
+| --- | --- | --- |
+| R1-1 scope judged on a different pipeline read than the one executed | P1 | Accepted. §7.2: the check runs inside the primitive on its own read. Full serialization of pipeline writes against pointer writes is declined, with the reason in §7.2 and §14. |
+| R1-2 bare `canary` passes | P1 | Accepted. §7.1 rule 4. |
+| R1-3 `auth whoami` has no service path | P2 | Accepted by withdrawing the claim. §8. |
+| R1-4 promote's stable refusal is not unconditional | P2 | Accepted. §1 and §5.2 corrected; the independent refusal is now the grant, §7.4. |
+| R1-5 hotfix and fast-track conflated | P2 | Accepted. §7.1 rule 1. |
+| R1-6 keep box-verified limits with a request-local scoped signer | P3 | Accepted and adopted. §7.4. |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-DEPLOY-CREDENTIAL-R1.md b/docs/superpowers/specs/CODEX-FINDINGS-DEPLOY-CREDENTIAL-R1.md
new file mode 100644
index 000000000..d3b560724
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-DEPLOY-CREDENTIAL-R1.md
@@ -0,0 +1,43 @@
+Reviewed `d30a74c2`. No files modified. Findings are based on source inspection; no tests were run.
+
+1. **R1-1 — P1 — §7.2: A pipeline change can invalidate the scope decision before promotion.**
+
+ The handler reads the pipeline at [apicli.go:772](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential/internal/console/apicli.go:772), where the proposed scope check would run. `PromoteOnLadder` independently reads it again at [release.go:930](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential/internal/release/release.go:930).
+
+ An `edge` credential can pass the first check while no pipeline exists. If an owner then installs a pipeline beginning with `edge`, the second read selects that pipeline and permits a fresh release onto its entry channel. The credential has now written a non-canary pointer on a pipelined app, contrary to R2. Pipeline passkey or approval rules introduced between those reads are also missed: the release primitive checks order, while those rules were evaluated against the earlier snapshot.
+
+ **Correction:** Define the concurrency contract for pipeline changes and pointer writes. Bind authorization and execution to the same pipeline generation, with serialization or equivalent conflict detection covering pipeline updates, promote and rollback. Add a test that installs a pipeline between authorization and execution.
+
+2. **R1-2 — P1 — §7.1: The check admits bare `canary`, outside R2’s `canary/*` restriction.**
+
+ `release.Rung("canary")` returns `("canary", true)` at [ladder.go:55](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential/internal/release/ladder.go:55). Consequently, all five proposed checks pass for a pipelined app and a credential holding `canary`. The downstream [PipelinePromotionAllowed](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential/internal/release/pipeline.go:327) also accepts it. Rollback can likewise move an existing bare-canary pointer.
+
+ **Correction:** For pipelined apps, require a valid channel beneath `canary/`, in addition to checking the rung. Test bare `canary` explicitly for both verbs.
+
+3. **R1-3 — P2 — §§6, 8: The promised service identity for `auth whoami` has no supported path.**
+
+ [authWhoami](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential/auth.go:785) reads the credentials file directly and never calls `resolveMode`. Changing `resolveMode` therefore leaves it reporting the stored person or failing when no file exists. Even if the environment token is passed explicitly, [handleCLIWhoami](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential/internal/console/authcli.go:1531) calls `cliUser`; [CLITokenAddress](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential/internal/console/authstore.go:515) refuses every service token.
+
+ **Correction:** Specify the service identity endpoint, response fields and CLI resolution path. Reconcile that endpoint with §6’s exhaustive door list. Preserve the person-only gates on account creation and logout rather than widening the shared person resolver.
+
+4. **R1-4 — P2 — §§1, 5.2, 7.4: Stable promotion does not have the claimed unconditional, independent refusal.**
+
+ The promote handler refuses stable when there is no pipeline or when stable is outside the pipeline. A pipeline containing `stable` instead enters the declared-channel branch at [apicli.go:813](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential/internal/console/apicli.go:813), where only its configured rules apply. [Pipeline validation](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential/internal/release/pipeline.go:255) permits that name without requiring a passkey. Rollback does have an unconditional stable refusal.
+
+ Thus §7.1 would protect deploy credentials, but §7.4’s claimed protection *independent of that function* does not exist for promotion. “A stable promote needs a passkey and always has” and the blanket description of publish’s stable exclusion are also inaccurate.
+
+ **Correction:** Correct those claims and explicitly add the independent stable refusal for deploy principals if that defense is intended. Keep existing roles unchanged, as R1 requires.
+
+5. **R1-5 — P2 — §7.1: Hotfix and fast-track are different authorities.**
+
+ The rationale for refusing `hotfix` conflates skipping ladder order with waiving a bake window. [Signer.PromoteOnLadder](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential/internal/release/release.go:945) permits an in-scope scoped signer to skip order using `Hotfix`. The root-only restriction in [signPointer](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential/internal/release/release.go:386) checks `FastTrack`, a separate field.
+
+ **Correction:** Describe the hotfix prohibition as an intentional restriction beyond scoped-key behavior. Retain it if intended, but distinguish order bypass from bake-window waiver and test them separately.
+
+6. **R1-6 — P3 — §7.4: Console-mediated deployment can retain box-verified rung limits using existing primitives.**
+
+ The stated exposure from root signing is accurate, but R3 and R4 do not require it. [MintKeyGrant](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential/internal/release/grant.go:80), [ScopedSigner](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential/internal/release/signer.go:42), and signer methods for promotion and rollback already exist. The console could mint a short-lived, request-local scoped signer from the credential’s rungs and use it for that write. The runner would still hold only its service token; auditing and serial notification would remain console-mediated.
+
+ **Correction:** Evaluate this alternative explicitly before discarding box-side enforcement. It protects against a missed rung check, though it does not protect against console compromise or independently enforce R2’s pipeline restriction.
+
+**Verdict: request changes.**
\ No newline at end of file
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-DEPLOY-CREDENTIAL.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-DEPLOY-CREDENTIAL.md
index 2d0cb9d67..f382e5900 100644
--- a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-DEPLOY-CREDENTIAL.md
+++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-DEPLOY-CREDENTIAL.md
@@ -72,3 +72,23 @@ to fix before the plan; P3 advisory), the section, the problem, the
evidence with citations, a proposed correction. End with a verdict:
accept, accept with corrections, or request changes. Do not soften, do
not pad, do not restate the spec.
+
+## Round 2
+
+Rev 2 answers your round 1 (`CODEX-FINDINGS-DEPLOY-CREDENTIAL-R1.md`); §15
+lists each disposition. Read rev 2 cold, then check:
+
+- Does each accepted finding's fix actually close it, as written?
+- §7.2: is "one snapshot, equivalent to promote-then-install" true of
+ `PromoteOnLadder` and `Signer.Rollback` as they are? Is there any state a
+ `deploy` credential can produce that no serial order produces? Is
+ declining full serialization defensible, or does it leave R2 open?
+- §7.4: can the console mint and use a request-local grant the way the
+ spec says, with `MintKeyGrant`, `ScopedSigner` and `verifyPointer` as
+ written? Check the minted-at lower bound, the five-minute lifetime
+ against `promoted_at`, history and tag writes that a scoped signer makes
+ or cannot make, and whether any box-side verifier rejects a grant it has
+ not seen before.
+- Anything the rev 2 changes broke that rev 1 had right.
+
+Number findings R2-1 onward. Same severities, same verdict line.