diff --git a/specs/011-summarise-analysis-results/research.md b/specs/011-summarise-analysis-results/research.md index 0acf99f..341eb92 100644 --- a/specs/011-summarise-analysis-results/research.md +++ b/specs/011-summarise-analysis-results/research.md @@ -131,10 +131,43 @@ the search path. The chat is now Turnstile-gated (2026-09-18), so the website ca demonstrate presence there. What it cannot do is let the search-page path silently satisfy a requirement that path was never designed to meet. -**Open with the website repo**: the shape of the assertion — most likely an -additional claim in the caller token, minted only after a Turnstile verification -they already perform. This needs their agreement and is recorded as a task, not -decided here. +**Agreed with the website session, 2026-09-19.** It rides as claims on the +caller token, minted only when their Turnstile-backed identity cookie validated +on the request: + +| claim | meaning | +|---|---| +| `human` | `true`, set only when a valid, unexpired identity cookie was presented. **Absent otherwise, never `false`**, so a missing claim and a failed check are indistinguishable to us | +| `human_iat` | when the challenge was solved, epoch seconds. Derived from the cookie's expiry minus their identity TTL; no cookie format change | +| `subject` | the cookie's random 16-byte identifier, for per-identity rate limiting. Carries nothing about the person | + +**Freshness is 30 minutes, enforced at both ends.** We refuse a `human_iat` +older than that, and they refuse to mint the claim past it, so neither side is +a single point of failure. Long enough that reading a result, choosing a +disclosure tier and requesting a summary is never re-challenged; short enough +that a stolen cookie is not a durable pass. The claim's lifetime is deliberately +shorter than the token's, because an HMAC cookie is itself a bearer credential. + +**They proposed gating on the analysis token instead, and withdrew it.** The +argument was that a token proves real work already happened, so it is decent +evidence somebody meant it — sound for abuse resistance, but this requirement is +about consent, not cost. A token proves an analysis happened; it does not prove +a person is present, nor that the person present is the one who ran it. Analysis +tokens travel in URLs that people paste into tickets and papers, so +token-as-authorization lets a forwarded link send someone else's identifier list +to a model provider. And FR-011/FR-012 make summarising opt-in with a choice of +disclosure tier — a choice a bot holding a link can make is a consent mechanism +that consents on the user's behalf, which is worse than no choice because it +looks like one. + +**Their caveat, adopted**: rate limit per token *and* per caller regardless. A +token asking for twenty summaries of one analysis is not a scientist. That is a +throttle, not evidence of a person, and it does not substitute for the claim. + +**Dependency that remains**: their proxy mints caller tokens for the answer +route only, so a summary route must be added before any claim can be carried. +Nothing here assumes the browser calls us directly — it cannot, since the cookie +is same-site to their origin. **Alternatives considered**: re-verifying a Turnstile token ourselves — rejected, it would put a second captcha secret and a second verification path in this diff --git a/specs/011-summarise-analysis-results/tasks.md b/specs/011-summarise-analysis-results/tasks.md index 68ed342..8fa5733 100644 --- a/specs/011-summarise-analysis-results/tasks.md +++ b/specs/011-summarise-analysis-results/tasks.md @@ -82,13 +82,21 @@ a deleted one, and that the same token returns the same text. - [ ] T029 Report `cached` on the `start` event in `src/api/analysis_summary.py`, so the interface can say a summary was reused rather than implying the generator is deterministic (FR-015) - [ ] T030 Record in [research.md](./research.md) that the first increment's store is in-process and lost on deploy, and open follow-up work for a durable store — beta sets no `POSTGRES_LANGGRAPH_DB` today (research D4) -## Phase 8: Human presence (FR-013) — BLOCKED +## Phase 8: Human presence (FR-013) — UNBLOCKED 2026-09-19 -**Blocked on the website repo.** Do not implement by inference, and do not let the -existing caller token satisfy this by default: spec 010's D1 settled that it -asserts service identity and deliberately says nothing about humanity. +Agreed with the website session; the shape is in [research.md](./research.md) D6. +Still do not let the plain caller token satisfy this by default: spec 010's D1 +settled that it asserts service identity and says nothing about humanity. The +claims below are additional, and their absence must refuse. -- [ ] T031 Agree with the website session how human presence is asserted — most likely an additional claim minted after the Turnstile check they already perform on the chat. **Dependency: their agreement.** Until it exists, the endpoint must refuse rather than assume +**One dependency remains, and it is theirs**: their proxy mints caller tokens +for the answer route only. A summary route must exist before any claim can be +carried. Everything in this phase can be built and tested before that lands. + +- [x] T031 Agree with the website session how human presence is asserted — `human`, `human_iat` and `subject` claims on the caller token, minted only when their Turnstile-backed identity cookie validated. They proposed gating on the analysis token instead and withdrew it: a token proves an analysis happened, not that a person is present, and tokens travel in pasted URLs +- [ ] T031a Verify `human_iat` against a **30-minute** freshness bound in `src/util/caller_token.py`, refusing an older one. They refuse to mint past the same bound, so it fails at both ends rather than relying on either alone +- [ ] T031b Key the rate limiter on `subject` when present, falling back to the caller identity, in `src/api/analysis_summary.py` — per-person throttling rather than per-proxy-address. Also limit per analysis token: twenty summaries of one analysis is not a scientist +- [ ] T031c Test that a `human` claim with a stale `human_iat` is refused with **zero model calls**, in `tests/api/test_analysis_summary.py` — the freshness bound is the half most likely to be dropped, because the claim being present looks like success - [ ] T032 [US1] Verify the assertion in `src/util/caller_token.py` once T031 is agreed, refusing before any model call - [ ] T033 [P] Test that a request without the assertion is refused and makes **zero model calls**, counted on a patched graph rather than inferred from timing, in `tests/api/test_analysis_summary.py` (SC-004) @@ -105,7 +113,7 @@ asserts service identity and deliberately says nothing about humanity. - T006 blocks T010, T017, T021, T024 — nothing can be summarised before a result can be fetched. - T008 blocks T027: the release is part of the storage key. - T013 blocks T015, T029, T034, T035. -- **T031 blocks T032 and T033, and T031 blocks nothing else** — every other story can be built and tested behind a refusing gate. +- **T031 is agreed (2026-09-19); T031a-c and T032-T033 follow from it, and block nothing else** — every other story can be built and tested behind a refusing gate. The website adding a summary route to its proxy is the only external dependency left. - US1 is independent. US2, US3 and US4 each build on US1's prompt-input path but are separately testable. ## Parallel opportunities