| index e27a71607..7f1694a7c 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,7 @@ |
| # A deploy credential for CI: promote through the console, limited to rungs |
| |
| **Date:** 2026-10-05 |
| -**Revision:** 2. |
| +**Revision:** 3. |
| |
| - Rev 1 (d30a74c2) went to Astra (via codex). The findings are in |
| `CODEX-FINDINGS-DEPLOY-CREDENTIAL-R1.md`, unedited. All six were checked |
| @@ -12,6 +12,16 @@ |
| 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). |
| +- Rev 2 (38dfb240) went back. The findings are in |
| + `CODEX-FINDINGS-DEPLOY-CREDENTIAL-R2.md`. All three were checked and |
| + held. Changes are marked "(codex R2-N)". |
| +- **Rev 3 withdraws a claim rev 2 made.** Rev 2 said a `deploy` promote |
| + racing a pipeline install always ends in a state some serial order |
| + produces. Astra gave a three-operation counterexample (codex R2-1). Rev 3 |
| + reorders the reads so the pointer's own compare-and-swap defeats that |
| + counterexample, states the guarantee as an invariant (§7.2, I1), and |
| + names the one window it does not close. **That window is a decision for |
| + Paul: §16.** |
| **Discussion:** carlos/platform#229. |
| **Code read at:** main `8d8e4bae`. Every line reference below is at that commit. |
| |
| @@ -245,45 +255,83 @@ principal it refuses, in this order: |
| 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. |
| +### 7.2 Where it runs, and what is guaranteed (codex 1, codex R2-1, codex R2-2) |
| + |
| +**The store gives one tool.** Every write in the release store is a |
| +compare-and-swap on a single object (`internal/release/store.go:33-44`). |
| +There is no lock in the console and no write that spans two objects. A |
| +pipeline and a channel pointer are two objects, so no ordering of reads |
| +makes "there is no pipeline" and "write this pointer" one atomic step. |
| + |
| +**What went wrong twice.** Rev 1 checked the scope on the handler's |
| +pipeline read (`apicli.go:772`), and the primitive acted on a second read |
| +(`release.go:930`). Rev 2 moved the check to that second read and claimed |
| +the result was always equivalent to "promote, then install the pipeline". |
| +It is not. Both primitives read the target pointer and its ETag after that |
| +point (`Signer.Promote`, `release.go:732`; `Signer.Rollback`, |
| +`release.go:1043`). So a pipeline could be installed, and an owner could |
| +promote under it, and the `deploy` write could still land on top with the |
| +owner's version in its history. |
| + |
| +**The mechanism.** The authorization runs between the pointer read and the |
| +pointer write, so the pointer's own compare-and-swap covers it: |
| + |
| +1. The primitive reads the target pointer and its ETag, as it does today. |
| +2. If the caller supplied an authorization hook, the primitive reads the |
| + pipeline fresh and calls the hook with it. |
| +3. The primitive signs and writes with that ETag, as it does today. A |
| + conflict is returned as a conflict. It is not retried inside the |
| + primitive, because a retry must start again from step 1. |
| + |
| +`Signer.Promote` and `Signer.Rollback` both gain the hook, carried from |
| +`PromoteOptions` for a promote. The console passes `deployScopeAllows` |
| +bound to the principal, the channel and the hotfix flag. A refusal is a |
| +typed error the handler maps to §7.3's 403. For any caller that passes no |
| +hook, both primitives behave exactly as they do today. |
| + |
| +`PromoteOnLadder`'s no-op return for a repeat promote (`release.go:887`) |
| +comes before all of this. When a hook is supplied, the primitive reads the |
| +pipeline and calls the hook before that return, so a repeat is refused on |
| +the same terms as a first attempt (codex R2-2). |
| + |
| +The handlers also call `deployScopeAllows` on their own read, before the |
| +rules switch at `apicli.go:803` and before any proposal is created. That |
| +is the early refusal with a clear message. It is not the authority. |
| + |
| +**Invariant I1.** For a write by a `deploy` credential to channel C: |
| + |
| +- (a) the pipeline it was authorized against was read after C's pointer |
| + was read; |
| +- (b) the write succeeds only if nothing wrote C between that pointer read |
| + and the write. |
| + |
| +What follows from I1: |
| + |
| +- A pipeline that existed before the request read C is seen, and the |
| + request is refused. |
| +- A `deploy` write never lands on top of a write to C that was made after |
| + the request read C. That includes every write made under a new pipeline. |
| + This is Astra's counterexample: the owner's promote changes C's ETag and |
| + the `deploy` write fails. |
| + |
| +**What I1 does not cover.** A pipeline installed inside the window between |
| +step 2 and step 3, with no write to C in that window. The `deploy` write |
| +lands on a channel that became a pipeline channel a moment earlier. The |
| +window is two store round trips. The version that lands was authorized |
| +when the request read the state, and it replaces only what the request |
| +itself read. |
| + |
| +This is not serializability, and the spec no longer says it is. Another |
| +operation that reads C inside that window, such as an owner's promote |
| +that computes a stage from C, sees C as it was before the `deploy` write. |
| +That is the same thing two concurrent promotes by people see today. |
| + |
| +Closing the window needs something the store does not have: a per-app |
| +lock held across pipeline writes and pointer writes, or a pipeline |
| +generation stamped into every pointer. §16 puts that choice to Paul. |
| + |
| +The rung limit does not depend on any of this. It is enforced by the |
| +grant (§7.4), which involves one object and has no race. |
| |
| Everything after the check is unchanged. The ladder order, the audit line |
| and the serial bump all still run for a `deploy` credential. |
| @@ -336,11 +384,13 @@ 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 |
| +(`release.go:480`), 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. |
| +Ship is unchanged. It uploads immutable artifacts and an unsigned |
| +manifest (`release.Ship`, `release.go:584`, which takes no signer) and |
| +moves no pointer (codex R2-3). |
| |
| ## 8. The CLI in CI |
| |
| @@ -419,8 +469,19 @@ credentials already are, and the mint line records the rungs. |
| `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). |
| +- **I1, for promote and for rollback (§7.2).** Each with the store paused |
| + at the named point: |
| + - a pipeline installed before the primitive's pointer read is refused; |
| + - a pipeline installed between the handler's read and the primitive's |
| + pointer read is refused; |
| + - Astra's three operations (pipeline installed, an owner promotes B to |
| + C, the `deploy` write resumes) end in a conflict, C still points at B, |
| + and B's pointer is untouched; |
| + - the same races with the requested version already at C's head, so the |
| + no-op path is covered (codex R2-2); |
| + - the uncovered window is pinned by a test that states it: a pipeline |
| + installed between the hook and the write, with no write to C, lands. |
| + If §16 chooses to close the window, that test flips. |
| - **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 |
| @@ -475,7 +536,7 @@ credentials already are, and the mint line records the rungs. |
| 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. |
| + pointer writes to one generation, which is its own piece of work (§16). |
| - A pipeline may name `stable` with no passkey rule, and promote then |
| applies only that channel's rules (§1). Existing behaviour, unchanged. |
| |
| @@ -492,3 +553,26 @@ Every citation was checked at main `8d8e4bae`. |
| | 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. | |
| +| R2-1 a write can land over an owner's promote made under a new pipeline | P1 | Accepted in part. §7.2: the hook moves between the pointer read and the write, so that counterexample fails on the pointer's compare-and-swap. The serializability claim is withdrawn and replaced by I1. Full serialization is not built; the remaining window is stated and put to Paul in §16. | |
| +| R2-2 the no-op return skips the hook | P2 | Accepted. §7.2. | |
| +| R2-3 ship does not use a signer; wrong citation for the lifetime check | P2 | Accepted. §7.4. | |
| + |
| +## 16. For Paul: the one window I1 leaves |
| + |
| +A pipeline installed in the two store round trips between a `deploy` |
| +request's authorization and its write is not seen (§7.2). Two ways to |
| +treat it: |
| + |
| +- **A. Accept it, as written (recommended).** R2 is a rule about which |
| + channels CI is offered, and the trust boundary is the rung limit, which |
| + the grant enforces with no race. Hitting the window takes an owner |
| + installing a pipeline in the same instant CI promotes, and the result is |
| + what a promote a moment earlier would have produced. The same window |
| + exists today for every person's promote against pipeline rules (§14). |
| +- **B. Close it for everyone.** Add a per-app lock in the store, or stamp a |
| + pipeline generation into every pointer, and take it in pipeline writes, |
| + promotes, rollbacks and proposal execution. That removes the window for |
| + `deploy`, for `publish` and for people. It is a change to the release |
| + store's concurrency model and should be its own spec, which this one |
| + would then depend on. |
| + |