| 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 |
| 6 | list can create, follow, cancel, expire and ack runs of those commands on its |
| 7 | own app's instances, and can recover from a lost response without starting a |
| 8 | second 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`). |
| 20 | The deployment bucket through `release.Store` (`Put(ctx, key, body, ifMatch)`: |
| 21 | an empty `ifMatch` means create-only, and `release.ErrCASConflict` is returned |
| 22 | on a 412). |
| 23 | |
| 24 | **Spec:** `docs/superpowers/specs/2026-10-24-service-runs-design.md`, which |
| 25 | amends `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 | |
| 60 | 1. **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. |
| 64 | 2. **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. |
| 67 | 3. **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. |
| 70 | 4. **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. |
| 73 | 5. **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 | |
| 477 | One branch, `pro-6214-service-runs`, landed through amadan as AGENTS.md |
| 478 | describes. Describe it with `amadan branch describe carlos/platform <branch>`, |
| 479 | then 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 |
| 481 | branch, because it grants authority (AGENTS.md delegation policy). Astra |
| 482 | reviews too, since the session is Opus. |
| 483 | |
| 484 | After it lands, roll the console AND the boxes, confirm every box runs a |
| 485 | build that includes Task 4, and only then set `CARLOS_RUN_KEYS=on` on the |
| 486 | console. Then Tito mints `fleet-runs` with |
| 487 | `--role run --app titogo --commands "<the import and sync commands>"`. The |
| 488 | exact commands are settled with the titogo side of PRO-6214, outside this |
| 489 | plan. |
| 490 | |