# A deploy credential for CI: promote through the console, limited to rungs

**Date:** 2026-10-05
**Revision:** 5.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).
- 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.**
- Rev 3 (2d9d528e) went back. The findings are in
  `CODEX-FINDINGS-DEPLOY-CREDENTIAL-R3.md`. All four were checked and held.
  Changes are marked "(codex R3-N)".
- **Rev 4 stops patching interleavings.** Three rounds each found a new
  ordering of the same fault: R2's pipeline rule reads one object and
  writes another, and the store cannot make that atomic. Rev 3's I1(b)
  said "nothing wrote C", which the store cannot promise either, because
  an ETag is a hash of the content (codex R3-1). Rev 4 states only what
  the store does guarantee (§7.2, G1 and G2), says in one sentence what
  that leaves open, and requires a randomized interleaving test of exactly
  those two properties (§12). §16's decision is unchanged in substance and
  is now stated in those terms.
- Rev 4 (2af4eba4) went back. The findings are in
  `CODEX-FINDINGS-DEPLOY-CREDENTIAL-R4.md`. Astra found no outcome outside
  the class §7.2's open sentence describes, and found the three hook call
  points sufficient. Two findings remained and both held: G2 assumed more
  of the directory store than it provides, and §12's randomized test
  asserted something G1 and G2 do not imply. Rev 5 qualifies G2 by backend
  and corrects the test's oracle. Changes are marked "(codex R4-N)".
- Rev 5 (e764b20c) went back. Astra's round 5
  (`CODEX-FINDINGS-DEPLOY-CREDENTIAL-R5.md`) found nothing at P1 or P2 and
  accepted. Rev 5 is unchanged by it.
- **Rev 5.1 records Paul's ruling on §16 as R5** (2026-10-05): the
  in-flight gap is accepted. Nothing else changes.
- **Rev 5.2 records Paul's ruling R6** (2026-10-05): CI must be able to run
  `carlos deploy`. §6 gains the two read doors its routine path needs.
**Discussion:** carlos/platform#229.
**Code read at:** main `8d8e4bae`. Every line reference below is at that commit.

## 1. The problem, as found

titogo's CI promotes every merge to `edge` with `carlos promote` in
direct-bucket mode. It does that because the README tells it to
(`README.md:170`): "CI and operators keep the existing path unchanged ...
logging in is for a human at a laptop, never a box or a pipeline."

A direct-bucket promote signs a pointer and writes it to S3. The console
never sees it, so the promote skips everything the console adds:

- the audit line;
- pipeline rules and pending approvals;
- the serial bump (`s.bumpSerial`, `internal/console/apicli.go:906`) that
  makes a box's `fastConvergeLoop` adopt within seconds.

