Skip to content

Settle the analysis-summary contract, and fix what sub actually means - #264

Merged
adamjohnwright merged 3 commits into
mainfrom
docs/011-summary-contract-settled
Sep 19, 2026
Merged

adamjohnwright merged 3 commits into
mainfrom
docs/011-summary-contract-settled

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

Settles every open item in the analysis-summary contract with the website session, and fixes a wrong description of deployed behaviour that the exchange turned up.

The contract is closed

  • Presence rides as human, human_iat, human_sub on the caller token, minted only when their Turnstile-backed identity cookie validated. human is absent rather than false, so a missing claim and a failed check are indistinguishable here.
  • Freshness is 30 minutes, inclusive — 1800.000s accepted, 1800.001s refused — enforced at both ends so neither side is a single point of failure. They have a test pinned at exactly that edge; we will mirror it.
  • The analysis token passes straight through. We already distinguish 404, 410 and the undocumented 500 from a malformed token; a second validator disagrees with the first eventually.
  • Refusals carry a reason: no_caller, no_human, stale_human. "Failed" is not something an interface can say to a person, and stale_human is the only refusal a reader can act on. Advisory — an unrecognised value must read as a plain refusal.

The more useful half: sub was documented wrong

I asked for human_sub rather than sub because sub was already taken by an opaque per-visit id. They corrected me: their callerSubject() prefers the identity cookie's subject whenever the reader has passed a challenge, falling back to the per-visit value only when they have not.

So sub has been browser-scoped for every gated reader since the gate shipped, and identity_of has been keying the answer endpoint's backstop limiter on an identifier that lasts as long as that cookie — counting a person across visits, not within one.

That is the stronger throttle and it is kept. No behaviour changes here. What was wrong was caller_token.py's docstring and rate_limit.py's comment, both of which named the fallback case as though it were the only case. Anyone reasoning about a burst on that endpoint had the wrong model, and the analysis-summary limiter would later have looked inexplicably similar to one they believed was scoped differently.

Also written down: while that preference holds, sub and human_sub carry the same value whenever both are present. They stay separate because they mean different things, would diverge the moment the preference changed, and because agreement between them is not a signal anything should test.

Documentation and comments only, no behaviour change. 501 passed, 1 skipped; mypy over all 137 files, ruff and ruff format clean.

🤖 Generated with Claude Code

adamjohnwright and others added 3 commits September 19, 2026 06:03
Agreed with the website session. Nothing in the contract is open now.

Human presence rides as `human`, `human_iat` and `human_sub` on the caller
token, minted only when their Turnstile-backed identity cookie validated.
Freshness is 30 minutes, inclusive at exactly 1800.000s, enforced at both ends
so neither side is a single point of failure. The analysis token is passed
straight through and never validated by the caller: this service already
distinguishes 404, 410 and the undocumented 500 a malformed token returns, and
a second validator disagrees with the first eventually.

A refusal now carries a `reason` -- `no_caller`, `no_human` or `stale_human` --
because "failed" is not something an interface can say to a person, and
`stale_human` is the only refusal a reader can act on. Advisory: an
unrecognised value must read as a plain refusal.

The claim is `human_sub` rather than `sub` because `sub` was already taken. And
then the more useful correction, which came back from them: this repository's
description of `sub` was wrong. Their `callerSubject()` prefers the identity
cookie's subject whenever the reader has passed a challenge, falling back to a
per-visit value only when they have not -- so `sub` has been browser-scoped for
every gated reader since the gate shipped, and the answer endpoint's backstop
limiter has been counting people across visits rather than within one.

That is the stronger throttle and it is kept deliberately. What was wrong was
the docstring in caller_token.py and the comment in rate_limit.py, both of
which named the fallback case as though it were the only case. Someone
reasoning about a burst on that endpoint would have had the wrong model, and
the analysis-summary limiter would have looked inexplicably similar to one they
thought was scoped differently.

Also recorded: while that preference holds, `sub` and `human_sub` carry the
same value whenever both are present. They stay separate because they mean
different things and would diverge the moment it changed, and because
agreement between them is not a signal anything should test.

Documentation and comments only; no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bound was written as "1800.000 accepted, 1800.001 refused", and both sides
were about to pin a test at exactly that edge. It is not a distinction the
claim can carry. `human_iat` is epoch seconds, derived as the cookie's expiry
minus a constant TTL, so it arrives already rounded to a second -- a
sub-second edge is a boundary neither side can be on, tested against a clock
finer than the value being tested.

Restated as `now - human_iat <= 1800` in whole seconds, with the consequence
written down: effective precision is one second, and a challenge solved 1800.4s
ago presents as 1800 and is accepted. The point they made about boundaries --
that one nobody tests is one nobody agreed on -- holds just as well for a
boundary tested at a precision the data does not have.

Also added while looking: the summary must not end with a prose list of its
citations. They arrive as `citation` events, and the duplicate is exactly what
cost the website a pattern it could not write correctly on the answer route.
This endpoint's prompt is its own, so the fix is not to ask for the list
rather than to strip it afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adamjohnwright
adamjohnwright merged commit 1c3b44d into main Sep 19, 2026
10 checks passed
@adamjohnwright
adamjohnwright deleted the docs/011-summary-contract-settled branch September 19, 2026 06:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant