ReactomeGSA client: run an analysis without the data touching the model - #290
Merged
Merged
Conversation
Measured against the live ReactomeGSA service rather than read off its
specification: a full load -> summary -> submit -> poll -> result round trip
completed, PADOG on the 16-sample melanoma example, Reactome release 97,
2,679 pathways back.
The numbers are the design. The matrix is 1.2 MB and the result 2.0 MB, so
neither can cross the model's context or an MCP tool call, and the run takes
minutes rather than a chat turn. `POST /analysis` has no by-reference
variant -- the matrix is required inline -- but `POST /data/load/{id}` loads
public datasets by identifier, which is why analysing GEO or Expression Atlas
is the P1 story and needs no upload at all.
Adam's correction is recorded where it changes the design: uploading is not
the boundary. Reactome runs on the project's own AWS, not OICR, and
reactome.org already accepts these files. What moves is results reaching
OpenAI, which has a mechanism and a warning already, so the spec reuses both
and adds the GSA field names the existing allow-list does not cover.
Two measured operational constraints that would otherwise be found late: a
submission returned 200 and then failed through /status only, and Chainlit
ships a 500 MB upload cap on a host with 4.8 GB free.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… model First slice of spec 012, and the layer everything else sits on: load a public dataset, download its matrix, submit, poll, parse. No chat wiring yet, and nothing in reactome-mcp -- the public MCP was deliberately narrowed this month and long-running jobs carrying user data do not belong there. The sizes are the design. A 16-sample dataset is a 1.2 MB matrix, a 1.5 MB submission and a 2.0 MB result holding 2,679 pathways. `POST /analysis` requires the matrix inline and has no by-reference form, even for a dataset the service itself just loaded. So `download_matrix` and `submit` exist to keep that string server-side, and `for_model` is the only thing that produces something small enough for a prompt. **Two defects the swagger would have caused, both found by building the fixture from a real response.** The result key is `method_name`. `AnalysisResult` in the swagger says `methodName`, so `parse` read None -- the same defect reactome-mcp fixed in ten tools in September, where a field path was asserted rather than verified. And `mappings` is 8,035 entries, 499 KB, of *the user's own row identifiers* mapped to UniProt. For an uploaded matrix those are the user's gene names. It is now in NEVER_SENT. Nobody would have thought to exclude it, which is the argument for an allow-list: `for_model` names six columns it may emit, so `mappings` was excluded before I knew it existed -- I only had to notice in order to write the test. Those field names are ReactomeGSA's, not the Analysis Service's. Reusing `analysis/disclosure.py`'s `fileName`/`sampleName`/`columnNames` here would have looked like protection and provided none. **The test that matters plants a marker in every user-supplied place and asserts it appears nowhere in the model's view** -- on values, not field names, since a field can be copied into a differently named one. It is paired with a control that puts the same marker in an allow-listed column and requires it to come through, because an absence assertion proves nothing until the same construction has produced a presence. Measured behaviours the code handles because they happened, not because they seemed likely: - a submission returned 200 and then failed through /status alone, with `CONNECTION_FORCED - broker forced connection closure`, so a receipt is not a result and `finished` covers failure as well as success - `/result` answers 406 while running, which is the happy path, so it raises `GsaNotReadyError` rather than collapsing into "broken" - `/data/download` needs `format=expr`; omitting it is 400 and `tsv` is rejected - a non-numeric FDR falls back to 1.0, not 0.0, so "NA" cannot sort to the top as the most significant pathway in the analysis Identifiers are interpolated into URL paths and come from the model, so they are constrained to what real ones look like; a value containing `/` addresses a different endpoint, which is a bypass rather than a 404. Both POSTs validate the ID they read out of the body instead of `str()`-ing whatever arrived. No browser user-agent, unlike `analysis/client.py`: gsa.reactome.org serves hypercorn directly and answers python-httpx with 200. Verified rather than assumed, because testing only with curl -- which is exempt from the automation block in front of reactome.org -- is how a sibling service was published and believed reachable. Verified by sabotage: defaulting a bad FDR to 0.0 fails the ranking test; adding the user's dataset name to the model view fails the marker test. 25 new tests, whole suite green, ruff and mypy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI lint failed on this branch while ruff and mypy passed locally. The difference: CI runs bare `poetry run mypy`, which checks 159 files. I ran `mypy src`, which checks 152. The seven it does not reach include the tests, and one of them returned `Any` from a function declared to return a dict. Annotated the fixture loader, and added `checks.sh` so the gap cannot reopen. It runs the four commands from `.github/workflows/ci.yml` verbatim, with no pipes around them -- an earlier commit in this repo went out with six mypy errors because the command was piped and the exit code read was the pipe's -- prints each exit code, and ends with one verdict. Verified it fails: swapping in `mypy --strict` produces "mypy FAILED (exit 1)", "1 of 4 checks FAILED", exit 1. A green gate that cannot go red is worse than no gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pass over the finished branch rather than the pieces as I wrote them.
Three findings, and the first is the one that mattered.
**1. The model was sent a capability, not a citation.** `for_model`
included `browser_links`, and the Pathway Browser URL embeds the analysis
token:
https://reactome.org/PathwayBrowser/#/DTAB=AN&ANALYSIS=MjAyNj...
Anyone holding that token can fetch the whole result back from the service
-- including `mappings`, which is the user's own gene identifiers, and
`fold_changes`, whose columns are their samples. So the allow-list stripped
the user's content out of the payload and then handed over a key that
retrieves it. That is the same disclosure by a longer route, and the marker
test could not see it because the marker is not *in* the URL.
The link now goes to `for_user`, for the chat to render. The person should
absolutely have it -- it is how they see their own result properly -- and
the model never does.
**2. An empty result was reported as an exact zero.** A result whose table
did not arrive parses to no pathways, and `for_model` said `pathway_count:
0, counts_are_exact: True`. A summary built on that tells the user their
data contained no enriched pathways: a confident answer to a question
nobody managed to ask. It now says `no_result` and stops claiming
exactness.
**3. `repr(GsaResult)` printed the whole table.** A dataclass generates a
`__repr__` over every field, and `raw_table` is ~500 KB in a real run, so
one `logger.debug("%s", result)`, one exception context or one failing
assertion would put the entire pathway table in a log. `field(repr=False)`.
The care taken over what reaches a model is wasted if the same content
reaches a log file by default.
Each has a test, each verified by sabotage against the specific test written
for it, and the two absence assertions are paired with controls -- the
user's link must still reach the user, and a real result must not be
flagged as missing.
30 tests in tests/gsa, whole suite green, ./checks.sh clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First slice of spec 012 — the layer the chat flow sits on. Load a public dataset, download its matrix, submit, poll, parse. No chat wiring yet, and nothing in reactome-mcp: the public MCP was deliberately narrowed this month, and long-running jobs carrying user data do not belong there.
The sizes are the design
POST /analysisrequires the matrix inline and has no by-reference form — not even for a dataset the service itself just loaded. Sodownload_matrixandsubmitexist to keep that string server-side, andfor_modelis the only thing producing something small enough for a prompt.Two defects the swagger would have caused
Both found by building the fixture from a real response rather than from the schema.
The result key is
method_name. The swagger'sAnalysisResultsaysmethodName, soparsereadNone— the same defect reactome-mcp fixed across ten tools in September: a field path asserted rather than verified.mappingsis 8,035 entries, 499 KB, of the user's own row identifiers mapped to UniProt. For an uploaded matrix those are the user's gene names. Now inNEVER_SENT.Nobody would have thought to exclude it — which is the argument for an allow-list.
for_modelnames the six columns it may emit, somappingswas excluded before I knew it existed; I only had to notice in order to write the test. A denial list would have shipped it.Those names are ReactomeGSA's, not the Analysis Service's. Reusing
analysis/disclosure.py'sfileName/sampleName/columnNameshere would have looked like protection and provided none.The test that matters
It plants a marker in every user-supplied place — dataset name,
fold_changes,mappings— and asserts it appears nowhere in the model's view. On values, not field names, since a field can be copied into a differently named one.It is paired with a control that puts the same marker in an allow-listed column and requires it through. An absence assertion proves nothing until the same construction has produced a presence.
Measured behaviours, handled because they happened
/statusalone, withCONNECTION_FORCED - broker forced connection closure. A receipt is not a result, sofinishedcovers failure as well as success./resultanswers 406 while running — the happy path — so it raisesGsaNotReadyErrorrather than collapsing "still going" into "broken"/data/downloadneedsformat=expr; omitting it is 400 andtsvis rejected"NA"cannot sort to the top as the most significant pathway in the analysisIdentifiers are interpolated into URL paths and come from the model, so they are constrained to what real ones look like — a value containing
/addresses a different endpoint, which is a bypass rather than a 404. Both POSTs validate the ID they read from the body instead ofstr()-ing whatever arrived.No browser user-agent, unlike
analysis/client.py.gsa.reactome.orgserves hypercorn directly and answerspython-httpxwith 200 — verified rather than assumed, because testing only withcurl, which is exempt from the automation block in front of reactome.org, is how a sibling service was recently published and believed reachable.Verification
Sabotage: defaulting a bad FDR to
0.0fails the ranking test; adding the user's dataset name to the model view fails the marker test.25 new tests, whole suite green, ruff and mypy clean.
Not in this PR
The Chainlit upload flow, the results file, and the async "I'll tell you when it's done" UX. Spec 012 records the constraints those must respect: Chainlit ships a 500 MB upload cap while this host has 4.8 GB free, and the run outlasts a chat turn.
🤖 Generated with Claude Code
Adversarial review of the finished branch
Three findings. The first is the one that mattered.
1. The model was sent a capability, not a citation
for_modelincludedbrowser_links, and the Pathway Browser URL embeds the analysis token:Anyone holding that token can fetch the whole result back from the service — including
mappings(the user's own gene identifiers) andfold_changes(whose columns are their samples).So the allow-list stripped the user's content out of the payload and then handed over a key that retrieves it. The same disclosure by a longer route — and the marker test could not see it, because the marker is not in the URL.
The link now goes to
for_user, for the chat to render. The person should absolutely have it; the model should not.2. An empty result was reported as an exact zero
A result whose table did not arrive parses to no pathways, and
for_modelsaidpathway_count: 0, counts_are_exact: True. A summary built on that tells the user their data contained no enriched pathways — a confident answer to a question nobody managed to ask. It now reportsno_resultand stops claiming exactness.3.
repr(GsaResult)printed the whole tableA dataclass generates
__repr__over every field, andraw_tableis ~500 KB in a real run. Onelogger.debug("%s", result), one exception context, or one failing assertion would put the entire pathway table into a log. Nowfield(repr=False).Care over what reaches a model is wasted if the same content reaches a log file by default.
Verification
Each finding has a test, each verified by sabotage against the specific test written for it:
browser_linksback in the model viewtest_the_analysis_token_never_reaches_the_modeltest_an_empty_result_is_not_reported_as_an_exact_zerorepr=Falsetest_the_result_does_not_print_its_tableBoth absence assertions are paired with controls — the user's link must still reach the user, and a real result must not be flagged as missing.
Also:
mypy srcis not what CI runsCI lint failed here while ruff and mypy passed locally, because CI runs bare
mypyover 159 files and I ranmypy srcover 152. The seven it misses include the tests. Addedchecks.sh, which runs the four commands fromci.ymlverbatim with no pipes around them — an earlier commit in this repo shipped six mypy errors because the exit code read was a pipe's. Verified it can go red: swapping inmypy --strictgives1 of 4 checks FAILED, exit 1.30 tests in
tests/gsa, whole suite green.