carlos / platform Public
Runs started by a service credential
Date: 2026-10-24 Status: Design, awaiting approval Owner: Paul Asked for by: Tito (Linear PRO-6214). Fleet's Classic import and recurring sync need to run an app command against an instance without a person at a terminal. Amends: 2026-10-08-carlos-run-design.md, § 2 ruling 1.
Why
carlos run lets a member of an account run the app's own command against an instance, as the instance. Fleet, Tito's back office, needs exactly that for two jobs:
- The Classic import. A person presses a button and Fleet copies one account's data in.
- The recurring sync. It keeps an imported account up to date on a schedule.
Fleet holds a server credential and no person's token. Today every run route refuses a service principal: runGate and handleAPIRunCreate check for PrincipalUser (internal/console/apiruns.go:156, internal/console/apirunsfollow.go:36). The work-arounds on the table are both unacceptable:
- A personal operator token in Tito's router. A person's whole membership would sit in a service, and nobody could tell that from the audit.
- Root or systemd jobs on the box. That goes around the platform, which is what
carlos runexists to end.
Decisions (Paul, 2026-10-24)
- Service credentials may start and follow runs. This reverses run spec § 2 ruling 1 ("service credentials are refused"), for the one role below only.
- A
runcredential names the commands it may run (option A). It does not get any command the binary has. A leaked credential can then run only those, not arbitrary commands against every instance of the app. - The person a run is "for" is recorded on the service's word. When Fleet starts a run because someone pressed a button, the run records the name Fleet gives, labelled as stated by that service. It grants nothing and is not verified. A scheduled run records no person.
Goals
- A service can create, read (progress and output), cancel, expire and ack runs on its own app's instances, and nothing else, once it has been granted that on purpose.
- An ungranted service, a service from the wrong app, and a revoked service are all refused.
- Deleting and recreating an account, or moving it, can never redirect a run.
- A caller that loses an HTTP response can find out what happened and carry on without starting a second run.
- Everything above goes through the supported console API, with no box access.
Non-goals
- Host-claim consoles. They refuse runs for everyone until the reservation protocol (#250) adds
cmd(see § 6). - Verifying who the person was. That is a later upgrade (§ 4).
- A scheduler. The caller decides when to run, and Fleet owns its schedule.
- Anything about what Fleet's command does: the Classic DB credentials, how snapshots are cut. See § 7 for the caller's side.
Anti-goals
- A role that quietly widens. No existing role,
Adminincluded, gains runs. A service that can provision instances cannot run commands on them unless it is separately minted for that. - A second run from a retry. A lost response retried must never start the command twice.
- An audit line that misleads. A service's run must never read as if a person ran it.
1. The capability: a new role, run
svcrole.Runjoins the closed catalogue (internal/svcrole/svcrole.go). It is app-scoped, soAppis required, refused account-wide the wayServiceis (ErrAccountScopedService). It carries no rungs.- It grants exactly the five run routes for instances of its own app: create, get (with
?after=), cancel, expire and ack (authcli.go:736-740). Those routes are registered withsvcrole.Set{Run}instead of the empty set. People keep the routes on the existing member rule, unchanged. - No other role implies it, Admin included (my call, holding the anti-goal). Today
svcrole.Allowalways adds Admin to a route's set (internal/svcrole/svcrole.go:126, pinned byTestAdminPermitsEveryNonEmptySet), so this IS new code. Add a constructor for an exact set (e.g.svcrole.Exactly(Run)) that Admin does not join, and amend that test and the Admin doc to name the one exception. A test asserts that every role exceptRun, Admin included, is refused on all five routes. - Minted, rotated and revoked like every other service:
carlos services create --role run --app <app> --commands "<prefix>,<prefix>" <name>, by an account owner, throughhandleAPIServiceCreate. Revocation is the existing soft revoke, and a revoked token is refused at the door. - The command list (Paul, 2026-10-24, option A). A
runcredential carries a non-empty list of NON-EMPTY allowed command prefixes. A zero-word prefix would match every argv, so it is refused at mint, refused when the credential is read back, and never matches in the matcher. The list is stored on itscli_tokensrow the wayDeploycarries rungs: an additive column, parsed only for this role, and refused at mint when it is empty (ErrRunNeedsCommands). - Each prefix is a sequence of argv words, written space-separated on the CLI.import classicis the wordsimport,classic. - A run is allowed only when its argv starts with every word of at least one prefix, compared word for word and exactly: no globbing, no case folding, no substring. - Create refuses anything else with 403command_not_granted, before any object is written, and with no key state either: an unkeyed refusal. -carlos services listshows the list. Changing it means minting a new credential (rotation keeps the list, and there is no edit door; my call). - Prefixes contain no empty word, and each word is at most 128 bytes, with at most 32 prefixes (the builder may tune these). - People are not affected: members keep running any command. - A service sees and acts on only the runs it started (my call). The run records the CREDENTIAL's id (its
cli_tokensrow), not its name, because a revoked name can be minted again (authstore.go:250) and must not inherit the old credential's runs. Rotation keeps the row, so a rotated credential keeps its runs. Get, cancel, expire and ack check that id. A run it didn't start answers 404, the same as an unknown id, so its existence doesn't leak. Members still see every run on their account, the service's included. - Create's "already has a run open" refusal is redacted for a service (my call). Today it names the open run's id and the member's email (
apiruns.go:32,:202). A service gets the refusal and its code without either, unless the open run is its own. - Rate budgets are unchanged: create spends
s.cli.instancesand the follow routes spends.cli.runs, both keyed on the token hash (apiruns.go:168,apirunsfollow.go:48). A service's runs therefore draw on that credential's own budget, which is separate from Fleet's provisioning credential.
2. What a service's run records
runs.Request (internal/runs/runs.go:166) gains fields, additively, so old readers ignore them:
| Field | For a person's run | For a service's run | |---|---|---| | by | the member's email (today) | what auditActor writes for a service today, service:<account sqid>/<name> (apicli.go:105) | | by_service | empty | the service name, for display | | by_credential | empty | the credential's id, which the own-runs check uses | | on_behalf_of | empty | the email the service gave, or empty for a scheduled run | | idempotency_key | the key's scoped hash (§ 3), when one was sent | the key's scoped hash, expected from Fleet |
Every place the run is shown or audited (the CLI's follow output, the console's run history, the account's audit log) shows a service's run as "run by service <name> for <email> (as stated by the service)", or "run by service <name> (scheduled)". It is never shown as if the email ran it.
on_behalf_of is validated for shape only: it must be an email, at most 254 bytes. It is not checked against membership, because it grants nothing.
3. Lost responses: idempotent create
Run ids are minted by the console (runs.MintID), so today a caller who loses the create response can't tell "started" from "never arrived". So:
- Create accepts an
Idempotency-Keyheader: 1–128 bytes of[A-Za-z0-9._:-], chosen by the caller. - A key is scoped to its caller (my call): to the credential id for a service, to the user for a person, and within that to the instance. One caller can never find, probe or collide with another's key.
- The proof that a key was admitted lives in the instance record, written by the same compare-and-swap that admits the run (my call, after Astra's review). The record gains
run_keys: a short list of recent admissions, each with a hash of (caller scope, key), the run id, the request digest, a hash of the caller scope (which the per-caller cap counts by) and the time.provision.RunAdmitrefuses to admit when the list already holds this caller's key, and returns that run instead. - Admission and dedupe are therefore one atomic write. Two identical creates at once can both write their objects, but only one is admitted. The other finds its key already in the record, deletes its own objects and answers with the winner's run. - There is no separate key object, so there is no pending or abandoned state, no sweep, and no crash window between "admitted" and "key recorded". - Ack, the run sweep and the next run replacing a finished one all keeprun_keys, so a retry after any of them still finds the run. - The digest covers the meta part only: argv, timeout,
on_behalf_of, and each file's name, size and sha256. A keyed create must declare each file's sha256 in the meta, and the console verifies the uploaded bytes against it. The meta is the first multipart part, so the key is checked against the record straight after parsing it, before the open-run, moving and import refusals and before any file byte is read. A retry of an admitted key is answered without reading the files. - The same key with a different digest is
409idempotency_key_reused, and nothing is written. - Answers to a keyed create: - a fresh admission and a replay of an admitted key both return
202, the status create gives today, with the same body, plus"replayed": trueon a replay; -GET .../instances/{host}/runs?key=<key>returns the run id from the record, or 404. - Refusals are not remembered (my call). Only an admitted run is. - A 4xx means THIS attempt admitted nothing. An earlier attempt with the same key may already have run: the shared gates (401, the rate budget's 429) come before the key lookup, so the caller confirms with
GET ...?key=before treating a 4xx on a retry as "never ran". - A retry with the same key is evaluated afresh: arun_openrefusal can succeed once the other run has finished, which is what the caller asked for. - A 5xx, a timeout or a dropped connection means unknown, and the caller retries with the same key. - How long a key lasts: each admission prunes
run_keysto entries younger than 24 hours (runs.KeyTTL), then keeps the newest 32 entries of each caller (runs.MaxRunKeysPerCaller). The cap is per caller, so one caller's admissions can never evict another's key. A key is honoured for 24 hours, or until that same caller has made 32 newer keyed admissions on this instance, whichever comes first. A caller must not retry a key past that. Fleet retries within minutes. request.jsonrecords the key asidempotency_key: its scoped hash, never the caller's own words. It links the run to its key after the record has pruned the entry.- Mixed versions: an older box binary rewriting the record would drop
run_keys. Keyed creates stay off (503keys_not_ready) until the console is told, by config, that every record writer preserves the field. - A known limit: deleting an instance deletes its record,
run_keyswith it. A retry that arrives after the host was deleted and created again is a fresh run on the new incarnation. It is still only a command the credential was granted, on its own app. - Refusal codes: the run handlers' own refusals carry a stable
codebesideerror, and the API docs list them. The shared gates before the handler keep their bodies exactly as today, deliberately identical across doors (AGENTS.md): not signed in, role mismatch, and the canonical membership 404. Those already distinguish themselves by status.
People may send the key too, and the CLI gains --idempotency-key. Nothing changes for a person who doesn't.
4. Upgrading "on the service's word" later
If the person ever needs to be verified, the request gains a signed assertion from the caller's identity system, and the console adds on_behalf_verified: true when it checks out. The fields above stay, so nothing already recorded changes meaning. Not built now.
5. What stays exactly as it is
These were already built in the run series, and must be shown to hold for a service's run too, each by a test that starts the run with a Run service:
- Incarnation binding. The incarnation is taken at create (
apiruns.go:250) and checked by the box before launch. A run created before an account is deleted and recreated, or moved, never launches against the new one. - One run per instance, through the
ActiveRuncompare-and-swap. This covers a service's run and a person's run on the same instance too. - The 64 MB file cap (
runs.MaxFileBytes), with the same 413. - Runs execute beside a live instance (the same database, WAL). Live sync relies on this.
- Cancel, expire, timeout, poll-and-ack, and the 24-hour sweep.
6. Host-claim consoles
A console with HostClaim on refuses every run route with claimRunUnsupportedMsg (apiruns.go:26, :164; apirunsfollow.go:44). That stays true for services. A granted Run service gets the same refusal a person does. An ungranted service gets 403 first, because the role check runs in cliRoute (authcli.go:455) before any handler, and that is fine. The API docs say so: a service run works only on a console without host-claim until #250 adds cmd to the reservation protocol. Nothing here bypasses or pre-empts #250.
Tito's console (carlos.tito.io) serves runs today: a follow of an unknown run there answers "no such run", not the host-claim refusal (checked 2026-10-24). So Fleet is not blocked by #250.
7. The caller's side (Tito; recorded here, built there)
- Fleet keeps the run id and key in its own store before calling, so a deploy or restart mid-run resumes by key, then follows by id, until the result is acked.
- The Classic DB credentials stay on the source reader, Fleet's side. They never go in argv (argv is audited) or in uploaded files, and never into a customer instance. Fleet reads Classic and uploads an account-specific snapshot.
- A snapshot over 64 MB is Fleet's problem to split into several runs, or to refuse with a clear message. The platform's cap does not change.
Tests and fences
- The command list: - an allowed prefix runs; - a longer argv under it runs; - an argv that only shares a substring of a word is refused; - a missing word is refused; - an empty list is refused at mint; - rotation keeps the list; - the refusal writes nothing.
- A role-by-route matrix:
Runis allowed on the five routes and refused everywhere else. Every other role is refused on all five. - Wrong app, wrong account, revoked, and a run that isn't this service's own: all refused or 404. So is a revoked name minted again: the new credential cannot see the old one's runs.
- Idempotency: - the same key and the same request give the same run with no second launch, after ack, after the sweep, and after a later run has replaced it in the record; - a different request is a 409; - two concurrent identical creates make one run, and the loser's objects are deleted; - another caller's key is invisible; - an admitted key is answered without reading the files; - a refusal is re-evaluated on retry; -
run_keysis pruned by age and count. - Incarnation, one-run and move tests repeated with a service principal.
- Audit: a service's run renders as the service's, never as the email's.
docs/site/cli.mdgains--idempotency-key, and the services docs gain therunrole (AGENTS.md: a command isn't done until the docs name it).
Left to the builder
- The exact shape of
run_keysentries. - The 4xx code list.
- The 24-hour and 32-per-caller bounds, if the builder finds a reason to change them, stated in the docs.