Skip to content

Two endpoints, one event shape, three quiet divergences - #289

Merged
adamjohnwright merged 2 commits into
mainfrom
011-contract-divergences
Sep 21, 2026
Merged

adamjohnwright merged 2 commits into
mainfrom
011-contract-divergences

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

All three found by the website building against both routes. The first caused reader-visible harm.

The success states differ and nothing said so

/api/answer says answered and nothing_found. This one says summarised and not_found. The event shapes are otherwise nearly identical.

They built one state list from both contracts, so summarised fell through to their unrecognised-state fallback — failed — which rendered a complete, correct summary as a truncated failure, with error text above and below the summary itself.

Each contract now warns about the other by name.

release was a string here and a number there

Same field, same event, different type: /api/answer parses it from the bundle directory and sends an int; this one passed through the Analysis Service's text body.

A consumer that required a number got null and did not notice, because null is a legitimate value for this field.

Parsed to a number now, null when unparseable, with the raw string kept as the storage key where only stability matters. This changes the wire format97 rather than "97".

The human-presence asymmetry was undocumented

A caller token with no presence claim gets a full answer from /api/answer and no_human from here. Deliberate and load-bearing — it is why the stricter gate applies to one route and not the other — and the website found it by measuring rather than reading.

Both contracts now state it and why: one returns public pathway text, the other a reader's own uploaded analysis.

The shape they share

Each contract was correct about itself. The divergence only exists when the two are held together — which is what a consumer does and neither document did. Same join problem that has produced most of this week's defects, at the level of documentation rather than code.

🤖 Generated with Claude Code

adamjohnwright and others added 2 commits September 21, 2026 15:31
All three found by the website building against both routes, and the first
cost a reader-visible failure.

**The success states differ and nothing said so.** `/api/answer` says
`answered` and `nothing_found`; this one says `summarised` and `not_found`.
The event shapes are otherwise nearly identical, so a consumer built one
state list from both contracts, and `summarised` fell through to their
unrecognised-state fallback of `failed` -- rendering a complete, correct
summary as a truncated failure, with the failure text above and below the
summary itself. Each contract now warns about the other by name.

**`release` was a string here and a number there.** Same field, same event,
different type: `/api/answer` parses it from the bundle directory and sends
an int, while this one passes through the Analysis Service's text body. A
consumer that required a number got null and did not notice, because null is
a legitimate value for this field. Parsed to a number now, null when
unparseable, with the raw string kept as the storage key where only
stability matters. **This changes what the field carries on the wire** --
`97` rather than `"97"`.

**The human-presence asymmetry was undocumented.** A caller token with no
presence claim gets a full answer from `/api/answer` and `no_human` from
here. That is deliberate and load-bearing -- it is why the stricter gate
applies to one route and not the other -- and the website discovered it by
measuring rather than reading. Both contracts now say it and why: one
returns public pathway text, the other returns a reader's own uploaded
analysis.

The shape of all three is the same. Each endpoint's contract was correct
about itself, and the divergence only exists when the two are held together
-- which is what a consumer does and neither document did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fix for two contracts that disagreed was prose in both contracts. Prose
does not fail when the next divergence appears, which is the whole reason
this one survived: each document was correct about itself and nothing
compared them.

`tests/api/test_endpoint_contracts_agree.py` compares them. The answer
endpoint's states are read from the graph's own Literal rather than
restated, so a new state there fails here instead of quietly escaping both
documents. The two state sets must overlap in exactly `refused` and
`failed`, every divergent state must appear in its own contract, and each
contract must name the other's success state -- which is the specific
confusion that rendered a correct summary as a truncated failure. Removing
the cross-reference from either document fails the test.

Two claims in the PR were checked rather than assumed, and both held. The
answer endpoint really does send `release` as an int -- I had asserted that
from a type annotation without ever seeing a `start` event, and the
deployed build confirms `{"release": 97}`. And `nothing_found` is really
emitted, at `graph.py:527`, rather than only being described in its
contract.

The release check is honest about being half a declaration check: the answer
endpoint puts `get_release`'s value straight on the wire, so its return type
is the contract, and reading the real value needs an installed bundle. The
summary side, where the type is actually decided by parsing text, is checked
by behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adamjohnwright
adamjohnwright merged commit 2ade251 into main Sep 21, 2026
10 checks passed
@adamjohnwright
adamjohnwright deleted the 011-contract-divergences branch September 21, 2026 15:45
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