ADR 0005: Local-first core with optional hosted services

Status: accepted, 2026-10-06

Context

Several features need somewhere shared, and none of them is shared by a single machine. Approvals can come from chat, from the CLI or from the web, and an approver named @platform has to be notified with enough of the plan to judge it. An agent should be free to stop while a run sits parked at review. A signed run log has to say who proposed a plan, who approved it and which digest shipped. Blocks are used from repositories that never run this binary, and a plan has to survive between the plan job and the apply job.

None of that may cost the local-first pitch (README.md, docs/ARCHITECTURE.md). Cratefield's limits then shape the answer — they are Cratefield's, not this repository's: /v1 request bodies are capped at 64 KiB, responses are buffered up to 1 MiB, there is no SSE, and Blob::signed_url is GET-only. The first two are pinned in this repository as HOSTED_BODY_LIMIT (crates/core/src/plan_summary.rs) and the HttpPolicy default response cap (crates/core/src/ports/http.rs).

This ADR decides the shape of ports that do not exist yet: ApprovalChannel, RunLog, StateStore and BlockSource appear in the architecture picture, and crates/core/src/ports/mod.rs says the remaining ports land with their issues. Nothing here describes code that is written; it fixes the boundary before the adapters are. The features are tracked as #93, #144 and #42; #151 consumes the egress list below. Like ADR 0013, the number comes from the block ADR 0000 planned — 0002–0012 stay with issues #3 to #13 — and this is issue #6's number in it.

The options, briefly:

Decision

Option 3.

What leaves the runner

Every request: the run id, a ULID minted by the runner's IdGen (crates/core/src/ports/id_gen.rs); the repository owner/name, workflow name and step name — none of which RunContext carries today (crates/core/src/ports/run_context.rs has the actor, event, capabilities and workspace), so the runner reads them from its own environment and workflow file; Actor.kind, Actor.display_name and the Establishment variant, with issuer and subject when it is CiOidc and a marker when it is Detected — a variable name, never its value; the audience-bound OIDC token when the runner has one (RunContext::oidc_token, a SecretValue); and the event — GitInfo.sha, GitInfo.git_ref, and the Trigger variant with its PR number or tag name.

An approval request: the plan digest; the gated step's ActionClass — its kind and, when it has one, env (crates/core/src/step.rs); approver handles such as @platform; and the masked PlanSummary — the add, change and destroy counts, and the risky and others entries, each with kind, address, reason and attributes path, before, after, forces_replacement, masked once by summarize — bounded by HOSTED_BODY_LIMIT; optionally full_plan_url. A per-environment setting drops the detail to counts and addresses, without attribute values or reasons, for projects that want the tighter shape; the wire format is #151's.

An approval decision travels the other way — hosted to runner — and is recorded as a run-log entry on arrival. What the hosted service holds for it: approved, by (a handle) with the approver's verified identity, a timestamp, the plan digest it binds, and its signature.

A run-log entry: sequence number; previous entry hash; run id; event kind — proposed, approved, rejected, applied or shipped; plan digest; shipped artifact digest; actor; timestamp; Signature { bytes, identity } (crates/core/src/ports/supply_chain.rs).

A blocks search: the query's text and limit (BlockQuery, crates/core/src/ports/blocks.rs). The answer is metadata, exactly the fields of the existing contract — GET {base}/v1/blocks/search with results, each entry name, publisher (kind, plus issuer/subject for keyless or key_id for a named key), readme (optional), and versions of version, digest and signature (signed or unsigned) (crates/cli/src/blocks_index.rs).

What never leaves the runner

Consequences