carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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_tokens columns go in the ALTER list (authstore.go:217-224), with defaults equal to today's value; - new runs.Request and runs.Meta fields are omitempty.
  • Run is reachable only through svcrole.Exactly(svcrole.Run). No other role reaches the run doors, and Admin does 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's run_keys keeps entries for 24 hours, and the newest 128 at most.
  • on_behalf_of: an email of at most 254 bytes, set only by a run service. A person who sends it gets a 400.
  • Every refusal written by a run HANDLER carries a stable code in its JSON body, alongside today's error message. The shared gates keep their bodies exactly as today, deliberately identical across doors: cliNotSignedIn, the role-mismatch 403 in cliRoute, and the canonical membership http.NotFound in apiAccountApp.
  • A new carlos flag isn't done until docs/site/cli.md names it (AGENTS.md).
  • Branch rules: the branch lands through amadan (amadan branch describe, then squash, then green CI, then amadan branch merge … -expect <tip>), never GitHub.

Review Focus

  1. 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 is TestRunKeyedRetryWhileOpenReturnsTheRun in Task 4.
  2. 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 TestRunServiceCannotSeeAPersonsRun in Task 3.
  3. A revoked credential name minted again. The new credential must not see the old one's runs. The pin is TestRunRemintedNameDoesNotInheritRuns in Task 3.
  4. Argv that matches a prefix only as a substring of a word. For example, importer under the prefix import. It is refused. The pin is in Task 2's TestCommandGranted table.
  5. 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 is TestRunKeyedHashMismatchWritesNothing in Task 4.

---

Task 1: The run role, which Admin does not reach

Files:

  • Modify: internal/svcrole/svcrole.go. Add Run, add it to catalogue, add Exactly, and update the Admin doc comment.
  • Test: internal/svcrole/svcrole_test.go

Interfaces:

  • Produces: - const Run Role = "run" - func Exactly(roles ...Role) Set. Like Allow, it panics on an unknown role, but it does NOT add Admin.
  • The doc on Admin changes from "the union of the five above" to say it is the union of every role except Run, which is reachable only through Exactly (spec § 1).
  • [ ] Step 1: Write the failing tests - TestRunIsInTheCatalogue: Parse("run") returns (Run, true), and All() contains Run. - TestExactlyDoesNotAddAdmin: for s := Exactly(Run), s.Permits(Run) is true and s.Permits(Admin) is false, as is every other role. - TestExactlyPanicsOnAnUnknownRole: Exactly(Role("rnu")) panics. - Amend TestAdminPermitsEveryNonEmptySet so it still covers every Allow set, then add: "an Exactly set admits only its listed roles."
  • [ ] Step 2: Run them and watch them fail. go test ./internal/svcrole/ fails: Run and Exactly are undefined.
  • [ ] Step 3: Implement in svcrole.go.
  • [ ] Step 4: Watch them pass. go test ./internal/svcrole/ passes. Then run go test ./internal/console/ -run 'Role|Service' and fix any role-list expectations that enumerate the catalogue. That includes roleNames() users in services.go. Every one of them must now include run.
  • [ ] 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 '' and ALTER TABLE cli_tokens ADD COLUMN commands TEXT NOT NULL DEFAULT ''. Then a one-time backfill: every row with kind a service and an empty credential_id gets a fresh random id. It is idempotent, and only empty rows are written.
  • Modify: internal/console/svcprincipal.go. Covers Principal, CLIPrincipal, minting, rotation and listing.
  • Modify: internal/console/apiservices.go. Create accepts commands, and create and list echo it.
  • Modify: services.go. Adds the --commands flag.
  • 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.go and services_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 returns ErrNoCommands for 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 for Run. - ErrRunNeedsCommands, ErrCommandsNeedRun. - NewServicePrincipalScoped(accountID, name, app, role, rungs, commands [][]string, mintedBy, now). The signature gains commands, and every caller is updated.
  • The stored form of commands is canonical JSON: an array of arrays of strings, in the order given. Rotation keeps both credential_id and commands. A new mint always gets a new credential_id.
  • CLIPrincipal refuses a run row whose commands can't be parsed or is empty, the same way it refuses a deploy row without rungs (svcprincipal.go:136-147).
  • --commands is 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 --app for run, using the same refusal path as Service.
  • [ ] 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 gives ErrNoCommands. A zero-word prefix, an empty word, a 129-byte word and 33 prefixes are each refused. CLIPrincipal also refuses a stored run row whose commands hold a zero-word prefix (TestRunCredentialRefusedWithAnEmptyPrefix). 32 prefixes of 128-byte words are accepted. - TestRunCredentialCarriesItsCommands: mint a Run principal with [["import","classic"]], and CLIPrincipal returns those Commands and a non-empty CredentialID. - TestRunCredentialNeedsCommands: minting Run with nil commands gives ErrRunNeedsCommands. Minting Instance with commands gives ErrCommandsNeedRun. Minting Run with no app gives the account-scoped refusal. - TestRotationKeepsCredentialIDAndCommands, and TestRemintGetsANewCredentialID (revoke, then mint the same name). - TestCredentialIDBackfillIsIdempotent: reopening the store keeps existing ids. - A services CLI test: --commands without --role run fails, and --role run without --commands fails.
  • [ ] 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 Service and 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 from svcrole.Set{} to svcrole.Exactly(svcrole.Run).
  • Modify: internal/console/apiruns.go. On create: - the principal check becomes "a person, or a Run service"; - the command check comes after the meta parse and before any write: 403, code command_not_granted; - on_behalf_of is validated; - the request fields are set; - the open-run refusal is redacted for a service.
  • Modify: internal/console/apirunsfollow.go. runGate admits a Run service. runCall gains the principal. Every read and write of a run checks request.json's ByCredential against a service principal's CredentialID, and a mismatch answers exactly what an unknown id does.
  • Modify: internal/runs/runs.go. - Request gains ByService string, ByCredential string and OnBehalfOf string (all omitempty). - Meta gains OnBehalfOf string (omitempty).
  • Modify: internal/console/authcli.go:1098. Add func 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 example not_found, host_claim, rate_limited, run_open, moving, import_uncommitted, files_too_large, malformed, command_not_granted and on_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 .By on runs.Request and the run audit rows (CLI follow output, the console run history and the audit log). A service's run reads run by service <name> for <email> (as stated by the service), or run by service <name> (scheduled) when OnBehalfOf is empty.
  • Test: internal/console/apiruns_service_test.go

