carlos / platform Public
Runs started by a scoped service credential: implementation plan
> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: A service credential minted with the new run role and a command list can create, follow, cancel, expire and ack runs of those commands on its own app's instances, and can recover from a lost response without starting a second run.
Architecture: There are three layers:
- the role and the credential's stored scope (
svcrole,cli_tokens,Principal); - the run doors admitting that principal with its narrower checks (
apiruns.go,apirunsfollow.go); - an idempotency key, recorded in the instance record by the same compare-and-swap that admits the run, so admission and dedupe are one atomic write.
Tech Stack: Go. The console's SQLite store (internal/console/authstore.go). The deployment bucket through release.Store (Put(ctx, key, body, ifMatch): an empty ifMatch means create-only, and release.ErrCASConflict is returned on a 412).
Spec: docs/superpowers/specs/2026-10-24-service-runs-design.md, which amends docs/superpowers/specs/2026-10-08-carlos-run-design.md § 2 ruling 1.
Global Constraints
- People's behaviour on every run door is unchanged. Every existing run test passes unmodified, apart from new fields in JSON bodies.
- Wire and storage changes are additive (AGENTS.md): - new
cli_tokenscolumns go in the ALTER list (authstore.go:217-224), with defaults equal to today's value; - newruns.Requestandruns.Metafields areomitempty. Runis reachable only throughsvcrole.Exactly(svcrole.Run). No other role reaches the run doors, andAdmindoes not.- Command prefixes: - at most 32 per credential; - each prefix has at least one word; - each word is 1–128 bytes; - matched word for word, exactly: no globbing, no case folding, no substring; - a zero-word prefix never matches.
Idempotency-Key: 1–128 bytes of[A-Za-z0-9._:-]. The record'srun_keyskeeps entries for 24 hours, and the newest 128 at most.on_behalf_of: an email of at most 254 bytes, set only by arunservice. A person who sends it gets a 400.- Every refusal written by a run HANDLER carries a stable
codein its JSON body, alongside today'serrormessage. The shared gates keep their bodies exactly as today, deliberately identical across doors:cliNotSignedIn, the role-mismatch 403 incliRoute, and the canonical membershiphttp.NotFoundinapiAccountApp. - A new
carlosflag isn't done untildocs/site/cli.mdnames it (AGENTS.md). - Branch rules: the branch lands through amadan (
amadan branch describe, then squash, then green CI, thenamadan branch merge … -expect <tip>), never GitHub.
Review Focus
- A retry of an admitted key while the first run is still open. Today the "already has a run open" refusal (
apiruns.go:201) fires before the body is read. A keyed retry must get its run back, not that refusal. The pin isTestRunKeyedRetryWhileOpenReturnsTheRunin Task 4. - A service listing or following a person's run by guessing its id. It must get a 404 identical to an unknown id. The pin is
TestRunServiceCannotSeeAPersonsRunin Task 3. - A revoked credential name minted again. The new credential must not see the old one's runs. The pin is
TestRunRemintedNameDoesNotInheritRunsin Task 3. - Argv that matches a prefix only as a substring of a word. For example,
importerunder the prefiximport. It is refused. The pin is in Task 2'sTestCommandGrantedtable. - A keyed create whose meta declares a sha256 that doesn't match the uploaded bytes. It gets a 400
file_hash_mismatch, and nothing is written. The pin isTestRunKeyedHashMismatchWritesNothingin Task 4.
---
Task 1: The run role, which Admin does not reach
Files:
- Modify:
internal/svcrole/svcrole.go. AddRun, add it tocatalogue, addExactly, and update the Admin doc comment. - Test:
internal/svcrole/svcrole_test.go
Interfaces:
- Produces: -
const Run Role = "run"-func Exactly(roles ...Role) Set. LikeAllow, it panics on an unknown role, but it does NOT add Admin. - The doc on
Adminchanges from "the union of the five above" to say it is the union of every role exceptRun, which is reachable only throughExactly(spec § 1).
- [ ] Step 1: Write the failing tests -
TestRunIsInTheCatalogue:Parse("run")returns(Run, true), andAll()containsRun. -TestExactlyDoesNotAddAdmin: fors := Exactly(Run),s.Permits(Run)is true ands.Permits(Admin)is false, as is every other role. -TestExactlyPanicsOnAnUnknownRole:Exactly(Role("rnu"))panics. - AmendTestAdminPermitsEveryNonEmptySetso it still covers everyAllowset, then add: "anExactlyset admits only its listed roles." - [ ] Step 2: Run them and watch them fail.
go test ./internal/svcrole/fails:RunandExactlyare undefined. - [ ] Step 3: Implement in
svcrole.go. - [ ] Step 4: Watch them pass.
go test ./internal/svcrole/passes. Then rungo test ./internal/console/ -run 'Role|Service'and fix any role-list expectations that enumerate the catalogue. That includesroleNames()users inservices.go. Every one of them must now includerun. - [ ] Step 5: Commit:
svcrole: a run role that only an exact set reaches, never Admin (PRO-6214)
---
Task 2: A run credential's id and command list
Files:
- Modify:
internal/console/authstore.go. Add to the ALTER list:ALTER TABLE cli_tokens ADD COLUMN credential_id TEXT NOT NULL DEFAULT ''andALTER TABLE cli_tokens ADD COLUMN commands TEXT NOT NULL DEFAULT ''. Then a one-time backfill: every row withkinda service and an emptycredential_idgets a fresh random id. It is idempotent, and only empty rows are written. - Modify:
internal/console/svcprincipal.go. CoversPrincipal,CLIPrincipal, minting, rotation and listing. - Modify:
internal/console/apiservices.go. Create acceptscommands, and create and list echo it. - Modify:
services.go. Adds the--commandsflag. - Create:
internal/runs/commands.go, holding the matcher. It stays a pure function so the console and its tests share it. - Test:
internal/runs/commands_test.go,internal/console/svcprincipal_run_test.goandservices_test.go(extended).
Interfaces:
- Produces in
internal/runs: -func CommandGranted(argv []string, prefixes [][]string) bool-func CheckCommandPrefixes(prefixes [][]string) error. It enforces the limits in Global Constraints and returnsErrNoCommandsfor an empty list. - Produces in
internal/console: -Principal.CredentialID string. It is set for every service principal, and empty for a person. -Principal.Commands [][]string. It is non-nil only forRun. -ErrRunNeedsCommands,ErrCommandsNeedRun. -NewServicePrincipalScoped(accountID, name, app, role, rungs, commands [][]string, mintedBy, now). The signature gainscommands, and every caller is updated. - The stored form of
commandsis canonical JSON: an array of arrays of strings, in the order given. Rotation keeps bothcredential_idandcommands. A new mint always gets a newcredential_id. CLIPrincipalrefuses arunrow whosecommandscan't be parsed or is empty, the same way it refuses a deploy row without rungs (svcprincipal.go:136-147).--commandsis a comma-separated list of prefixes, and each prefix's words are separated by spaces:--commands "import classic,import sync". It is required with--role run, and refused with any other role. Create also requires--appforrun, using the same refusal path asService.
- [ ] Step 1: Write the failing tests -
TestCommandGranted, a table test: -{"import","classic","--slug","x"}under[["import","classic"]]is true; -{"import"}under[["import","classic"]]is false; -{"importer","classic"}under[["import","classic"]]is false; -{"Import","classic"}is false; -{}under anything is false; - with two prefixes, matching the second is true. - a zero-word prefix ([[]], and[nil]) matches nothing. -TestCheckCommandPrefixes: an empty list givesErrNoCommands. A zero-word prefix, an empty word, a 129-byte word and 33 prefixes are each refused.CLIPrincipalalso refuses a storedrunrow whose commands hold a zero-word prefix (TestRunCredentialRefusedWithAnEmptyPrefix). 32 prefixes of 128-byte words are accepted. -TestRunCredentialCarriesItsCommands: mint aRunprincipal with[["import","classic"]], andCLIPrincipalreturns thoseCommandsand a non-emptyCredentialID. -TestRunCredentialNeedsCommands: mintingRunwith nil commands givesErrRunNeedsCommands. MintingInstancewith commands givesErrCommandsNeedRun. MintingRunwith no app gives the account-scoped refusal. -TestRotationKeepsCredentialIDAndCommands, andTestRemintGetsANewCredentialID(revoke, then mint the same name). -TestCredentialIDBackfillIsIdempotent: reopening the store keeps existing ids. - A services CLI test:--commandswithout--role runfails, and--role runwithout--commandsfails. - [ ] Step 2: Run them and watch them fail.
go test ./internal/runs/ ./internal/console/ -run 'Command|RunCredential|Rotation|Remint|CredentialID'fails to compile. - [ ] Step 3: Implement. Follow how rungs are minted, stored, parsed and echoed, which is the pattern for every piece.
- [ ] Step 4: Watch them pass, plus
go test ./internal/console/ -run Serviceand the services CLI tests. - [ ] Step 5: Commit:
console: a run credential carries its own id and the commands it may run (PRO-6214)
---
Task 3: The run doors admit a run service, for its own runs only
Files:
- Modify:
internal/console/authcli.go:736-740. The five run routes go fromsvcrole.Set{}tosvcrole.Exactly(svcrole.Run). - Modify:
internal/console/apiruns.go. On create: - the principal check becomes "a person, or aRunservice"; - the command check comes after the meta parse and before any write: 403, codecommand_not_granted; -on_behalf_ofis validated; - the request fields are set; - the open-run refusal is redacted for a service. - Modify:
internal/console/apirunsfollow.go.runGateadmits aRunservice.runCallgains the principal. Every read and write of a run checksrequest.json'sByCredentialagainst a service principal'sCredentialID, and a mismatch answers exactly what an unknown id does. - Modify:
internal/runs/runs.go. -RequestgainsByService string,ByCredential stringandOnBehalfOf string(allomitempty). -MetagainsOnBehalfOf string(omitempty). - Modify:
internal/console/authcli.go:1098. Addfunc jsonErrorCode(w http.ResponseWriter, status int, code, msg string), which writes{"error": msg, "code": code}. Every refusal on the five run routes switches to it. Name the codes for what they refuse (for examplenot_found,host_claim,rate_limited,run_open,moving,import_uncommitted,files_too_large,malformed,command_not_grantedandon_behalf_of_invalid), and list them in one place:var runRefusalCodes = []string{...}. - Modify: wherever a run's requester is shown. Find these by grepping for
.Byonruns.Requestand the run audit rows (CLI follow output, the console run history and the audit log). A service's run readsrun by service <name> for <email> (as stated by the service), orrun by service <name> (scheduled)whenOnBehalfOfis empty. - Test:
internal/console/apiruns_service_test.go
Interfaces:
- Consumes:
svcrole.Exactly,svcrole.Run(Task 1);Principal.CredentialID,Principal.Commandsandruns.CommandGranted(Task 2). - Produces: -
runs.Request{ByService, ByCredential, OnBehalfOf}; -jsonErrorCode; -runRefusalCodes; -func (s *Server) runVisibleTo(p Principal, req runs.Request) bool, which is true for a person (membership was already checked) and, for a service, true only whenreq.ByCredential == p.CredentialID.
- [ ] Step 1: Write the failing tests (use
runFixtureandserviceToken, and mintRunwith[["import","classic"]]): -TestRunRoleMatrix: on all five routes, aRunservice passes the role gate.Publish,Deploy,Instance,Operate,ServiceandAdmineach getcliRoute's role-mismatch 403 (authcli.go:467), not the closed-door message, because the set is no longer empty. Update the tests that pin the closed-door registrations and the route-role table (svcroutes_test.go:226and the fixed door count there). Keep explicit coverage that Admin is refused on the run doors. -TestRunServiceStartsAGrantedCommand: argvimport classic --slug xgets a 202, as create gives today (apiruns.go:317), and request.json hasByService,ByCredentialandOnBehalfOf. -TestRunServiceRefusedAnUngrantedCommand: argvdb shellgets 403command_not_granted, and no run objects exist. -TestRunServiceWrongAppRefused: aRuncredential for appotheronshopis refused. -TestRunRevokedServiceRefused. -TestRunServiceCannotSeeAPersonsRun: the person starts a run, and the service's GET, cancel, expire and ack on its id each give a body identical to an unknown id's. -TestRunRemintedNameDoesNotInheritRuns: the service starts a run, the credential is revoked, the same name is minted again, and the new credential's GET is the unknown-id answer. -TestRunOpenRefusalRedactedForServices: a person's run is open, and the service's create getsrun_openwith neither that run's id nor the person's email anywhere in the body. -TestRunPersonMaySetNoOnBehalfOf: a person's meta withon_behalf_ofgets 400on_behalf_of_invalid. -TestRunOnBehalfOfShape: not an email, or 255 bytes, gets 400. -TestRunRefusalsCarryCodes: every refusal a run HANDLER writes has acodethat is inrunRefusalCodes. The shared-gate refusals (401, the role 403, the membership 404) are byte-identical to today, andapiruns_test.go:195's canonical-404 test is unchanged. -TestServiceRunRendersAsTheServices: the follow and history rendering of a service's run containsrun by service ci for paul@example.com (as stated by the service)and never presents the email as the actor. - Repeat the existing incarnation, one-run and moving tests with aRunservice as the creator: delete and recreate, a move in progress, and a person's run plus a service's run on one instance. - [ ] Step 2: Run them and watch them fail.
go test ./internal/console/ -run 'TestRun'fails. - [ ] Step 3: Implement.
- [ ] Step 4: Watch them pass, and run all of
go test ./internal/console/ -run 'Run|Service', with every pre-existing run test unmodified. - [ ] Step 5: Commit:
console: a run service starts and follows its own runs of the commands it was granted (PRO-6214)
---
Task 4: Idempotent create, settled by the record
Files:
- Modify:
internal/provision/provision.go.RecordgainsRunKeys []RunKey \json:"run_keys,omitempty"\``. - Modify:
internal/provision/run.go. -RunAdmissiongainsKey string(the scoped key hash) andDigest string. -RunAdmithandles the key inside the same mutation. - Every record mutation that replaces or clearsActiveRun(the next admission, ack, expire, the sweep's clear) keepsRunKeys. - Create:
internal/runs/keys.go - Modify:
internal/runs/runs.go.MetaFilegainsSHA256 string(omitempty). Convert the positionalMetaFile{...}literals in existing tests (internal/runs/runs_test.go:74-89and any others the compiler finds) to named fields. - Modify:
internal/console/apiruns.go - Test:
internal/runs/keys_test.go,internal/provision/run_keys_test.go,internal/console/apiruns_key_test.go
Interfaces:
- Produces in
internal/runs: -func ValidKey(k string) bool-func ScopedKey(scope, key string) string. It returnshex(sha256(scope + "\x00" + key)).scopeis"svc:" + CredentialIDfor a service and"user:" + addressfor a person. -func MetaDigest(m Meta) string: sha256 over a canonical encoding of argv, timeouts, onbehalf_of and every file's name, size and sha256. -const KeyTTL = 24 * time.Hour,const MaxRunKeys = 128. - Produces in
internal/provision: -type RunKey struct { Key, RunID, Digest string; At time.Time }-type RunKeyReplay struct { RunID string }, which implementserror, returned byRunAdmitwhen the key is already there with the same digest; -var ErrRunKeyReused, for when the key is already there with a different digest. RunAdmit's mutation, in this order inside the one CAS: 1. pruneRunKeysto entries younger thanKeyTTL; 2. ifp.Key != ""and an entry has that key: returnErrRunKeyReusedif its digest differs, else&RunKeyReplay{RunID}. Both run BEFORE today's open-run, moving and import refusals, so a replay is answered even while its own run is still open; 3. today's admission; 4. on success, whenp.Key != "", append{Key, RunID, Digest, now}and trim to the newestMaxRunKeys.- Create's order, but only for a keyed request (an unkeyed request keeps today's exact order): 1. principal, membership, host-claim and rate budget; 2. read the record (a 404 stays a 404); 3. parse the meta; 4. look up the key in that record read. On the same digest, answer
202with the run ("replayed": true) and stop reading the body. On a different digest,409idempotency_key_reused; 5. today's class, move, open-run and import refusals; 6. the command check (Task 3); 7. spool the files and verify each against its declared sha256 (400file_hash_mismatch, nothing written); 8. mint the id, write the objects, and admit withKeyandDigest; 9. if admission returns*RunKeyReplay, compare itsRunIDwith the id this request minted: - equal: this request's own admission committed, and the CAS retry insideupdateRecordfound its own key. That is success: answer202as a fresh admission, and delete nothing; - different: a concurrent twin won. Delete ONLY this request's candidate prefix (runs.Prefix(acct, app, mintedID)), then answer202with the winner's run, replayed;
ErrRunKeyReused answers 409; 10. today's lost-admission settling (apiruns.go:279-291) is unchanged, and is now also correct for keys, because the key commits with the admission or not at all.
- The rollout gate (mixed versions). A box running an older binary rewrites the record through a typed
Recordthat doesn't knowrun_keys, and drops it. So keyed admission is OFF until every record writer preserves the field: - the console config gainsRunKeysEnabled bool, fromCARLOS_RUN_KEYS=on; - while it is off, a keyed create is refused503keys_not_readybefore anything is written, never silently treated as unkeyed; - it is turned on only after the box fleet has rolled to a build that includes this task. The branch description and the landing note say so. GET .../instances/{host}/runs?key=<k>: a new route on the run set (Exactly(Run), plus people). It returns{"run_id": ...}from the record'sRunKeysfor the caller's scoped key, or 404.
- [ ] Step 1: Write the failing tests -
TestValidKey: a table of good keys, plus an empty key, a 129-byte key and one containing/or a space. -TestScopedKeySeparatesCallers. -TestMetaDigestCoversEveryField: changing argv, timeout, onbehalfof, a file name, a size or a sha256 each changes the digest. -TestRunAdmitRecordsAndReplaysAKey, at the provision level: - admit with K and D, then admit again with K and D, which givesRunKeyReplaywith the first id; - K with D2 givesErrRunKeyReused; - after the run is acked or cleared, K and D still replay; - after a second, unkeyed run replaces it, K and D still replay. -TestRunKeysPrunedByAgeAndCount: entries older than 24h are dropped, and at most 128 are kept, newest first. -TestRunKeyedCreateThenRetryReturnsTheSameRun: same id, one request.json, and the retry carriesreplayed: true. -TestRunKeyedRetryWhileOpenReturnsTheRun-TestRunKeyedRetryAfterAckStillReturnsTheRun-TestRunKeyReusedWithADifferentRequest: 409, with nothing written. -TestRunConcurrentIdenticalKeyedCreatesMakeOneRun: two goroutines with the same key and request. Exactly one admission happens, both answers name the same id, and the loser's objects are deleted. -TestRunKeyOfAnotherCallerIsInvisible: the person's K used by the service is fresh, and?key=Kfrom the service is a 404. -TestRunKeyedHashMismatchWritesNothing-TestRunKnownKeyAnsweredWithoutReadingFiles: retry with a body whose file part errors on read, and still get a 202 with the id. -TestRunRefusedKeyIsReevaluated: a keyed create refused withrun_openstarts the run once the other run is acked. -TestRunKeyLookup-TestRunUnkeyedOrderUnchanged: an unkeyed create against an open run is still refused before the body is read. -TestAPIRunCreateAdmissionWhoseAnswerWasLost: extend the existing test to a keyed request. The store's conflict-after-commit gives aRunKeyReplaywith this request's OWN id, the answer is a fresh202, and the run's objects still exist. -TestRunKeysSurviveEveryRecordMutation: a record carryingrun_keys, passed through each of the sixActiveRunmutations ininternal/provision/run.goand the move and import mutations, still carries them. -TestKeyedCreateRefusedUntilKeysAreOn: withRunKeysEnabledfalse, a keyed create gets503keys_not_readyand nothing is written. An unkeyed create is unaffected. - [ ] Step 2: Run them and watch them fail.
go test ./internal/runs/ ./internal/provision/ ./internal/console/ -run 'Key|Keyed|Digest'fails. - [ ] Step 3: Implement.
- [ ] Step 4: Watch them pass, plus every pre-existing test under
go test ./internal/provision/ ./internal/console/ -run Run. - [ ] Step 5: Commit:
console: a keyed run create is admitted once, settled by the instance record, and a retry gets the same run (PRO-6214)
---
Task 5: The CLI flag, the docs and the run-spec amendment
Files:
- Modify:
run.go.carlos rungains--idempotency-key <key>, sends it as the header, and sends each file's sha256 in the meta when a key is given. It accepts the202replay ("replayed": true) the same way as a fresh202(run.go:223), saying "already started: run <id>" before following it. It prints the run id before streaming, as it does today. - Modify:
docs/site/cli.md, coveringcarlos run --idempotency-keyandcarlos services create --role run --commands. - Modify: the services and run docs pages under
docs/site/, wherever the role catalogue and the run API are described. Add therunrole, the command-list rule,on_behalf_of, the refusal codes (fromrunRefusalCodes) and the key lifetime. - Modify:
docs/superpowers/specs/2026-10-08-carlos-run-design.md§ 2 ruling 1. Add one line: "Amended 2026-10-24: arunservice credential may, for the commands it was granted. See2026-10-24-service-runs-design.md." - Test:
run_test.go(extended), plus the docs test that checksdocs/site/cli.mdnames every command or flag, if one exists. Find it withgrep -rn "cli.md" *_test.go.
Interfaces:
- Consumes:
runs.ValidKey(Task 4).
- [ ] Step 1: Write the failing tests -
TestRunIdempotencyKeyFlag: the request carriesIdempotency-Key, and the meta's files carrysha256. -TestRunCLIRetryFollowsTheReplayedRun: against a test console, a secondcarlos runwith the same key prints "already started", follows the same id, and starts no second run. - An invalid key is refused by the CLI before any network call. -TestRunRefusalCodesAreDocumented: every entry inrunRefusalCodesappears in the run API doc page. - [ ] Step 2: Run them and watch them fail.
- [ ] Step 3: Implement.
- [ ] Step 4: Watch them pass, and run
make testonce, before the branch is described. - [ ] Step 5: Commit:
cli, docs: carlos run takes an idempotency key, and the run role is documented (PRO-6214)
---
Landing
One branch, pro-6214-service-runs, landed through amadan as AGENTS.md describes. Describe it with amadan branch describe carlos/platform <branch>, then squash to one commit, wait for CI to go green on the tip, and merge with -expect <tip>. Before landing, an Opus or Fable review covers the whole branch, because it grants authority (AGENTS.md delegation policy). Astra reviews too, since the session is Opus.
After it lands, roll the console AND the boxes, confirm every box runs a build that includes Task 4, and only then set CARLOS_RUN_KEYS=on on the console. Then Tito mints fleet-runs with --role run --app titogo --commands "<the import and sync commands>". The exact commands are settled with the titogo side of PRO-6214, outside this plan.