Agent interface
The three programmatic faces of remote CLI, HTTP API and MCP, the wire envelope, and the error and exit-code taxonomy.
Status: accepted — the decision is recorded in Decision 0014 (agent-facing surface)
1. Vision
exocompd is already designed as one operational core with thin faces — the
architecture overview states it plainly: internal/service is meant to own all
business logic ("no HTTP, no terminal, no HTML — so no client can have a divergent code
path"), internal/api is a thin HTTP/JSON layer over it, and TUI / web / CLI / webhooks
are faces (docs/architecture/overview.md:93-104). Today only the human faces exist. This
document adds the programmatic faces a machine operator needs — a remote CLI client, an
operator HTTP/JSON API, and an MCP server — as three thin reflections of the same core,
never as parallel execution paths.
The primary goal is concrete: run homelab-ansible ops from an operator workstation, over the tailnet, against a remote host's exocomp API — plan/apply a workspace, run a playbook, watch a job, decide an approval — without SSHing into that host and without scraping aligned text. The same surface must be local-AI-friendly: a weak, small-context model should be able to drive it by branching on integers and typed codes, not by parsing prose.
Four design commitments make that possible, and every one of them already exists as a core primitive we are exposing rather than inventing:
- Exit-code-is-truth. Subprocess correctness is decided by exit codes only; parsed output only enriches the display (AGENTS.md invariant 3; internal/queue/queue.go:357-379). A machine branches on a number.
- Values-free, high-signal structured output. The drift payload already emits resource address + actions + attribute keys and never values, because "a value is often a secret" (internal/executor/tofu_drift.go:9-31). That is the template for the whole contract: identifiers, states, keys, counts — never values, ciphertext, or full stdout.
- Deterministic, cache-aware reads. Append-only
job_eventis tailed by a monotonicSeqcursor and ends on adoneevent (internal/web/stream.go:49-78); the workspace bearer is HKDF-deterministic (internal/api/server.go:32-37). Stable bytes for unchanged state make repeated agent polls diffable and prompt-cache-stable. - One queue, no side doors. Every execution path converges on the job queue before anything runs; guardrails live in the queue, not in configuration (AGENTS.md invariant 5; docs/architecture/overview.md:48-72). Every new face enqueues — it never shells out.
2. Current-surface reality and the gap
2.1 What consumes exocomp today
| Surface | Transport | Shape | Auth | Machine-usable? |
|---|---|---|---|---|
exocomp CLI |
local process, reads data dir, Drains its own jobs |
aligned human text (272 Fprintf/tabwriter sites; zero --json/--output flag; zero cobra Example) |
filesystem perms on data dir | No — screen-scrape only |
| TUI | local + ssh (Wish) | Bubble Tea render | data-dir / ssh | No |
| Web + phone approval | --web-addr TLS listener |
templ HTML behind session cookie | Decision 0009 bearer→session, three scopes | No — HTML only |
| OpenTofu state backend | loopback 127.0.0.1 only |
JSON | HKDF-derived per-workspace bearer | Yes, but workspace-state only, not general ops (internal/api/state.go) |
/metrics, /healthz |
daemon listener | Prometheus / text | none | Read-only telemetry |
The only machine-readable HTTP is the loopback tofu state backend and metrics. The
internal/service layer that is supposed to hold business logic is a three-line stub
(internal/service/doc.go); the logic lives inside cobra RunE bodies, the store, the queue,
and reactive. internal/api is only the tofu backend, bound loopback-only.
2.2 The gap to the primary goal
To run ops from workstation over the tailnet to server, four things are missing:
- No general operator API. The tofu backend speaks only tfstate/tflock and binds
loopback; there is no
/api/v1/jobs,/workspaces,/approvals. The web listener carries the right auth (Decision 0009) but emits HTML. - No remote CLI client mode. The CLI always operates locally against the data dir. There
is no
--remote/EXOCOMP_REMOTEthat makes it a thin HTTP client of a remote daemon. - No structured-output contract. Nothing emits a stable, versioned, values-free JSON an
agent or small model can branch on. Errors are flat
{"error": msg}(internal/api/state.go) or free-text prose; there is no typed{code, message, hint}. - No MCP face. No tool/resource catalog, so an agent harness cannot discover or drive exocomp as tools.
The tailnet itself is already the intended remote path (docs/architecture/platform-contract.md:47-49
"wizard detects a tailnet and configures the TCP listener bound to the tailnet address"), and
Decision 0009 already delivers the credential (token new), the transport guard (non-loopback bind
refuses without a token), and the pinning material (self-signed cert fingerprint printed for
trust-on-first-use). The pieces are on the bench; they are not assembled into a programmatic
operator surface.
3. Target architecture
workstation (operator workstation / agent host)
┌───────────────┬──────────────┬───────────────┐
│ exocomp CLI │ HTTP client │ MCP client │
│ (local/--remote)│ (curl/SDK) │ (bearer /mcp) │
└───────┬───────┴──────┬───────┴───────┬────────┘
│ tailnet (bearer token + pinned cert / Tailscale Serve TLS)
server ─────┼──────────────┼───────────────┼──────────────────────
┌───────▼──────────────▼───────────────▼────────┐
│ Decision 0009 token-gated listener (internal/web) │ ← one auth story
│ / (HTML, existing) │
│ /api/v1/... (JSON, new) ── requireAuth+scope │
│ POST /mcp (MCP over HTTP, bearer+scope) │
└───────────────────────┬────────────────────────┘
▼
internal/service ← ALL business logic (verbs)
▼
queue.Enqueue / EnqueueGated / Release ← the one execution path
▼
store · reactive · executor · secrets
3.1 One operational core — extract internal/service
Every programmatic face calls the same service verbs; none reaches the store or the queue directly. This is the load-bearing decision: it is what makes invariant 5 hold for agents, and it is what lets the CLI, the API, and MCP share one marshaller and one set of golden tests.
Proposed verbs (thin wrappers over today's RunE logic):
ListJobs / GetJob / GetJobEvents / FollowJob
ListWorkspaces / GetWorkspace / GetWorkspaceDrift
PlanWorkspace / ApplyWorkspace (enqueue / EnqueueGated)
RunPlaybook / RunCommand (enqueue, --require-approval gates)
SyncSource / ListSources
ListApprovals / DecideApproval (reactive.Decide, fail-closed)
ListSchedules / ListRules / ListSilences / ListEvents / ExplainEvent
ListSecretsMeta (metadata only — no plaintext code path)
ListBackups / Health (doctor summary)
The service package imports queue, store, reactive, executor, secrets — never net/http,
cobra, or templ. Sink-time redaction (internal/queue/redact.go) and gate defaults are
inherited for free by everything above it.
3.2 The structured-output contract (internal/wire)
A single presentation-only package holds the envelope, the schemas, and the error taxonomy,
and is the only marshaller the CLI --output json, the HTTP API, and MCP tool results use.
One shared marshaller under golden tests is what keeps the three faces byte-identical.
Envelope. Every success and every failure is one shape:
{ "schema_version": 1, "kind": "job.list", "data": [ … ] }
{ "schema_version": 1, "kind": "error",
"error": { "code": "concurrency_held", "message": "…", "hint": "wait for job 412 or cancel it" } }
schema_versionis a pinned contract; a shape change bumps it (prompt caches and parsers depend on it).kindnames the payload so an agent can dispatch without guessing.
Values-free and low-token, following the drift precedent (tofu_drift.go): emit identifiers,
states, keys, counts — never attribute values, ciphertext, or full stdout. The log body is
fetched separately, cursor-paged. omitempty everywhere, so a quiet system returns a tiny
document. The field roster per kind is not pinned here: Machine-readable
output is the authoritative field reference, so a view's
fields are documented once, not mirrored into a design doc that would drift.
Deterministic ordering. Sort by a stable key exactly as the store already orders
(ListJobs oldest-first, internal/store/job.go:216; tokens by id). Never map-iteration order.
Timestamps are fixed RFC3339 UTC (the store normalizes to UTC ms). Volatile fields (a relative
"age", a "now") are omitted from the cacheable body — let the agent compute them.
Typed error taxonomy, mapped 1:1 onto the store/reactive sentinels so an agent branches on
code, never on a sentence:
code |
Source sentinel | Meaning |
|---|---|---|
not_found |
store.ErrNotFound (store.go:20) | no such entity |
conflict |
store.ErrStateConflict (store.go:26) | wrong state for the transition |
duplicate |
store.ErrDuplicate (store.go:28) | name/key already taken |
concurrency_held |
store.ErrConcurrencyHeld (store.go:31) | another run holds the workspace |
stale_plan |
reactive.ErrStalePlan (approval.go:27) | workspace moved under the saved plan |
kdf_busy |
web.ErrKDFBusy (auth.go:41) | auth gate saturated; retry (transient 503) |
streams_over_cap |
the SSE stream cap (stream.go) | too many live streams; close a tab or retry |
unauthenticated |
web.ErrUnauthenticated | no valid token/session |
forbidden |
scope check (auth.go:241) | token scope too low for this verb |
Exit-code table (the primary agent signal — codify what invariant 3 already gives). Kept deliberately small, and defined in the same record as the contract:
| Code | Meaning |
|---|---|
| 0 | ok |
| 1 | generic failure (a run settled failed; a request errored) |
| 2 | changes present — tofu plan -detailed-exitcode (cmd/exocomp/workspace.go:432-436) |
| 3 | not found |
| 4 | unauthenticated / forbidden |
| 5 | conflict / concurrency held / locked |
| 6 | awaiting approval (a gated write landed pending) |
| 7 | busy — retry later (kdf_busy) |
The JSON error.code and the process exit code are two views of the same taxonomy; a weak
model can use either.
3.3 Remote CLI client mode
The CLI gains a client mode: when a host is configured it becomes a thin HTTP client of a
remote daemon instead of operating on a local data dir. Same verbs, same --output json, same
envelope — the difference is transport.
exocomp login server.tailnet.ts.net # TOFU: fetches cert, shows fingerprint,
# stores pinned fp + token (known-hosts style)
exocomp --remote server jobs list --output json
EXOCOMP_REMOTE=server EXOCOMP_TOKEN=… EXOCOMP_OUTPUT=json exocomp workspace plan web
exocomp --remote server approval decide 42 --approve
- Config precedence:
--remoteflag →EXOCOMP_REMOTE→ a valueless--remotefills from the~/.config/exocomp/hosts.tomldefault. Token from--token→EXOCOMP_TOKEN→ the host entry. A bareexocomp <cmd>with neither flag nor env stays local against the data dir (unchanged), so nothing an operator onserverdoes today breaks. The selector is--remote, not--host:--hostis already the ansible target pattern (exocomp command --host …), so reusing it for the daemon would collide. internal/clientis the typed HTTP client (theapi/"monorepo seam" package the layout reserves) generated from / kept in lockstep with the API's OpenAPI 3.1 spec.- New global flags land here and apply in both modes:
--output text|json,--remote,--token,--idempotency-key, and the non-interactive contract (--assume-yes,--no-input, replacing the divergent--force/--yes-really).
3.4 Operator HTTP/JSON API (/api/v1, mounted on the Decision 0009 listener)
The general API is not a new listener and not a second auth story. It is JSON routes
added to the existing token-gated web mux (internal/web/server.go:121-148), behind the same
requireAuth + scope check, wrapped by the same securityHeaders. The web server already reads
the store directly "so there is one authentication story (the LAN token), not two"
(server.go:58-60) — the JSON API rides that exact seam. internal/web has no route-extension
hook today (the Mount seam lives only on the separate loopback backend internal/api), so
the first step is a small mount seam on internal/web mirroring it; after that the JSON routes
sit behind the same requireAuth+scope. The loopback tofu backend stays as it is; this is
orthogonal.
Read endpoints (scope readonly+):
GET /api/v1/jobs?state=&runbook=&concurrency_key=&limit=&cursor= → mirrors store.JobFilter
GET /api/v1/jobs/{id}
GET /api/v1/jobs/{id}/events?since=<seq> → cursor-paged, redacted
GET /api/v1/jobs/{id}/stream → SSE, reuses stream.go
GET /api/v1/workspaces · /workspaces/{name} · /workspaces/{name}/drift
GET /api/v1/approvals?state=pending · /approvals/{id}
GET /api/v1/sources · /schedules · /rules · /silences · /events · /events/{id}(trace)
GET /api/v1/secrets → metadata only (name, kind, source, expiry, timestamps)
GET /api/v1/backups · /health → doctor summary, one pass/fail per subsystem
GET /api/v1/commands → self-describing command tree (use/short/example/flags)
Write endpoints (scope operator, every one enqueues):
POST /api/v1/workspaces/{name}/plan → queue.Enqueue, returns {job_id}
POST /api/v1/workspaces/{name}/apply → queue.EnqueueGated, returns {approval_id, state:pending, ttl}
POST /api/v1/playbooks/{name}/run → Enqueue (Gated if destructive/--require-approval)
POST /api/v1/commands → ad-hoc, injection-guarded (command.go:101-114)
POST /api/v1/sources/{name}/sync · /backups/run · /schedules …
Approval endpoints (scope approve or operator, mayApprove reused verbatim):
POST /api/v1/approvals/{id}/decision {decision: approve|reject} → reactive.Decide (fail-closed)
Conventions: every mutating request accepts an Idempotency-Key header →
Spec.IdempotencyKey → JobByIdempotencyKey dedup (queue.go:117-126), so a retry never
double-enqueues. The header is namespaced server-side by the authenticated token id and the
route before it reaches the queue: the dedup space is per-principal, per-verb, and the
queue's reserved wf: step keys are unaddressable from any face. httpError's flat {"error":msg} (state.go) is replaced everywhere by the
wire typed envelope. Apply applies the saved plan, not a fresh one — the plan→apply seam
is two linked job rows and the approver sees the actual plan (overview.md:64-66).
3.5 MCP face (MCP over HTTP, POST /mcp on the Decision 0009 listener)
exocomp maintains exactly one programmatic MCP surface: MCP over HTTP, mounted at POST /mcp
on the same Decision 0009 listener as /api/v1, behind the same bearer+scope guard
(internal/web/mcp_http.go). It is a thin adapter that maps MCP tool calls to
internal/service verbs and marshals the identical wire envelope — the agent-aware wrapper
around the /api/v1 bearer tokens, not a second server. Every MCP principal presents a scoped
token; there is no ambient-authority MCP path. Tool visibility is scope-gated: a readonly
token advertises only R tools.
The LOCAL agent path is the CLI, which is already agent-aware (--output json, the exit-code
table, the same wire envelope) — a local harness drives exocomp through the CLI, not through
an MCP server of its own. exocomp ships and maintains no stdio MCP server; a harness that must
speak stdio MCP runs an off-the-shelf http-to-stdio bridge (e.g. npx mcp-remote or
supergateway) around POST /mcp with a bearer token.
Tools (R = read, W = write/enqueues, A = approval-gated):
| Read (readonly) | Write (operator) | Approve (approve only) |
|---|---|---|
| list_sources, get_source | sync_source (W) | approve_job / reject_job (A) |
| list_workspaces, get_workspace | workspace_plan (W) | list_pending_approvals |
| get_drift (values-suppressed) | workspace_apply (A → gated) | |
| list_jobs, get_job | run_playbook (W/A) | |
| get_job_events / follow_job | run_command_adhoc (W/A) | |
| list_schedules, list_rules | backup_run (W) | |
| list_events, explain_event (Trace) | schedule_create/enable/disable (W) | |
| list_approvals, get_approval | secret_add/rotate/rm (W, value only IN) | |
| list_secrets (metadata only) | source/inventory/runsecret add/rm (W) | |
| list_backups, health/doctor |
Never expose as tools: reveal_secret / any plaintext read (no code path exists to call),
raw ciphertext, or an approval decision link as a return value — a decision URL is a
single-use HMAC capability, and returning one to a low-scope agent silently promotes it to
approver (reactive/approval.go; cmd/exocomp/approval.go:92-99). Approve/reject are scoped tools
that call reactive.Decide server-side instead.
Every MCP write is attributed and answerable. A write enqueued here records an actor
naming the mcp face, so an agent's action is never read off the ledger as the operator's
own CLI run; and tools/call/resources/read sent as a JSON-RPC notification are refused
with invalid_request rather than executed silently, because a mutation that writes no
frame back leaves an audit wrapper nothing to record.
The A column is gated on the approve scope alone, and W and A are disjoint. Approval
requires an explicitly minted approve-scope token; approve_job/reject_job are absent from
the catalog a lesser scope sees, and the scope that enqueues a gated apply may not settle it —
nor may any principal decide the approval for a job its own token asked for
(docs/security/threat-model.md). Because every MCP principal presents a scoped token and
none acts un-scoped, a face that raises a gate with workspace_apply can never also hold its
release; there is no ambient approver.
Resources (stable, cacheable context an agent fetches or subscribes to):
exocomp://workspaces, …/workspaces/{name}/drift, exocomp://jobs/{id},
exocomp://jobs/{id}/log (append-only, Seq-cursorable), exocomp://approvals/pending,
exocomp://schedules, exocomp://events/recent, exocomp://rules, exocomp://silences,
exocomp://secrets (metadata), exocomp://backups/status, exocomp://health. Tools operate;
resources are the "be aware of" surface.
4. Auth and safety model
- Reuse Decision 0009 tokens + three scopes.
token new <name> --scope operator|readonly|approve(store/token.go:13-17); argon2id-hashed at rest, revocable, minted only with the data dir onserver. Scope maps 1:1 onto R / W / A across CLI, API, and MCP. A non-loopback bind still refuses without an active token (web/auth.go:250-268). An out-of-scope call is rejected withforbiddenbefore any store touch. - Bearer failures back off, and the failure path is constant-cost. Consecutive failures
are counted per source and per token row; past a threshold the source serves a doubling
backoff (429,
Retry-After) that costs no KDF, and the run is logged once. An unknown or revoked token id pays the same argon2id a live one does, so response time is not an existence oracle. - A revoked token cuts an OPEN stream too. The SSE tail re-resolves its token row on every poll and ends the stream when the row stops authenticating, with a hard 12h lifetime as backstop — a revoke is not something a caller can outrun by holding a socket open.
/api/v1writes take a bearer credential only. A browser session cookie authenticates reads but is refused on every write route: an ambient credential rides whatever request a browser is talked into making, and SameSite does not scope to a port, so a second app on the same host could otherwise drive operator writes. The browser's own same-origin decide form is where a session acts.- W and A are disjoint on the machine face.
/api/v1's decision route takes theapprovescope alone: the token that may enqueue a gated apply must not also carry its release (docs/security/threat-model.md). The browser/phone surface keeps operator-or-approve — that is the human at the console — and both paths refuse a principal deciding an approval for a job its own token asked for. An operator driving a remote daemon withapproval decide --remoteneeds anapprove-scope token for that verb. - Cert pinning for the bare tailnet. The listener already prints its self-signed cert
fingerprint for trust-on-first-use (Decision 0009 §4).
exocomp loginperforms the TOFU pin and stores{host, fingerprint, token}in~/.config/exocomp/hosts.toml(SSH-known-hosts discipline); later calls verify the pinned fingerprint. Where Tailscale Serve / Caddy+ACME fronts the surface, real TLS supersedes the pin. Operator-scope MCP/API over--insecure-httpcleartext is disallowed — a bearer on the wire is sniffable (auth.go:262-266). - Approval gating is a queue state, fail-closed. A destructive write goes straight into
awaiting_approvalviaEnqueueGated— never queued first (queue/gate.go:11-51); no claim statement admits that state (store/job.go:18-53). The API/MCP write returns{approval_id, state:pending, ttl, expires_at}; an unanswered approval auto-rejects on TTL (SweepApprovals), and an approved plan refuses to apply if the workspace moved (stale_plan). Tools must surfacettl/expires_atso an agent reads a safety rejection as intended, not as an error. - Secrets are structurally non-exfiltrable. No surface prints a sealed value; secrets rows
hold only ciphertext/nonce/AAD; the Redactor scrubs raw/base64/hex before the append-only
job_eventwrite (invariant 4; internal/queue/redact.go; internal/secrets/redact.go). Secret tools return metadata only, and log/event tools read the already-redactedjob_event— never re-open raw subprocess stdout for an agent. - Idempotency + concurrency are safe by construction. Caller-settable idempotency keys
dedup retries; the DB refuses a second apply per workspace via
concurrency_key(queue.go:256-282). A retrying, flag-hallucinating small model cannot double-apply. - Kill switch is visible. The global reactive silence (the one-row pause) is surfaced on
exocomp://health//api/v1/health, so an agent sees when the plane is paused.
5. Reconciling the design approaches
- CLI
--jsonfirst vs extract the service first, then all three faces. These are sequenced, not opposed. Thewirecontract and CLI--output jsonland first because they are a pure presentation projection over existing store read models — cheap, network-free, and they force the schema and error taxonomy to exist and be golden-tested. The service extraction proceeds in parallel and is the prerequisite for the API and MCP faces, which must not fork logic. The CLI's JSON path and the API/MCP path converge on the onewiremarshaller, so they stay byte-identical. - The caution about a general HTTP API (risk of reintroducing the Decision 0001 bypass)
is honored: the API is a thin reflection whose every write is
queue.Enqueue/EnqueueGated. No endpoint shells out or mutates state directly; the queue remains the sole execution authority. The API also reuses the existing Decision 0009 listener and auth rather than standing up a second session system (which Decision 0001 explicitly rejected). - Where the operator API lives. Not the loopback tofu backend (
internal/api, which stays workspace-state-only) and not a new listener — the JSON routes mount on the token-gated web listener that already carries Decision 0009 auth, keeping one auth story.
6. Build order
Each step is a self-contained unit; the earlier steps ship value on their own and lock the
schema the later faces reuse. The wire contract and CLI JSON (step 1) do not depend on the
service extraction (step 2), so they land first.
- Contract foundation (no network). The governing record +
internal/wireenvelope, error taxonomy, exit-code table +--output jsonon all read/list/status CLI verbs +EXOCOMP_OUTPUT. Golden tests pin the schema. cobraExampleon every command +exocomp commands --output json. A local agent onservercan already branch on JSON. - Core + write ergonomics. Extract
internal/servicefrom theRunE/store/queue logic (today a stub with no callers, so this is a rewrite, not a refactor). Surface--idempotency-keyon mutating paths. Land the non-interactive contract (--assume-yes/--no-input, unify--force/--yes-really). doctor--output json/summary. - Operator HTTP API (read). JSON read endpoints on the Decision 0009 listener over the service
verbs, typed
wireerrors, OpenAPI 3.1 spec +internal/client. SSE job-follow. - Remote CLI + write API.
exocomp login(TOFU pinning) +--remote/EXOCOMP_REMOTEclient mode; write/approval endpoints (plan/apply/run/decide) through queue+gate.workstation → tailnet → serverworks end to end. - MCP face.
POST /mcp(MCP over HTTP) adapter over the service verbs, same envelope; tool/resource catalog with scope gating, on the Decision 0009 bearer+scope guard. The local agent path is the agent-aware CLI; exocomp ships no stdio MCP server (a stdio client bridges toPOST /mcpwith an off-the-shelf http-to-stdio wrapper).
7. Risks and open decisions
- Charter scope. No existing design doc names an agent/MCP/remote-CLI consumer as a requirement; the original operator-centric charter did not name agent-fit as first-class. Decision 0014 records that decision and its one resolved design question — a single MCP surface over HTTP with no ambient authority — so the surface is a decision with consequences, not an assumption.
- Schema is a contract. If the CLI and API/MCP envelopes ever diverge, or a field shape
changes without a
schema_versionbump, agent parsers and prompt caches break silently. One sharedwiremarshaller under golden tests is the mitigation. - Presentation-only JSON. The envelope must never leak into persistence — invariant 2 bans bespoke stored formats. It is a projection of store rows, never a stored artifact schema.
- Stale examples mislead. cobra
Exampleblocks and the command dump must be asserted in CI (flags exist / examples parse) undermake check, or they rot into active misdirection. - Redaction on the new paths. Every JSON output path must route through the same sink-time
redactor and the tofu backend's internal-hiding
fail()discipline, or a verbose small model re-exposes what invariant 4 protects. - Carve-outs stay read-only.
restoreis deliberately outside the queue (overview.md:77-80). MCP marks such carve-outs read-only rather than inventing a queued path the core does not have.
8. Evidence
- docs/architecture/overview.md:93-104 — one core, thin clients;
internal/serviceowns all business logic; OpenAPI 3.1 as the monorepo seam; TUI/web/CLI/webhooks as faces. - internal/service/doc.go — service layer is still a stub; logic lives in
RunE/store/queue. - internal/api/server.go:32-37,55-60,100-125 — tofu backend binds loopback only; deterministic
HKDF workspace bearer;
Mountallows extra routes on the one listener;/healthz. - internal/api/state.go:51-235 — JSON backend; flat
{"error":msg}viahttpError;fail()hides internals from job subprocesses. - internal/web/server.go:58-60,121-148,174-187 — web reads the store directly for one auth
story; the mux behind
requireAuth; strict CSP/security headers (the seam the JSON API rides). - internal/web/auth.go:41,173-268 — Decision 0009 resolve (session→bearer),
mayApprove, KDF gate (ErrKDFBusy), refuse non-loopback bind with no active token,--insecure-httpwarning. - internal/store/token.go:13-45,136-146 — three scopes; argon2id at rest; revoke;
CountActiveTokens(the bind guard). - internal/queue/queue.go:112-161,256-282,357-379 —
Enqueueidempotency dedup; concurrency serialization decided by the store; exit-code → state mapping (invariant 3). - internal/queue/gate.go:11-51 —
EnqueueGatedwrites destructive work straight intoawaiting_approval, never queued first. - internal/store/job.go:18-53,209-217 — job state machine,
awaiting_approvalclaimable by nothing;JobFilter{States,ConcurrencyKey,Runbook,Limit}for the list endpoint. - internal/executor/tofu_drift.go:9-31 —
ResourceDelta/TofuPlanOutcomesuppress values, emit keys/actions/counts (the token-efficient contract template). - internal/web/stream.go:16-90 — SSE tail over append-only
job_event, monotonicSeqcursor,doneon terminal (the follow_job //streammodel). - internal/store/store.go:20-31 + internal/reactive/approval.go:27 — the sentinel set the error
taxonomy maps onto (
ErrNotFound/ErrStateConflict/ErrDuplicate/ErrConcurrencyHeld/ErrStalePlan). - internal/queue/redact.go + internal/secrets/redact.go — Redactor scrubs raw/base64/hex before the append-only write (invariant 4).
- cmd/exocomp/workspace.go:432-436 — plan runs
-detailed-exitcode, exits 2 on changes while the job succeeds (exit-code 2 in the table). - cmd/exocomp/command.go:46-47,101-114 —
--require-approvalgating + injection guard on ad-hoc runs. - cmd/exocomp/serve.go:109-128 —
--portloopback state backend vs--web-addrtoken-gated LAN web+API surface over TLS;--web-insecure-http. - docs/decisions/0009-lan-access-tokens.md:15-35 — bearer required for non-loopback; mint via local auth; TLS + printed fingerprint for TOFU pinning; audit records token name.
- docs/architecture/platform-contract.md:47-49 — the tailnet is the intended remote path (wizard binds the TCP listener to the tailnet address).
- AGENTS.md invariants 1-5 — one operator/container/no external DB; no bespoke stored formats; exit codes decide correctness; secrets never in logs/events; every path through the one queue.