carlos / platform Public
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
deploycredential'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
deploypromote 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: main8d8e4bae. 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'sfastConvergeLoopadopt 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 whenpipe == nil). Promote refuses the stable rung when the app has no pipeline or its pipeline does not namestable. A pipeline that does namestableis 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
- 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.
- CI can supply that credential to the CLI from its environment, with no credentials file.
- The README and CLI docs tell CI to use the console. Direct-bucket mode is documented as the operator's and break-glass path.
- 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 plusConvergeGraceof 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
publishcredentials. - 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
deploycredential reachescanary/*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
deployrequest 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 onlyshipandpromote. Adeploycredential therefore reaches the read doorscarlos deployuses 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).canarycovers everycanary/<branch>channel, as it does in a key grant. stableis refused at mint and again at the door, mirroringinternal/release/grant.go:103and:219. No delegated key has ever been able to reach stable, and nodeploycredential 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
deployrequires a non-empty app, the same wayservicedoes atsvcprincipal.go:189; - role
deployrequires 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
publishwould 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 creategains--rungs <list>. The usage line and the role list inservices.go:33and:157gaindeploy.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 lsand 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
deploycredential is refused at those doors with the role gate's message, and the deploy stops there. --hoston 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 deployreads 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:
hotfixis set. This is a deliberate restriction beyond what a scoped release key carries: a scoped signer may skip ladder order withHotfix(internal/release/release.go:945). What a scoped key cannot do isFastTrack, which waives a bake window and is a separate field (release.go:383). Adeploycredential gets neither. The console's promote door has no fast-track input, and the plan pins that with a test (codex 5).release.Rung(channel)(ladder.go:55) says the channel is not a rung.- The rung is
stable. - The rung is
canaryand the channel is notcanary/<name>.release.Rung("canary")answerscanary, so the bare name would otherwise pass. It is refused for every app, pipelined or not (codex 2). - The rung is not in the credential's rungs.
- 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).
S3Storesends the condition with the write (store.go:613-628), and S3 evaluates it atomically. G2 holds against every writer.DirStorecompares 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:
- 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). - After the pointer read and before the write, in
Signer.Promote(release.go:732) andSigner.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. - On the repeat path.
PromoteOnLadderreturns 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:
- 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 satisfiesgrantNameRe(grant.go:81). - It builds a
release.ScopedSigner(signer.go:42) from them and passes that signer toPromoteOnLadderorRollback. - 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
edgeisedgeto 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) readsCARLOS_SERVICE_TOKEN. When it is set, the session uses that token and the console named by--consoleorCARLOS_CONSOLE. The credentials file is not consulted.CARLOS_SERVICE_TOKENwith no console named is an error that namesCARLOS_CONSOLE. There is no default console for a token.CARLOS_SERVICE_TOKENtogether withCARLOS_DEPLOYMENT_BUCKETorCARLOS_DEPLOYMENT_DIRis refused, with an error naming both variables. The two modes carry different authority, andresolveModealready refuses--consolebeside 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 whoamiis unchanged and stays person-only. It reads the credentials file and never callsresolveMode(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,
deploytoken. Two checks refuse it, and the first is the one that fires (codex R3-4).CLIPrincipalrejects a stored service role it cannot parse (svcprincipal.go:126), socliRouteanswers as it does for no credential at all, before its role gate. If a role did resolve,cliRoutechecksallow.Permits(p.Role)before any handler runs (authcli.go:430), and an old binary's sets do not containdeploy. The plan pins both, separately: a stored role the binary does not know is refused as not signed in at every registeredcliRoute; 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 deployfails 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-180is rewritten: CI uses the console with adeploycredential. Direct-bucket mode is described as the operator's path, with the four cases in R3.docs/site/cli.md(theci-shipperexample 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
canaryfor 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, barecanary, a hotfix, and a non-canary channel on a pipelined app each give a 403 and write nothing. A pipeline that namesstablewith no passkey rule still refuses adeploycredential. - 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
deploywrite: conflict, and C still points at B; - a pipeline installed and then two rollbacks of C that restore its bytes, before thedeploywrite: 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
deploypromote,deployrollback, 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 everydeployrequest, 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
deploypromote carries a grant for exactly the credential's rungs and verifies withverifyPointer. 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
cliRouteand assertsdeployis permitted on exactly the §6 list. A route added later is refused fordeployunless someone adds it to the list on purpose. - Mint.
deploywith no app, with no rungs, withstable, with an unknown rung; another role with rungs. Each refused through both doors. - Read-back. A
deployrow 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
- Merge and release the platform.
- Roll Tito's console and boxes. Tito runs its own console.
- An owner mints
deployfor apptitogo, rungscanary,edge. - 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); setsCARLOS_SERVICE_TOKENandCARLOS_CONSOLE. This is a titogo change, tracked on #229. - Confirm on the box that an edge promote is adopted (staged and repointed) within seconds, then remove
carlos-adopt.timer.d/canary-cadence.conf. - 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 canaryuses in API mode. If it needs aninstancedoor, session canaries keep a separate credential, and that is out of scope here.
14. Out of scope, recorded so nobody rediscovers it
- A
publishcredential can promotebetaand 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 changepublish. - 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
PromoteOnLadderacts on a second one. A pipeline changed between the two is judged on the old rules. §7.2 narrows this fordeployto G1 and G2, and leaves it as found for people and forpublish. 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
stablewith 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.