Fork Horse repository, listed by its owner. Publisher snapshot.
docs/receipts.md
Snapshot at 34e3175
1# Check receipts
2
3FH-032 wires the runner into core. All acceptance evidence for this packet is
4**offline workerd with mocked container ports**. FH-010 adds real local
5Dynamic Worker execution, with fixture Artifacts reads. It does not establish container
6startup, registry access, deployed bindings, provider token revocation or live
7promotion. Those remain behind FH-032L.
8
9## Workflow and settings
10
11`POST /projects` accepts owner-only `requireReceipt: boolean`, defaulting to true.
12A stored project with no settings uses `{ requireReceipt: false }` until its owner
13opts in. This backward-compatible fallback also covers the existing development
14project; this change does not read or modify any live project.
15
16Owner-only `PATCH /projects/:id/settings` accepts exactly
17`{ "requireReceipt": true }` or `{ "requireReceipt": false }` and returns
18`200 { "settings": { "requireReceipt": boolean } }`. Agent credentials cannot
19change the setting. Browser owner requests retain Access, same-origin and JSON
20content-type checks. `/state` always includes `project.settings`.
21
22An authorized agent's successful `POST /reviews` reserves a fresh execution UUID,
23clears the digest and returns immediately with a running receipt summary. The
24Supervisor starts checks in the background. Authority comes from the stored
25project, registered caller-owned fork, live packet claim and exact fork head.
26Public input cannot select a generation, image URL, authority, remote or digest.
27
28The packet ruling of **2026-10-12 16:55 ET** supersedes the original unconditional
29before-promote requirement and 409 wording. The state wire shape below is unchanged.
30
31When `requireReceipt` is **true**, approval calls the runner validator against the
32exact commit's checks and attached immutable record. It rejects missing, stale,
33altered, incomplete, failed, `mocked-container-port` and `mocked-worker-loader` evidence. Promote validates
34owner consent for the exact commit, then reruns checks with a new generation and
35the `before-promote` trigger, holding the Supervisor's workflow mutex until
36completion. It compares the new digest, trigger, commit and generation and
37rechecks both repository heads before minting a canonical write credential. The
38ledger records this fresh digest. A failed, timed-out, missing or infrastructure
39result returns exactly `409 { "error": "Checks failed before promote." }`.
40The failed before-promote summary stays visible. Mocked successful evidence also
41fails the required gate.
42
43When `requireReceipt` is **false**, receipts are advisory. Review-created checks
44still execute when the commit contains `.forkhorse/checks`; their results remain
45visible, including failures. An absent check file leaves `receipt: null` after
46configuration discovery. Invalid or unsafe configuration is an infrastructure
47error, but does not gate approval or promotion. Approve and promote ignore receipt
48status, digest changes and in-flight execution. Promote starts no checks and does
49not wait for checks or return a check-related 409. A review-created result can
50attach after advisory promotion without overwriting the promotion or owner consent.
51The ledger captures the digest known at promotion; a later advisory result can
52therefore differ from that historical ledger entry.
53
54A settings change affects later approve and promote calls. Turning the requirement
55on also gates an already-approved review at its next promote: it must complete a
56new required run, even if its earlier approval or receipt was advisory. Turning it
57off makes subsequent calls advisory. Owner authorization, exact-commit consent,
58live claims, fork-head checks, canonical fast-forward/CAS and uncertain-outcome
59fencing apply in both modes.
60
61## Fixed state wire contract
62
63Every `/state` review has `receipt: null` for an older review without an execution,
64or this shape:
65
66```ts
67{
68 status: "running" | "passed" | "failed" | "infrastructure-error" | "timed-out";
69 trigger: "review-created" | "before-promote";
70 digest: string | null;
71 startedAt: string;
72 finishedAt: string | null;
73 durationMs: number | null;
74 checks: {
75 name: string;
76 tier: "quick" | "full";
77 runtime?: "dynamic-worker" | "container";
78 message?: string;
79 outcome: "running" | "passed" | "failed" | "infrastructure-error" | "timed-out";
80 durationMs: number | null;
81 }[];
82}
83```
84
85There are no logs, images, tokens or execution-generation fields in this summary.
86While definitions load, `checks` is empty. The digest is null while running and
87when infrastructure fails before a record can be safely published. Missing or
88invalid checks, unresolved images and unconfirmed cleanup fail closed.
89
90Owner-only `GET /projects/:id/reviews/:rid/receipt` returns the currently attached
91`{ digest, receipt }` record, including each check's bounded logs, or 404 if there
92is no attached record. It never accepts receipt input. Each stream retains at
93most 32 KiB of original source bytes; UTF-8 replacement decoding can expand its
94serialized size. Agent access returns 403.
95
96## Persistence and capability boundary
97
98Receipt records are content-addressed SHA-256 values, stored in 16,384-code-unit
99chunks. Even worst-case UTF-8 chunks stay under 128 KiB. All chunks and their
100manifest commit atomically. Attachment is a separate atomic compare-and-set over
101commit, generation, trigger and live claim; a stale execution may leave an
102unattached immutable record but cannot replace the current digest. Approval also
103compares the current receipt inside a transaction before persisting owner consent.
104
105Core and the runner share one Worker script. `ReceiptRunner` is a SQLite Durable
106Object with `durable_object` container scheduling; `GitReadGateway` is a loopback
107entrypoint created through the same `ctx.exports`. The Supervisor passes a
108one-execution `RpcTarget` authority directly to the runner. These callbacks use
109storage transactions rather than the workflow mutex, avoiding deadlock while a
110promote waits. The runner opens only the capability-authorized fork within the
111configured Artifacts namespace. The canonical write token remains confined to
112core's promote path. Neither runner nor gateway has a public HTTP route.
113
114On Supervisor restart, interrupted running summaries become infrastructure errors
115and their generations are invalidated. On runner restart, any existing container
116is destroyed before another RPC can proceed. Normal execution still refuses
117concurrent/reused running containers and destroys them on every completed attempt.
118A terminated runner's unrevoked read token is bounded by its 180-second expiry;
119live restart/recovery behavior still needs operator verification.
120
121## Quick checks (FH-010)
122
123The exact reviewed commit's `.forkhorse/checks` also accepts this strict shape:
124
125```json
126{"name":"brand-quick","tier":"quick","module":".forkhorse/quick/brand.js"}
127```
128
129No `image` or `run` is allowed on a quick check. A module must be one `.js` file
130immediately under `.forkhorse/quick/`, exporting a default async function:
131
132```js
133export default async (ctx) => {
134 const text = await ctx.read("brand.json");
135 return { ok: text !== null, message: "Checked brand.json." };
136};
137```
138
139`ctx.read(path)` returns a regular file's UTF-8 text or null if absent.
140`ctx.stat(path)` returns `{size, binary}` or null if absent: `size` is the blob's
141byte size, and `binary` is true when its first 8 KiB contains a NUL byte. This
142heuristic does not validate UTF-8 or identify every binary format. Only that
143bounded sample is materialized; the file's contents are never returned by stat.
144`ctx.list(prefix)` returns sorted regular file paths starting with that literal
145prefix; `""` lists the snapshot and `"src/"` lists that directory's descendants.
146Executable regular files are included. Paths are repository-relative; absolute
147paths, traversal, empty components, backslashes, control bytes and `.git`
148components are rejected. Symlinks and submodules are never followed. Text reads
149reject malformed UTF-8; missing provider objects and unsafe trees fail closed
150for stat as well as read/list. Snapshot access uses only
151Artifacts `readCommit`/`readTree`/`readBlob` at the reviewed SHA, never branch refs.
152
153Each check gets a fresh `LOADER.load()` isolate with `globalOutbound: null`, only
154a `FILES` RPC loopback in its environment, no Node compatibility, no tails and
155no secrets. A trusted main-module harness imports the unchanged check module;
156this adapts its default function to a Worker handler, like upstream's overseer
157harness. Importable environment access is disabled. The loopback names the
158execution's Durable Object and a random per-check capability ID. The ID is not
159a Git/API token; it authorizes only read/list/stat of one snapshot. The runner retains
160all state and budgets. No token is minted for quick execution.
161
162Aggregate limits per check are 2 MiB of reads and stat samples (including module source),
1632,000 listed paths (including repeated listings), 10,000 fetched tree entries
164and file calls, and 64 path levels. Budget reservation happens before async blob
165materialization or return, including concurrent calls. Validated immutable tree
166metadata is cached per snapshot, so list/read/stat reuse it without refetching.
167Modules return
168`{ok: boolean, message?: string}`. Messages are limited to 4 KiB of UTF-8, with
169control/bidi escapes and stack-shaped suffixes removed. Thrown exceptions are
170replaced by static text; provider exception text and stacks never enter receipts.
171Returned messages are project-authored check output, not trusted provider diagnostics.
172
173A 10-second wall deadline covers module loading, startup and execution. It aborts
174the dispatched fetch and revokes the file capability, including pending reads.
175The loader also receives a 10,000 ms CPU limit. A thrown check or `ok:false` is
176`failed`; startup/import errors are `infrastructure-error`; the deadline is
177`timed-out`. No late result can turn a timed-out receipt into a pass. A process
178restart drops these capabilities; there is no resumable quick execution.
179
180Quick checks run first, preserving order within each tier. Any non-passing quick
181result prevents every full container start and token mint. Full checks then have
182`outcome:"failed"`, `durationMs:null`, and the exact message
183`"Skipped: a quick check failed."`. Quick-only suites never start a container.
184Full image preflight and container cleanup are otherwise unchanged.
185
186Receipt schema remains 1. New check records include `runtime:"dynamic-worker"`
187or `"container"`. Quick `imageDigest` is `module:sha256:<digest of module source>`;
188when no source could be loaded, it is empty and the check cannot pass. A skipped
189full check also has an empty image digest. Top-level evidence is `dynamic-worker`
190for quick-only, `container` for full-only, or `mixed` for both actual runtimes.
191Any mocked port keeps a mocked evidence label and cannot satisfy the required
192gate. The gate independently hashes each quick module from the reviewed commit
193and checks its definition, runtime, source digest, generation and receipt digest.
194Old full-only records retain their original digest serialization; an absent
195runtime field is accepted only for legacy full/container records.
196
197Core's Wrangler template declares `worker_loaders: [{binding:"LOADER"}]`;
198the existing deployment generator preserves it without additional owner config.
199
200## Image and demo checks
201
202Core's Wrangler configuration names `default`, built from
203`packages/forkhorse-runner/container/Dockerfile`: Node 24 LTS slim, Git, CA
204certificates, POSIX `sh` and GNU coreutils (`timeout`, `sleep`). Wrangler builds,
205pushes and resolves the image during the separately authorized deployment. The
206runner accepts only immutable `@sha256:` references from `ctx.container.images`.
207Runtime internet remains disabled; only the selected fork's Git read protocol is
208intercepted. Dependencies must be baked into an owner-approved image.
209
210The root `.forkhorse/checks` first runs `.forkhorse/quick/brand.js`, checking that
211`brand.json` parses and has non-empty `name`, `slug` and `mark` strings. Then the
212full `large-files` check runs one Node command in the `default` container image
213to scan checkout metadata, excluding `.git` entries and not following symlinks.
214It fails on any regular file of 10,000,000 bytes (10 MB) or more and never reads
215file contents. Repository-wide serial stat RPCs exceeded the quick tier's
21610-second deadline in the operator's live run; this check uses the full tier.
217`.forkhorse/quick/large-files.js` remains an unregistered stat API example for
218small snapshots, not the repository's acceptance check.
219See [large files and binaries](large-files.md) for limits and the storage roadmap.
220The existing full `brand` container check remains and needs no project
221dependencies. For a smoke repository, commit:
222
223```json
224[{"name":"test","tier":"full","image":"default","run":"npm test"}]
225```
226
227alongside this dependency-free `package.json`:
228
229```json
230{"scripts":{"test":"node -e \"if (2 + 2 !== 4) process.exit(1)\""}}
231```
232
233A failing test can use `process.exit(1)` to exercise refusal. No package install
234runs inside the container. The separately gated live demo uses the smoke project.
235
236## Offline validation and D5
237
238Both package suites run under workerd. The installed Vitest/Miniflare supports a
239real Worker Loader: quick pass/fail, throws, startup failures, HTTP/TCP egress
240blocking, RPC reads/listing and the real 10-second deadline execute in local
241Dynamic Workers. A separately labeled fake-loader test inspects abort signaling
242and capability revocation. Artifacts, Git and all containers remain fixtures;
243local workerd does not establish production CPU enforcement or deployed latency.
244
245The post-deploy operator gate remains open: on `fh-smoke`, show one quick check
246passing well under one second with `runtime:"dynamic-worker"`, followed by a full
247check; then fail a quick check and verify no container started. No deployment or
248live smoke is performed by this implementation.
249
250Primary references: [Dynamic Worker API](https://developers.cloudflare.com/dynamic-workers/api-reference/),
251[egress control](https://developers.cloudflare.com/dynamic-workers/usage/egress-control/),
252and [resource limits](https://developers.cloudflare.com/dynamic-workers/usage/limits/).
253 Core includes a same-script loopback DO RPC
254test using the production adapter and an explicitly mocked container port. Tests
255of required approval use separately fabricated validator records; mocked runner
256results are never relabeled as real container evidence.
257
258Fresh-clone validation runs root/upstream frozen installs, both package tests and
259`types:check`, root `pnpm test`, `pnpm lint`, and `pnpm check`. D5 remains **eight
260Worker dry runs**: runner and gateway are exports of core, not another Worker.
261`--check` passes `--dry-run --containers-rollout none` to Wrangler. The installed
262Wrangler 4.149.0 implementation returns before both container-image build paths
263for that flag; plain `--dry-run` alone would build Dockerfiles. D5 therefore checks
264Worker bundles/configuration without building, pulling or resolving the image.
265
266Run D5 only in a fresh review clone with an exclusively created mode-0600 synthetic
267`deployment.local.jsonc`, a temporary empty HOME and environment, metrics disabled,
268external networking and Keychain access denied, and cleanup limited to those owned
269synthetic files. Refuse an existing local owner file. No live configuration or
270credentials are needed. D5 is not image or deployment acceptance.
271
272FH-032L remains locked: operator deployment, budget alert, one live smoke receipt,
273container start, exact checkout, token revocation, attached digest, gated approval,
274cleanup and restart/orphan recovery must all be verified separately.
275