From 1b7930b32669288186d1e200f5aafbc1e5822ced Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Sat, 19 Sep 2026 03:32:11 +0000 Subject: [PATCH] Record the agreed human-presence mechanism; spec 011 Phase 8 is unblocked The website session and I settled FR-013. Presence rides as claims on the caller token they already mint, set only when their Turnstile-backed identity cookie validated on the request: `human` (absent rather than false, so a missing claim and a failed check look the same to us), `human_iat` for freshness, and `subject`, a random 16-byte value, for per-person throttling. Freshness is 30 minutes and both sides enforce it -- they refuse to mint past it, we refuse to accept past it -- so neither is a single point of failure. The claim's lifetime is deliberately shorter than the token's, because an HMAC cookie is a bearer credential and a long-lived presence claim is a durable bot pass with extra steps. They first proposed gating on the analysis token instead, on the grounds that running an analysis is real work already done and so decent evidence somebody meant it. That is sound for abuse resistance and wrong for this requirement, which is about consent. A token proves an analysis happened, not that a person is present, and not that the person present is the one who ran it -- analysis tokens travel in URLs people paste into tickets and papers. Worse, FR-011 and FR-012 make summarising opt-in with a choice of disclosure tier, and a choice a bot holding a forwarded link can make is a consent mechanism that consents on the user's behalf. That is worse than offering no choice, because it looks like one. They agreed and withdrew it. Their rate-limiting caveat is adopted as its own task: per token and per caller regardless, because a token asking for twenty summaries of one analysis is not a scientist. It is a throttle, not evidence of a person. T031 is done. T031a-c are the work it implies. One external dependency remains and it is theirs: their proxy mints caller tokens for the answer route only, so a summary route must exist before any claim can be carried. Nothing here assumes the browser calls us directly -- it cannot, the cookie is same-site to their origin. Co-Authored-By: Claude Opus 5 --- .../research.md | 41 +++++++++++++++++-- specs/011-summarise-analysis-results/tasks.md | 20 ++++++--- 2 files changed, 51 insertions(+), 10 deletions(-) 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