Skip to content

Commit b0ff6ec

Browse files
Two endpoints, one event shape, three quiet divergences
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>
1 parent 7eacb1c commit b0ff6ec

4 files changed

Lines changed: 94 additions & 3 deletions

File tree

‎specs/010-search-page-answers/contracts/answer_endpoint.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ data: {"state": "answered", "seconds": 8.4}
6464

6565
`state` is one of `answered`, `nothing_found`, `refused`, `failed`.
6666

67+
> **This endpoint does not require a human-presence claim and
68+
> `/api/analysis-summary` does.** A caller token without one gets a full
69+
> answer here and `no_human` there. Deliberate: this returns public pathway
70+
> text, that one returns a reader's own uploaded analysis.
71+
>
72+
> **`/api/analysis-summary` uses different names for the same ideas**:
73+
> `summarised` for success and `not_found` for the empty case. The event
74+
> shapes are nearly identical, so do not reuse one state list for both — a
75+
> consumer did, and a correct summary rendered as a truncated failure. See
76+
> `specs/011-summarise-analysis-results/contracts/summary_endpoint.md`.
77+
6778
### Why citations are separate events
6879

6980
So the website renders links in its own style. Returning prose with embedded HTML

‎specs/011-summarise-analysis-results/contracts/summary_endpoint.md‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@ identifiers, filenames, sample names or expression column labels.
2727

2828
### Human presence
2929

30+
**This endpoint requires it and `/api/answer` does not.** A caller token with
31+
no presence claim gets a full answer from `/api/answer` and `no_human` from
32+
here. That asymmetry is deliberate and load-bearing — it is why the stricter
33+
gate can be applied to one route and not the other — and it was not written
34+
down until the website measured it on 2026-09-21.
35+
36+
The reason is what each endpoint discloses. `/api/answer` returns public
37+
pathway text, and the search path it serves has no human gate at all; spec
38+
010's D1 settled that its token asserts *service identity* and deliberately
39+
says nothing about a person. This endpoint sends a user's own uploaded
40+
analysis to a model provider, and at the disclosing tier their submitted
41+
identifiers with it. The choice of what to disclose is only meaningful if a
42+
person made it.
43+
44+
3045
`caller_token` carries three additional claims, minted only when the website's
3146
Turnstile-backed identity cookie validated on that request:
3247

@@ -95,7 +110,20 @@ data: {"state": "summarised", "seconds": 6.2}
95110
```
96111

97112
`state` is one of `summarised`, `not_found`, `gone`, `unsupported`, `refused`,
98-
`failed`. Anything but `summarised` means render no summary. Always HTTP 200 —
113+
`failed`.
114+
115+
> **These are not `/api/answer`'s state names, and the two are easy to
116+
> conflate.** That endpoint's success state is `answered` and its empty state
117+
> is `nothing_found`; this one's are `summarised` and `not_found`. The event
118+
> shapes are otherwise nearly identical, so a consumer building both panels
119+
> from one mental model will map a *successful* summary onto an unrecognised
120+
> state — which, if the fallback is `failed`, renders a complete and correct
121+
> summary as a truncated failure above and below the text. That happened on
122+
> 2026-09-21.
123+
>
124+
> `release` is a **number** on both, though it reaches this one as text from
125+
> the Analysis Service and is parsed here. It is null when unparseable, which
126+
> is a legitimate value on both endpoints. Anything but `summarised` means render no summary. Always HTTP 200 —
99127
never an error code, so the analysis page cannot be broken by this service.
100128

101129
**No prose source list.** Citations arrive as `citation` events, as on

‎src/api/analysis_summary.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,22 @@ class SummaryRequest(BaseModel):
9191
disclosure: Tier
9292

9393

94+
def _as_number(release: str | None) -> int | None:
95+
"""The release as a number, or None if it is not one.
96+
97+
Kept separate from the storage key, which stays the raw string: the key
98+
only has to be stable, while the contract field has to match the other
99+
endpoint's type.
100+
"""
101+
if release is None:
102+
return None
103+
try:
104+
return int(release)
105+
except ValueError:
106+
logger.warning("release %r is not a number; reporting null", release)
107+
return None
108+
109+
94110
def _sse(event: str, payload: dict[str, Any]) -> str:
95111
return f"event: {event}\ndata: {json.dumps(payload)}\n\n"
96112

@@ -206,7 +222,14 @@ async def stream() -> AsyncIterator[str]:
206222
yield _sse(
207223
"start",
208224
{
209-
"release": release,
225+
# A number, matching `/api/answer`'s `release`. The
226+
# Analysis Service answers `/database/version` as
227+
# text, so this arrives as a string and went out as
228+
# one -- the same field in the same event shape with
229+
# a different type on each endpoint. A consumer that
230+
# required a number got null and did not notice,
231+
# because null is a legitimate value here.
232+
"release": _as_number(release),
210233
"analysis_type": model_input.get("analysis_type"),
211234
# Stability is reuse, not determinism (FR-015). This
212235
# is how the interface knows which it is looking at.

‎tests/api/test_analysis_summary.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ def test_a_verified_human_caller_gets_a_summary(keys: tuple[str, str]) -> None:
167167
assert "citation" in kinds
168168
assert "token" in kinds
169169
start = events[0][1]
170-
assert start["release"] == "97"
170+
assert start["release"] == 97
171171
assert start["analysis_type"] == "OVERREPRESENTATION"
172172
assert start["cached"] is False
173173
assert events[-1][1]["state"] == "summarised"
@@ -790,3 +790,32 @@ def test_the_specific_presence_failure_is_logged_but_never_returned(
790790
logged = " ".join(r.getMessage() for r in caplog.records)
791791
assert expected_log in logged, f"not diagnosable from the log: {logged}"
792792
assert expected_log not in response.text, "the detail reached the caller"
793+
794+
795+
def test_release_is_a_number_as_the_answer_endpoint_sends_it(
796+
keys: tuple[str, str],
797+
) -> None:
798+
# The Analysis Service answers `/database/version` as text, so this
799+
# arrived as a string while `/api/answer` sends an int -- the same field
800+
# in the same event shape with a different type on each endpoint. A
801+
# consumer required a number, got null, and did not notice, because null
802+
# is legitimate here.
803+
private, public = keys
804+
start = _events(_post(public, caller_token=_token(private)).text)[0][1]
805+
assert start["release"] == 97
806+
assert isinstance(start["release"], int)
807+
808+
809+
def test_a_non_numeric_release_is_null_rather_than_a_string(
810+
keys: tuple[str, str], monkeypatch: pytest.MonkeyPatch
811+
) -> None:
812+
# Null is already a legitimate value for this field, so degrading to it
813+
# keeps the type honest. Emitting the raw string would put a second type
814+
# back on the wire for the case nobody tests.
815+
async def _odd() -> str:
816+
return "97-beta"
817+
818+
monkeypatch.setattr("api.analysis_summary.current_release", _odd)
819+
private, public = keys
820+
start = _events(_post(public, caller_token=_token(private)).text)[0][1]
821+
assert start["release"] is None

0 commit comments

Comments
 (0)