carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Runs started by a scoped service credential: implementation plan
2
3> **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.
4
5**Goal:** A service credential minted with the new `run` role and a command
6list can create, follow, cancel, expire and ack runs of those commands on its
7own app's instances, and can recover from a lost response without starting a
8second run.
9
10**Architecture:** There are three layers:
11- the role and the credential's stored scope (`svcrole`, `cli_tokens`,
12 `Principal`);
13- the run doors admitting that principal with its narrower checks
14 (`apiruns.go`, `apirunsfollow.go`);
15- an idempotency key, recorded in the instance record by the same
16 compare-and-swap that admits the run, so admission and dedupe are one
17 atomic write.
18
19**Tech Stack:** Go. The console's SQLite store (`internal/console/authstore.go`).
20The deployment bucket through `release.Store` (`Put(ctx, key, body, ifMatch)`:
21an empty `ifMatch` means create-only, and `release.ErrCASConflict` is returned
22on a 412).
23
24**Spec:** `docs/superpowers/specs/2026-10-24-service-runs-design.md`, which
25amends `docs/superpowers/specs/2026-10-08-carlos-run-design.md` § 2 ruling 1.
26
27## Global Constraints
28
29- **People's behaviour on every run door is unchanged.** Every existing run
30 test passes unmodified, apart from new fields in JSON bodies.
31- **Wire and storage changes are additive** (AGENTS.md):
32 - new `cli_tokens` columns go in the ALTER list (`authstore.go:217-224`), with
33 defaults equal to today's value;
34 - new `runs.Request` and `runs.Meta` fields are `omitempty`.
35- **`Run` is reachable only through `svcrole.Exactly(svcrole.Run)`.** No other
36 role reaches the run doors, and `Admin` does not.
37- **Command prefixes:**
38 - at most 32 per credential;
39 - each prefix has at least one word;
40 - each word is 1–128 bytes;
41 - matched word for word, exactly: no globbing, no case folding, no substring;
42 - a zero-word prefix never matches.
43- **`Idempotency-Key`:** 1–128 bytes of `[A-Za-z0-9._:-]`. The record's
44 `run_keys` keeps entries for 24 hours, and the newest 128 at most.
45- **`on_behalf_of`:** an email of at most 254 bytes, set only by a `run`
46 service. A person who sends it gets a 400.
47- **Every refusal written by a run HANDLER carries a stable `code`** in its
48 JSON body, alongside today's `error` message. The shared gates keep their
49 bodies exactly as today, deliberately identical across doors:
50 `cliNotSignedIn`, the role-mismatch 403 in `cliRoute`, and the canonical
51 membership `http.NotFound` in `apiAccountApp`.
52- **A new `carlos` flag isn't done until `docs/site/cli.md` names it**
53 (AGENTS.md).
54- **Branch rules:** the branch lands through amadan (`amadan branch describe`,
55 then squash, then green CI, then `amadan branch merge … -expect <tip>`),
56 never GitHub.
57
58## Review Focus
59
601. **A retry of an admitted key while the first run is still open.** Today the
61 "already has a run open" refusal (`apiruns.go:201`) fires before the body is
62 read. A keyed retry must get its run back, not that refusal. The pin is
63 `TestRunKeyedRetryWhileOpenReturnsTheRun` in Task 4.
642. **A service listing or following a person's run by guessing its id.** It
65 must get a 404 identical to an unknown id. The pin is
66 `TestRunServiceCannotSeeAPersonsRun` in Task 3.
673. **A revoked credential name minted again.** The new credential must not see
68 the old one's runs. The pin is `TestRunRemintedNameDoesNotInheritRuns` in
69 Task 3.
704. **Argv that matches a prefix only as a substring of a word.** For example,
71 `importer` under the prefix `import`. It is refused. The pin is in Task 2's
72 `TestCommandGranted` table.
735. **A keyed create whose meta declares a sha256 that doesn't match the
74 uploaded bytes.** It gets a 400 `file_hash_mismatch`, and nothing is
75 written. The pin is `TestRunKeyedHashMismatchWritesNothing`
76 in Task 4.
77
78---
79
80### Task 1: The `run` role, which Admin does not reach
81
82**Files:**
83- Modify: `internal/svcrole/svcrole.go`. Add `Run`, add it to `catalogue`,
84 add `Exactly`, and update the Admin doc comment.
85- Test: `internal/svcrole/svcrole_test.go`
86
87**Interfaces:**
88- Produces:
89 - `const Run Role = "run"`
90 - `func Exactly(roles ...Role) Set`. Like `Allow`, it panics on an unknown
91 role, but it does NOT add Admin.
92- The doc on `Admin` changes from "the union of the five above" to say it is
93 the union of every role except `Run`, which is reachable only through
94 `Exactly` (spec § 1).
95
96- [ ] **Step 1: Write the failing tests**
97 - `TestRunIsInTheCatalogue`: `Parse("run")` returns `(Run, true)`, and
98 `All()` contains `Run`.
99 - `TestExactlyDoesNotAddAdmin`: for `s := Exactly(Run)`,
100 `s.Permits(Run)` is true and `s.Permits(Admin)` is false, as is every
101 other role.
102 - `TestExactlyPanicsOnAnUnknownRole`: `Exactly(Role("rnu"))` panics.
103 - Amend `TestAdminPermitsEveryNonEmptySet` so it still covers every `Allow`
104 set, then add: "an `Exactly` set admits only its listed roles."
105- [ ] **Step 2: Run them and watch them fail.**
106 `go test ./internal/svcrole/` fails: `Run` and `Exactly` are undefined.
107- [ ] **Step 3: Implement** in `svcrole.go`.
108- [ ] **Step 4: Watch them pass.** `go test ./internal/svcrole/` passes. Then
109 run `go test ./internal/console/ -run 'Role|Service'` and fix any role-list
110 expectations that enumerate the catalogue. That includes `roleNames()` users
111 in `services.go`. Every one of them must now include `run`.
112- [ ] **Step 5: Commit**: `svcrole: a run role that only an exact set reaches,
113 never Admin (PRO-6214)`
114
115---
116
117### Task 2: A `run` credential's id and command list
118
119**Files:**
120- Modify: `internal/console/authstore.go`. Add to the ALTER list:
121 `ALTER TABLE cli_tokens ADD COLUMN credential_id TEXT NOT NULL DEFAULT ''`
122 and `ALTER TABLE cli_tokens ADD COLUMN commands TEXT NOT NULL DEFAULT ''`.
123 Then a one-time backfill: every row with `kind` a service and an empty
124 `credential_id` gets a fresh random id. It is idempotent, and only empty
125 rows are written.
126- Modify: `internal/console/svcprincipal.go`. Covers `Principal`,
127 `CLIPrincipal`, minting, rotation and listing.
128- Modify: `internal/console/apiservices.go`. Create accepts `commands`, and
129 create and list echo it.
130- Modify: `services.go`. Adds the `--commands` flag.
131- Create: `internal/runs/commands.go`, holding the matcher. It stays a pure
132 function so the console and its tests share it.
133- Test: `internal/runs/commands_test.go`,
134 `internal/console/svcprincipal_run_test.go` and `services_test.go`
135 (extended).
136
137**Interfaces:**
138- Produces in `internal/runs`:
139 - `func CommandGranted(argv []string, prefixes [][]string) bool`
140 - `func CheckCommandPrefixes(prefixes [][]string) error`. It enforces the
141 limits in Global Constraints and returns `ErrNoCommands` for an empty
142 list.
143- Produces in `internal/console`:
144 - `Principal.CredentialID string`. It is set for every service principal,
145 and empty for a person.
146 - `Principal.Commands [][]string`. It is non-nil only for `Run`.
147 - `ErrRunNeedsCommands`, `ErrCommandsNeedRun`.
148 - `NewServicePrincipalScoped(accountID, name, app, role, rungs, commands [][]string, mintedBy, now)`.
149 The signature gains `commands`, and every caller is updated.
150- The stored form of `commands` is canonical JSON: an array of arrays of
151 strings, in the order given. Rotation keeps both `credential_id` and
152 `commands`. A new mint always gets a new `credential_id`.
153- `CLIPrincipal` refuses a `run` row whose `commands` can't be parsed or is
154 empty, the same way it refuses a deploy row without rungs
155 (`svcprincipal.go:136-147`).
156- `--commands` is a comma-separated list of prefixes, and each prefix's words
157 are separated by spaces: `--commands "import classic,import sync"`. It is
158 required with `--role run`, and refused with any other role. Create also
159 requires `--app` for `run`, using the same refusal path as `Service`.
160
161- [ ] **Step 1: Write the failing tests**
162 - `TestCommandGranted`, a table test:
163 - `{"import","classic","--slug","x"}` under `[["import","classic"]]` is
164 true;
165 - `{"import"}` under `[["import","classic"]]` is false;
166 - `{"importer","classic"}` under `[["import","classic"]]` is false;
167 - `{"Import","classic"}` is false;
168 - `{}` under anything is false;
169 - with two prefixes, matching the second is true.
170 - a zero-word prefix (`[[]]`, and `[nil]`) matches nothing.
171 - `TestCheckCommandPrefixes`: an empty list gives `ErrNoCommands`. A
172 zero-word prefix, an empty word, a 129-byte word and 33 prefixes are each
173 refused. `CLIPrincipal` also refuses a stored `run` row whose commands hold
174 a zero-word prefix (`TestRunCredentialRefusedWithAnEmptyPrefix`). 32 prefixes of
175 128-byte words are accepted.
176 - `TestRunCredentialCarriesItsCommands`: mint a `Run` principal with
177 `[["import","classic"]]`, and `CLIPrincipal` returns those `Commands` and a
178 non-empty `CredentialID`.
179 - `TestRunCredentialNeedsCommands`: minting `Run` with nil commands gives
180 `ErrRunNeedsCommands`. Minting `Instance` with commands gives
181 `ErrCommandsNeedRun`. Minting `Run` with no app gives the account-scoped
182 refusal.
183 - `TestRotationKeepsCredentialIDAndCommands`, and
184 `TestRemintGetsANewCredentialID` (revoke, then mint the same name).
185 - `TestCredentialIDBackfillIsIdempotent`: reopening the store keeps existing
186 ids.
187 - A services CLI test: `--commands` without `--role run` fails, and
188 `--role run` without `--commands` fails.
189- [ ] **Step 2: Run them and watch them fail.**
190 `go test ./internal/runs/ ./internal/console/ -run 'Command|RunCredential|Rotation|Remint|CredentialID'`
191 fails to compile.
192- [ ] **Step 3: Implement.** Follow how rungs are minted, stored, parsed and
193 echoed, which is the pattern for every piece.
194- [ ] **Step 4: Watch them pass**, plus `go test ./internal/console/ -run Service`
195 and the services CLI tests.
196- [ ] **Step 5: Commit**: `console: a run credential carries its own id and the
197 commands it may run (PRO-6214)`
198
199---
200
201### Task 3: The run doors admit a `run` service, for its own runs only
202
203**Files:**
204- Modify: `internal/console/authcli.go:736-740`. The five run routes go from
205 `svcrole.Set{}` to `svcrole.Exactly(svcrole.Run)`.
206- Modify: `internal/console/apiruns.go`. On create:
207 - the principal check becomes "a person, or a `Run` service";
208 - the command check comes after the meta parse and before any write: 403,
209 code `command_not_granted`;
210 - `on_behalf_of` is validated;
211 - the request fields are set;
212 - the open-run refusal is redacted for a service.
213- Modify: `internal/console/apirunsfollow.go`. `runGate` admits a `Run`
214 service. `runCall` gains the principal. Every read and write of a run
215 checks `request.json`'s `ByCredential` against a service principal's
216 `CredentialID`, and a mismatch answers exactly what an unknown id does.
217- Modify: `internal/runs/runs.go`.
218 - `Request` gains `ByService string`, `ByCredential string` and
219 `OnBehalfOf string` (all `omitempty`).
220 - `Meta` gains `OnBehalfOf string` (`omitempty`).
221- Modify: `internal/console/authcli.go:1098`. Add
222 `func jsonErrorCode(w http.ResponseWriter, status int, code, msg string)`,
223 which writes `{"error": msg, "code": code}`. Every refusal on the five run
224 routes switches to it. Name the codes for what they refuse (for example
225 `not_found`, `host_claim`, `rate_limited`, `run_open`, `moving`,
226 `import_uncommitted`, `files_too_large`, `malformed`, `command_not_granted`
227 and `on_behalf_of_invalid`), and list them in one place:
228 `var runRefusalCodes = []string{...}`.
229- Modify: wherever a run's requester is shown. Find these by grepping for
230 `.By` on `runs.Request` and the run audit rows (CLI follow output, the
231 console run history and the audit log). A service's run reads `run by
232 service <name> for <email> (as stated by the service)`, or `run by service
233 <name> (scheduled)` when `OnBehalfOf` is empty.
234- Test: `internal/console/apiruns_service_test.go`
235
236**Interfaces:**
237- Consumes: `svcrole.Exactly`, `svcrole.Run` (Task 1); `Principal.CredentialID`,
238 `Principal.Commands` and `runs.CommandGranted` (Task 2).
239- Produces:
240 - `runs.Request{ByService, ByCredential, OnBehalfOf}`;
241 - `jsonErrorCode`;
242 - `runRefusalCodes`;
243 - `func (s *Server) runVisibleTo(p Principal, req runs.Request) bool`, which
244 is true for a person (membership was already checked) and, for a service,
245 true only when `req.ByCredential == p.CredentialID`.
246
247- [ ] **Step 1: Write the failing tests** (use `runFixture` and
248 `serviceToken`, and mint `Run` with `[["import","classic"]]`):
249 - `TestRunRoleMatrix`: on all five routes, a `Run` service passes the role
250 gate. `Publish`, `Deploy`, `Instance`, `Operate`, `Service` and `Admin`
251 each get `cliRoute`'s role-mismatch 403 (`authcli.go:467`), not the
252 closed-door message, because the set is no longer empty. Update the tests
253 that pin the closed-door registrations and the route-role table
254 (`svcroutes_test.go:226` and the fixed door count there). Keep explicit
255 coverage that Admin is refused on the run doors.
256 - `TestRunServiceStartsAGrantedCommand`: argv `import classic --slug x`
257 gets a 202, as create gives today (`apiruns.go:317`), and request.json has
258 `ByService`, `ByCredential` and `OnBehalfOf`.
259 - `TestRunServiceRefusedAnUngrantedCommand`: argv `db shell` gets 403
260 `command_not_granted`, and no run objects exist.
261 - `TestRunServiceWrongAppRefused`: a `Run` credential for app `other` on
262 `shop` is refused.
263 - `TestRunRevokedServiceRefused`.
264 - `TestRunServiceCannotSeeAPersonsRun`: the person starts a run, and the
265 service's GET, cancel, expire and ack on its id each give a body identical
266 to an unknown id's.
267 - `TestRunRemintedNameDoesNotInheritRuns`: the service starts a run, the
268 credential is revoked, the same name is minted again, and the new
269 credential's GET is the unknown-id answer.
270 - `TestRunOpenRefusalRedactedForServices`: a person's run is open, and the
271 service's create gets `run_open` with neither that run's id nor the
272 person's email anywhere in the body.
273 - `TestRunPersonMaySetNoOnBehalfOf`: a person's meta with `on_behalf_of`
274 gets 400 `on_behalf_of_invalid`.
275 - `TestRunOnBehalfOfShape`: not an email, or 255 bytes, gets 400.
276 - `TestRunRefusalsCarryCodes`: every refusal a run HANDLER writes has a
277 `code` that is in `runRefusalCodes`. The shared-gate refusals (401, the
278 role 403, the membership 404) are byte-identical to today, and
279 `apiruns_test.go:195`'s canonical-404 test is unchanged.
280 - `TestServiceRunRendersAsTheServices`: the follow and history rendering of
281 a service's run contains `run by service ci for paul@example.com (as
282 stated by the service)` and never presents the email as the actor.
283 - Repeat the existing incarnation, one-run and moving tests with a `Run`
284 service as the creator: delete and recreate, a move in progress, and a
285 person's run plus a service's run on one instance.
286- [ ] **Step 2: Run them and watch them fail.**
287 `go test ./internal/console/ -run 'TestRun'` fails.
288- [ ] **Step 3: Implement.**
289- [ ] **Step 4: Watch them pass**, and run all of
290 `go test ./internal/console/ -run 'Run|Service'`, with every pre-existing run
291 test unmodified.
292- [ ] **Step 5: Commit**: `console: a run service starts and follows its own
293 runs of the commands it was granted (PRO-6214)`
294
295---
296
297### Task 4: Idempotent create, settled by the record
298
299**Files:**
300- Modify: `internal/provision/provision.go`. `Record` gains
301 `RunKeys []RunKey \`json:"run_keys,omitempty"\``.
302- Modify: `internal/provision/run.go`.
303 - `RunAdmission` gains `Key string` (the scoped key hash) and `Digest string`.
304 - `RunAdmit` handles the key inside the same mutation.
305 - Every record mutation that replaces or clears `ActiveRun` (the next
306 admission, ack, expire, the sweep's clear) keeps `RunKeys`.
307- Create: `internal/runs/keys.go`
308- Modify: `internal/runs/runs.go`. `MetaFile` gains `SHA256 string`
309 (`omitempty`). Convert the positional `MetaFile{...}` literals in existing
310 tests (`internal/runs/runs_test.go:74-89` and any others the compiler
311 finds) to named fields.
312- Modify: `internal/console/apiruns.go`
313- Test: `internal/runs/keys_test.go`, `internal/provision/run_keys_test.go`,
314 `internal/console/apiruns_key_test.go`
315
316**Interfaces:**
317- Produces in `internal/runs`:
318 - `func ValidKey(k string) bool`
319 - `func ScopedKey(scope, key string) string`. It returns
320 `hex(sha256(scope + "\x00" + key))`. `scope` is `"svc:" + CredentialID`
321 for a service and `"user:" + address` for a person.
322 - `func MetaDigest(m Meta) string`: sha256 over a canonical encoding of argv,
323 timeout_s, on_behalf_of and every file's name, size and sha256.
324 - `const KeyTTL = 24 * time.Hour`, `const MaxRunKeys = 128`.
325- Produces in `internal/provision`:
326 - `type RunKey struct { Key, RunID, Digest string; At time.Time }`
327 - `type RunKeyReplay struct { RunID string }`, which implements `error`,
328 returned by `RunAdmit` when the key is already there with the same digest;
329 - `var ErrRunKeyReused`, for when the key is already there with a different
330 digest.
331- `RunAdmit`'s mutation, in this order inside the one CAS:
332 1. prune `RunKeys` to entries younger than `KeyTTL`;
333 2. if `p.Key != ""` and an entry has that key: return `ErrRunKeyReused` if
334 its digest differs, else `&RunKeyReplay{RunID}`. Both run BEFORE today's
335 open-run, moving and import refusals, so a replay is answered even while
336 its own run is still open;
337 3. today's admission;
338 4. on success, when `p.Key != ""`, append `{Key, RunID, Digest, now}` and
339 trim to the newest `MaxRunKeys`.
340- **Create's order, but only for a keyed request** (an unkeyed request keeps
341 today's exact order):
342 1. principal, membership, host-claim and rate budget;
343 2. read the record (a 404 stays a 404);
344 3. parse the meta;
345 4. **look up the key in that record read.** On the same digest, answer
346 `202` with the run (`"replayed": true`) and stop reading the body. On a
347 different digest, `409` `idempotency_key_reused`;
348 5. today's class, move, open-run and import refusals;
349 6. the command check (Task 3);
350 7. spool the files and verify each against its declared sha256 (`400`
351 `file_hash_mismatch`, nothing written);
352 8. mint the id, write the objects, and admit with `Key` and `Digest`;
353 9. if admission returns `*RunKeyReplay`, compare its `RunID` with the id this
354 request minted:
355 - **equal:** this request's own admission committed, and the CAS retry
356 inside `updateRecord` found its own key. That is success: answer `202`
357 as a fresh admission, and delete nothing;
358 - **different:** a concurrent twin won. Delete ONLY this request's
359 candidate prefix (`runs.Prefix(acct, app, mintedID)`), then answer `202`
360 with the winner's run, replayed;
361
362 `ErrRunKeyReused` answers `409`;
363 10. today's lost-admission settling (`apiruns.go:279-291`) is unchanged, and
364 is now also correct for keys, because the key commits with the
365 admission or not at all.
366- **The rollout gate (mixed versions).** A box running an older binary
367 rewrites the record through a typed `Record` that doesn't know `run_keys`,
368 and drops it. So keyed admission is OFF until every record writer preserves
369 the field:
370 - the console config gains `RunKeysEnabled bool`, from `CARLOS_RUN_KEYS=on`;
371 - while it is off, a keyed create is refused `503` `keys_not_ready` before
372 anything is written, never silently treated as unkeyed;
373 - it is turned on only after the box fleet has rolled to a build that
374 includes this task. The branch description and the landing note say so.
375- `GET .../instances/{host}/runs?key=<k>`: a new route on the run set
376 (`Exactly(Run)`, plus people). It returns `{"run_id": ...}` from the
377 record's `RunKeys` for the caller's scoped key, or 404.
378
379- [ ] **Step 1: Write the failing tests**
380 - `TestValidKey`: a table of good keys, plus an empty key, a 129-byte key and
381 one containing `/` or a space.
382 - `TestScopedKeySeparatesCallers`.
383 - `TestMetaDigestCoversEveryField`: changing argv, timeout, on_behalf_of, a
384 file name, a size or a sha256 each changes the digest.
385 - `TestRunAdmitRecordsAndReplaysAKey`, at the provision level:
386 - admit with K and D, then admit again with K and D, which gives
387 `RunKeyReplay` with the first id;
388 - K with D2 gives `ErrRunKeyReused`;
389 - after the run is acked or cleared, K and D still replay;
390 - after a second, unkeyed run replaces it, K and D still replay.
391 - `TestRunKeysPrunedByAgeAndCount`: entries older than 24h are dropped, and
392 at most 128 are kept, newest first.
393 - `TestRunKeyedCreateThenRetryReturnsTheSameRun`: same id, one request.json,
394 and the retry carries `replayed: true`.
395 - `TestRunKeyedRetryWhileOpenReturnsTheRun`
396 - `TestRunKeyedRetryAfterAckStillReturnsTheRun`
397 - `TestRunKeyReusedWithADifferentRequest`: 409, with nothing written.
398 - `TestRunConcurrentIdenticalKeyedCreatesMakeOneRun`: two goroutines with
399 the same key and request. Exactly one admission happens, both answers name
400 the same id, and the loser's objects are deleted.
401 - `TestRunKeyOfAnotherCallerIsInvisible`: the person's K used by the service
402 is fresh, and `?key=K` from the service is a 404.
403 - `TestRunKeyedHashMismatchWritesNothing`
404 - `TestRunKnownKeyAnsweredWithoutReadingFiles`: retry with a body whose file
405 part errors on read, and still get a 202 with the id.
406 - `TestRunRefusedKeyIsReevaluated`: a keyed create refused with `run_open`
407 starts the run once the other run is acked.
408 - `TestRunKeyLookup`
409 - `TestRunUnkeyedOrderUnchanged`: an unkeyed create against an open run is
410 still refused before the body is read.
411 - `TestAPIRunCreateAdmissionWhoseAnswerWasLost`: extend the existing test to
412 a keyed request. The store's conflict-after-commit gives a `RunKeyReplay`
413 with this request's OWN id, the answer is a fresh `202`, and the run's
414 objects still exist.
415 - `TestRunKeysSurviveEveryRecordMutation`: a record carrying `run_keys`,
416 passed through each of the six `ActiveRun` mutations in
417 `internal/provision/run.go` and the move and import mutations, still
418 carries them.
419 - `TestKeyedCreateRefusedUntilKeysAreOn`: with `RunKeysEnabled` false, a
420 keyed create gets `503` `keys_not_ready` and nothing is written. An unkeyed
421 create is unaffected.
422- [ ] **Step 2: Run them and watch them fail.**
423 `go test ./internal/runs/ ./internal/provision/ ./internal/console/ -run 'Key|Keyed|Digest'`
424 fails.
425- [ ] **Step 3: Implement.**
426- [ ] **Step 4: Watch them pass**, plus every pre-existing test under
427 `go test ./internal/provision/ ./internal/console/ -run Run`.
428- [ ] **Step 5: Commit**: `console: a keyed run create is admitted once,
429 settled by the instance record, and a retry gets the same run (PRO-6214)`
430
431---
432
433### Task 5: The CLI flag, the docs and the run-spec amendment
434
435**Files:**
436- Modify: `run.go`. `carlos run` gains `--idempotency-key <key>`, sends it as
437 the header, and sends each file's sha256 in the meta when a key is given.
438 It accepts the `202` replay (`"replayed": true`) the same way as a fresh
439 `202` (`run.go:223`), saying "already started: run <id>" before following
440 it. It prints the run id before streaming, as it does today.
441- Modify: `docs/site/cli.md`, covering `carlos run --idempotency-key` and
442 `carlos services create --role run --commands`.
443- Modify: the services and run docs pages under `docs/site/`, wherever the
444 role catalogue and the run API are described. Add the `run` role, the
445 command-list rule, `on_behalf_of`, the refusal codes (from
446 `runRefusalCodes`) and the key lifetime.
447- Modify: `docs/superpowers/specs/2026-10-08-carlos-run-design.md` § 2 ruling 1.
448 Add one line: "Amended 2026-10-24: a `run` service credential may, for the
449 commands it was granted. See `2026-10-24-service-runs-design.md`."
450- Test: `run_test.go` (extended), plus the docs test that checks
451 `docs/site/cli.md` names every command or flag, if one exists. Find it with
452 `grep -rn "cli.md" *_test.go`.
453
454**Interfaces:**
455- Consumes: `runs.ValidKey` (Task 4).
456
457- [ ] **Step 1: Write the failing tests**
458 - `TestRunIdempotencyKeyFlag`: the request carries `Idempotency-Key`, and the
459 meta's files carry `sha256`.
460 - `TestRunCLIRetryFollowsTheReplayedRun`: against a test console, a second
461 `carlos run` with the same key prints "already started", follows the same
462 id, and starts no second run.
463 - An invalid key is refused by the CLI before any network call.
464 - `TestRunRefusalCodesAreDocumented`: every entry in `runRefusalCodes`
465 appears in the run API doc page.
466- [ ] **Step 2: Run them and watch them fail.**
467- [ ] **Step 3: Implement.**
468- [ ] **Step 4: Watch them pass**, and run `make test` once, before the branch
469 is described.
470- [ ] **Step 5: Commit**: `cli, docs: carlos run takes an idempotency key, and
471 the run role is documented (PRO-6214)`
472
473---
474
475## Landing
476
477One branch, `pro-6214-service-runs`, landed through amadan as AGENTS.md
478describes. Describe it with `amadan branch describe carlos/platform <branch>`,
479then squash to one commit, wait for CI to go green on the tip, and merge with
480`-expect <tip>`. Before landing, an Opus or Fable review covers the whole
481branch, because it grants authority (AGENTS.md delegation policy). Astra
482reviews too, since the session is Opus.
483
484After it lands, roll the console AND the boxes, confirm every box runs a
485build that includes Task 4, and only then set `CARLOS_RUN_KEYS=on` on the
486console. Then Tito mints `fleet-runs` with
487`--role run --app titogo --commands "<the import and sync commands>"`. The
488exact commands are settled with the titogo side of PRO-6214, outside this
489plan.
490