Interfaces:

  • Consumes: svcrole.Exactly, svcrole.Run (Task 1); Principal.CredentialID, Principal.Commands and runs.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 when req.ByCredential == p.CredentialID.
  • [ ] Step 1: Write the failing tests (use runFixture and serviceToken, and mint Run with [["import","classic"]]): - TestRunRoleMatrix: on all five routes, a Run service passes the role gate. Publish, Deploy, Instance, Operate, Service and Admin each get cliRoute'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:226 and the fixed door count there). Keep explicit coverage that Admin is refused on the run doors. - TestRunServiceStartsAGrantedCommand: argv import classic --slug x gets a 202, as create gives today (apiruns.go:317), and request.json has ByService, ByCredential and OnBehalfOf. - TestRunServiceRefusedAnUngrantedCommand: argv db shell gets 403 command_not_granted, and no run objects exist. - TestRunServiceWrongAppRefused: a Run credential for app other on shop is 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 gets run_open with neither that run's id nor the person's email anywhere in the body. - TestRunPersonMaySetNoOnBehalfOf: a person's meta with on_behalf_of gets 400 on_behalf_of_invalid. - TestRunOnBehalfOfShape: not an email, or 255 bytes, gets 400. - TestRunRefusalsCarryCodes: every refusal a run HANDLER writes has a code that is in runRefusalCodes. The shared-gate refusals (401, the role 403, the membership 404) are byte-identical to today, and apiruns_test.go:195's canonical-404 test is unchanged. - TestServiceRunRendersAsTheServices: the follow and history rendering of a service's run contains run 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 a Run service 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. Record gains RunKeys []RunKey \json:"run_keys,omitempty"\``.
  • Modify: internal/provision/run.go. - RunAdmission gains Key string (the scoped key hash) and Digest string. - RunAdmit handles the key inside the same mutation. - Every record mutation that replaces or clears ActiveRun (the next admission, ack, expire, the sweep's clear) keeps RunKeys.
  • Create: internal/runs/keys.go
  • Modify: internal/runs/runs.go. MetaFile gains SHA256 string (omitempty). Convert the positional MetaFile{...} literals in existing tests (internal/runs/runs_test.go:74-89 and 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 returns hex(sha256(scope + "\x00" + key)). scope is "svc:" + CredentialID for a service and "user:" + address for 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 implements error, returned by RunAdmit when 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. prune RunKeys to entries younger than KeyTTL; 2. if p.Key != "" and an entry has that key: return ErrRunKeyReused if 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, when p.Key != "", append {Key, RunID, Digest, now} and trim to the newest MaxRunKeys.
  • 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 202 with the run ("replayed": true) and stop reading the body. On a different digest, 409 idempotency_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 (400 file_hash_mismatch, nothing written); 8. mint the id, write the objects, and admit with Key and Digest; 9. if admission returns *RunKeyReplay, compare its RunID with the id this request minted: - equal: this request's own admission committed, and the CAS retry inside updateRecord found its own key. That is success: answer 202 as a fresh admission, and delete nothing; - different: a concurrent twin won. Delete ONLY this request's candidate prefix (runs.Prefix(acct, app, mintedID)), then answer 202 with 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 Record that doesn't know run_keys, and drops it. So keyed admission is OFF until every record writer preserves the field: - the console config gains RunKeysEnabled bool, from CARLOS_RUN_KEYS=on; - while it is off, a keyed create is refused 503 keys_not_ready before 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's RunKeys for 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 gives RunKeyReplay with the first id; - K with D2 gives ErrRunKeyReused; - 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 carries replayed: 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=K from 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 with run_open starts 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 a RunKeyReplay with this request's OWN id, the answer is a fresh 202, and the run's objects still exist. - TestRunKeysSurviveEveryRecordMutation: a record carrying run_keys, passed through each of the six ActiveRun mutations in internal/provision/run.go and the move and import mutations, still carries them. - TestKeyedCreateRefusedUntilKeysAreOn: with RunKeysEnabled false, a keyed create gets 503 keys_not_ready and 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 run gains --idempotency-key <key>, sends it as the header, and sends each file's sha256 in the meta when a key is given. It accepts the 202 replay ("replayed": true) the same way as a fresh 202 (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, covering carlos run --idempotency-key and carlos 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 the run role, the command-list rule, on_behalf_of, the refusal codes (from runRefusalCodes) 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: a run service credential may, for the commands it was granted. See 2026-10-24-service-runs-design.md."
  • Test: run_test.go (extended), plus the docs test that checks docs/site/cli.md names every command or flag, if one exists. Find it with grep -rn "cli.md" *_test.go.

Interfaces:

  • Consumes: runs.ValidKey (Task 4).
  • [ ] Step 1: Write the failing tests - TestRunIdempotencyKeyFlag: the request carries Idempotency-Key, and the meta's files carry sha256. - TestRunCLIRetryFollowsTheReplayedRun: against a test console, a second carlos run with 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 in runRefusalCodes appears 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 test once, 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.