Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions specs/011-summarise-analysis-results/research.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 14 additions & 6 deletions specs/011-summarise-analysis-results/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
Loading