From 3c952d2cf2a0d5f0ca4a6db86a02b17430fb9479 Mon Sep 17 00:00:00 2001 From: "David J. Kim" Date: Thu, 23 Jul 2026 17:52:43 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20pin-to-contract=20from=20the=20per-?= =?UTF-8?q?call=20view=20=E2=80=94=20the=20console=20closes=20the=20loop?= =?UTF-8?q?=20(Wave=20A=20PR-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R9: POST /calls//pin, serve's first and only write route. Delegates to the existing fleet machinery (ingest_recording -> discover -> contract_from_candidate): human label + sealed .hotato bundle + registry registration in one atomic step; refusals mirror the CLI (4xx with reason, no artifact) for NOT_SCORABLE/ERROR calls, bad candidate refs, stale evidence_sha256 bindings, changed recordings (re-scan must reproduce the onset), and trust-preflight refusals. Auth = the same bearer/cookie check as every view; CSRF fence = SameSite=Strict cookie + same-origin Origin/Referer check on cookie-authenticated POSTs (bearer needs none); every attempt lands one append-only audit line. Per-call pin forms are plain HTML POST (work without JS; fetch enhancement shows results inline); the /calls feed header shows 'N contracts protecting this agent' counted from the fleet registry's contracts table. R10: 'Production health' -> 'Suite health' everywhere it surfaces (title, heading, nav, docs, JSON view id suite_health); /health + /production routes unchanged. Nav reads Calls · Suite health · Failure clusters · Failure records · Release readiness; scenario matrix becomes a Release readiness drill-in. R11: a finalized (COMPLETE/DEGRADED) session's per-call view surfaces the exact 'hotato production export-regression' command for that session. Docs (WORKSPACE/SELF-HOST/THREAT-MODEL/llms) updated + llms-full rebuilt; CHANGELOG additive; 14 new tests in tests/test_serve_pin.py. --- CHANGELOG.md | 37 +++ docs/SELF-HOST.md | 11 +- docs/THREAT-MODEL.md | 24 +- docs/WORKSPACE.md | 71 ++++- llms-full.txt | 106 +++++-- llms.txt | 17 +- src/hotato/cli.py | 20 +- src/hotato/serve/app.py | 169 ++++++++++- src/hotato/serve/data.py | 39 ++- src/hotato/serve/pin.py | 196 +++++++++++++ src/hotato/serve/render.py | 172 ++++++++++-- tests/test_serve.py | 8 +- tests/test_serve_pin.py | 561 +++++++++++++++++++++++++++++++++++++ 13 files changed, 1335 insertions(+), 96 deletions(-) create mode 100644 src/hotato/serve/pin.py create mode 100644 tests/test_serve_pin.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d5c6816..b80f416 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,16 @@ Every entry reports millisecond measurement error and a confusion matrix. See `d refuse convention), so exit-code-only CI separates "fix the agent" from "fix the judge". Ungated runs stay advisory (exit 0) with the ERROR reported. +- **Naming honesty in the workspace: "Suite health", and a nav that reads as + one product.** The serve view that renders CI-suite history from the fleet + registry -- ingest volume, evaluated coverage, per-dimension failure rate + over time -- is now labeled **Suite health** everywhere it surfaces (page + title, heading, nav, docs, and the JSON mirror's `view: "suite_health"`); + "production" in the console means live call sessions, one coherent + picture. The `/health` route keeps working (as does `/production`). The + nav now reads Calls · Suite health · Failure clusters · Failure records · + Release readiness, with the scenario matrix as a drill-in from Release + readiness. ### Added - **`hotato console` + `/calls`: the daily surface over scored production @@ -101,6 +111,33 @@ Every entry reports millisecond measurement error and a confusion matrix. See `d survive a swapped candidate. This makes the strongest proof scope produced by a drift-checked flow rather than an operator-asserted string; the hash is a measured binding of the configuration, not authentication of the runner. +- **Pin-to-contract from the per-call view -- the console closes the loop.** + Every top-ranked candidate moment on a SCORED call at `/calls/` now + carries a pin form (plain HTML POST, works with JavaScript off; with it + on, the result shows inline): choose expect-yield or expect-hold and the + new `POST /calls//pin` route mints a portable `.hotato` failure + contract from that exact moment by delegating to the existing fleet + machinery (`FleetAPI.contract_from_candidate` -- human label, sealed + bundle, and registry registration in one atomic step, under agent id + `production`). Refusals mirror the CLI, fail closed: a NOT_SCORABLE/ERROR + call, a bad candidate reference, a stale page (the form binds the score's + `evidence_sha256`), a recording that changed on disk (the re-scan must + reproduce the pinned moment's onset), or a trust-preflight refusal each + answer 4xx with the reason and leave no artifact. This is serve's one + write route, and it is fenced accordingly: the same bearer/cookie auth as + every view, a `SameSite=Strict` session cookie, a same-origin + `Origin`/`Referer` check on cookie-authenticated POSTs (a forged + cross-site form is 403 before any handler runs), and one append-only + audit line per attempt, accepted or refused. The `/calls` feed header now + shows **"N contracts protecting this agent"**, counted from the fleet + registry's contracts table for the workspace, so every pin visibly grows + the net. Success shows the contract id, the bundle path, and the exact + `hotato contract verify` command that proves it. +- **The per-call view surfaces the regression-export path.** Where a call's + session has finalized (`COMPLETE`/`DEGRADED` -- the states + `hotato production export-regression` accepts), `/calls/` and its JSON + mirror carry the exact `hotato production export-regression --out DIR + --db FILE` command for that session, wired to the existing export path. ## [1.15.1] - 2026-07-22 diff --git a/docs/SELF-HOST.md b/docs/SELF-HOST.md index 30369b3..91089b5 100644 --- a/docs/SELF-HOST.md +++ b/docs/SELF-HOST.md @@ -11,9 +11,10 @@ The stack is small on purpose: - **hotato**: the `pip`-installable package. Stdlib-only core (zero runtime dependencies) keeps the default image offline at run time, with zero added supply-chain surface. -- **`hotato serve`**: the read-only, token-authenticated team workspace (five - views: release readiness, scenario matrix, conversation inspector, failure - clusters, production health). See [`docs/WORKSPACE.md`](WORKSPACE.md). +- **`hotato serve`**: the token-authenticated team workspace (calls, suite + health, failure clusters, failure records, release readiness, plus the + scenario-matrix and conversation-inspector drill-ins; one write route, + pin-to-contract). See [`docs/WORKSPACE.md`](WORKSPACE.md). - **Ollama** (optional): a local model judge for the rubric lane, opt-in behind a compose profile. The default path stays local end to end. @@ -352,7 +353,9 @@ judge), the whole stack runs fully offline. - This covers the default stack's run-time behaviour: the workspace server binds a listening socket and stops there, imports nothing that phones home, and keeps audio, traces, and evaluations on the machine. The - workspace is read-only, writing only its own append-only audit log. + workspace's own writes stay local too: its append-only audit log, plus + the contract bundles and registry rows a pin-to-contract POST creates + through the fleet machinery. - The default workspace runs on a normal Docker bridge so its port can publish; the guarantee rests on the server's own behaviour. Verify it directly on your own machine, independent of any firewall: diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md index 565101f..848e8c5 100644 --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -248,7 +248,7 @@ See [`docs/DRIVE-A-CALL.md`](DRIVE-A-CALL.md) and the drive-a-call rows in ## Team workspace (`hotato serve`): a new local listening socket `hotato serve` (`src/hotato/serve/`, wired into the CLI) opens a new local -HTTP listening socket to serve the five conversation-QA views over the +HTTP listening socket to serve the conversation-QA views over the fleet registry and evidence store. Its threat surface, and the controls on it: @@ -264,13 +264,21 @@ it: printed `/?token=…` URL, then the token is stripped from the address bar via a redirect; it stays out of every response body. An unauthenticated request gets `401` before it's routed anywhere. -- **Read-only.** The server issues only `SELECT`s against the registry and - reads evidence blobs by digest: no write endpoint, no workspace - mutation. Reviews and labels stay CLI-driven. The only file it writes is - the append-only audit log (`…/serve//audit.jsonl`, `0600`), - recording who (token/session prefix, the secret stays out of it), what - (method + path, token stripped from the query), when, and the response - status of every request. +- **Reads everywhere; one fenced write route.** Every view issues only + `SELECT`s against the registry and reads evidence blobs by digest. The + single write endpoint, `POST /calls//pin`, delegates to the same + fleet label/contract machinery the CLI drives, and is fenced beyond the + bearer/cookie auth above: the session cookie is `SameSite=Strict`, and a + cookie-authenticated POST must carry a same-origin `Origin`/`Referer` + header matching the request's own `Host` -- a forged cross-site form is + refused `403` before any handler runs (a bearer-authenticated request + needs no origin header; a cross-site attacker cannot set one). The + delegated mint is atomic, so a refused pin (4xx with reason) leaves no + artifact. The serve layer's own file stays the append-only audit log + (`…/serve//audit.jsonl`, `0600`), recording who + (token/session prefix, the secret stays out of it), what (method + path, + token stripped from the query), when, and the response status of every + request -- pin attempts included, accepted or refused. - **Zero egress.** The server only binds a listening socket: no outbound connection, nothing that phones home. Audio, traces, and evaluations stay on the machine. A test allowlists loopback and fails if any view diff --git a/docs/WORKSPACE.md b/docs/WORKSPACE.md index 2e2caab..32a3ba1 100644 --- a/docs/WORKSPACE.md +++ b/docs/WORKSPACE.md @@ -1,8 +1,9 @@ # The team workspace: `hotato serve` and `hotato console` -A self-hosted, local web app for a team to read a voice agent's -conversation-QA state: the live call feed, release readiness, the scenario -matrix, a conversation inspector, failure clusters, and production health. +A self-hosted, local web app for a team's voice-agent conversation QA: +the live call feed (with pin-to-contract on every scored moment), suite +health, failure clusters, failure records, release readiness, plus the +scenario-matrix and conversation-inspector drill-ins. Stdlib-only (`http.server` + `sqlite3`) -- no framework, no build step, nothing that phones home. It serves the same fleet registry and evidence store the CLI writes, reading that data directly instead of passing a @@ -22,7 +23,7 @@ hotato serve: workspace 'default' token: Ab3xQ_p1… (generated, stored 0600 at …/serve/default/token) open: http://127.0.0.1:8321/?token=Ab3xQ_p1… audit log: /home/you/.hotato/fleet/serve/default/audit.jsonl (append-only) - read-only: the server issues only SELECTs; reviews/labels stay CLI-driven. No telemetry, no external calls. + writes: one route -- pin-to-contract (POST /calls//pin, CSRF-fenced, audited); every view reads with SELECTs. No telemetry, no external calls. ``` Open the `open:` URL in a browser; the server sets a session cookie and @@ -65,15 +66,20 @@ exit codes apply. Every view has a machine mirror at `?format=json` (same auth, same data) so agents and scripts can drive the workspace without scraping HTML. +The nav reads as one product: **Calls · Suite health · Failure clusters · +Failure records · Release readiness**; the scenario matrix, one call, one +conversation, and one record are drill-ins. + | View | URL | Shows | |---|---|---| -| **Calls** | `/calls` | The console feed over scored production calls (see [The call feed](#the-call-feed-calls)). | -| **One call** | `/calls/` | One call's derived score record: per-dimension observations, ranked candidate moments, the timing waterfall, evidence lanes, and the audio path as recorded. | +| **Calls** | `/calls` | The console feed over scored production calls, with the "contracts protecting this agent" count in its header (see [The call feed](#the-call-feed-calls)). | +| **One call** | `/calls/` | One call's derived score record: per-dimension observations, ranked candidate moments **each with a pin-to-contract form**, the timing waterfall, evidence lanes, and the audio path as recorded. | +| **Suite health** | `/health` | Your CI suite's history from the fleet registry: ingest counts, evaluated coverage, and per-dimension failure rate over time, **separated for real and simulated** conversations. Sparse days/dimensions read *not enough history* rather than a misleading point. No single combined quality score -- each dimension keeps its own number. (`/production` serves the same view for URL compatibility.) | +| **Failure clusters** | `/clusters` | Failed evaluations and assertions grouped by **observable signature** (dimension + assertion kind + reason-class), with counts and drill-through into the inspector -- it groups what was observed; the cause stays yours to determine. | +| **Failure records** | `/records` | The read-only Failure Record viewer over `hotato.failure-record.v1`. | | **Release readiness** | `/` | Pre-ship home screen: per-release rollup of suites/runs/evaluations -- required-suite completion, scenario/run counts, **failures by dimension** (outcome / policy / conversation / speech / reliability), inconclusive count, real-vs-simulated split, and **new-vs-fixed since the previous release**. Small samples flagged (`low sample, N=3`), never smoothed. | | **Scenario matrix** | `/scenarios` | Rows are scenarios, columns are the current and previous release, with a per-dimension status and **reliability** (`pass^k` where a scenario has repetitions). Filterable by `agent`, `release`, `suite`, `status`. | | **Conversation inspector** | `/conversation/` | One conversation: evidence manifest, transcript, trace spans, per-dimension evaluations with rationale and citations (deterministic checks and model-judged/advisory results in **separate lanes**), reviewer decisions. Every digest links to the raw evidence (`/evidence/`); redacted transcript segments and trace spans render `[redacted]`, in both HTML and JSON. | -| **Failure clusters** | `/clusters` | Failed evaluations and assertions grouped by **observable signature** (dimension + assertion kind + reason-class), with counts and drill-through into the inspector -- it groups what was observed; the cause stays yours to determine. | -| **Production health** | `/health` | Ingest counts, evaluated coverage, and per-dimension failure rate over time, **separated for real and simulated** conversations. Sparse days/dimensions read *not enough history* rather than a misleading point. No single combined quality score -- each dimension keeps its own number. | ### Optional production-evidence bridge @@ -168,6 +174,11 @@ that mirror every few seconds, shows an "updated Ns ago" indicator, and re-renders the rows only when the ETag changes -- no external code, and with JavaScript off the page is complete as served (reload for the latest). +The feed header shows **"N contracts protecting this agent"** -- a read-only +`COUNT(*)` over the fleet registry's existing `contracts` table for the serve +workspace, the same table `hotato fleet` registration and the pin route +write through. + `/calls/` is one call: per-dimension observations (candidate counts and worst measured magnitude per scan kind, each on its own), the ranked candidate moments with their measured magnitudes and plain-English timing @@ -176,6 +187,39 @@ from event-reported values; every hop row with its timestamp, latency, and declared authority), scorer version + config hash, the session's evidence lanes, a link to the production evidence plane, and the local audio path exactly as the evidence recorded it -- the recording stays on this machine. +Where the session has finalized (`COMPLETE`/`DEGRADED`), the view also shows +the exact `hotato production export-regression --out DIR --db FILE` +command that exports it as an offline-verifiable regression candidate. + +### Pin-to-contract (`POST /calls//pin`) + +Each top-ranked candidate moment on a SCORED call carries a small form: +choose `expect yield` or `expect hold` and pin. The POST delegates to the +same fleet machinery the CLI drives -- ingest the recorded audio +(content-addressed), re-scan it, then +`fleet contract create --from-candidate` semantics +(`FleetAPI.contract_from_candidate`): the human label, the sealed portable +`.hotato` contract bundle, and the registry registration land in one atomic +step, under agent id `production` in the serve workspace. On success the +page shows the contract id and bundle path (with JavaScript on, inline; with +it off, as a result page), and the feed's contract count moves. + +A pin **refuses with its reason -- HTTP 4xx, no artifact** -- exactly where +the CLI would: a NOT_SCORABLE/ERROR call, a candidate reference that does +not exist, a recording that changed on disk since scoring (the re-scan must +reproduce the chosen moment's onset), a stale page (the form binds the score +record's `evidence_sha256`; a rebuilt sidecar refuses), or a trust-preflight +refusal. The mint + label + registration step is atomic, so a refused pin +leaves no contract, no label, and no registry row. + +The route is the server's one write action and is fenced three ways: the +same bearer/cookie auth as every view (an unauthenticated POST is 401), the +session cookie's `SameSite=Strict`, and a same-origin check -- a +cookie-authenticated POST must carry an `Origin` (or `Referer`) header +matching the request's own `Host`, so a forged cross-site form is refused +403 before any handler runs; a request presenting the bearer secret itself +needs no origin header. Every attempt, accepted or refused, appends one +line to the audit log. ## Auth @@ -189,7 +233,10 @@ The token is compared in constant time (`hmac.compare_digest`). Without `--token`/`--token-file`, one is generated with `secrets.token_urlsafe` on first start and stored `0600` at `/serve//token`, so a restart keeps the same URL. Sessions live only in memory, never -persisted, never cross-tenant. +persisted, never cross-tenant. The session cookie is `HttpOnly` + +`SameSite=Strict`, and the one write route adds a same-origin check on +cookie-authenticated POSTs (see +[Pin-to-contract](#pin-to-contract-post-callsidpin)). ## Audit log @@ -201,8 +248,10 @@ Every request appends one JSONL line to ``` `who` is a token/session **prefix**, never the secret; the `token` -parameter is stripped from the recorded query. The audit log is the -**only** file the server writes. +parameter is stripped from the recorded query. The audit log is the only +file the serve layer itself writes; a pin-to-contract POST additionally +writes its contract bundle and registry rows through the fleet machinery, +and each attempt is one audit line. ## Binding 127.0.0.1 by default diff --git a/llms-full.txt b/llms-full.txt index 7ecfeef..1b9185b 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -4591,9 +4591,10 @@ FILE: docs/WORKSPACE.md # The team workspace: `hotato serve` and `hotato console` -A self-hosted, local web app for a team to read a voice agent's -conversation-QA state: the live call feed, release readiness, the scenario -matrix, a conversation inspector, failure clusters, and production health. +A self-hosted, local web app for a team's voice-agent conversation QA: +the live call feed (with pin-to-contract on every scored moment), suite +health, failure clusters, failure records, release readiness, plus the +scenario-matrix and conversation-inspector drill-ins. Stdlib-only (`http.server` + `sqlite3`) -- no framework, no build step, nothing that phones home. It serves the same fleet registry and evidence store the CLI writes, reading that data directly instead of passing a @@ -4613,7 +4614,7 @@ hotato serve: workspace 'default' token: Ab3xQ_p1… (generated, stored 0600 at …/serve/default/token) open: http://127.0.0.1:8321/?token=Ab3xQ_p1… audit log: /home/you/.hotato/fleet/serve/default/audit.jsonl (append-only) - read-only: the server issues only SELECTs; reviews/labels stay CLI-driven. No telemetry, no external calls. + writes: one route -- pin-to-contract (POST /calls//pin, CSRF-fenced, audited); every view reads with SELECTs. No telemetry, no external calls. ``` Open the `open:` URL in a browser; the server sets a session cookie and @@ -4656,15 +4657,20 @@ exit codes apply. Every view has a machine mirror at `?format=json` (same auth, same data) so agents and scripts can drive the workspace without scraping HTML. +The nav reads as one product: **Calls · Suite health · Failure clusters · +Failure records · Release readiness**; the scenario matrix, one call, one +conversation, and one record are drill-ins. + | View | URL | Shows | |---|---|---| -| **Calls** | `/calls` | The console feed over scored production calls (see [The call feed](#the-call-feed-calls)). | -| **One call** | `/calls/` | One call's derived score record: per-dimension observations, ranked candidate moments, the timing waterfall, evidence lanes, and the audio path as recorded. | +| **Calls** | `/calls` | The console feed over scored production calls, with the "contracts protecting this agent" count in its header (see [The call feed](#the-call-feed-calls)). | +| **One call** | `/calls/` | One call's derived score record: per-dimension observations, ranked candidate moments **each with a pin-to-contract form**, the timing waterfall, evidence lanes, and the audio path as recorded. | +| **Suite health** | `/health` | Your CI suite's history from the fleet registry: ingest counts, evaluated coverage, and per-dimension failure rate over time, **separated for real and simulated** conversations. Sparse days/dimensions read *not enough history* rather than a misleading point. No single combined quality score -- each dimension keeps its own number. (`/production` serves the same view for URL compatibility.) | +| **Failure clusters** | `/clusters` | Failed evaluations and assertions grouped by **observable signature** (dimension + assertion kind + reason-class), with counts and drill-through into the inspector -- it groups what was observed; the cause stays yours to determine. | +| **Failure records** | `/records` | The read-only Failure Record viewer over `hotato.failure-record.v1`. | | **Release readiness** | `/` | Pre-ship home screen: per-release rollup of suites/runs/evaluations -- required-suite completion, scenario/run counts, **failures by dimension** (outcome / policy / conversation / speech / reliability), inconclusive count, real-vs-simulated split, and **new-vs-fixed since the previous release**. Small samples flagged (`low sample, N=3`), never smoothed. | | **Scenario matrix** | `/scenarios` | Rows are scenarios, columns are the current and previous release, with a per-dimension status and **reliability** (`pass^k` where a scenario has repetitions). Filterable by `agent`, `release`, `suite`, `status`. | | **Conversation inspector** | `/conversation/` | One conversation: evidence manifest, transcript, trace spans, per-dimension evaluations with rationale and citations (deterministic checks and model-judged/advisory results in **separate lanes**), reviewer decisions. Every digest links to the raw evidence (`/evidence/`); redacted transcript segments and trace spans render `[redacted]`, in both HTML and JSON. | -| **Failure clusters** | `/clusters` | Failed evaluations and assertions grouped by **observable signature** (dimension + assertion kind + reason-class), with counts and drill-through into the inspector -- it groups what was observed; the cause stays yours to determine. | -| **Production health** | `/health` | Ingest counts, evaluated coverage, and per-dimension failure rate over time, **separated for real and simulated** conversations. Sparse days/dimensions read *not enough history* rather than a misleading point. No single combined quality score -- each dimension keeps its own number. | ### Optional production-evidence bridge @@ -4759,6 +4765,11 @@ that mirror every few seconds, shows an "updated Ns ago" indicator, and re-renders the rows only when the ETag changes -- no external code, and with JavaScript off the page is complete as served (reload for the latest). +The feed header shows **"N contracts protecting this agent"** -- a read-only +`COUNT(*)` over the fleet registry's existing `contracts` table for the serve +workspace, the same table `hotato fleet` registration and the pin route +write through. + `/calls/` is one call: per-dimension observations (candidate counts and worst measured magnitude per scan kind, each on its own), the ranked candidate moments with their measured magnitudes and plain-English timing @@ -4767,6 +4778,39 @@ from event-reported values; every hop row with its timestamp, latency, and declared authority), scorer version + config hash, the session's evidence lanes, a link to the production evidence plane, and the local audio path exactly as the evidence recorded it -- the recording stays on this machine. +Where the session has finalized (`COMPLETE`/`DEGRADED`), the view also shows +the exact `hotato production export-regression --out DIR --db FILE` +command that exports it as an offline-verifiable regression candidate. + +### Pin-to-contract (`POST /calls//pin`) + +Each top-ranked candidate moment on a SCORED call carries a small form: +choose `expect yield` or `expect hold` and pin. The POST delegates to the +same fleet machinery the CLI drives -- ingest the recorded audio +(content-addressed), re-scan it, then +`fleet contract create --from-candidate` semantics +(`FleetAPI.contract_from_candidate`): the human label, the sealed portable +`.hotato` contract bundle, and the registry registration land in one atomic +step, under agent id `production` in the serve workspace. On success the +page shows the contract id and bundle path (with JavaScript on, inline; with +it off, as a result page), and the feed's contract count moves. + +A pin **refuses with its reason -- HTTP 4xx, no artifact** -- exactly where +the CLI would: a NOT_SCORABLE/ERROR call, a candidate reference that does +not exist, a recording that changed on disk since scoring (the re-scan must +reproduce the chosen moment's onset), a stale page (the form binds the score +record's `evidence_sha256`; a rebuilt sidecar refuses), or a trust-preflight +refusal. The mint + label + registration step is atomic, so a refused pin +leaves no contract, no label, and no registry row. + +The route is the server's one write action and is fenced three ways: the +same bearer/cookie auth as every view (an unauthenticated POST is 401), the +session cookie's `SameSite=Strict`, and a same-origin check -- a +cookie-authenticated POST must carry an `Origin` (or `Referer`) header +matching the request's own `Host`, so a forged cross-site form is refused +403 before any handler runs; a request presenting the bearer secret itself +needs no origin header. Every attempt, accepted or refused, appends one +line to the audit log. ## Auth @@ -4780,7 +4824,10 @@ The token is compared in constant time (`hmac.compare_digest`). Without `--token`/`--token-file`, one is generated with `secrets.token_urlsafe` on first start and stored `0600` at `/serve//token`, so a restart keeps the same URL. Sessions live only in memory, never -persisted, never cross-tenant. +persisted, never cross-tenant. The session cookie is `HttpOnly` + +`SameSite=Strict`, and the one write route adds a same-origin check on +cookie-authenticated POSTs (see +[Pin-to-contract](#pin-to-contract-post-callsidpin)). ## Audit log @@ -4792,8 +4839,10 @@ Every request appends one JSONL line to ``` `who` is a token/session **prefix**, never the secret; the `token` -parameter is stripped from the recorded query. The audit log is the -**only** file the server writes. +parameter is stripped from the recorded query. The audit log is the only +file the serve layer itself writes; a pin-to-contract POST additionally +writes its contract bundle and registry rows through the fleet machinery, +and each attempt is one audit line. ## Binding 127.0.0.1 by default @@ -11851,9 +11900,10 @@ The stack is small on purpose: - **hotato**: the `pip`-installable package. Stdlib-only core (zero runtime dependencies) keeps the default image offline at run time, with zero added supply-chain surface. -- **`hotato serve`**: the read-only, token-authenticated team workspace (five - views: release readiness, scenario matrix, conversation inspector, failure - clusters, production health). See [`docs/WORKSPACE.md`](WORKSPACE.md). +- **`hotato serve`**: the token-authenticated team workspace (calls, suite + health, failure clusters, failure records, release readiness, plus the + scenario-matrix and conversation-inspector drill-ins; one write route, + pin-to-contract). See [`docs/WORKSPACE.md`](WORKSPACE.md). - **Ollama** (optional): a local model judge for the rubric lane, opt-in behind a compose profile. The default path stays local end to end. @@ -12192,7 +12242,9 @@ judge), the whole stack runs fully offline. - This covers the default stack's run-time behaviour: the workspace server binds a listening socket and stops there, imports nothing that phones home, and keeps audio, traces, and evaluations on the machine. The - workspace is read-only, writing only its own append-only audit log. + workspace's own writes stay local too: its append-only audit log, plus + the contract bundles and registry rows a pin-to-contract POST creates + through the fleet machinery. - The default workspace runs on a normal Docker bridge so its port can publish; the guarantee rests on the server's own behaviour. Verify it directly on your own machine, independent of any firewall: @@ -12819,7 +12871,7 @@ See [`docs/DRIVE-A-CALL.md`](DRIVE-A-CALL.md) and the drive-a-call rows in ## Team workspace (`hotato serve`): a new local listening socket `hotato serve` (`src/hotato/serve/`, wired into the CLI) opens a new local -HTTP listening socket to serve the five conversation-QA views over the +HTTP listening socket to serve the conversation-QA views over the fleet registry and evidence store. Its threat surface, and the controls on it: @@ -12835,13 +12887,21 @@ it: printed `/?token=…` URL, then the token is stripped from the address bar via a redirect; it stays out of every response body. An unauthenticated request gets `401` before it's routed anywhere. -- **Read-only.** The server issues only `SELECT`s against the registry and - reads evidence blobs by digest: no write endpoint, no workspace - mutation. Reviews and labels stay CLI-driven. The only file it writes is - the append-only audit log (`…/serve//audit.jsonl`, `0600`), - recording who (token/session prefix, the secret stays out of it), what - (method + path, token stripped from the query), when, and the response - status of every request. +- **Reads everywhere; one fenced write route.** Every view issues only + `SELECT`s against the registry and reads evidence blobs by digest. The + single write endpoint, `POST /calls//pin`, delegates to the same + fleet label/contract machinery the CLI drives, and is fenced beyond the + bearer/cookie auth above: the session cookie is `SameSite=Strict`, and a + cookie-authenticated POST must carry a same-origin `Origin`/`Referer` + header matching the request's own `Host` -- a forged cross-site form is + refused `403` before any handler runs (a bearer-authenticated request + needs no origin header; a cross-site attacker cannot set one). The + delegated mint is atomic, so a refused pin (4xx with reason) leaves no + artifact. The serve layer's own file stays the append-only audit log + (`…/serve//audit.jsonl`, `0600`), recording who + (token/session prefix, the secret stays out of it), what (method + path, + token stripped from the query), when, and the response status of every + request -- pin attempts included, accepted or refused. - **Zero egress.** The server only binds a listening socket: no outbound connection, nothing that phones home. Audio, traces, and evaluations stay on the machine. A test allowlists loopback and fails if any view diff --git a/llms.txt b/llms.txt index fb41c8f..621e79c 100644 --- a/llms.txt +++ b/llms.txt @@ -345,11 +345,14 @@ auto-deploys. Zero-dependency local mode (a SQLite registry + a content-addressed store under ~/.hotato/fleet). Detail: `docs/GUARDIAN-FLEET.md`. - `hotato serve [--workspace ID] [--host H] [--port P] [--token-file F]` -- - serve an authenticated, read-only local web app over the fleet registry + - conversation artifacts (release readiness, scenario matrix, conversation - inspector, failure clusters, production health; each with a ?format=json - mirror). Stdlib-only (http.server + sqlite3); binds 127.0.0.1; a bearer token - is generated + stored 0600 on first start; nothing leaves the machine. + serve an authenticated local web app over the fleet registry + conversation + artifacts (calls, suite health, failure clusters, failure records, release + readiness, plus scenario-matrix and conversation-inspector drill-ins; each + with a ?format=json mirror). Every view reads with SELECTs; the one write + route is pin-to-contract (POST /calls//pin -- CSRF-fenced, audited, + refuses with reason). Stdlib-only (http.server + sqlite3); binds 127.0.0.1; + a bearer token is generated + stored 0600 on first start; nothing leaves + the machine. Reviews and labels stay CLI-driven. Detail: `docs/WORKSPACE.md`. - `hotato describe [--format json]` -- generated capability manifest of the whole CLI: every subcommand's name, purpose, argument list, and documented @@ -525,8 +528,8 @@ it, never whether the policy itself was right. - docs/RELEASE-COMPARE.md: `hotato release compare`, the per-dimension, per-scenario, digest-exact diff of two recorded releases (new failures / fixed-since; never one blended delta). -- docs/WORKSPACE.md: `hotato serve`, the self-hosted, read-only local web app - over the fleet registry. +- docs/WORKSPACE.md: `hotato serve`, the self-hosted local web app over the + fleet registry, with pin-to-contract as its one write route. - docs/GUARDIAN-FLEET.md: `hotato fleet`, the private control plane: ingest, discover, human-label, and run manifest-bound before/after experiments that recommend, never auto-deploy. diff --git a/src/hotato/cli.py b/src/hotato/cli.py index 8817cf4..c7296f3 100644 --- a/src/hotato/cli.py +++ b/src/hotato/cli.py @@ -10306,16 +10306,18 @@ def _fleet_parser(parent, name, dotted, help_text): srv = sub.add_parser( "serve", - help="run the self-hosted local team workspace (five conversation-QA views)", + help="run the self-hosted local team workspace (the conversation-QA views)", description=( - "Serve an authenticated, read-only local web app over the fleet " - "registry + conversation artifacts: release readiness, scenario " - "matrix, conversation inspector, failure clusters, and production " - "health (each with a ?format=json mirror). Stdlib-only " - "(http.server + sqlite3); binds 127.0.0.1 by default; a bearer " - "token is generated + stored 0600 on first start if not supplied; " - "every request is authenticated and audited; nothing leaves the " - "machine. Read-only: reviews and labels stay CLI-driven."), + "Serve an authenticated local web app over the fleet registry + " + "conversation artifacts: calls, suite health, failure clusters, " + "failure records, release readiness, plus scenario-matrix and " + "conversation-inspector drill-ins (each with a ?format=json " + "mirror). Stdlib-only (http.server + sqlite3); binds 127.0.0.1 by " + "default; a bearer token is generated + stored 0600 on first " + "start if not supplied; every request is authenticated and " + "audited; nothing leaves the machine. Every view reads with " + "SELECTs; the one write route is pin-to-contract on a call's " + "candidate moments (CSRF-fenced, audited, refuses with reason)."), epilog=_exit_codes_epilog("serve"), formatter_class=argparse.RawDescriptionHelpFormatter, ) diff --git a/src/hotato/serve/app.py b/src/hotato/serve/app.py index e68c0fb..47cca38 100644 --- a/src/hotato/serve/app.py +++ b/src/hotato/serve/app.py @@ -1,5 +1,5 @@ -"""The ``hotato serve`` HTTP server: a threaded, token-authenticated, read-only -local web app over the fleet registry + conversation artifacts (GOAL §6). +"""The ``hotato serve`` HTTP server: a threaded, token-authenticated local web +app over the fleet registry + conversation artifacts (GOAL §6). Stdlib only -- ``http.server.ThreadingHTTPServer`` + ``sqlite3`` -- no framework dependency. Design points: @@ -14,13 +14,17 @@ an unauthenticated request gets 401 and is never routed. * **Append-only audit.** Every request (authenticated or not) appends one JSONL line -- who (token/session prefix), what (method + path, token stripped from - the query), when, and the response status. It is the ONLY thing the server - writes. + the query), when, and the response status. It is the ONLY file the server + itself writes. * **Zero egress.** The server only binds a listening socket; it never opens an outbound connection and imports nothing that phones home. Evidence stays local. -* **Read-only.** Each request opens its own :class:`Registry` connection (the - registry is single-thread per connection) and only ``SELECT``s. Reviews and - labels remain CLI-driven; there are no write endpoints. +* **Reads everywhere, one write route.** Each GET opens its own + :class:`Registry` connection (the registry is single-thread per connection) + and only ``SELECT``s. The single write route is ``POST /calls//pin`` + (:mod:`hotato.serve.pin`): it delegates to the EXISTING fleet + label/contract machinery, is CSRF-fenced (same-origin check on cookie auth) + and audit-logged, and refuses -- with its reason, HTTP 4xx, no artifact -- + exactly where the CLI would. Every view has a ``?format=json`` machine mirror built from the SAME model dict the HTML renderer formats, so the two can never diverge. @@ -66,6 +70,8 @@ # are small JSON/text. Audio is stored separately and never linked here. _EVIDENCE_MAX_BYTES = 5 * 1024 * 1024 _HEX64 = re.compile(r"^[0-9a-f]{64}$") +# The one write route (R9): pin a scored candidate moment to a contract. +_PIN_PATH = re.compile(r"^/calls/(.+)/pin$") def _safe_dirname(workspace: str) -> str: @@ -282,6 +288,141 @@ def do_GET(self): # noqa: N802 - http.server dispatch name query=clean_qs, status=status, remote=remote) self._send(status, body, ctype, extra) + def do_POST(self): # noqa: N802 - http.server dispatch name + """The server's ONE write route: ``POST /calls//pin`` (R9). + + Auth is the same bearer/cookie check every GET runs -- an + unauthenticated POST is a 401 and is never routed (no courtesy landing + on a write). On top of it sits a CSRF fence for the browser path: the + session cookie is already ``SameSite=Strict``, and a cookie- + authenticated POST must ALSO present a same-origin ``Origin`` (or + ``Referer``) header matching the request's own ``Host`` -- a forged + cross-site form carries a foreign or absent Origin and is refused 403 + before any handler runs. A request that authenticates by presenting + the bearer secret itself (header or ``?token=``) needs no origin + check: a cross-site attacker cannot set an ``Authorization`` header or + know the token. Every attempt -- accepted or refused -- lands in the + same append-only audit log as every read.""" + ctx: ServeContext = self.server.context # type: ignore[attr-defined] + parsed = urlsplit(self.path) + path = parsed.path + query = parse_qs(parsed.query, keep_blank_values=True) + fmt = ((query.get("format") or ["text"])[0] or "text").lower() + remote = self.client_address[0] if self.client_address else "" + clean_qs = _strip_token_qs(parsed.query) + + ok, who, cookie, via = ctx.authenticate(self.headers, query) + if not ok: + ctx.audit.record(who="-", method=self.command, path=path, + query=clean_qs, status=401, remote=remote) + if fmt == "json": + self._send(401, _json_bytes({ + "error": "unauthenticated", + "hint": "send Authorization: Bearer ", + }), "application/json; charset=utf-8", + {"WWW-Authenticate": "Bearer"}) + else: + self._send(401, _render.render_401_html( + host_display=self._display_host()).encode("utf-8"), + "text/html; charset=utf-8", {"WWW-Authenticate": "Bearer"}) + return + extra = {} + if cookie: + extra["Set-Cookie"] = cookie + + if via == "cookie" and not self._post_same_origin(): + status, body, ctype, rextra = self._pin_refused( + ctx, fmt, 403, + "cross-origin write refused: this POST authenticated with a " + "session cookie but carried no same-origin Origin/Referer " + "header. Use the workspace page's own form, or send " + "Authorization: Bearer .") + else: + try: + status, body, ctype, rextra = self._route_post(ctx, path, fmt) + except Exception as exc: # never leak a stack trace to the client + status, body, ctype, rextra = self._server_error(exc, fmt) + extra.update(rextra or {}) + ctx.audit.record(who=who, method=self.command, path=path, + query=clean_qs, status=status, remote=remote) + self._send(status, body, ctype, extra) + + def _post_same_origin(self) -> bool: + """True when the POST's ``Origin`` (or, absent that, ``Referer``) + names this server itself. The comparison is host:port against the + request's own ``Host`` header -- the value the browser used to reach + us -- so the check holds on any bind address without configuration. + Absent both headers the answer is ``False`` (fail closed).""" + host = (self.headers.get("Host") or "").strip().lower() + if not host: + return False + origin = (self.headers.get("Origin") or "").strip() + if origin: + return urlsplit(origin).netloc.lower() == host + referer = (self.headers.get("Referer") or "").strip() + if referer: + return urlsplit(referer).netloc.lower() == host + return False + + def _route_post(self, ctx, path, fmt): + match = _PIN_PATH.match(path) + if not match: + return self._not_found(ctx, fmt, "No such write route: " + path) + subject = unquote(match.group(1)) + from .pin import PinRefused, pin_candidate + try: + form = self._read_form() + except ValueError as exc: + return self._pin_refused(ctx, fmt, 400, str(exc)) + try: + result = pin_candidate( + home=ctx.home, workspace=ctx.workspace, + production_db=ctx.production_db, subject=subject, + candidate=form.get("candidate"), expect=form.get("expect"), + evidence_sha256=form.get("evidence_sha256"), + rationale=form.get("rationale"), + reviewer=form.get("reviewer")) + except PinRefused as refusal: + return self._pin_refused(ctx, fmt, refusal.status, refusal.reason) + if fmt == "json": + return 200, _json_bytes(result), "application/json; charset=utf-8", {} + doc = _render.page("Pinned to contract", "/calls", + _render.render_pin_result(result), + workspace=ctx.workspace) + return 200, doc.encode("utf-8"), "text/html; charset=utf-8", {} + + def _read_form(self, limit: int = 64 * 1024) -> dict: + """The POST body as a flat form dict. Only + ``application/x-www-form-urlencoded`` is accepted (the plain-HTML form + and the fetch enhancement both send it), the size is bounded, and a + malformed body raises ``ValueError`` (-> 400).""" + ctype = (self.headers.get("Content-Type") or "").split(";", 1)[0] + if ctype.strip().lower() != "application/x-www-form-urlencoded": + raise ValueError( + "the pin form posts application/x-www-form-urlencoded; got " + + repr(ctype or None)) + try: + length = int(self.headers.get("Content-Length") or "0") + except ValueError: + raise ValueError("Content-Length must be an integer") from None + if not 0 <= length <= limit: + raise ValueError("request body must be 0..%d bytes" % limit) + raw = self.rfile.read(length).decode("utf-8", "replace") + return {key: values[0] + for key, values in parse_qs(raw, keep_blank_values=True).items()} + + def _pin_refused(self, ctx, fmt, status, reason): + """A refused pin: the status + reason, and NO artifact exists. One + shape for both surfaces, mirroring the CLI's refuse-with-reason exit.""" + if fmt == "json": + return status, _json_bytes({"error": "pin refused", + "message": reason}), \ + "application/json; charset=utf-8", {} + doc = _render.page("Pin refused", "/calls", + _render.render_pin_refused(reason), + workspace=ctx.workspace) + return status, doc.encode("utf-8"), "text/html; charset=utf-8", {} + # -- routing ---------------------------------------------------------- def _route(self, ctx, path, query, fmt): @@ -335,7 +476,7 @@ def _view(self, ctx, name, query, fmt): ws, production_evidence=ctx.read_production_evidence(), ) - title, active = "Production health", "/health" + title, active = "Suite health", "/health" html = _render.render_production_health(model) finally: reg.close() @@ -372,14 +513,17 @@ def _calls_feed(self, ctx, query, fmt): poll costs one bounded read and no transfer while nothing changed. A malformed filter/cursor/limit is a 400, never a silently dropped filter.""" + reg = ctx.open_registry() try: model = _data.build_calls_feed( - ctx.workspace, ctx.production_db, + ctx.workspace, ctx.production_db, reg=reg, state=_q(query, "state"), scorability=_q(query, "scorability"), since=_q(query, "since"), until=_q(query, "until"), cursor=_q(query, "cursor"), limit=_q(query, "limit")) except ValueError as exc: return self._bad_request(ctx, fmt, str(exc)) + finally: + reg.close() body = _json_bytes(model) etag = '"%s"' % hashlib.sha256(body).hexdigest()[:32] if self.headers.get("If-None-Match") == etag: @@ -719,7 +863,7 @@ def _print_banner(ctx, host, port, *, source, generated, state_dir, url, out = sys.stderr bar = " " + "-" * 60 print("", file=out) - print(" hotato workspace · %r · self-hosted, read-only" % ctx.workspace, + print(" hotato workspace · %r · self-hosted" % ctx.workspace, file=out) print(bar, file=out) print(" Open this in your browser:", file=out) @@ -741,6 +885,7 @@ def _print_banner(ctx, host, port, *, source, generated, state_dir, url, print(" token: %s (%s)" % (ctx.token, source), file=out) print(" audit log: %s (append-only)" % os.path.join(state_dir, "audit.jsonl"), file=out) - print(" read-only: the server issues only SELECTs; reviews and labels stay " - "CLI-driven. No telemetry, no external calls.", file=out) + print(" writes: one route -- pin-to-contract (POST /calls//pin, " + "CSRF-fenced, audited); every view reads with SELECTs. No " + "telemetry, no external calls.", file=out) out.flush() diff --git a/src/hotato/serve/data.py b/src/hotato/serve/data.py index d9963cf..a579627 100644 --- a/src/hotato/serve/data.py +++ b/src/hotato/serve/data.py @@ -29,6 +29,7 @@ import json import os import re +import shlex import sqlite3 import stat from collections import OrderedDict @@ -746,7 +747,7 @@ def _add(sig_dim, sig_kind, lane, reason_cls, member): # ========================================================================= -# View 5 -- Production health +# View 5 -- Suite health (CI-suite history from the fleet registry) # ========================================================================= def _health_series_for(reg: Registry, ws: str, convs: List[dict]) -> dict: @@ -823,7 +824,7 @@ def build_production_health( if c.get("created_at") is not None}) model = { - "view": "production_health", + "view": "suite_health", "workspace": ws, "ingested_total": len(convs), "origins": per_origin, # real / simulated separated @@ -1189,6 +1190,7 @@ def _calls_feed_shell(ws: str, filters: Dict[str, Any]) -> Dict[str, Any]: "configured": False, "source": None, "sidecar": {"path": None, "present": False}, + "contracts": None, "filters": filters, "trends": None, "rows": [], @@ -1198,7 +1200,26 @@ def _calls_feed_shell(ws: str, filters: Dict[str, Any]) -> Dict[str, Any]: } +def _contracts_protecting(reg: Optional[Registry], ws: str) -> Optional[Dict[str, Any]]: + """The feed header's "N contracts protecting this agent" count (R9): a + read-only ``COUNT(*)`` over the fleet registry's EXISTING ``contracts`` + table for this workspace -- the same table ``hotato fleet`` registration + and the pin route write through, so the number is the store of record, not + a parallel tally. ``None`` when the caller supplied no registry handle.""" + if reg is None: + return None + row = reg._one( + "SELECT COUNT(*) AS c FROM contracts WHERE workspace_id=?", (ws,)) + count = int(dict(row)["c"]) if row is not None else 0 + return { + "count": count, + "source": "fleet-registry:contracts", + "scope": "workspace", + } + + def build_calls_feed(ws: str, production_db: Optional[str], *, + reg: Optional[Registry] = None, state: Optional[str] = None, scorability: Optional[str] = None, since: Optional[str] = None, @@ -1234,6 +1255,7 @@ def build_calls_feed(ws: str, production_db: Optional[str], *, return model production_path, console_path = _console_paths(production_db) model["configured"] = True + model["contracts"] = _contracts_protecting(reg, ws) model["source"] = { "production_db": production_path, "console_db": console_path, @@ -1468,6 +1490,19 @@ def build_call_detail(ws: str, production_db: Optional[str], "completeness": _lane_completeness( sess["evidence_json"], sess["required_evidence_json"]), } + if sess["state"] in ("COMPLETE", "DEGRADED"): + # R11: a finalized session is exportable as an offline-verifiable + # regression candidate through the EXISTING production CLI path + # (`ProductionStore.export_regression_candidate`, which itself + # refuses a non-finalized session); the exact command for THIS + # session and database is surfaced only where that path holds. + session["export_regression"] = { + "command": ("hotato production export-regression %s --out " + "regression/%s --db %s" + % (shlex.quote(subject), shlex.quote(subject), + shlex.quote(production_path))), + "source": "production.export_regression_candidate", + } from .production_bridge import ProductionBridgeError, _evidence, _required_lanes try: diff --git a/src/hotato/serve/pin.py b/src/hotato/serve/pin.py new file mode 100644 index 0000000..e6a95a9 --- /dev/null +++ b/src/hotato/serve/pin.py @@ -0,0 +1,196 @@ +"""Pin-to-contract: the workspace server's one write action (spec R9). + +``POST /calls//pin`` turns ONE scored candidate moment from the +console sidecar into a portable ``.hotato`` failure contract by delegating to +the EXISTING fleet review machinery -- :meth:`hotato.fleet.api.FleetAPI. +ingest_recording` -> :meth:`~hotato.fleet.api.FleetAPI.discover` -> +:meth:`~hotato.fleet.api.FleetAPI.contract_from_candidate` -- so no contract- +minting logic lives in the server. That chain is the same one ``hotato fleet`` +drives from the CLI: the trust preflight refuses unscorable input, the mint + +seal step refuses insufficient evidence (``ValueError``), and the label + +contract + candidate-status registry writes are one atomic transaction, so a +refusal on ANY step surfaces as an HTTP 4xx with its reason and never leaves a +partial artifact. + +Fail-closed identity checks before delegating: + +* the pin binds to the exact scored evidence log: the form carries the score + record's ``evidence_sha256`` and a mismatch refuses (the sidecar was rebuilt + since the page rendered); +* the recording is RE-scanned through ``discover`` and the chosen moment's + onset must reproduce exactly -- a recording that changed on disk since + scoring refuses rather than pinning a different moment. + +Pinned contracts register in the fleet registry's ``contracts`` table for the +serve workspace under agent id :data:`PIN_AGENT_ID`; the ``/calls`` feed's +"contracts protecting this agent" count reads that same table. +""" +from __future__ import annotations + +import os +from typing import Any, Dict, Optional + +__all__ = ["PIN_AGENT_ID", "PIN_REVIEW_DEPTH", "PinRefused", "pin_candidate"] + +# The registered agent identity for contracts pinned from the console: the +# production deployment this workspace's console watches (the production event +# schema itself carries no agent id). +PIN_AGENT_ID = "production" + +# ``FleetAPI.discover`` surfaces the top five ranked moments -- the fleet +# review-queue depth. Pinning reaches exactly those moments. +PIN_REVIEW_DEPTH = 5 + +_EXPECTS = ("yield", "hold") + + +class PinRefused(Exception): + """A refused pin: carries the HTTP status and the human-readable reason. + + Raised for every non-success outcome -- bad fields, an unknown call or + candidate, unscorable/changed evidence, a mint refusal. No artifact exists + when this is raised (the delegated machinery is atomic by construction). + """ + + def __init__(self, status: int, reason: str) -> None: + super().__init__(reason) + self.status = int(status) + self.reason = reason + + +def _refuse(status: int, reason: str) -> "PinRefused": + return PinRefused(status, reason) + + +def pin_candidate( + *, + home: str, + workspace: str, + production_db: Optional[str], + subject: str, + candidate: Optional[str], + expect: Optional[str], + evidence_sha256: Optional[str], + rationale: Optional[str] = None, + reviewer: Optional[str] = None, +) -> Dict[str, Any]: + """Mint a portable contract from scored candidate moment ``candidate`` of + call ``subject`` by delegating to the fleet label/contract machinery. + + Returns the pin-result model (the same dict the JSON mirror returns). + Raises :class:`PinRefused` (-> HTTP 4xx, no artifact) on every refusal. + """ + from ..fixture import _default_reviewer_principal + from . import data as _data + + if str(expect or "").strip().lower() not in _EXPECTS: + raise _refuse(400, "expect must be 'yield' or 'hold', got %r" % (expect,)) + expect = str(expect).strip().lower() + try: + index = int(str(candidate).strip()) + except (TypeError, ValueError): + raise _refuse(400, "candidate must be the integer index of a ranked " + "candidate moment, got %r" % (candidate,)) from None + if index < 0: + raise _refuse(400, "candidate index must be >= 0, got %d" % index) + + if not production_db: + raise _refuse(404, "no production evidence database is wired; there " + "are no scored calls to pin from") + try: + model = _data.build_call_detail(workspace, production_db, subject) + except ValueError as exc: + raise _refuse(400, str(exc)) from exc + if model is None: + raise _refuse(404, "no scored call %r in the console sidecar" % subject) + score = model["score"] + + if score.get("state") != "SCORED": + raise _refuse(409, "call %r is %s (%s); a pin needs a SCORED call's " + "candidate moment" % (subject, score.get("state"), + score.get("reason"))) + recorded_sha = score.get("evidence_sha256") or "" + if (evidence_sha256 or "") != recorded_sha: + raise _refuse(409, "the evidence log for call %r changed since this " + "score was rendered; reload the call and pin again" + % subject) + candidates = score.get("candidates") or [] + if index >= len(candidates): + raise _refuse(404, "call %r has %d ranked candidate moment(s); there " + "is no candidate #%d" + % (subject, len(candidates), index)) + if index >= PIN_REVIEW_DEPTH: + raise _refuse(409, "pinning reaches the top %d ranked moments (the " + "fleet review-queue depth); candidate #%d ranks " + "below that" % (PIN_REVIEW_DEPTH, index)) + chosen = candidates[index] + onset = chosen.get("onset_sec") + if onset is None: + onset = chosen.get("t_sec") + + audio = score.get("audio") or {} + path = audio.get("path") + if not path: + raise _refuse(409, "this score record carries no audio reference; a " + "pin binds the recorded two-channel call") + if not os.path.isfile(path): + raise _refuse(409, "the recording %r named by the evidence is not a " + "readable file on this machine" % path) + + from ..fleet.api import FleetAPI + + api = FleetAPI(home=home) + try: + api.registry.ensure_workspace(workspace) + try: + ingested = api.ingest_recording(workspace, PIN_AGENT_ID, path) + discovered = api.discover(workspace, PIN_AGENT_ID, path, + recording_id=ingested["recording_id"]) + except (OSError, ValueError) as exc: + raise _refuse(409, "the recording %r did not re-ingest cleanly: %s" + % (path, exc)) from exc + if not discovered.get("scorable"): + raise _refuse(409, "the recording is not scorable under the trust " + "preflight (%s); nothing was pinned" + % (discovered.get("recommendation"),)) + surfaced = discovered.get("candidates") or [] + if index >= len(surfaced): + raise _refuse(409, "the recording's re-scan surfaced %d candidate " + "moment(s), not the %d this score recorded; " + "the recording changed since scoring -- rebuild " + "the sidecar (hotato serve --production-db DB " + "--rebuild-scores)" % (len(surfaced), index + 1)) + fleet_cand = surfaced[index] + re_onset = fleet_cand.get("onset_sec") + if (onset is None or re_onset is None + or abs(float(re_onset) - float(onset)) > 1e-6): + raise _refuse(409, "the recording no longer reproduces candidate " + "#%d at %ss; the recording changed since " + "scoring -- rebuild the sidecar (hotato serve " + "--production-db DB --rebuild-scores)" + % (index, onset)) + try: + minted = api.contract_from_candidate( + workspace, fleet_cand["candidate_id"], + reviewer=(reviewer or "").strip() or _default_reviewer_principal(), + decision=expect, rationale=(rationale or "").strip() or None) + except ValueError as exc: + raise _refuse(409, str(exc)) from exc + finally: + api.close() + + return { + "view": "pin_result", + "workspace": workspace, + "subject": subject, + "candidate": index, + "candidate_kind": chosen.get("kind"), + "onset_sec": onset, + "expect": expect, + "contract_id": minted["contract_id"], + "label_id": minted["label_id"], + "dir": minted["dir"], + "agent_id": PIN_AGENT_ID, + "delegated_to": "fleet.contract_from_candidate", + "verify": "hotato contract verify %s" % minted["dir"], + } diff --git a/src/hotato/serve/render.py b/src/hotato/serve/render.py index bc8de79..9326cfc 100644 --- a/src/hotato/serve/render.py +++ b/src/hotato/serve/render.py @@ -24,6 +24,8 @@ from urllib.parse import quote as _quote from urllib.parse import urlencode as _urlencode +from .pin import PIN_REVIEW_DEPTH as _PIN_DEPTH + # --- house style, imported from the report with a same-look fallback --------- try: from ..report import ( @@ -162,15 +164,16 @@ def _esc(x) -> str: # a first-class refusal -- NOT_SCORABLE never reads as OK "SCORED": "green", "NOT_SCORABLE": "ember"} -# The top tabs. The conversation inspector, a single call, and a single record -# are drill-ins. +# The top tabs, ordered as one product: live calls first, then the suite's CI +# history, the failure groupings, the shareable records, and the ship gate +# (R10). The scenario matrix, the conversation inspector, a single call, and a +# single record are drill-ins. _TABS = ( ("/calls", "Calls"), - ("/", "Release readiness"), - ("/scenarios", "Scenario matrix"), + ("/health", "Suite health"), ("/clusters", "Failure clusters"), - ("/health", "Production health"), ("/records", "Failure records"), + ("/", "Release readiness"), ) @@ -278,11 +281,12 @@ def page(title: str, active: str, body: str, *, workspace: str) -> str: def _footer() -> str: return ( '
' - '
Read-only v1. This server issues only SELECTs ' - 'against your workspace; reviews and labels stay CLI-driven ' - '(hotato fleet review / ' - 'hotato label). The only file it writes is the ' - 'append-only audit log.
' + '
Self-hosted. Every view reads your workspace ' + 'with SELECTs; the one write action is pin-to-contract on a call\'s ' + 'candidate moments, which runs the same fleet review machinery as ' + 'hotato fleet review / ' + 'hotato label and lands in the append-only ' + 'audit log.
' '
No telemetry, no external calls. ' 'Evidence (audio, traces, evaluations) stays on this machine. Every ' 'dimension is scored separately; there is no combined number.
' @@ -300,7 +304,9 @@ def render_release_readiness(m: dict) -> str: parts = ['

Release readiness

', '

The pre-ship home screen: does the current release ' 'clear its required suites, and what changed since the last one? ' - 'Each dimension is scored on its own.

'] + 'Each dimension is scored on its own. Drill into the per-scenario ' + 'grid on the scenario matrix' + '.

'] if not cur_rel: parts.append('
No releases recorded yet in this ' @@ -858,12 +864,13 @@ def _cluster_member(mem: dict) -> str: # ========================================================================= -# View 5 -- Production health +# View 5 -- Suite health (CI-suite history from the fleet registry) # ========================================================================= def render_production_health(m: dict) -> str: - parts = ['

Production health

', - '

Ingest volume, evaluated coverage, and per-dimension ' + parts = ['

Suite health

', + '

Your CI suite\'s history from the fleet registry: ' + 'ingest volume, evaluated coverage, and per-dimension ' 'failure rate over time; real and simulated kept strictly ' 'apart, no combined number.

'] @@ -1456,6 +1463,15 @@ def render_calls_feed(m: dict, *, etag: Optional[str] = None) -> str: data_etag = f' data-etag="{_esc(etag)}"' if etag else "" parts.append(f'
') + contracts = m.get("contracts") + if contracts is not None: + parts.append( + '
' + f'{_esc(contracts.get("count", 0))} contracts ' + 'protecting this agent' + 'counted from the fleet registry\'s contracts ' + 'table for this workspace · pin a candidate moment on any ' + 'call to add one
') parts.append(_calls_trends_html(m.get("trends"))) rows = m.get("rows") or [] @@ -1538,6 +1554,13 @@ def render_call_detail(m: dict) -> str: parts.append('
raw session: ' 'production evidence plane' '
') + export = (session or {}).get("export_regression") + if export: + # R11: the finalized session's offline-verifiable regression bundle, + # through the existing production CLI path -- the exact command. + parts.append('
regression ' + 'bundle: ' + f'{_esc(export.get("command"))}
') parts.append('') # per-dimension observations -- each stands on its own, nothing combined @@ -1560,17 +1583,22 @@ def render_call_detail(m: dict) -> str: parts.append('
') parts.append('') - # ranked candidate moments with measured magnitudes + # ranked candidate moments with measured magnitudes; a SCORED call with a + # recorded audio reference gets a pin form per top-ranked moment (R9) -- + # a plain HTML POST that works without JavaScript, enhanced by _PIN_JS. candidates = score.get("candidates") or [] + audio = score.get("audio") + pinnable = bool(score.get("state") == "SCORED" and (audio or {}).get("path")) parts.append('
Ranked candidate ' 'moments
') if not candidates: parts.append('
No candidate moments on this record.' '
') else: + pin_head = 'pin' if pinnable else '' parts.append('
' '' - '') + f'{pin_head}') for rank, cand in enumerate(candidates, start=1): durations = cand.get("durations") or {} measured = " · ".join( @@ -1579,13 +1607,30 @@ def render_call_detail(m: dict) -> str: sentence = cand.get("plain_english") sentence_html = (_esc(sentence) if sentence else '-') + pin_cell = '' + if pinnable: + if rank <= _PIN_DEPTH: + pin_cell = '' % _pin_form( + m.get("subject"), rank - 1, + score.get("evidence_sha256")) + else: + pin_cell = '' parts.append( f'' f'' f'' f'' - f'') + f'{pin_cell}') parts.append('
#kindatmeasuredwhat was measured
what was measured
%s-
{rank}{_esc(cand.get("kind"))}{_fmt_sec(cand.get("t_sec"))}{measured}{sentence_html}
{sentence_html}
') + if pinnable: + parts.append( + '
Pinning mints a ' + 'portable .hotato failure contract ' + 'from that exact moment through the fleet review machinery ' + '(label + contract + registration, one atomic step) and it ' + 'counts toward the contracts protecting this agent. The top ' + 'five ranked moments -- the fleet review-queue depth -- carry ' + 'a pin form.
') parts.append('
') # timing waterfall: turn spans (derived vs reported kept apart) + hops @@ -1619,6 +1664,8 @@ def render_call_detail(m: dict) -> str: '
' f'{_esc(json.dumps(config, sort_keys=True, indent=2))}' '
') + if pinnable: + parts.append(_PIN_JS) return "".join(parts) @@ -1711,6 +1758,97 @@ def _call_evidence_section(session: Optional[dict]) -> str: return "".join(parts) +# ========================================================================= +# pin-to-contract (R9): the per-call pin form + result surfaces +# ========================================================================= + +def _pin_form(subject: Any, index: int, evidence_sha256: Any) -> str: + """One candidate moment's pin form: a plain HTML POST to + ``/calls//pin`` that works with JavaScript off. The hidden + ``evidence_sha256`` binds the pin to the exact evidence log this page + rendered from, so a pin against a rebuilt sidecar refuses instead of + pinning a different moment.""" + action = "/calls/%s/pin" % _quote(str(subject or ""), safe="") + return ( + f'
' + f'' + '' + ' ' + '' + '
') + + +# Fetch enhancement for the pin forms: with JavaScript on, the form posts to +# the same route's JSON mirror and shows the contract id (or the refusal +# reason) inline; with JavaScript off, the form's own POST renders the full +# result page. Same-origin fetch carries the session cookie and the Origin +# header the CSRF fence checks. +_PIN_JS = """ + +""" + + +def render_pin_result(m: dict) -> str: + """The no-JavaScript success page: the minted contract's id and bundle + path, plus the way back to the call and the feed.""" + call_href = "/calls/%s" % _quote(str(m.get("subject") or ""), safe="") + return ( + '

Pinned to contract

' + '
' + '
' + + _kv("contract", str(m.get("contract_id")), mono=True) + + _kv("bundle", str(m.get("dir")), mono=True) + + _kv("label", str(m.get("label_id")), mono=True) + + _kv("expect", str(m.get("expect")), mono=True) + + '
' + '
Minted from candidate ' + f'moment #{_esc(m.get("candidate"))} of call ' + f'{_esc(m.get("subject"))} through the fleet ' + 'label/contract machinery; verify it any time with ' + f'{_esc(m.get("verify"))}.
' + '' + '
') + + +def render_pin_refused(reason: str) -> str: + return ('

Pin refused

' + f'
{_esc(reason)}
' + '
Nothing was written: a refused pin leaves no ' + 'contract, no label, and no registry row.
') + + # ========================================================================= # error pages # ========================================================================= diff --git a/tests/test_serve.py b/tests/test_serve.py index 37926d5..c859e95 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -419,10 +419,12 @@ def test_failure_clusters_render_real_content(live): assert "/conversation/conv-a1" in body # drill-through link -def test_production_health_renders_real_content(live): +def test_suite_health_renders_real_content(live): + # R10 naming honesty: the CI-suite history view is labeled Suite health. code, body, _h = _req(live.base, "/health", token=live.token) assert code == 200 - assert "Production health" in body + assert "Suite health" in body + assert "Production health" not in body assert "real" in body and "simulated" in body @@ -440,7 +442,7 @@ def test_json_mirror_for_every_view(live): "/?format=json": "release_readiness", "/scenarios?format=json": "scenario_matrix", "/clusters?format=json": "failure_clusters", - "/health?format=json": "production_health", + "/health?format=json": "suite_health", "/conversation/conv-a1?format=json": "conversation_inspector", } for path, view in expect.items(): diff --git a/tests/test_serve_pin.py b/tests/test_serve_pin.py new file mode 100644 index 0000000..84cf99f --- /dev/null +++ b/tests/test_serve_pin.py @@ -0,0 +1,561 @@ +"""``POST /calls//pin`` -- serve's one write route (Wave A R9-R11). + +Starts the real threaded server over an evidence database + score sidecar and +proves, against the live HTTP surface: + +* pin success mints a contract through the EXISTING fleet label/contract + machinery, and the bundle verifies under ``hotato.contract.verify_contracts`` + (re-scored, authenticated, and green on its own recording); +* every refusal -- unknown call, bad candidate ref, bad expect, an unscorable + (NOT_SCORABLE) call, a stale ``evidence_sha256``, a recording missing from + disk -- is an HTTP 4xx carrying its reason and leaves NO artifact: no + bundle on disk, no contracts/labels row, candidate status untouched; +* an unauthenticated POST is 401 and never routed; +* the CSRF fence: a cookie-authenticated POST without a same-origin + ``Origin``/``Referer`` header (a forged cross-site form) is refused 403, + while the same POST with the page's own origin succeeds; bearer-token + POSTs need no origin (a cross-site attacker cannot set that header); +* accepted AND refused attempts each land one line in the append-only audit + log; +* the ``/calls`` feed header counts contracts protecting this agent from the + fleet registry's contracts table, and a pin moves the count; +* R10: the nav reads Calls · Suite health · Failure clusters · Failure + records · Release readiness, and ``/health`` is labeled Suite health; +* R11: a finalized (COMPLETE) session's per-call view carries the exact + ``hotato production export-regression`` command; a non-finalized session's + view carries none. +""" +from __future__ import annotations + +import glob +import json +import os +import re +import shutil +import threading +import time +import urllib.error +import urllib.request +from importlib import resources +from urllib.parse import urlencode + +import pytest + +from hotato.console_store import ConsoleStore +from hotato.console_worker import default_console_path, rebuild_sidecar +from hotato.contract import verify_contracts +from hotato.fleet.registry import Registry +from hotato.production import ProductionStore +from hotato.serve import build_server +from hotato.serve.app import ServeContext +from hotato.serve.security import AuditLog, SessionStore + +_TOKEN = "tok_pin_TEST_0123456789_xyz" +_KEY = "test-pin-signing-key-abc123" + + +# ========================================================================= +# fixture: an evidence db + sidecar with a pinnable call +# ========================================================================= + +def _stereo_fixture() -> str: + return str( + resources.files("hotato").joinpath( + "data", "audio", "01-hard-interruption.example.wav" + ) + ) + + +def _event(event_id, event_type, *, subject, time_value, sequence, data=None, + authority="adapter_reported"): + return { + "specversion": "1.0", + "id": event_id, + "source": "pin-fixture", + "type": event_type, + "subject": subject, + "time": time_value, + "sequence": sequence, + "data": {} if data is None else data, + "authority": { + "kind": authority, + "eligible_for_execution_claim": authority + in ("measured", "signed_attestation"), + }, + } + + +def _ingest_call(store, subject, *, seq_base, audio_path=None, + with_media_event=True): + """One fully-sequenced call session (lifecycle + audio asset + a timed + turn), so ``finalize`` can reach COMPLETE.""" + seq = iter(range(seq_base, seq_base + 10)) + events = [ + _event(f"{subject}-start", "session.started", subject=subject, + time_value="2026-07-17T12:00:00Z", sequence=next(seq)), + ] + if with_media_event: + data = {"availability": "available", "channels": 2} + if audio_path is not None: + data["path"] = audio_path + events.append(_event( + f"{subject}-audio", "media.asset.available", subject=subject, + time_value="2026-07-17T12:00:00.500Z", sequence=next(seq), + data=data, authority="measured")) + events.extend([ + _event(f"{subject}-turn-a", "turn.started", subject=subject, + time_value="2026-07-17T12:00:01Z", sequence=next(seq)), + _event(f"{subject}-turn-b", "turn.ended", subject=subject, + time_value="2026-07-17T12:00:03.500Z", sequence=next(seq)), + _event(f"{subject}-end", "session.ended", subject=subject, + time_value="2026-07-17T12:00:06Z", sequence=next(seq)), + ]) + for event in events: + store.ingest(event, redact_payloads=False) + + +def _seed_evidence(tmp_path) -> str: + """Three sessions: ``call-pin`` (SCORED, finalized COMPLETE, pinnable), + ``call-open`` (SCORED, still QUIESCENT), and ``call-noaudio`` + (NOT_SCORABLE). Returns the evidence db path.""" + db_path = str(tmp_path / "production.sqlite3") + movable = str(tmp_path / "movable.wav") + shutil.copy(_stereo_fixture(), movable) + + clock = [1000.0] + evidence = ProductionStore(db_path, clock=lambda: clock[0]) + _ingest_call(evidence, "call-pin", seq_base=1, + audio_path=_stereo_fixture()) + # finalize BEFORE the later arrivals so only call-pin quiesces to COMPLETE + evidence.finalize(quiescence_seconds=0, now=1500.0, + required_lanes=("participant_audio",)) + clock[0] = 2000.0 + _ingest_call(evidence, "call-open", seq_base=101, audio_path=movable) + clock[0] = 3000.0 + _ingest_call(evidence, "call-noaudio", seq_base=201, + with_media_event=False) + evidence.close() + + store = ConsoleStore(default_console_path(db_path)) + try: + summary = rebuild_sidecar(db_path, store) + finally: + store.close() + assert summary["scored"] == 2 + assert summary["not_scorable"] == 1 + return db_path + + +class _Live: + def __init__(self, base, token, home, production_db, audit_path, server, + thread): + self.base = base + self.token = token + self.home = home + self.production_db = production_db + self.audit_path = audit_path + self._server = server + self._thread = thread + + def stop(self): + self._server.shutdown() + self._thread.join(timeout=5) + self._server.server_close() + + +def _start_server(home: str, production_db): + os.makedirs(home, exist_ok=True) + Registry(home=home).close() + state_dir = os.path.join(home, "serve", "default") + os.makedirs(state_dir, exist_ok=True) + audit_path = os.path.join(state_dir, "audit.jsonl") + ctx = ServeContext( + home=home, workspace="default", + store_root=os.path.join(home, "artifacts"), token=_TOKEN, + state_dir=state_dir, audit=AuditLog(audit_path), + sessions=SessionStore(), bind_host="127.0.0.1", + production_db=production_db) + server = build_server(ctx, "127.0.0.1", 0) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return _Live("http://127.0.0.1:%d" % port, _TOKEN, home, production_db, + audit_path, server, thread) + + +@pytest.fixture() +def live(tmp_path, monkeypatch): + monkeypatch.setenv("HOTATO_ATTEST_KEY", _KEY) + monkeypatch.setenv("HOTATO_REVIEWER", "pin-reviewer") + production_db = _seed_evidence(tmp_path) + server = _start_server(str(tmp_path / "fleet"), production_db) + try: + yield server + finally: + server.stop() + + +# ========================================================================= +# helpers +# ========================================================================= + +def _req(base, path, *, method="GET", token=None, cookie=None, headers=None, + form=None): + body = urlencode(form).encode("utf-8") if form is not None else None + req = urllib.request.Request(base + path, data=body, method=method) + if form is not None: + req.add_header("Content-Type", "application/x-www-form-urlencoded") + if token is not None: + req.add_header("Authorization", "Bearer " + token) + if cookie is not None: + req.add_header("Cookie", cookie) + for key, value in (headers or {}).items(): + req.add_header(key, value) + try: + resp = urllib.request.urlopen(req, timeout=10) + return resp.getcode(), resp.read().decode("utf-8"), dict(resp.headers) + except urllib.error.HTTPError as exc: + return exc.code, exc.read().decode("utf-8"), dict(exc.headers) + + +def _json(base, path, token): + code, body, _h = _req(base, path, token=token) + assert code == 200, path + return json.loads(body) + + +def _score(live, subject): + return _json(live.base, "/calls/%s?format=json" % subject, + live.token)["score"] + + +def _pin_form(live, subject, *, candidate="0", expect="yield", sha=None): + if sha is None: + sha = _score(live, subject)["evidence_sha256"] + return {"candidate": candidate, "expect": expect, "evidence_sha256": sha} + + +def _bundles(live): + return sorted(glob.glob(os.path.join(live.home, "contracts", "default", + "*.hotato"))) + + +def _contract_rows(live): + reg = Registry(home=live.home) + try: + rows = reg._all( + "SELECT contract_id, label_id, agent_id FROM contracts " + "WHERE workspace_id='default'", ()) + labels = reg._all( + "SELECT label_id FROM labels WHERE workspace_id='default'", ()) + finally: + reg.close() + return [dict(r) for r in rows], [dict(r) for r in labels] + + +def _audit_lines(live): + time.sleep(0.05) # give the append a beat under the threaded server + with open(live.audit_path, "r", encoding="utf-8") as fh: + return [json.loads(line) for line in fh if line.strip()] + + +def _assert_nothing_written(live): + rows, labels = _contract_rows(live) + assert rows == [] and labels == [] + assert _bundles(live) == [] + + +# ========================================================================= +# auth + CSRF fence (I7) +# ========================================================================= + +def test_unauthenticated_pin_post_is_401_and_not_routed(live): + code, body, headers = _req(live.base, "/calls/call-pin/pin", method="POST", + form=_pin_form(live, "call-pin")) + assert code == 401 + assert "bearer" in headers.get("WWW-Authenticate", "").lower() + assert "contract" not in body.lower() or "token" in body.lower() + _assert_nothing_written(live) + + +def _session_cookie(live): + """Mint a browser session exactly as a person does: open ``/?token=…`` + once and keep the HttpOnly cookie from the redirect.""" + class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *a, **k): + return None + + req = urllib.request.Request(live.base + "/?token=" + live.token) + opener = urllib.request.build_opener(_NoRedirect()) + try: + resp = opener.open(req, timeout=5) + headers = resp.headers + except urllib.error.HTTPError as exc: + headers = exc.headers + set_cookie = headers.get("Set-Cookie", "") + assert "hotato_session=" in set_cookie + sid = set_cookie.split("hotato_session=", 1)[1].split(";", 1)[0] + return "hotato_session=" + sid + + +def test_forged_cross_origin_cookie_post_is_refused(live): + cookie = _session_cookie(live) + form = _pin_form(live, "call-pin") + + # a cross-site form post: cookie present, Origin absent -> refused closed + code, body, _h = _req(live.base, "/calls/call-pin/pin", method="POST", + cookie=cookie, form=form) + assert code == 403 + assert "cross-origin" in body.lower() + # a cross-site form post from a foreign origin -> refused + code, _b, _h = _req(live.base, "/calls/call-pin/pin", method="POST", + cookie=cookie, form=form, + headers={"Origin": "http://evil.example"}) + assert code == 403 + _assert_nothing_written(live) + # the refusals are audited + assert [r for r in _audit_lines(live) + if r["method"] == "POST" and r["status"] == 403] + + # the page's own origin passes the fence and pins + origin = live.base + code, body, _h = _req(live.base, "/calls/call-pin/pin", method="POST", + cookie=cookie, form=form, + headers={"Origin": origin}) + assert code == 200 + assert "ct-cand-" in body + + +# ========================================================================= +# pin success (R9): the existing machinery, a verifiable artifact +# ========================================================================= + +def test_pin_mints_a_contract_the_existing_verify_logic_proves(live): + code, body, _h = _req(live.base, "/calls/call-pin/pin?format=json", + method="POST", token=live.token, + form=_pin_form(live, "call-pin")) + assert code == 200, body + result = json.loads(body) + assert result["view"] == "pin_result" + assert result["delegated_to"] == "fleet.contract_from_candidate" + assert result["contract_id"].startswith("ct-cand-") + assert os.path.isdir(result["dir"]) + + # the bundle verifies under the EXISTING contract-verify logic: re-scored + # green on its own recording and authenticated (HMAC key in the fixture) + proof = verify_contracts(result["dir"]) + assert proof["count"] == 1 + assert proof["exit_code"] == 0 + checked = proof["results"][0] + assert checked["passed"] is True + assert checked["scorable"] is True + assert checked.get("authenticity") == "authenticated" + + # the sealed contract carries the label identity the fleet row does + with open(os.path.join(result["dir"], "contract.json"), + encoding="utf-8") as fh: + contract = json.load(fh) + assert contract["identity"]["reviewer"] == "pin-reviewer" + assert contract["source"]["candidate_ref"] == \ + result["contract_id"][len("ct-"):] + + # registered in the fleet registry: one contract, one label + rows, labels = _contract_rows(live) + assert [r["contract_id"] for r in rows] == [result["contract_id"]] + assert rows[0]["label_id"] == result["label_id"] + assert rows[0]["agent_id"] == "production" + assert [l["label_id"] for l in labels] == [result["label_id"]] + + # the accepted attempt is audited through the same append-only path + assert [r for r in _audit_lines(live) + if r["method"] == "POST" and r["path"] == "/calls/call-pin/pin" + and r["status"] == 200] + + +def test_feed_header_counts_contracts_from_the_registry(live): + before = _json(live.base, "/calls?format=json", live.token)["contracts"] + assert before == {"count": 0, "source": "fleet-registry:contracts", + "scope": "workspace"} + code, body, _h = _req(live.base, "/calls", token=live.token) + assert code == 200 + assert "contracts protecting this agent" in body + + code, _b, _h = _req(live.base, "/calls/call-pin/pin?format=json", + method="POST", token=live.token, + form=_pin_form(live, "call-pin")) + assert code == 200 + + after = _json(live.base, "/calls?format=json", live.token)["contracts"] + assert after["count"] == 1 + code, body, _h = _req(live.base, "/calls", token=live.token) + assert ">1 contracts protecting this agent" in body + + +def test_pin_is_idempotent_on_the_same_candidate(live): + form = _pin_form(live, "call-pin") + first = _req(live.base, "/calls/call-pin/pin?format=json", method="POST", + token=live.token, form=form) + second = _req(live.base, "/calls/call-pin/pin?format=json", method="POST", + token=live.token, form=form) + assert first[0] == 200 and second[0] == 200 + assert (json.loads(first[1])["contract_id"] + == json.loads(second[1])["contract_id"]) + rows, labels = _contract_rows(live) + assert len(rows) == 1 and len(labels) == 1 + + +def test_pin_form_and_result_work_without_javascript(live): + # the per-call page carries a plain HTML form per top-ranked moment + code, body, _h = _req(live.base, "/calls/call-pin", token=live.token) + assert code == 200 + assert 'method="post" action="/calls/call-pin/pin"' in body + assert 'name="evidence_sha256"' in body + assert 'name="candidate"' in body + assert "Pin to contract" in body + + # a plain form POST (no JS, no ?format=json) renders the result page + code, body, _h = _req(live.base, "/calls/call-pin/pin", method="POST", + token=live.token, form=_pin_form(live, "call-pin")) + assert code == 200 + assert "Pinned to contract" in body + assert "ct-cand-" in body + assert ".hotato" in body # the bundle path is shown + + +# ========================================================================= +# refusals: 4xx with reason, never a partial artifact (I2 / fail-closed) +# ========================================================================= + +def test_pin_refusals_are_4xx_with_reason_and_no_artifact(live): + sha = _score(live, "call-pin")["evidence_sha256"] + + def post(path, form): + code, body, _h = _req(live.base, path + "?format=json", method="POST", + token=live.token, form=form) + return code, json.loads(body) + + # unknown call + code, m = post("/calls/nope/pin", + {"candidate": "0", "expect": "yield", + "evidence_sha256": "x"}) + assert code == 404 and "nope" in m["message"] + + # bad candidate refs + code, m = post("/calls/call-pin/pin", + {"candidate": "99", "expect": "yield", + "evidence_sha256": sha}) + assert code == 404 and "no candidate #99" in m["message"] + code, m = post("/calls/call-pin/pin", + {"candidate": "snack", "expect": "yield", + "evidence_sha256": sha}) + assert code == 400 and "candidate" in m["message"] + + # bad expect + code, m = post("/calls/call-pin/pin", + {"candidate": "0", "expect": "maybe", + "evidence_sha256": sha}) + assert code == 400 and "yield" in m["message"] + + # an unscorable call refuses with the scorer's own state + reason + noaudio = _score(live, "call-noaudio") + code, m = post("/calls/call-noaudio/pin", + {"candidate": "0", "expect": "yield", + "evidence_sha256": noaudio["evidence_sha256"]}) + assert code == 409 + assert "NOT_SCORABLE" in m["message"] + + # a stale evidence binding refuses (the sidecar the page rendered from + # is not the sidecar on disk any more) + code, m = post("/calls/call-pin/pin", + {"candidate": "0", "expect": "yield", + "evidence_sha256": "sha256:" + "0" * 64}) + assert code == 409 and "changed" in m["message"] + + _assert_nothing_written(live) + # every refusal above is audited with its status + statuses = {r["status"] for r in _audit_lines(live) + if r["method"] == "POST"} + assert {404, 400, 409} <= statuses + + +def test_pin_refuses_when_the_recording_left_the_disk(live, tmp_path): + form = _pin_form(live, "call-open") + os.remove(str(tmp_path / "movable.wav")) + code, body, _h = _req(live.base, "/calls/call-open/pin?format=json", + method="POST", token=live.token, form=form) + assert code == 409 + assert "not a readable file" in json.loads(body)["message"] + _assert_nothing_written(live) + + +def test_pin_bad_body_is_400(live): + req = urllib.request.Request( + live.base + "/calls/call-pin/pin?format=json", + data=b'{"candidate": 0}', method="POST") + req.add_header("Authorization", "Bearer " + live.token) + req.add_header("Content-Type", "application/json") + try: + resp = urllib.request.urlopen(req, timeout=10) + code = resp.getcode() + except urllib.error.HTTPError as exc: + code = exc.code + assert code == 400 + _assert_nothing_written(live) + + +def test_get_views_stay_read_only(live): + # exercising every read view leaves the fleet registry without a single + # contract/label row -- the pin POST is the one write path + for path in ["/calls", "/calls/call-pin", "/", "/health", "/clusters", + "/records", "/scenarios"]: + code, _b, _h = _req(live.base, path, token=live.token) + assert code == 200, path + _assert_nothing_written(live) + + +# ========================================================================= +# R10: naming honesty + nav order +# ========================================================================= + +def test_nav_reads_as_one_product_in_order(live): + code, body, _h = _req(live.base, "/calls", token=live.token) + assert code == 200 + nav = re.search(r'', body, re.S).group(1) + labels = re.findall(r">([^<>]+)", nav) + assert labels == ["Calls", "Suite health", "Failure clusters", + "Failure records", "Release readiness"] + + +def test_suite_health_label_replaces_production_health(live): + for path in ["/health", "/production"]: + code, body, _h = _req(live.base, path, token=live.token) + assert code == 200, path + assert "Suite health" in body + assert "Production health" not in body + model = _json(live.base, "/health?format=json", live.token) + assert model["view"] == "suite_health" + + +# ========================================================================= +# R11: the export-regression path, surfaced only where it holds +# ========================================================================= + +def test_finalized_call_links_the_export_regression_command(live): + model = _json(live.base, "/calls/call-pin?format=json", live.token) + assert model["session"]["state"] == "COMPLETE" + export = model["session"]["export_regression"] + assert export["command"].startswith( + "hotato production export-regression call-pin --out ") + assert "--db" in export["command"] + code, body, _h = _req(live.base, "/calls/call-pin", token=live.token) + assert code == 200 + assert "export-regression call-pin" in body + + +def test_non_finalized_call_carries_no_export_reference(live): + model = _json(live.base, "/calls/call-open?format=json", live.token) + assert model["session"]["state"] == "QUIESCENT" + assert "export_regression" not in model["session"] + code, body, _h = _req(live.base, "/calls/call-open", token=live.token) + assert code == 200 + assert "export-regression" not in body From 6ce6b5be3db34af0b7ee107a0ae03fec49ccc12b Mon Sep 17 00:00:00 2001 From: "David J. Kim" Date: Thu, 23 Jul 2026 18:15:07 -0400 Subject: [PATCH 2/3] fix(pin): refuse a changed decision on an already-sealed candidate (409) The fleet API reconciles a re-pin of the same candidate by reusing its sealed bundle; pin.py echoed the REQUESTED decision, so a re-pin with a different decision returned 200 confirming a write that never happened. Read back the sealed decision and refuse the mismatch loudly. Found by the fresh adversarial verifier beyond the shipped test coverage. Claude-Session: https://claude.ai/code/session_015njkxtoAKioqTPT2k14xFr --- src/hotato/serve/pin.py | 30 ++++++++++++++++++++++++++++++ tests/test_serve_pin.py | 25 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/hotato/serve/pin.py b/src/hotato/serve/pin.py index e6a95a9..51563d6 100644 --- a/src/hotato/serve/pin.py +++ b/src/hotato/serve/pin.py @@ -62,6 +62,23 @@ def _refuse(status: int, reason: str) -> "PinRefused": return PinRefused(status, reason) +def _persisted_decision(bundle_dir: str) -> Optional[str]: + """The decision sealed inside a minted bundle (``label.expected_behavior``). + + ``None`` when the bundle cannot be read -- the caller then has no basis to + dispute the mint, so the requested decision stands. + """ + import json + + try: + with open(os.path.join(bundle_dir, "contract.json"), encoding="utf-8") as fh: + cjson = json.load(fh) + except (OSError, ValueError): + return None + sealed = (cjson.get("label") or {}).get("expected_behavior") + return str(sealed).strip().lower() if isinstance(sealed, str) else None + + def pin_candidate( *, home: str, @@ -176,6 +193,19 @@ def pin_candidate( decision=expect, rationale=(rationale or "").strip() or None) except ValueError as exc: raise _refuse(409, str(exc)) from exc + # The fleet API reconciles a re-pin of the same candidate by reusing its + # sealed bundle; the sealed decision, not the requested one, is what + # exists. A changed decision must refuse -- returning 200 with the + # requested value would confirm a write that never happened. + sealed = _persisted_decision(minted["dir"]) + if sealed is not None and sealed != expect: + raise _refuse(409, "candidate #%d already has a sealed contract " + "%s recording decision %r; a pin cannot change " + "a sealed decision. Mint a fresh contract " + "deliberately: hotato fleet contract create " + "--from-candidate %s --contract-id " + % (index, minted["contract_id"], sealed, + fleet_cand["candidate_id"])) finally: api.close() diff --git a/tests/test_serve_pin.py b/tests/test_serve_pin.py index 84cf99f..cc05d51 100644 --- a/tests/test_serve_pin.py +++ b/tests/test_serve_pin.py @@ -404,6 +404,31 @@ def test_pin_is_idempotent_on_the_same_candidate(live): assert len(rows) == 1 and len(labels) == 1 +def test_pin_refuses_a_changed_decision_on_the_same_candidate(live): + # A re-pin with a DIFFERENT decision must refuse: the first pin sealed the + # decision into the bundle, and a 200 echoing the new decision would + # confirm a write that never happened. + first = _req(live.base, "/calls/call-pin/pin?format=json", method="POST", + token=live.token, form=_pin_form(live, "call-pin")) + assert first[0] == 200 + sealed_bundles = _bundles(live) + + code, body, _h = _req(live.base, "/calls/call-pin/pin?format=json", + method="POST", token=live.token, + form=_pin_form(live, "call-pin", expect="hold")) + assert code == 409 + assert "sealed" in body and "'yield'" in body + + # the sealed bundle is untouched and still records the original decision + assert _bundles(live) == sealed_bundles + with open(os.path.join(sealed_bundles[0], "contract.json"), + encoding="utf-8") as fh: + cjson = json.load(fh) + assert cjson["label"]["expected_behavior"] == "yield" + rows, labels = _contract_rows(live) + assert len(rows) == 1 and len(labels) == 1 + + def test_pin_form_and_result_work_without_javascript(live): # the per-call page carries a plain HTML form per top-ranked moment code, body, _h = _req(live.base, "/calls/call-pin", token=live.token) From 2206083cc352f29cfabfcd35294696f48f1a9c20 Mon Sep 17 00:00:00 2001 From: "David J. Kim" Date: Thu, 23 Jul 2026 18:27:57 -0400 Subject: [PATCH 3/3] fix(pin): route the sealed-decision read through open_regular (FIFO-safe) Claude-Session: https://claude.ai/code/session_015njkxtoAKioqTPT2k14xFr --- src/hotato/serve/pin.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/hotato/serve/pin.py b/src/hotato/serve/pin.py index 51563d6..d1c5019 100644 --- a/src/hotato/serve/pin.py +++ b/src/hotato/serve/pin.py @@ -70,8 +70,11 @@ def _persisted_decision(bundle_dir: str) -> Optional[str]: """ import json + from ..errors import open_regular + try: - with open(os.path.join(bundle_dir, "contract.json"), encoding="utf-8") as fh: + with open_regular(os.path.join(bundle_dir, "contract.json"), "r", + encoding="utf-8") as fh: cjson = json.load(fh) except (OSError, ValueError): return None