docs: plan for runs started by a scoped service credential (PRO-6214)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+442
−0
docs/superpowers/plans/2026-10-24-service-runs.md+442 −0
diff --git a/docs/superpowers/plans/2026-10-24-service-runs.md b/docs/superpowers/plans/2026-10-24-service-runs.md| new file mode 100644 |
| index 000000000..3e3c08730 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-24-service-runs.md |
| @@ -0,0 +1,442 @@ |
| +# 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, stored create-only beside the run objects, that answers |
| + retries from its own state. |
| + |
| +**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 word 1–128 bytes, with no empty word; |
| + - matched word for word, exactly: no globbing, no case folding, no substring. |
| +- **`Idempotency-Key`:** 1–128 bytes of `[A-Za-z0-9._:-]`. It is honoured for 7 |
| + days from the create. |
| +- **Timings:** |
| + - create handler deadline: 2 minutes; |
| + - pending-key abandon threshold: 5 minutes; |
| + - key sweep age: 7 days. |
| +- **`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 on a run door carries a stable `code`** in its JSON body, |
| + alongside today's `error` message. |
| +- **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 a known 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, key included. 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. |
| + - `TestCheckCommandPrefixes`: an empty list gives `ErrNoCommands`. An empty |
| + word, a 129-byte word and 33 prefixes are each refused. 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` |
| + get the "closed to service credentials" 403. Today's |
| + `apiruns_test.go:189` expectation for `Instance` still holds. |
| + - `TestRunServiceStartsAGrantedCommand`: argv `import classic --slug x` |
| + gets a 201 or 200 (as today), 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 the run tests provoke has a |
| + `code` that is in `runRefusalCodes`. |
| + - `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 |
| + |
| +**Files:** |
| +- Create: `internal/runs/keys.go` |
| +- Modify: `internal/console/apiruns.go` |
| +- Modify: the run sweep (`internal/console/runsweep.go`), adding a key sweep |
| +- Test: `internal/runs/keys_test.go`, `internal/console/apiruns_key_test.go` |
| + |
| +**Interfaces:** |
| +- Produces in `internal/runs`: |
| + - `func ValidKey(k string) bool` |
| + - `func KeyObject(acct, app, scope, host, key string) string`. It returns |
| + `RunsPrefix(acct, app) + "keys/" + hex(sha256(scope + "\x00" + host + "\x00" + key)) + ".json"`. |
| + `scope` is `"svc:" + CredentialID` for a service and `"user:" + address` |
| + for a person. |
| + - `type KeyState string`, with `KeyPending`, `KeyAdmitted`, `KeyRefused` |
| + and `KeyAbandoned`. |
| + - `type Key struct { Schema int; State KeyState; RunID string; Digest string; Code string; Status int; CreatedAt, UpdatedAt time.Time }` |
| + - `func MetaDigest(m Meta) string`: sha256 over a canonical encoding of argv, |
| + timeout_s, on_behalf_of and every file's name, size and sha256. |
| +- `MetaFile` gains `SHA256 string` (`omitempty`). A keyed create requires it |
| + on every file. The spooled bytes are verified against it, and a mismatch |
| + gives 400 `file_hash_mismatch` before any write. |
| +- **Create's order changes, but only for a request with an `Idempotency-Key`.** |
| + An unkeyed request keeps today's exact order, so Global Constraint 1 holds. |
| + For a keyed create: |
| + 1. principal, membership, host-claim and rate budget; |
| + 2. read the record (a 404 stays a 404); |
| + 3. parse the meta; |
| + 4. check the key (below); |
| + 5. today's class, move, open-run and import refusals, then the command check |
| + (Task 3); |
| + 6. spool and verify the files; |
| + 7. mint the id; |
| + 8. write the `pending` key create-only; |
| + 9. write the objects; |
| + 10. admit; |
| + 11. CAS the key to `admitted` or `refused`, recording the code and status. |
| +- **The key check**, at step 4: |
| + - **No key object:** continue. |
| + - **Digest differs:** 409 `idempotency_key_reused`, and write nothing. |
| + - **`admitted`:** 200 with the create response for `RunID` (its id and the |
| + record's state if the record still holds it, else the terminal state from |
| + result.json, else `done`). Stop reading the body. |
| + - **`refused`:** repeat the stored `Status` and `Code`. |
| + - **`pending`, younger than 5 minutes:** 409 `in_progress`, with |
| + `Retry-After: 5`. |
| + - **`pending`, 5 minutes or older:** re-read the record. If its `ActiveRun.ID` |
| + equals `RunID`, CAS the key to `admitted` and answer as `admitted`. |
| + Otherwise CAS it to `abandoned` and proceed as fresh. |
| + - **`abandoned`:** proceed as fresh, overwriting the key by CAS on its etag |
| + at step 8. |
| +- The create handler's context gets a 2-minute deadline that covers the whole |
| + keyed path. The admission's own 30-second write context sits inside it. |
| +- `GET .../instances/{host}/runs?key=<k>` is a new route on the same |
| + `Exactly(Run)` set plus people. It returns |
| + `{"state": ..., "run_id": ...}` or a 404, scoped by the caller's `scope`. |
| +- **The key sweep** deletes key objects whose `CreatedAt` is older than 7 |
| + days. It runs in the same loop as the run sweep, and it logs a count and |
| + never deletes anything younger. |
| + |
| +- [ ] **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. |
| + - `TestKeyObjectSeparatesCallersAndHosts`: the same key under two scopes, or |
| + two hosts, gives two different objects. |
| + - `TestMetaDigestCoversEveryField`: changing argv, timeout, on_behalf_of, a |
| + file name, a size or a sha256 each changes the digest. |
| + - `TestRunKeyedCreateThenRetryReturnsTheSameRun`: create with key K, retry |
| + the identical request, get the same `id`, and the box saw one request |
| + (one request.json). |
| + - `TestRunKeyedRetryWhileOpenReturnsTheRun`: the run is still `queued` or |
| + `picked`, and the retry gives a 200 with that id, not `run_open`. |
| + - `TestRunKeyedRetryAfterAckStillReturnsTheRun`: ack, sweep the run's |
| + objects, retry, and still get the same id with no new run. |
| + - `TestRunKeyReusedWithADifferentRequest`: 409 `idempotency_key_reused`, |
| + with nothing written. |
| + - `TestRunConcurrentIdenticalKeyedCreatesMakeOneRun`: two goroutines create |
| + with the same key. One run results, and the other answer is either 409 |
| + `in_progress` or the same id. |
| + - `TestRunAbandonedPendingKeySettles`: a planted `pending` key, 6 minutes |
| + old (the test clock), whose run the record holds, settles to `admitted`. |
| + The same, with the record not holding it, settles to `abandoned` and a |
| + fresh run is created. |
| + - `TestRunRefusedKeyRepeatsItsRefusal`: a keyed create refused for |
| + `command_not_granted` is refused the same way on retry. |
| + - `TestRunKeyOfAnotherCallerIsInvisible`: the person's key K, used by the |
| + service, behaves as a fresh key, and `?key=K` from the service is a 404. |
| + - `TestRunKeyedHashMismatchWritesNothing` |
| + - `TestRunKnownKeyAnsweredWithoutReadingFiles`: retry with a body whose file |
| + part errors on read. The answer is still a 200 with the id. |
| + - `TestRunKeyLookup`: `?key=` returns the state and run id. |
| + - `TestRunKeySweepRemovesOnlyOldKeys`: an 8-day-old key is gone, and a 6-day |
| + one stays. |
| + - `TestRunUnkeyedOrderUnchanged`: an unkeyed create against an open run is |
| + still refused before the body is read (today's order). |
| +- [ ] **Step 2: Run them and watch them fail.** |
| + `go test ./internal/runs/ ./internal/console/ -run 'Key|Keyed|Digest'` fails. |
| +- [ ] **Step 3: Implement.** |
| +- [ ] **Step 4: Watch them pass**, plus every pre-existing test under |
| + `go test ./internal/console/ -run Run`. |
| +- [ ] **Step 5: Commit**: `console: a keyed run create answers a retry from the |
| + key's own state and never runs twice (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 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`. |
| + - 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 and the console is rolled, 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. |