Decision 0021 — Keep the host scope write-only; the request response is the polling surface
The host scope stays write-only: the request response returns terminal state, and a same-key retry polls the original job without enumeration.
Status: accepted · 2026-10-14
Context
A managed host holds a host-scoped token and calls POST /api/v1/hosts/self/requests to ask exocomp to run an approved class of job on
itself. The host identity comes from the token, never the body, so a host can
only ever request action on itself. The scope authorizes nothing else: a host
token is refused by every read route, including GET /jobs (the write-only
stance TestAPIHostRequestScopeIsolated pins).
The question this record settles is whether that stance should stay, or whether
host should gain a read so a host can follow up on an asynchronous request.
Two things already existed when the decision was taken:
- The request response is a blocking path.
?wait=true(or?wait=1) makes the handler wait until the job reaches a terminal state and returnjob_stateandexit_codein the same response, bounded by?timeout=(a Go duration). - Dedup is keyed on the caller's
Idempotency-Key. Reissuing the same request with the same key is suppressed onto the original event, and the handler looks the original decision up so the retry gets the originaljob_idback.
Decision
Keep the host scope write-only, and make the request response itself the
polling surface. The blocking path already returns terminal state, so a
synchronous caller needs no read. For an asynchronous follow-up, a host reissues
the same request with the same Idempotency-Key: dedup suppression returns the
original job_id, and ?wait=true can be added to that retry to wait on the
original job. That gives poll-without-enumeration — a host follows the outcome of
its own request without ever listing jobs or events.
Consequences
- A host token gains no read path. Giving
hosta generalcanReadwould hand every managed host a view of all jobs and events, across every other host and every operator action — a fleet-wide disclosure from a credential minted to run one class of job on one machine. - The narrower alternative — a read scoped to "only your own jobs" — is a bigger,
separate capability: resource-level authorization. There is no per-result
ownership model to gate it on today (jobs and events are not owned by the host
that requested them), so it cannot be carved out honestly inside
canRead. - The named future step, if a first-class async status path is wanted later, is a
narrow
GET /api/v1/hosts/self/requests/{id}(or extending the response above) rather than broadeningcanReadfor host tokens. - The response carries two values that are deliberately not
store.JobState:"pending"when the deadline passed without a terminal state (an approval-gated job lands here, withapproval_idpresent), and"disconnected"when the waiter's connection went away — the job is untouched, so claiming it wascancelledwould be a lie about the job. The wait is bounded byhostRequestMaxWait(5m); an over-cap?timeout=is a400naming the maximum, never a silent clamp.