Without the bump, a box picks the promote up on `carlos-adopt.timer`, hourly
by default. Tito's box carries a hand-made 2-minute drop-in to make that
bearable. Measured on 2026-10-05, it adds 30 seconds to 3.5 minutes to every
titogo deploy.

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 `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
beta or stable, and it cannot do anything else. Moving CI to `publish` would
widen its authority, so the Tito session asked for the feature instead of
working around the gap. That is the right call, and every Carloku customer
with a CI pipeline will need the same thing: on Carloku a customer never
holds bucket credentials, so the console is their only door.

## 2. Goals and non-goals

**Goals**

1. A console service credential that CI can hold with authority no wider
   than a scoped release key: ship, and promote or roll back only the rungs
   it was granted.
2. CI can supply that credential to the CLI from its environment, with no
   credentials file.
3. The README and CLI docs tell CI to use the console. Direct-bucket mode is
   documented as the operator's and break-glass path.
4. A console rolled back to a build from before this change refuses the new
   credential at every door.

**What "adopts in seconds" means here.** This spec claims adoption: the box
stages the new version and repoints to it within seconds of the promote. It
does not claim the new build is serving within seconds.

- For a unit-backed route, which is what titogo's fleet runs on, serving
  follows adoption as soon as the unit restarts, so #229's goal stands.
- For an awake exec-backed instance, adoption does not cycle the running
  process today. On a real box the root adopt pass stages and repoints
  through `rootRestartRoute` (`internal/host/agent.go:3033`), which cycles
  no exec child, and the agent then sees the version already current and
  never calls its own restart. The activator's sweep catches it later: a
  30-second tick plus `ConvergeGrace` of 90 seconds
  (`internal/activator/activator.go:532`). The platform session measured
  110 to 200 seconds from ship to serving on the flagship over 3 to 5
  October.

That gap is being fixed on its own branch by another session, and the
handover that makes a shipped release reach running instances immediately
is a separate spec. Neither is part of this one.

**Non-goals**

- Making an adopted release serve sooner on exec-backed instances (above).

- Scopes that name pipeline channels (ruling R2).
- Narrowing or changing existing `publish` credentials.
- Expiry on service credentials. Rotate and revoke stay as they are.
- Any change to direct-bucket mode. In particular, a direct promote still
  does not bump the serial (ruling R4).
- Changing who restarts titogo's units. That is titogo's own work
  (teamtito/titogo#3872).

## 3. Rulings (Paul, 2026-10-05)

- **R1.** A new role, `deploy`, plus a rung scope on the credential. Existing
  roles are unchanged.
- **R2.** The scope names rungs only, exactly as a scoped release key does.
  For an app with a pipeline a `deploy` credential reaches `canary/*` and
  nothing else.
- **R3.** The console is the normal path for CI. Direct-bucket mode stays as
  the operator's tool: bootstrap before a console exists, deployments with
  no console, recovery when the console is down, and the platform's own
  releases.
- **R4.** #229's option 2 is declined in both forms. A direct promote that
  bumps the serial makes the bypass more attractive. Polling every followed
  channel pointer multiplies the one serial read per 2-second tick that the
  serial exists to protect.
- **R5.** A `deploy` request in flight when a pipeline is installed may
  complete, within G1 and G2 (§7.2, §16). The gap is accepted and is not
  closed by this spec.
- **R6.** CI must be able to run `carlos deploy`, not only `ship` and
  `promote`. A `deploy` credential therefore reaches the read doors
  `carlos deploy` uses on an app that is already set up (§6).

## 4. The shape, in one paragraph

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

### 5.1 Role

`internal/svcrole/svcrole.go` gains `Deploy Role = "deploy"` in the
catalogue. `Allow()` keeps adding `Admin` to every set, unchanged.

### 5.2 Rungs

- Allowed values: `canary`, `edge`, `beta` (`release.RungCanary`,
  `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`. 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

One additive column on `cli_tokens`, beside the service columns at
`internal/console/authstore.go:210-216`:

    ALTER TABLE cli_tokens ADD COLUMN rungs TEXT NOT NULL DEFAULT ''

`Principal` (`internal/console/svcprincipal.go:48`) gains `Rungs []string`.

### 5.4 Mint rules

Both mint doors (`carlos services create` through `handleAPIServiceCreate`,
and the browser form at `POST /a/{sqid}/services/new`) write through
`Store.NewServicePrincipal` (`svcprincipal.go:171`). The rules live there,
so neither door can skip them:

- role `deploy` requires a non-empty app, the same way `service` does at
  `svcprincipal.go:189`;
- role `deploy` requires at least one rung, each one of the three in §5.2;
- any other role with a non-empty rungs value is refused. A rung scope on
  `publish` would read as a limit and enforce nothing.

There is no default rung set. The person minting says what the credential
may move.

### 5.5 Reading it back

A `deploy` principal whose stored rungs are empty or do not parse is refused
when the token is resolved. It is never treated as unscoped. This is the
case a hand-edited row or a future bug would produce, and it must fail
closed.

Rotate keeps the rungs. There is no edit: to change a credential's rungs,
mint a new one and revoke the old.

### 5.6 CLI and browser

- `carlos services create` gains `--rungs <list>`. The usage line and the
  role list in `services.go:33` and `:157` gain `deploy`.
- `printServiceCreated` (`services.go:236`) states the rungs in words next to
  the app scope, and its delivery hint covers the CI case (§8).
- `carlos services ls` and the services page show the rungs.
- The browser mint form offers the role and the three rungs.

## 6. Doors

`deploy` is added to the allow set of exactly these routes in
`internal/console/authcli.go`:

| Door | Why |
| --- | --- |
| `GET /api/cli/apps` | the CLI resolves the app |
| `GET .../{app}/channels` | read what is promoted where |
| `POST .../{app}/releases` | ship |
| `GET .../{app}/releases` | list what was shipped |
| `POST .../{app}/promote` | scoped, §7 |
| `POST .../{app}/rollback` | scoped, §7 |
| `GET .../{app}/tags`, `GET .../{app}/tags/{version}` | read |
| `GET .../{app}/version-target` | read |
| `GET .../{app}/pipeline` | read |
| `GET .../{app}/pending` | read |
| `GET .../{app}/instances` | read: `carlos deploy` lists the instances it will watch (R6) |
| `GET .../{app}/regions` | read: `carlos deploy --region` checks the app's region (R6) |

It is added to nothing else. In particular it does not reach: app create,
trash, restore, retention (read or write), version-target write, domains,
features, logs, or any other `instance`, `operate` or `admin` door.

**What `carlos deploy` does with a `deploy` credential (R6).** Its routine
path on an app that is already set up reads the instance list, reads the
channels, ships, promotes, and watches the app's own hosts, and with
`--region` reads the app's region. All of that is in the table. Three
things it does only in particular cases stay out of reach:

- **First-deploy setup.** Creating the app, enabling instances and minting
  the first platform host are writes a person does once. A `deploy`
  credential is refused at those doors with the role gate's message, and
  the deploy stops there.
- **`--host` on a static deploy** attaches a domain, which is a domains
  write. Refused the same way.
- **The watch's diagnosis.** When a watched host times out, `carlos deploy`
  reads one line of the app's platform log to say why. That read is
  best-effort: refused, the deploy reports the timeout without the reason.
  Logs can carry customer data, so a CI credential does not read them.

The existing app binding (`apiAccountAppBy`, `apicli.go:70`) applies as it
does to every app-bound service credential: another app's path is a 404.
`refuseServiceControlPlane` (`controlplane.go:108`) applies unchanged.

Ship is not rung-scoped. Shipping uploads a release and never moves a
pointer.

## 7. Enforcement at promote and rollback

### 7.1 One function

A pure function decides, so it can be table-tested without a server:

    deployScopeAllows(p Principal, pipelined bool, channel string, hotfix bool) error

For any principal whose role is not `deploy` it returns nil. For a `deploy`
principal it refuses, in this order:

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 `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, and what is guaranteed (codex 1, R2-1, R2-2, R3-1, R3-2, R3-3)

**The store gives one tool.** Every write in the release store is a
conditional write on a single object: create-only, or replace-only against
an ETag (`internal/release/store.go:33-44`). An ETag is a hash of the
object's bytes (`store.go:279`; S3's is too). So a conditional write proves
one thing: at write time the object's bytes were the bytes the writer
read. It does not prove nobody wrote the object in between. Two rollbacks
inside one second can restore a pointer byte for byte, because signing
truncates `promoted_at` to the second (`release.go:371`) (codex R3-1).

There is no lock in the console and no write that spans two objects. A
pipeline and a channel pointer are two objects. No ordering of reads makes
"there is no pipeline" and "write this pointer" one atomic step.

**The two backends differ, and G2 below is only as good as the backend**
(codex R4-1).

- `S3Store` sends the condition with the write (`store.go:613-628`), and S3
  evaluates it atomically. G2 holds against every writer.
- `DirStore` compares and then renames under a mutex that belongs to one
  handle (`store.go:258-262`, `:316-348`). Its own comment calls this
  "single-process fidelity, which is what the tests and a one-box
  deployment need" (`store.go:254-257`). Two handles on one directory,
  such as a console and a direct-mode CLI (`cmd/console/main.go:482`,
  `cli.go:1948`), can both pass the comparison and the later rename wins.
  On a directory store G2 holds only among writers that share one handle.

That is an existing limit of every conditional write on a directory
store, for every caller. This spec does not change it and does not depend
on a directory store being safe across processes (§14).

**What a `deploy` request therefore is.** It is authorized against the
pipeline it read, and it may complete after a pipeline is installed. That
is what an in-flight request is under any authorization system: a policy
change governs the requests that start after it. The mechanism below makes
the in-flight case as narrow as one object's conditional write allows, and
the spec claims nothing beyond that.

**The mechanism.** The console passes an authorization hook,
`deployScopeAllows` bound to the principal, the channel and the hotfix
flag. The primitives call it with a pipeline they have just read, at
three points:

1. **On the ladder's pipeline read** in `PromoteOnLadder`
   (`release.go:930`), before the order check. This is not the authority.
   It exists so that a pipeline installed after the handler's read is
   refused with §7.3's 403, and not with the ladder's 409 about channel
   order (codex R3-3).
2. **After the pointer read and before the write**, in `Signer.Promote`
   (`release.go:732`) and `Signer.Rollback` (`release.go:1043`). The
   primitive reads the pointer and its ETag as it does today, reads the
   pipeline fresh, calls the hook, then signs and writes against that
   ETag. This is the authority for a write.
3. **On the repeat path.** `PromoteOnLadder` returns a no-op when the
   channel already points at the requested version (`release.go:887`).
   With a hook, it reads the pipeline after that pointer read and calls
   the hook before returning (codex R2-2).

A refusal is a typed error the handler maps to §7.3's 403. A failed
conditional write is returned as a conflict and is not retried inside the
primitive, because a retry must start again from the pointer read. A
caller that passes no hook gets today's behaviour at all three points,
with no extra read.

The handlers also call `deployScopeAllows` on their own pipeline read,
before the rules switch at `apicli.go:803`. This is what keeps a `deploy`
credential off the proposal path: the handler creates a proposal only
when its own read shows a pipeline that names the channel, and that same
read refuses a `deploy` credential for any channel but `canary/<name>`.

**What is guaranteed.** For a write by a `deploy` credential to channel C:

- **G1.** It was authorized against a pipeline read made after the read of
  C's pointer.
- **G2.** It lands only if C's pointer, at write time, is byte for byte
  what the request read. For a first promote, it lands only if C has no
  pointer at write time. This holds to the extent the backend's
  conditional write does, as set out above.

From G1: a pipeline that existed before the request read C, and still
exists when the authorization read is made, is seen. A `deploy` request
for any channel but `canary/<name>` is then refused. A pipeline that was
installed and removed again before the authorization read is not seen,
and nothing requires it to be: the request is judged on the pipeline the
authorization read returned (codex R4-2).

From G2: a write to C that leaves it different from what the request read
defeats the `deploy` write. That covers Astra's round 2 counterexample,
where an owner promotes B under a new pipeline.

**What is not guaranteed, in one sentence.** A pipeline installed after
the request's authorization read is not seen, and the write lands if G2
holds.

That sentence covers every case found so far, and it is the whole of what
R5 accepts (§16):

- the pipeline is installed and nothing touches C before the write;
- the pipeline is installed and C is written and restored to identical
  bytes, as two rollbacks in one second do (codex R3-1);
- C has no pointer, a pipeline is installed, and the create-only write
  lands on a channel that is still empty.

In each, the pointer the `deploy` write replaces is exactly the state the
request read, and the version was authorized when the request read it.
This is not serializability and the spec does not claim it. Another
operation that reads C before the `deploy` write lands sees the earlier
state, as it would between two people promoting today.

**A no-op is an observation, not a write** (codex R3-2). A repeat promote
answers "at the moment this request read C, C pointed at this version,
and the credential was in scope on a pipeline read after that". It makes
no conditional write, so it detects no later change to C. If an owner
installs a pipeline and promotes B after the request's reads, the request
still reports the no-op for A. It has changed nothing, and its answer was
true when it was read. No conflict is promised for this path.

The rung limit depends on none of this. The grant enforces it (§7.4) on
one object, with 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.

### 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 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. 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: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. 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

Today the CLI finds a console credential only in a credentials file
(`auth.go:140`). `CARLOS_SERVICE_TOKEN` is printed by `services create` as
a delivery hint and read by nothing in the CLI.

- `resolveMode` (`apimode.go:93`) reads `CARLOS_SERVICE_TOKEN`. When it is
  set, the session uses that token and the console named by `--console` or
  `CARLOS_CONSOLE`. The credentials file is not consulted.
- `CARLOS_SERVICE_TOKEN` with no console named is an error that names
  `CARLOS_CONSOLE`. There is no default console for a token.
- `CARLOS_SERVICE_TOKEN` together with `CARLOS_DEPLOYMENT_BUCKET` or
  `CARLOS_DEPLOYMENT_DIR` is refused, with an error naming both variables.
  The two modes carry different authority, and `resolveMode` already
  refuses `--console` beside either store variable for the same reason
  (`apimode.go:86-99`).
- A bare store variable with no token still selects direct mode silently,
  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` 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
(`internal/console/svcpage.go:143`). A `carlos` invoked inside an instance
would now pick it up. Confirm that is harmless for every box-side and
in-instance caller, or choose a second name for the CLI.

## 9. Mixed versions and rollback

- **Old console, `deploy` token.** Two checks refuse it, and the first is
  the one that fires (codex R3-4). `CLIPrincipal` rejects a stored service
  role it cannot parse (`svcprincipal.go:126`), so `cliRoute` answers as it
  does for no credential at all, before its role gate. If a role did
  resolve, `cliRoute` checks `allow.Permits(p.Role)` before any handler
  runs (`authcli.go:430`), and an old binary's sets do not contain
  `deploy`. The plan pins both, separately: a stored role the binary does
  not know is refused as not signed in at every registered `cliRoute`; and
  a known role outside a door's set is refused by the role gate. It must
  also confirm both hold in v0.21.0 by reading it.
- **Old console, new column.** An old binary ignores `rungs`. That is safe
  only because of the line above: it never lets the credential through.
- **New console, old CLI.** `services create --role deploy` fails in the old
  CLI's own role check (`services.go:174`). Promote and ship with a token
  need the new CLI for §8 only.
- **Roll order.** Console first, then mint, then CI.

## 10. Audit

No new audit shape. A `deploy` promote writes the same `promote` activity
line as any CLI promote, attributed to the service credential. Minting,
rotating and revoking a `deploy` credential are audited as service
credentials already are, and the mint line records the rungs.

## 11. Docs and copy

- `README.md:170-180` is rewritten: CI uses the console with a `deploy`
  credential. Direct-bucket mode is described as the operator's path, with
  the four cases in R3.
- `docs/site/cli.md` (the `ci-shipper` example at `:1419`) uses
  `--role deploy --rungs canary,edge`.
- Every new user-facing string goes through the copy-review gate before it
  is written into a file: CLI usage and help, the mint output, the three
  CLI errors in §8, the 403 in §7.3, the browser form, and the two doc
  passages above.

## 12. Testing

- **Scope function.** A table over role, pipelined, channel and hotfix,
  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`, 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.
- **G1 and G2, 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
    with §7.3's 403;
  - a pipeline that excludes the requested rung, installed between the
    handler's read and the ladder's read, is refused with §7.3's 403 and
    not the ladder's 409 (codex R3-3);
  - a pipeline installed and then an owner's promote of B to C, before
    the `deploy` write: conflict, and C still points at B;
  - a pipeline installed and then two rollbacks of C that restore its
    bytes, before the `deploy` write: the write lands. The test names
    this as the accepted in-flight case (codex R3-1);
  - a pipeline installed with no write to C, and a first promote onto an
    empty C: each lands, named the same way.
  These three pin R5. If the gap is ever closed, they flip.
- **No-op (§7.2).** Its own assertions, never a conflict (codex R3-2):
  refused when a pipeline existed before the pointer read; a successful
  no-op when the pipeline, or an owner's later promote, arrives after the
  request's reads.
- **Randomized interleavings.** One example per round was how three
  rounds went. The plan adds a test that runs random schedules of `deploy`
  promote, `deploy` rollback, owner promote, owner rollback, pipeline
  install and pipeline removal against a store that can pause between any
  two of a request's steps, over many seeds.
  - It records three events for every `deploy` request, independently: the
    pointer read (with the bytes read), the authorization read (with the
    pipeline returned), and the write (with the bytes replaced).
  - The oracle is G1 and G2 and nothing else (codex R4-2). G1: the
    authorization read came after the pointer read, and the pipeline it
    returned permits the request under §7.1. G2: the bytes replaced equal
    the bytes read. It does not assert anything about a pipeline that
    existed at some other moment, since a pipeline can be removed again
    before the authorization read.
  - Two mutants must each fail it, on a schedule the test prints so the
    failure can be replayed: the authoritative hook moved before the
    pointer read (breaks G1), and the write made unconditional (breaks G2
    when another writer changes C between the read and the write).
  - It runs on one shared store handle, which is where G2 holds for a
    directory store. It therefore cannot show the two-handle directory
    race (codex R4-1), and does not claim to. The S3 path's conditional
    write is covered by the existing store tests
    (`internal/release/store_test.go`, `store_rgw_test.go`).
- **No hook, no change.** A caller that passes no hook makes the same
  store calls in the same order as before, for promote, repeat promote
  and rollback.
- **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.
- **Mint.** `deploy` with no app, with no rungs, with `stable`, with an
  unknown rung; another role with rungs. Each refused through both doors.
- **Read-back.** A `deploy` row with empty rungs is refused at resolve.
- **Unknown role.** §9's two fail-closed tests.
- **CLI.** Token plus console selects API mode; token with no console
  errors; token plus a store variable errors; a bare store variable is
  still direct mode.

## 13. Rollout

1. Merge and release the platform.
2. Roll Tito's console and boxes. Tito runs its own console.
3. An owner mints `deploy` for app `titogo`, rungs `canary,edge`.
4. titogo's CI switches: drops its scoped key and grant and
   `CARLOS_DEPLOYMENT_BUCKET` (and its AWS role, if nothing else in the
   workflow uses it); sets `CARLOS_SERVICE_TOKEN` and
   `CARLOS_CONSOLE`. This is a titogo change, tracked on #229.
5. Confirm on the box that an edge promote is adopted (staged and
   repointed) within seconds, then
   remove `carlos-adopt.timer.d/canary-cadence.conf`.
6. Remove the scoped release key and its grant from the runner's secrets.
   The grant expires on its own within its 365-day life.

**To check before step 4.**

- The console's ship door caps one request at 256 MiB
  (`apiShipMaxBytes`, `apicli.go:1404`). Confirm titogo's artifact fits.
- The GitHub runner can reach Tito's console.
- Which doors `carlos canary` uses in API mode. If it needs an `instance`
  door, session canaries keep a separate credential, and that is out of
  scope here.

## 14. Out of scope, recorded so nobody rediscovers it

- A `publish` credential can promote `beta` and hotfix past the ladder on an
  app with no pipeline (§1). That is existing behaviour for a role that was
  designed to be wide. This spec gives CI a narrower role and does not
  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
  narrows this for `deploy` to G1 and G2, 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 (§16).
- A pipeline may name `stable` with no passkey rule, and promote then
  applies only that channel's rules (§1). Existing behaviour, unchanged.
- A directory store's conditional write is not safe across handles or
  processes (§7.2, codex R4-1). It affects every conditional write on a
  directory-backed deployment, whoever makes it. Fixing it, with a file
  lock or by refusing a second writer, is separate work.

## 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. |
| 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. |
| R3-1 a restored ETag makes I1(b) false | P1 | Accepted. §7.2: I1 is replaced by G1 and G2, which claim only what a content-hash conditional write proves. The restored-bytes case is named as part of the in-flight gap. |
| R3-2 the no-op path detects no conflict | P2 | Accepted. §7.2 and §12: a no-op is an observation, with its own assertions. |
| R3-3 the relocated hook loses the scope refusal to a ladder error | P2 | Accepted. §7.2: the hook is also called on the ladder's pipeline read. |
| R3-4 wrong location and missing first refusal in §9 | P2 | Accepted. §9. |
| R4-1 G2 fails across two `DirStore` handles on one directory | P1 | Accepted as a qualification. §7.2 states G2 per backend. The directory store's cross-handle limit is existing, documented in the store, and recorded in §14 as separate work. |
| R4-2 the randomized test's oracle rejects a write that satisfies G1 and G2 | P2 | Accepted. §7.2's inference and §12's oracle are restated on the pipeline the authorization read returned. Mutants must fail on a printed, replayable schedule. |

## 16. Requests in flight when a pipeline is installed (R5)

§7.2 guarantees G1 and G2 and nothing more: a pipeline installed after a
`deploy` request's authorization read is not seen, and the request's write
lands if the pointer is still byte for byte what it read.

**Ruled (R5, Paul, 2026-10-05): accepted.** This is how an in-flight
request behaves under any authorization system: the policy change governs
requests that start after it. R2 is a rule about which channels CI is
offered. The trust boundary is the rung limit, which the grant enforces
with no race. Reaching the gap takes an owner installing a pipeline while
a CI promote is between two store calls, and the result is what a promote
a moment earlier would have produced. The same gap exists today for every
person's promote against pipeline rules (§14).

**Not chosen.** Closing it for everyone: stamp a pipeline generation into
every pointer, or add a per-app lock in the store, and take it in pipeline
writes, promotes, rollbacks and proposal execution. That changes the
release store's concurrency model. If it is ever wanted it is its own
spec.
