avoigt1121
merge: label-join decided as route (c) β€” no cBioPortal PDAC subtype call exists
83b08b3
|
Raw
History Blame Contribute Delete
166 kB
# pdac-genomics-agent β€” memory
Current status + history. Architecture β†’ `CLAUDE.md`. Backlog β†’ `TODO.md`.
---
## 2026-08-14 β€” The subtype-label search is closed: no cohort exists, and the tab now says so
Re-opened the "find a cohort with variant data **and** an in-metadata subtype label" item, and it
closes as a **negative result with evidence** rather than a curation. The 2026-07-28 sweep was
re-run, not trusted: **539 cBioPortal studies** (one added since), **19,165 clinical attributes**,
**680** of them on pancreatic studies. **No study carries a Moffitt / Bailey / Collisson / PurIST
molecular-subtype attribute.**
Three near-misses, all correctly rejected β€” and the third is new to the record:
| candidate | values | why it is not a subtype label |
|---|---|---|
| `paad_tcga_pan_can_atlas_2018.SUBTYPE` | `"PAAD"` Γ—152 | a disease code, not a subtype |
| `paad_iatlas_prince_2022.IMMUNE_SUBTYPE` | C1–C6 | Thorsson immune clusters, different taxonomy |
| **`pdac_msk_2024.GENOMIC_GROUP`** ("Genomic Subtype") | `KRAS-MUT` 2209 / `Other-MAPK-MUT` 76 / `MAPK-WT` 51 | **DNA-derived** β€” it *is* mutation status. Joining it against KRAS would return a near-perfect association that is pure tautology wearing a p-value. |
`GENOMIC_GROUP` is the one worth remembering: its display name is literally "Genomic Subtype", it
sits on our largest cohort, and it would have produced the most persuasive-looking result in the
repo. The closed vocabulary (ADR-0006 Β§3) rejects it on values, and it is not in
`SUBTYPE_ATTRIBUTES` so it is never even probed β€” the guard held twice.
**So the documented fallback was taken.** The Variant Γ— subtype tab now states plainly that the
join is **orchestrator-only today**, names why each near-miss is refused, and says a routed result
is the *correct answer rather than a bug* β€” the failure mode being a user who clicks "Test
association" seven times, gets a route every time, and reads it as broken. The controls stay live:
a cohort that does carry a label (or one named explicitly via `subtype_attribute`) still joins.
**A false claim in ADR-0006 was corrected, not quietly patched.** Its Context Β§2 cited
`paad_tcga β†’ MOFFITT_SUBTYPE` as a live local-join example, and Β§1's sample payload still uses it.
That cohort has never carried it. A dated correction block now records this: **the contract is
unaffected** β€” the local branch is specified, implemented and fixture-tested, and is correct the
moment a labelled cohort is registered. What changed is only its *status*.
**Two tests lock it** (`tests/test_variant_by_subtype.py`): every curated artifact must route to
the orchestrator (with a non-empty-list guard so it cannot pass vacuously), and the UI caution must
carry the "no curated cohort does today" sentence. The first is the useful one β€” a future curation
that *does* add a label fails it, forcing the UI copy to be revisited alongside the data instead of
silently going stale. **247 tests pass** (was 245).
Curation-time API only (ADR-0005 C4); nothing was curated, so no artifact changed.
---
## 2026-08-14 β€” the same question from the orchestrator side: route (a) was already built
Ran independently of, and reached the same negative result as, the entry above β€” **read that one
for the portal-wide sweep and the near-miss table; it is the fuller record.** Confirmed here on a
narrower probe (all attributes of the 7 curated studies + all 59 pancreas-matching studies), with
one addition worth keeping: `paad_cptac_2021`'s full 43-attribute list contains nothing
subtype-like at all, so the "CPTAC carries Moffitt" intuition from the registry table does not
transfer β€” that label lives in the **expression** manifest (`cptac_pda_counts`), the side a DNA
agent structurally does not read. "Curate in a labelled cohort" was never a small unlisted-
attribute fix; there is no attribute to list.
**What this lane adds: the route with the real product value is already implemented and live, in
the other repo.** `pdac-analysis-orchestrator` has `cross_modality_join.py` (its ADR-0003),
`router._maybe_cross_modality_join`, and three test files, and its `agents.yaml` registers this
agent with `cross_modality_join: {status: implemented, partner_agent: pdac_subtype_agent}`. Its
`EXPRESSION_COUNTERPART` maps `paad_tcga β†’ tcga_paad_tpm` at **150/150** sample ids matched β€” after
a barcode-normalisation fix, since cBioPortal's 15-char barcodes vs the h5ad's vial letter matched
0/150 exactly at first β€” and `paad_cptac_2021 β†’ cptac_pda` at 131/131. Its statistics are a
deliberate mirror of `subtype_join.contingency_and_test`, so one cohort gets one answer whichever
route reaches it.
So the three-route question in `TODO.md` resolves to **(c)** on evidence, not preference: (b) is
refuted upstream and (a) needs no scoping because it already ships. Nothing to build; the in-agent
half stays correct, tested and dormant, and the only real defect was wording β€” now merged with the
other lane's tab copy (both survived; neither was discarded).
Left for the orchestrator repo, not touched from here (file partitioning): its `agents.yaml`
`dataset_clarification` still calls `paad_cptac_2021` "the one cohort the variant Γ— subtype join
can run on" β€” stale, and contradicted by a comment four lines above it. Display-only text.
## 2026-08-14 β€” the 300,000-pixel raw-result accordion (`2f6f12d`, dev-verified)
**The TODO's stated cause was wrong, and acting on it would not have fixed the page.** The item
blamed `samples_profiled` printing 2,336 ids three times. Measured: that is 7,008 lines of 21,187.
`per_sample` + `provenance` are ~13,200 more β€” KRAS alone is altered in 2,188 of 2,336 samples.
Eliding the named block would have left ~14,000 lines and a still-unusable page. **Measure the
payload before believing a size diagnosis**; the block that is easiest to name is not necessarily
the one that dominates.
**Nothing was removed from the payload, and that was the constraint, not a courtesy.**
`samples_profiled` is the only way a machine caller can tell wild-type from never-sequenced (the
`per_sample_contract` in `deploy/orchestrator_registration.yaml`); dropping it is exactly what made
the orchestrator's join report every gene as "no variation in alteration status". So the fix is a
DISPLAY split: `_as_json` (machine) is untouched and was verified over `gradio_client` to still
return 3 Γ— 2,336 ids as lists, while the UI gets `_ui_json` over the same object.
**Four tiers, widest first.** A summary table (gene Γ— modality, each cell carrying the percentage
AND the fraction β€” `93.7% (2188 / 2336)`, because the denominator is the thing people get wrong);
the three charts; a download of the complete payload; then the abridged JSON. The table is what
makes the abridgement acceptable β€” the numbers are on the page rather than inside a collapsed
accordion.
**The abridged block is deliberately a different SHAPE from what it replaces** β€” a dict where a
list was, stating the true count. A truncated list that still looks like a list is how a reader
ends up believing a cohort has 8 profiled samples. Small payloads (denial, refusal, error) are
under the threshold and pass through verbatim, so the ADR-0004 denial-visibility guarantee is
untouched; the anonymous click-through on the dev Space confirms it live.
**`gr.Code(max_lines=...)` DOES NOT WORK in gradio 6.18.** Measured in the browser: the code box
still rendered 23,323px tall with `max_lines=25` set. The height cap is CSS on an `elem_id`, and
that is what makes the page height constant rather than a function of the payload β€” abridgement
alone only got ~300,000px down to ~25,681px. Final: **2,818px with the accordion open.** If this
regresses, check the CSS before checking the abridgement.
**Empty summary cells NAME their absence** ("not on DNA panel" / "not in cohort" / "not curated")
and are never blank or zero β€” the same anti-padding rule as the charts. NRG1 on `pdac_msk_2024` is
the case that proves it: `0.3% (6 / 2336)` in the SV column, refused in the other two.
The clearing frame blanks the two new slots as it does the charts. A stale download link is the
worst version of that bug: the file leaves the page entirely and gets opened later with nothing to
say which query produced it.
---
## 2026-08-14 β€” the modality radio was out of date with ADR-0008 (`9f3852c`, dev-verified)
The TODO item asked a judgment question β€” should the UI offer a third modality choice, or should
the test suite own the `unsupported_modality` case alone? **Both, and they are separable questions
that were tangled together.**
**The refusal can never be reached from the radio, whatever its choices.** A `gr.Radio` validates
the submitted value against its OWN `choices`, so gradio returns the generic
`Value: 'fusion' … is not in the list of choices` before `variant_by_subtype` runs. Widening the
radio moves that wall; it does not open it. The only surface that can carry a free-form modality is
the `gr.api` endpoint the orchestrator calls, so that is where the refusal is now exercised β€”
permanently, not as a stopgap. **"Make the refusal reachable from the UI" was the wrong frame.**
**But the radio was independently wrong.** `["mutation", "cnv"]` was a hand-written literal that
outlived the SV modality (ADR-0008) for its whole existence: the tool accepted `sv`,
`deploy/orchestrator_registration.yaml` published `sv`, and the only surface refusing it was the UI
β€” offering a NARROWER contract than the machine endpoint. That is the same family as the modality
fallback the refusal exists to prevent (a surface answering a different question than its label),
one level up. Choices are now derived from a new `variant_by_subtype.SUPPORTED_MODALITIES`; the
regression test asserts **parity with the tool's closed set**, not the refusal, so it fails on the
old literal *and* on a future fourth modality. Verified failing against the two-choice radio first.
**Checked the SV choice was not decorative before adding it.** On `ccle_broad_2019` (SV plus a
named subtype attribute) the join runs and returns contingency counts; most genes fall below
`MIN_ALTERED_FOR_TEST` and report `testable: false`. Sparse-but-labelled with the SV rarity caveat
beats a capability the UI hides β€” the alternative is the UI deciding for the reader that an honest
answer isn't worth showing.
**A bug this change would otherwise have shipped.** `unavailable_modality` and
`unsupported_modality` had no branch in `_subtype_caution_md` and fell through to `caveats`, which
a refusal does not carry β€” the box stayed `visible=False` and the reader saw an empty plot and
nothing else. **That is the ADR-0004 invisible-denial failure in a second place**, harmless only
while unreachable: 5 of the 7 curated cohorts publish no SV, so adding the choice made
`unavailable_modality` an ordinary click result. Both routes now render; `unavailable_modality`
also echoes `modality` so the answer can name its own subject.
**Surveyed while deciding, worth keeping:** *no* curated cohort auto-detects a subtype label. Six
carry only `CANCER_TYPE` + `ONCOTREE_CODE`; `ccle_broad_2019`'s `SUBTYPE` is histology, which
`looks_like_pdac_subtype` rejects on purpose. So every registered cohort routes to the orchestrator
unless the caller names `subtype_attribute` explicitly. Correct, but it means the label-join half
and its modality argument are reachable only by the explicit path today β†’ new TODO item. Also note
the ordering: the label check precedes the modality check, so a bad modality on a label-less cohort
reports `orchestrator`, not `unsupported_modality`.
**Dev-verified live** on `anne-voigt/pdac-genomics-agent-dev` at `9f3852c`: `/config` serves
`[mutation, cnv, sv]` with `mutation` as default; via `gradio_client` with an explicit
`x-orchestrator-token` header, `"fusion"` and `"mutaton"` both return
`route: "unsupported_modality"` with `supported_modalities: [cnv, mutation, sv]`, `""` and `" "`
still resolve to `mutation`, and `sv` returns a real join. 255 tests pass.
**PROMOTED TO PROD same day** (`f9cee52..f2d366c`, Annie's call). Gates clean β€” 255 tests,
pip-audit "no known vulnerabilities", gitleaks 97 commits no leaks; hook mirrored to `github`
first; build watched to `RUNNING` (the wedged-restart trap did not fire). Verified on prod:
`/config` serves `[mutation, cnv, sv]`, `"fusion"` β†’ `unsupported_modality`, blank β†’ `mutation`,
`sv` β†’ a real join. **The first push was rejected non-fast-forward** β€” another lane promoted while
the gates were running. Reconciled by **merge**, not rebase (those shas are published), then
re-pushed. Worth remembering: run the gates, but re-fetch immediately before the push, because a
clean gate run is not evidence the remote stood still.
**Integration note:** the push to `hf-dev/main` fast-forwarded cleanly from `35722c3`, so the work
is on the integration tip without a merge commit. The local `dev` branch is checked out in another
lane's worktree and was left untouched β€” it needs only a pull. Not promoted to prod; that is Annie's
call. No ADR claimed: this applies ADR-0008 to the UI surface rather than contesting it.
---
## 2026-08-13 β€” dev Space configured & verified: the gate is live on staging (no code change)
The dev Space `anne-voigt/pdac-genomics-agent-dev` went from "RUNNING but unconfigured" to a
valid staging target for gate changes. Anne entered the settings; this lane prepared the exact
values and verified afterward.
**Settings now on dev** β€” variables: `ACCESS_CONTROL=enforce`, `LOG_SINK=hf`,
`LOG_SINK_HF_DATASET=anne-voigt/pdac-genomics-results-dev`; secrets: `ADMIN_IDS`, `ALLOWED_IDS`,
`LOG_SINK_HF_TOKEN`. Two corrections to the TODO item as written:
1. **It omitted `LOG_SINK=hf`** β€” the dataset/token vars are inert while the sink defaults to
`local` (`logging_sink.get_log_sink`).
2. **"`ADMIN_IDS`/`ALLOWED_IDS` as variables, matching prod" was wrong about prod** β€” checked via
the HF API: prod holds them as *secrets* (only `ACCESS_CONTROL` is a visible variable). Dev now
matches prod's actual posture.
Also found while checking prod: **prod had no `LOG_SINK*` settings at all and its default dataset
`anne-voigt/pdac-genomics-results` did not exist** β€” prod audit records were going to ephemeral
`./run_logs` and dying on every restart. Spun off to a separate lane (which created the prod
dataset and set the prod variables; its `LOG_SINK_HF_TOKEN` secret was still pending Anne at the
time of writing β€” see the status fragment).
**Verification (all live against the dev Space):** machine API with no credential β†’
`machine_auth: no_token_header` denial; bogus token β†’ `token_unresolved` fail-closed denial;
valid token β†’ resolves and returns the 36-gene panel. Audit records with `enforced: true` land in
`pdac-genomics-results-dev` under `runs/`, and none in prod's dataset. Signed-out UI access is
moot while the Space is private (HF's own wall precedes ours), and the anonymous-denial records
prove the gate's fail-closed path regardless. Note learned: a *successful* `panel:api` call
writes no audit record by design β€” `panel()` returns after the gate without `record_run`; only
denials are recorded on that endpoint.
Created the private dataset `anne-voigt/pdac-genomics-results-dev`. This item gated the
"make the dev Space public?" decision β€” that is now unblocked.
## 2026-08-13 (latest) β€” dev Space PUBLIC; the staging lane is whole
Anne flipped visibility the same day. Post-flip verification, all green:
- `space_info` (anonymous) β†’ `private: False`; `anne-voigt-pdac-genomics-agent-dev.hf.space`
returns HTTP 200 with no cookies and renders the full app β€” the 404 / cross-origin-iframe /
`__sign=` password-form maze from 2026-08-05 is gone.
- Signed-out **click-through** on "Query variant status" β†’ `{"status": "denied", "reason":
"πŸ”’ Please sign in…"}`. The ADR-0004 UI gate is enforced and fail-closed on staging, matching
prod's public+gated model.
- Allow-listed access answers with grounded numbers (machine path, same day: KRAS 136/150 =
90.7% on `paad_tcga`); audit records land in `pdac-genomics-results-dev` only.
**Consequence: the dev lane can now verify that the Space answers, not merely that it boots.**
Server-shaped changes (auth, secrets, artifact loading) no longer take their first real
click-through in production. The 2026-08-05 caveat about local-run verification being the only
option is retired.
---
## 2026-08-13 (later) β€” dev Space configured; gate verified pre-flip; only the public flip remains
Anne set the dev Space settings the same day. Verified from here while the Space is still private
(machine path works through privacy; only browser click-through doesn't):
- Variables read via `HfApi.get_space_variables`: `ACCESS_CONTROL=enforce`, `LOG_SINK=hf`,
`LOG_SINK_HF_DATASET=anne-voigt/pdac-genomics-results-dev`. `ADMIN_IDS`/`ALLOWED_IDS` are
secrets β€” and that MATCHES prod (another lane checked prod the same day); the old TODO wording
"as variables, matching prod" misdescribed prod. `CBIOPORTAL_CONTACT` deliberately unset.
- Gate, three ways: allow-listed token in `x-orchestrator-token` β†’ real grounded answer
(KRAS 136/150 = 90.7% on `paad_tcga` β€” the post-denominator-fix number); garbage token β†’
`token_unresolved`; no header β†’ `no_token_header`. Fail-closed confirmed on staging.
- Sink separation: the test runs wrote 6 `runs/*.json` into `pdac-genomics-results-dev`;
prod's `pdac-genomics-results` has none.
- ⚠️ Tooling trap worth remembering: gradio 6's `Client(token=…)` does **not** send
`x-hf-authorization` β€” without an explicit `headers={"x-orchestrator-token": …}` even a valid
admin token reads as `no_token_header`. The 2026-07-28 "gradio_client attaches it automatically"
note no longer holds on the pinned client.
Remaining: Anne flips visibility β†’ public (Settings UI), then the browser-level checks
(`*.hf.space` loads, signed-out UI denied, signed-in allow-listed answers).
---
## 2026-08-13 β€” dev-Space visibility: recommendation written, flip awaits Anne
The "public vs prod-only click-through" decision (TODO, open since 2026-08-05) now carries a
worked recommendation + runbook in TODO.md: **make dev public with `ACCESS_CONTROL=enforce`**,
matching prod's model. The load-bearing findings: the licensing argument for privacy is stale β€”
`ccle_broad_2019` and `pdac_msk_2024` have been served by the *public* prod Space since
2026-07-28, with the Broad continuity clause discharged in-repo and the ND question resolved
2026-08-05, so a public dev Space adds no new data exposure; secrets are unaffected by
visibility; the only genuine precondition is sequencing β€” the gate is DARK on dev, so secrets/
variables must be configured (and gate-verified via `gradio_client` + token, which works while
private) BEFORE the flip. Both the secret-setting and the visibility flip need the Space settings
UI, so this is Annie's action; verification steps are in the runbook. No push made from this lane.
---
## 2026-08-06 β€” legibility: the header names SV, and the chart no longer stacks (`408908b`, NOT deployed)
**Header.** Title and the variant-status blurb both said "mutation and copy-number status". SV has
been shipping since `0f39c3b` and was named nowhere a reader looks first.
**The stacked chart was wrong twice over, not merely cramped.** A sample can be BOTH mutated and
copy-number altered, so the segments are not disjoint and stacking double-counts; and the modalities
have **different denominators** (`ccle_broad_2019`: 53 / 44 / 41 profiled; `paad_tcga`: 150 mutation
vs 184 cnv), so the segments are not percentages of the same thing. A stacked bar asserts "these
parts make up the whole". KRAS reached ~120% on an axis labelled "% altered".
Now **one chart per modality**, each naming its own denominator, each a single series. Gradio's
native BarPlot has no grouping control and the Space has no other plotting library, so splitting is
the only honest option inside the current dependency set β€” and it deletes the categorical colour
scale whose stale domain caused both wrong-answer bugs of 2026-08-05.
**Two further distortions surfaced only by looking at the rendered result:**
1. Autoscale started the y-axis at the **data minimum** β€” a CCLE chart ran from 20%, so SMAD4 at
~21% looked like nothing beside KRAS at 90%. Baseline now pinned to zero.
2. Fixing the ceiling at 100 to match then made SV unreadable: NRG1 is 6/2336 = **0.26%**, a
sub-pixel sliver β€” the same "reported as nothing" failure in new clothes. Ceiling is fitted per
chart, floored so one tiny bar is not stretched to full height, capped at 100.
**A third bug caught BEFORE shipping, which is the point.** The clearing frame first hid all three
charts; the answer then re-showed them. That hide→show flip lost the race for the third chart about
half the time β€” it stayed unmounted (browser: "null does not exist"), so a cohort **with** structural
variants rendered as mutation+CNV only, reading as "no fusions here". The clearing frame now blanks
each chart's VALUE and leaves visibility alone.
**Deployed `3ad3d85` 2026-08-07 and re-verified on the live prod Space:** header + tab blurb name
the fusion modality; a fresh-load FIRST query draws all three charts (53 / 44 / 41 profiled);
`paad_tcga` draws exactly two and no SV; narrowing to NRG1 draws one SV chart on a single click
and widening back draws three with all 33 genes and KRAS present. Gates clean beforehand (237
tests, pip-audit, gitleaks).
**What caught it was re-running the same query from a fresh load instead of accepting one good
render.** The first render looked right; the failure only appeared on a first query after load, so a
single happy screenshot would have shipped it. That is the third time in two days this exact class β€”
a chart silently not showing β€” has appeared, and the first time it was caught before deploy.
Verified across 4 fresh-load trials (including a click during hydration and the double-click that
first exposed it), plus `paad_tcga` showing exactly 2 charts, plus both narrow→wide and wide→narrow
on single clicks. **237 tests** (was 235).
---
## 2026-08-05 (latest) β€” I shipped a regression to prod and caught it in the post-deploy click-through (`819da30`, NOT deployed)
`23d37b6` went to prod and fixed the three real bugs. It also introduced a fourth, which the
post-deploy click-through found: **on a query that NARROWED the gene set, the chart body lagged one
render behind** β€” caution and legend updated, the bars kept the previous answer's data. One cohort's
numbers under another cohort's warning, which is the same class of wrong answer the whole change set
existed to remove.
**`colors_in_legend` was the cause, and I had added it as the *cosmetic* half of the fix.** Passing a
value that CHANGES between renders is what forced the colour scale to rebuild β€” which is what fixed
the narrow→wide direction. It was never cosmetic; it was load-bearing for a reason I had not
understood. Measured, each against `40ab3dd` as control:
| build | narrow β†’ wide | wide β†’ narrow |
|---|---|---|
| `40ab3dd` (before) | marks dropped | correct |
| `23d37b6` (`colors_in_legend`) | correct | **stale one render** |
| `color_map` alone | marks dropped | correct |
So the two configurations I had were **the same bug pointing in opposite directions**, and the
direction I happened to test first was the one that looked fixed.
**Also tried and rejected:** holding the modality categories open with NaN placeholder rows so the
domain never changes shape. NaN poisons the y extent β€” no bars and no axis render at all. (Zeros
would have worked visually and were never an option: NRG1 is not 0% mutated, it is unassayed.)
**The fix stops relying on the plot diffing correctly.** `_ui_variant_status` is now a generator: it
blanks the chart, then yields the answer. The worst intermediate state a reader can catch is an
EMPTY chart, which claims nothing, rather than a stale one, which claims something false. Both
directions verified repainting on a single click. `test_ui_handler_gating` reads the final frame via
`_final()`; the ADR-0004 assertions are untouched and a denied path still yields exactly one frame.
**Deployed `125b8a5` 2026-08-06** (gates clean: 234 tests, pip-audit, gitleaks; hook mirrored to
`github` first). **Verified on the live prod Space in both directions, each on a single click** β€”
CCLE wide β†’ MSK+NRG1 narrow (correct NRG1 bar immediately, not CCLE's) β†’ full panel wide (KRAS ~94%
across all 33 genes). The OAuth session drops when the Space rebuilds, so sign in again before
verifying; an anonymous query renders nothing visible because the plot is None and the JSON
accordion is collapsed.
**Three process lessons, all of them mine:**
1. **I explained away the symptom the first time.** I saw the stale chart locally, guessed "my click
missed the button", and moved on. It had not missed. A surprising observation deserves one cheap
check before a comfortable explanation.
2. **A fix verified in one direction is not verified.** Both bugs here are the same scale-rebuild
fault; testing narrow→wide only was the whole gap.
3. **Green tests proved nothing about either.** Every version passed its own tests, because the tests
asserted the payload and the hypothesis, not the rendered result across a SEQUENCE of renders.
---
## 2026-08-05 β€” first signed-in click-through since 2026-07-27; two display bugs found (`6c7b8cc`, NOT deployed)
Everything shipped that day had been verified only by reading the deployed tree over `resolve/main`
and by the local suite. That proves what is *served*, not that it *answers* correctly, and the panel
had since gone 19 β†’ 36 genes and gained a third modality. So: signed in as `anne-voigt` and ran real
queries. **Every payload the Space produced was correct.** Both bugs are display-layer, which is why
219 passing tests saw neither β€” and which also means the orchestrator's machine path, reading JSON
rather than pixels, was never affected.
**Passed as specified:** NRG1 on `pdac_msk_2024` returns `assayed: false` *and* a valid `sv` block at
6/2336 (the ADR-0008 modality-scoped gate, live); `paad_tcga` names `sv` in `unavailable_modalities`
with no SV numbers anywhere; BYOD refuses a `Fusion` MAF with `sv_not_supported` and accepts a clean
one as `grounded=false`; licence caution + `citation.curation` ride on every answer; the machine
endpoints still refuse a cookie-only caller.
**The chart could show an empty panel while the JSON said 93.7%.** The colour scale's domain was
derived from the current frame and never rebuilt when the next frame carried more categories. NRG1
alone β†’ sv-only chart; the next full-panel query redrew with the legend still `["sv"]` and two thirds
of the marks dropped. The y-axis rescaled to 0–100 β€” proof the data had arrived and only the colour
scale was stale. Same staleness in the other direction put an `sv` swatch on `paad_tcga` under a
caution saying it has no SV data: **one cause, both symptoms**, and it only loses data narrow β†’ wide,
which is exactly the direction a scientist moves (check one gene, then open the panel).
Fixed by pinning `color_map` + per-answer `colors_in_legend`.
**The tempting fix for that is wrong and there is now a test saying so.** Padding every gene to all
three modalities would stabilise the category set β€” and report NRG1 as 0% mutated on a cohort that
never sequenced it. That is the false negative `assayed: false` exists to prevent. Stabilise the
SCALE, never the DATA.
**`caveats` never reached the variant-status tab.** Only `_subtype_caution_md` read it, so
`ccle_broad_2019`'s uncharacterized-SV warning β€” generated correctly, carried correctly β€” stopped one
function short of the screen, on the one tab where SV data is actually queried. A warning that exists
so "no in-frame fusions" cannot be read as biology is not doing its job from inside a collapsed JSON
accordion.
**226 tests pass** (was 219). The new `tests/test_plot_and_caveat_rendering.py` asserts on the
*rendered surface* β€” the plot's colour scale and the caution markdown β€” because that is the only
place either bug existed.
**Still open, reported not fixed** (see `TODO.md`): item 5 of the check (unsupported-modality refusal)
is unreachable from the UI because Modality is a radio, and Gradio's own choice validation intercepts
first; no curated cohort carries a subtype label, so the label-join half cannot be demonstrated in
the Space.
---
## 2026-08-05 (later) β€” the SV under-call: a plausible cause that was wrong, and the real one (`03e0388`, `e0d0ac0`, NOT deployed)
Follow-up to the click-through above. The six NRG1 events on `pdac_msk_2024` really were being
reported as bare `rearrangement` while the payload's own provenance called them in-frame fusions β€”
but **the cause was not what I first said it was**, and the way that surfaced is the part worth
keeping.
**The wrong answer.** The provenance text reads "an **in-frame** fusion", hyphenated, and
`classify_sv` tested `"in frame"`. Clean, sufficient, matched the evidence I had. Fixed it, added
tests, they passed, committed (`03e0388`). Then re-curated β€” **and the diff was empty. Zero cells
moved.** Nothing else would have caught it: the tests were green, because they tested the
hypothesis rather than the cohort.
**The real answer**, read off the live API rather than inferred from the artifact:
| field | value |
|---|---|
| `eventInfo` | `ATP1B1-NRG1 Fusion - Archer` ← a bare label |
| `annotation` | `POSITIVE FOR GENE FUSIONS…: The rearrangement is an in-frame fusion between genes ATP1B1 Exon2 (NM_001677) and NRG1 Exon2 (NM_004495).` ← the finding |
| `site2EffectOnFrame` | `'NA'` β€” on **all 110** somatic panel rows |
`classify_sv` read only `eventInfo`, so those rows fell to the bare-partner default **correctly**.
The frame claim lived in a field nothing consulted. The hyphenation was real but downstream of the
actual gap β€” it only matters once `annotation` is read at all, because that is where the hyphenated
spelling lives. `site2EffectOnFrame` is the field that ought to carry this and is the same trap
`variantClass` already set for CCLE: structured, present, and `NA` throughout.
**Scope was larger than first reported: 9 cells over the whole KRAS-wild-type actionable tier** β€”
NRG1 Γ—6, NTRK1, NTRK3 (ETV6-NTRK3), RET (NCOA4-RET) β€” not 6 NRG1 alone. The RET cell went
`fusion_out_of_frame` β†’ `fusion_in_frame`, the only transition that *reduces* caution: that sample
carries two RET events and the in-frame row now outranks the other under the existing specificity
ordering, with provenance moving to match.
**Why `annotation` is safe to read**, checked across both SV cohorts before trusting it: no row says
"negative", none says "out of frame" there, and CCLE's 322 annotations never mention a frame at all
(they are partner/panel strings). So it can only fire where the source states a frame. `eventInfo`
still wins where both make a claim β€” annotation is a fallback, not an override. `--dry-run` confirms
CCLE still reports `uncharacterized`, so its caveat is untouched; that was checked deliberately,
since the caveat rests entirely on bare labels never being promoted.
**All three fixes verified by clicking through the merged build** (`d956d91`), 2026-08-05:
- `ccle_broad_2019` now renders the caveat in full β€” "…this cohort can never return
`fusion_in_frame`. Read an absence of in-frame fusions here as a fact about the annotation, NOT
about the biology" β€” **alongside** the Broad licence line, not instead of it.
- The chart regression is gone: NRG1-alone (single red `sv` bar, y-axis 0–0.30, `sv`-only legend)
followed immediately by the full panel now draws KRAS ~94%, TP53 ~78%, CDKN2A ~40% across all 33
genes with the full mutation/cnv/sv legend. That is the exact sequence that used to draw empty.
- `sv` renders **red** in both the narrow and wide charts β€” the colour no longer depends on how many
modalities happen to be present.
- NRG1 on `pdac_msk_2024`: `assayed: false` + its note (the ADR-0008 gate is intact), 6/2336 =
0.257%, and all six now `fusion_in_frame` rather than bare `rearrangement`.
⚠️ **The dev Space could NOT be used for this, and this is structural, not a one-off.** It is
**private**, so `anne-voigt-pdac-genomics-agent-dev.hf.space` returns **404** β€” the session cookie
lives on `huggingface.co`, not on the `hf.space` origin. Through the embed on the Space page the app
renders but is a cross-origin iframe, and synthetic clicks do not reach it. Promoting the iframe's
signed `__sign=` URL to top level lands on a **username/password form** with "Could not get API
info. Connection errored out." So: **while the dev Space is private, it can be built and booted but
not clicked through.** The verification above was done against the merged branch running locally on
the pinned gradio 6.18.0 β€” legitimate here because both display bugs are purely client-side, but it
is NOT a substitute for a real click-through of anything server-shaped. Make the dev Space public
(or accept prod-only click-through) before relying on the dev lane for UI verification.
**Two habits earned their keep and should be kept:** re-curating with `--check` first (all seven
`up_to_date`, so this was a re-derivation and not a data refresh hiding behind a code change), and
reading the re-curation diff **cell by cell** instead of trusting the summary line β€” which was
identical (`106 SV cells (characterized)`) before and after both attempts.
---
## 2026-07-29 β€” the registration descriptor caught up with the Space (docs-only)
`deploy/orchestrator_registration.yaml` exists so "the trigger keywords and tool list can't drift
from what the agent actually exposes". It had drifted on six counts, all now fixed against the live
Space at `894e710`: `genes` is a **comma-separated string** (not `list[str]`; blank β‡’ the full
panel), `variant_by_subtype` takes **four** args (`genes, source, subtype_attribute, modality`),
`/panel` was missing entirely, `samples_profiled` was undocumented, the cohort list still said four,
and the auth/visibility notes still said "PRIVATE until the gate is enforced" (the gate is
ENFORCED, the Space PUBLIC since `0ffadd1`) with no mention that a machine caller must use the
custom `x-orchestrator-token` header.
Nothing was broken by any of it β€” the orchestrator's own `agents.yaml` was written from the live
endpoints, not from this file. The risk was only that the next reader trusts it, which is exactly
the risk the header claims the file removes.
Three things worth keeping:
- **The `per_sample` sparsity rule is now a named section of the descriptor** (`per_sample_contract`),
not a footnote: for a gene with `assayed != false`, a sample in `samples_profiled[modality]` but
absent from `per_sample` is WT/neutral; a sample NOT in that list was never interrogated and is
neither β€” exclude it from a join rather than counting it WT. The orchestrator shipped a join
reporting all 19 genes as "no variation" precisely because it had to guess this.
- **The cohort set is no longer restated as a list of facts** β€” the descriptor now says the set IS
`src/resources/curated/*.json` and gives the seven only as an illustrative tail. This is the
second time the restated list went stale, and prose is not the thing to trust twice.
- **Bare `KRAS` was DROPPED from `trigger_keywords`, with the reason recorded inline.** This is the
orchestrator's deliberate divergence, not an oversight to correct: bare KRAS collides with
`pdac-subtype-agent`'s KRAS-Science-2024 signature sets and DecoupleRpy pathway questions, and the
hotspot alleles G12D/G12V/G12R carry the DNA intent. Verified live in prod β€” "Score the KRAS
signatures on gse205154_sears" reaches the subtype agent. The comment says don't re-add it.
**The drift was invisible to the tests, so the tests changed too.** `test_deploy_surface.py` only
did string containment on tool names, which catches a rename but not a wrong signature. Two new
structural tests: the descriptor's `args` are compared to the live `inspect.signature` (names AND
`str` types, per endpoint), and the cohort ids it mentions are compared to the curated directory.
Both fail on the drift they exist to catch. **174 tests pass** (was 169; the descriptor file also
turns out not to have been valid YAML before β€” `{genes: list[str]}` is not a legal flow mapping, so
nothing had ever parsed it).
## 2026-07-28 β€” `paad_cptac_2021` curated + DEPLOYED (`b79f05e`); and a payload gap it exposed
Curated **alongside** `pancreas_cptac_gdc`, not instead of it. Same CPTAC-3 patients, same paper
(Cao et al. *Cell* 2021) β€” never pool them. Each covers what the other cannot:
| cohort | n | CNV? | sample ids | why it exists |
|---|---|---|---|---|
| `pancreas_cptac_gdc` | 183 | **yes** | `C3L-00017-02` (aliquot-suffixed) | richer modality coverage β€” why it was picked first, and that still holds |
| `paad_cptac_2021` | 140 | no (mutation only) | `C3L-00017` | ids match the expression registry's `cptac_pda` **131/131**; the suffixed GDC ids match it **zero** times |
That id match is the point: it is what lets `pdac-analysis-orchestrator` run its cross-modality
variantΓ—subtype join (its ADR-0003), which aligns PurIST subtypes computed from `cptac_pda` against
variant status from here. The earlier note calling `paad_cptac_2021` "superseded by the GDC rebuild"
was right about CNV and simply could not see the join constraint. Both cohorts are needed.
C2 discharged BEFORE fetching: access tier from cBioPortal's own `publicStudy`/`readPermission`
(open), plus a `COMMERCIAL_USE_REVIEW` entry β€” **permitted**, CPTAC-3 open tier, confidence recorded as
one step less direct than the GDC instrument since this is the authors' own deposition rather than a
GDC rebuild. `ATTRIBUTION.md` carries the never-pool warning.
**Deployed and verified live** through the orchestrator's own `x-orchestrator-token` path: 140 samples,
`unavailable_modalities: ['cnv']`, KRAS 96.4% / TP53 75.0% / CDKN2A 20.7% / SMAD4 17.1% (identical to
local), `study_terms` surfacing open + permitted, `variant_by_subtype` correctly routing to the
orchestrator. **169 tests pass.**
**Two process notes worth keeping.** The push was rejected on a diverged remote (another lane had added
the GitHub-mirror pre-push hook and docs) and was **rebased, not forced** β€” their two new tests then ran
against the new artifact and passed. But the rebase **silently dropped this entry's first version**:
both lanes had appended to `memory.md` at the same anchor, and the replay treated the hunk as already
applied, keeping only the `TODO.md` half of the same commit. Nothing conflicted and nothing warned. If
you rebase a docs commit in this family, re-read the file afterwards rather than trusting a clean exit.
## The finding that matters more than the curation: `per_sample` is SPARSE
The first real run of the orchestrator's join exposed a **payload contract gap in
`/query_variant_status`** β€” not a curation problem:
`genes.<G>.mutation.per_sample` contains **only altered samples**; WT is omitted. Verified on
`paad_cptac_2021` (KRAS 135 entries, all `hotspot`; SMAD4 24, none WT) and `paad_tcga` (KRAS 136,
SMAD4 37) β€” the general shape, not new.
Internally harmless: `variant_by_subtype` feeds `contingency_and_test` a **dense** StatusMatrix row, so
the local label-join is correct. But a **machine caller cannot reconstruct WT** β€” it receives altered
samples only, and the payload carries `n_samples`/`n_profiled` **counts** with no profiled-sample **id
list**. The orchestrator therefore scored a WT column of 0 for all 19 genes and reported "no variation
in alteration status": a wrong reason wearing the clothes of a real finding. It fails safe (no
fabricated p-value) but it misleads. Its alignment was perfect β€” 131 joined / 140 / 131.
**FIXED (same day).** `/query_variant_status` now returns **`samples_profiled`** β€” the denominator as
an ID SET, per modality, not just as a count. `samples_profiled` was chosen over a dense `per_sample`
for two reasons: one list per modality instead of the same list repeated across 19 genes (`pdac_msk_2024`
has 2336 samples), and it keeps `per_sample` meaning "what was found" rather than "the whole matrix".
**Per MODALITY is the correct granularity here, and that had to be checked rather than assumed:** within
an assayed gene the denominator IS the modality's profiled set (`StatusMatrix.denominator`); genes a
targeted panel never carried are refused separately via `assayed: False`/`unassayed_genes`, so they never
reach the densification rule.
The documented caller rule, now stated in the payload docstring: for a gene with `assayed != False`, a
sample in `samples_profiled[modality]` but absent from that gene's `per_sample` is WT/neutral β€” and a
sample NOT in that list was never interrogated and is neither. Invariant asserted in tests:
`len(samples_profiled[m]) == n_profiled[m]`. A modality the cohort lacks contributes **no key at all**
rather than an empty list, because an empty list reads as "profiled nobody", which is a claim.
**172 tests** (+3, on the existing 4-sample partial-profiling fixture where S4 was never sequenced β€”
precisely the sample a naive "absent β‡’ WT" would invent).
---
## 2026-07-23 β€” Repo created (scaffold)
New **third specialist** behind the PDAC Analysis Orchestrator, for DNA-level analysis
(mutation/CNV status + the variant↔subtype question). Split out as its own agent per
`docs/adr/ADR-0001-genomics-specialist.md`, which repeats `pdac-subtype-agent`'s ADR-0001 reasoning
(distinct domain β†’ distinct agent) for the larger DNA-vs-RNA gap. Originating decision +
resolved scope: `pdac-subtype-agent` **ADR-0019** (Accepted 2026-07-23); Carl resolved scope
(build now/ship later; mutation + CNV; variantΓ—subtype join *and* lookup; cell-lines first, human v1).
**Feasibility proven before scaffolding** (`pdac-subtype-agent` `docs/spikes/SPIKE-dna-panel-feasibility.md`):
cBioPortal's public REST API serves mutation + CNV + expression for PDAC cell lines
(`ccle_broad_2019`) and tumors (`paad_tcga`) via per-gene JSON β€” no bulk downloads β€” and the
sample-id join across all three modalities works. KRAS returned `G12D/G12R/G12V`; driver
frequencies matched the literature.
Scaffold in place: README (HF Space front-matter, `hf_oauth: true`), CLAUDE.md, TODO.md (milestones),
ADR-0001 + the number ledger (started day one), the cBioPortal client seeded from the spike
(`src/workflows/cbioportal_io.py`), and the 19-gene v1 panel (`src/resources/pdac_panel.tsv`).
Tools are **contracts, not implemented**. **Local only β€” no HF/GitHub remote yet** (awaiting an
account/Space choice; working name `pdac-genomics-agent`).
**Now in `SHOWCASE_STATUS.md`** (row added 2026-07-24) β€” see the 2026-07-24 Space-created entry below.
Next: Milestone 1 β€” curate the cBioPortal status matrix + `query_variant_status`.
---
## 2026-07-24 β€” Milestone 1 done (status matrix + `query_variant_status`)
Core curation implemented and tested hermetically. **Contract pinned in ADR-0002**
(cBioPortal β†’ geneΓ—sample status-matrix curation contract).
- **`variant_status.py`** β€” `build_status_matrix(source, sample_ids=None)` pulls mutations +
discrete CNV via `cbioportal_io`, classifies each: mutation β†’ `WT/missense/truncating/hotspot`
(hotspot = `HOTSPOTS` explicit set **+** `HOTSPOT_CODONS` recurrent-codon rule β†’ KRAS G12x/G13x/Q61x
caught for any residue; truncating from `mutationType`; silent β†’ WT), CNV `-2..2` β†’
`deep_del/loss/neutral/gain/amp`. Specific variant kept as provenance. `WT` resolved against the
full sample-id list (mutation queries return only mutated samples).
- **Per-study modality gate** β€” `modalities` set from real profile presence; no CNV profile β‡’
`cnv=None`, tool omits it + lists it in `unavailable_modalities`. Never a null "no alteration" column.
- **`lineage.py`** β€” pancreatic subset of pan-cancer CCLE via `CANCER_TYPE`/`ONCOTREE_CODE`; refuses
on empty. Registered PDAC cohorts skip it.
- **`query_variant_status(genes, source)`** β€” per-sample status + cohort frequency + provenance +
`modalities_available`/`grounded`. Applies the lineage filter for pan-cancer sources.
- Added two client helpers: `cbioportal_io.sample_ids()` and `.clinical_data()`.
- **Tests** β€” `tests/fixtures/cbioportal_paad_mini.json` (real field shapes, anonymized samples) +
`test_variant_status.py` / `test_lineage.py`; hermetic (conftest monkeypatches the client). Smoke
test updated (M1 contracts now callable; `variant_by_subtype` still an M2 stub). **13 passed, no network.**
- **Live spot-check reproduces the spike** (not committed to CI): TCGA-PDAC CDKN2A deep-del 52,
MYC amp 23 (exact); KRAS 136 / TP53 104 / SMAD4 37 (within a few of the spike's 140/105/38);
`paad_qcmg_uq_2016` reports cnv unavailable; CCLE narrows to 57 pancreatic lines (KRAS 84%).
Run tests: `DecoupleRpy_Agent/.venv/bin/python -m pytest -q` (this repo has no own venv).
Still **local only β€” no remote**. Next: Milestone 2 β€” `variant_by_subtype` label-join + association test.
---
## 2026-07-24 β€” Milestone 2 done (`variant_by_subtype` label-join)
The stateless half of the cross-modality question. No new ADR β€” this implements what ADR-0001
already decided (general join is orchestration; the label-join is the half this agent can do alone).
- **`subtype_join.py`** β€” `find_subtype_labels(study, attr=None)` probes known subtype clinical
attributes (`SUBTYPE_ATTRIBUTES`: Moffitt/MRNA/Bailey/Collisson/PurIST/…) or an explicit one;
`contingency_and_test(per_sample_status, labels, altered)` inner-joins on sample id and runs
**Fisher's exact** (2Γ—2) or **chi-square** (RΓ—2). Returns `testable=False` + reason for a single
subtype class or an all-altered/all-WT column (no spurious p-value).
- **`variant_by_subtype(genes, source, subtype_attribute=None, modality="mutation")`** β€” for a
label-bearing cohort returns `{join_available: True, subtype_source, modality, n_labeled,
genes: {G: {contingency, n, testable, test?/reason}}, caveats}`. `modality="cnv"` supported.
Honors the M1 coverage + lineage gates.
- **Routing contract**: BYOD source, or no subtype attribute in metadata, returns a structured
`{join_available: False, route: "orchestrator", reason, note}` β€” the orchestrator then gets
subtype from pdac-subtype-agent + variant status from `query_variant_status` and joins on sample
id. Orchestrator-side wiring is NOT built here (belongs in `pdac-analysis-orchestrator`).
- **Tests** β€” `test_variant_by_subtype.py` (association unit: Fisher/chi-square/untestable; tool
join over the labeled fixture; CNV modality; both orchestrator-routing paths). Fixture gained a
`MOFFITT_SUBTYPE` clinical block; conftest patches `clinical_data`. Smoke test updated
(variant_by_subtype now callable, no longer a stub). **20 passed, no network.**
- **Live check**: real `paad_tcga` carries no subtype clinical attribute β†’ correctly routes to the
orchestrator (confirms the label-join only fires when a label genuinely exists).
Still **local only β€” no remote**. Next: Milestone 3 β€” BYOD upload + deploy hardening.
---
## 2026-07-24 β€” Milestone 3 done (BYOD + deploy hardening, code)
Code-complete; the remaining M3 items are ops tasks that need an HF Space (see TODO.md
"M3 deploy checklist"). Two ADRs published: **ADR-0003** (BYOD contract), **ADR-0004** (auth gate).
- **BYOD** (`src/workflows/byod.py`, ADR-0003) β€” `BYODSpec(genome_build, species, maf_text?,
cnv_text?)` β†’ `curate_byod_upload()` β†’ StatusMatrix `grounded=False`, never persisted. Build +
species are REQUIRED declarations (untrusted, ADR-0006 mirror). Hard-gate refusals via
`BYODRefusal(reason=…)`: `build_unsupported`, `build_mismatch` (file build column contradicts
declared), `non_human_species` (mouse), `unparseable` (missing cols / non-integer / out-of-range
GISTIC / empty). Reuses M1 `classify_mutation`/`classify_cnv`/`HOTSPOTS` verbatim; off-panel genes
dropped. Routed from `build_status_matrix(BYODSpec)` (lazy import β€” byod depends on variant_status).
- **Auth gate** (`access.py` + `tests/test_access.py`, ADR-0004) β€” ported verbatim from the siblings:
`ALLOWED_IDS`/`ADMIN_IDS`(+`UPLOAD_ADMIN_IDS`) allow-list, `ACCESS_CONTROL` dark-launch (OFF by
default, fail-closed when ON), `check_access`/`role_for`/`is_authorized`/`principal_trace_fields`.
- **cBioPortal cache/backoff** β€” `cbioportal_io._req` now caches idempotent GET metadata
(process-local, `clear_cache()`/`_CACHE_GET` for tests) and backs off exponentially. **ToS
confirmation for automated use is still an open pre-prod ops task.**
- **Orchestrator registration** β€” `deploy/orchestrator_registration.yaml` (this repo's artifact;
tools, DNA-specific trigger keywords, the cross-modality handoff contract). NOT wired into the
orchestrator here β€” that repo merges it at deploy (ADR-0001: we don't touch siblings).
- **Security scan port** β€” `security/scan.sh` (bandit/pip-audit/gitleaks/trivy β†’ git-ignored
timestamped reports) + `security/.gitignore`, mirroring the siblings. Not run in CI; run before
the OHSU review. (bandit not in the shared venv.)
- **Tests** β€” `test_byod.py` (curate + all five refusal gates + routing through build_status_matrix)
and `test_access.py` (ported gate suite). **49 passed, no network.**
Deploy surface still stubbed on purpose: `gradio_ui.py`/`src/server.py` are built at the deploy step
(M3 checklist), where the `access.py` gate + audit sink get wired in. Still **local only β€” no remote**.
---
## 2026-07-24 β€” cBioPortal terms & deployability (pre-prod gate closed)
Closed the spike's open item ("confirm cBioPortal automated-use terms + add caching/backoff") β†’
`docs/cbioportal-terms-and-deployability.md`. **Verdict: usable for a deployed prod Space WITH
CONDITIONS** β€” no hard blocker.
- **No ToS ban on automated/programmatic access** to the public API (`www.cbioportal.org/api`) β€” the
website runs on the same API and maintainers publish official clients. **Self-host NOT required for
v1.** v1 = public instance + **curate-and-cache** (runtime reads the cached status matrix; API is a
curation-time dependency only), which neutralizes rate-limit/availability risk without ops cost.
- **Conditions (all engineering/provenance, fold into M1/M2):** (C1) attribution β€” cite cBioPortal
[Cerami 2012 / Gao 2013 / de Bruijn 2023] **and** each per-study source; (C2) carry per-study
license flag (ODbL default; some studies restrict commercial use; TCGA/GDC + ICGC per-study terms;
serve only open-access, refuse controlled); (C3) polite client β€” exp backoff+jitter on 429/5xx,
self-throttle, descriptive `User-Agent` w/ contact; (C4) curate-and-cache so no live API call on
the request path.
- **No documented public rate limit** β€” absence β‰  permission; self-throttle (~2–5 req/s, serial).
## 2026-07-24 β€” HF Space created
Space **`anne-voigt/pdac-genomics-agent`** created via `hf` CLI (auth'd as `anne-voigt`), SDK gradio
(matches README front-matter), **PRIVATE + empty β€” nothing deployed** (app code isn't ready and the
M3 auth gate is unbuilt, so keep it private/ungated until launch). Working name is now confirmed as
the real slug. `SHOWCASE_STATUS.md` Fleet row added (honest state: created/private/no-deploy).
**Repo is still LOCAL ONLY** β€” `origin` is NOT yet pointed at the Space (per the sibling model,
`origin` remote IS the Space; wire that up at first deploy, on `main`, after M1/M2 merge from
`claude/serene-diffie-b17166`). Do NOT push an empty/broken tree to it.
## 2026-07-24 β€” cBioPortal public-vs-self-host ADR
- **Public-vs-self-host decision ratified** as its own ADR β†’ `docs/adr/ADR-0005-cbioportal-public-vs-selfhost.md`
(Accepted 2026-07-24): **public instance + curate-and-cache for v1, no self-host**; revisit triggers =
unavailable studies / sustained throttling / controlled-access need. **ADR-number note:** written on
this lane as 0003 (it had seen the sibling lane's 0002 and skipped it) and **renumbered to 0005 on
2026-07-25**, because that lane had *also* published 0003 (BYOD) ~2 min earlier plus 0004 (auth
gate). Different filenames β†’ git would have merged two 0003s silently. Earlier claim keeps the
number; see `docs/adr/RESERVED.md`.
- **License:** data default **ODbL** (attribution + share-alike on redistributed derived *database*;
serving derived *answers* is lower-friction). TODO Milestone 3 item marked done (research half);
implementation of cache/backoff+attribution remains in M1/M2.
---
## 2026-07-25 β€” Founding docs seeded, 0003 collision fixed, both lanes merged to `main`
Three things, in order: the founding planning docs were seeded and reconciled, the ADR-number
collision between the two lanes was resolved, and both branches were merged. **`main` now holds all
of M0–M3.**
**Seeded** β€” the founding material had been drafted in `pdac-subtype-agent/docs/genomics-agent-seed/`
before this repo existed (its README said "move these to the new repo"). Now here:
- `docs/design/DESIGN-pdac-genomics-agent.md` β€” the north-star design. Body kept **verbatim**; a new
**Β§0** records what held and what diverged. (It self-dates 2026-07-26 β€” a drafting artifact.)
- **ADR-0001 enriched, not duplicated.** The seed's own draft ADR-0001 was a longer version of the
one already published here; its missing content (founding commitments β€” new modality never a
`data_level`, status matrix, hard gates, family maturity from commit one; alternatives 4–6;
follow-ups) was merged into the existing file rather than landing a second ADR-0001.
- **`RESERVED.md` rebuilt** with the family-wide **EXTERNAL** rows (0014 security scan β†’
`DecoupleRpy_Agent`; 0015 registry semantics β†’ `biodata-registry`), which were missing here, plus a
new protocol rule 5: claims land on `main`, alone and immediately.
**The design's "hard blocker" is not one.** It made a `biodata-registry` `modality` extension
(`somatic_mutation`/`copy_number`) a hard prerequisite for M1. M1–M3 shipped without it: the build
reads cBioPortal directly and **does not depend on `biodata-registry` at all** (absent from
`requirements.txt`, no imports). Recorded as an **ADR-0001 amendment** β€” not a blocker, but an open
fork (direct-source vs registry-routed) needing its own ADR if reopened.
**ADR-0003 collision β€” resolved.** `serene` published 0003 (BYOD) at 14:04:34; `upbeat` claimed 0003
(cBioPortal public-vs-self-host) at 14:06:28, having skipped the sibling's 0002 but not its
two-minute-old 0003. Different filenames β†’ git would have merged both **silently**. The ledger failed
to catch it because each lane kept its *own copy* of `RESERVED.md`; the one-shared-row design only
conflicts when both lanes edit the same file in the same history. Fixed on the branch before merge
(`15b3ade`): earlier claim keeps 0003, cBioPortal ADR renumbered **0003 β†’ 0005** (file, header
rationale, terms-doc ref, memory refs, ledger). When the two lanes then met in `main`, the ledger rows
**did** conflict β€” the mechanism works, it just needs one shared copy.
**Merged** β€” `claude/serene-diffie-b17166` (M1/M2/M3: status matrix, `query_variant_status`,
`variant_by_subtype`, BYOD, `access.py`, security scan, orchestrator descriptor, ADR-0002/0003/0004)
then `claude/upbeat-hofstadter-353bb8` (cBioPortal terms doc, ADR-0005, Space-created note).
Conflicts resolved by hand: ledger rows unioned to a clean 0001–0005; `memory.md` keeps both lanes'
history chronologically; `TODO.md` took serene's completed M3 with the ToS checklist item flipped to
done by upbeat's research. **49 tests pass, no network.**
**Carried into `TODO.md` as pre-deploy gates:** the Rosie/Carl confirm that per-sample status is the
real near-term need (specified pre-M1, never done, now pre-deploy), and cBioPortal conditions C1–C4
(attribution, per-study license flag, polite client, curate-and-cache) β€” research closed, only the
backoff/cache half implemented.
**Left alone deliberately:** the seed folder still exists in `pdac-subtype-agent`; its README says to
delete it after moving, but deleting files in a sibling repo is that repo's call.
Still **local only β€” no remote**. Next: the deploy step (`gradio_ui.py` + `src/server.py` with the
gate and audit sink wired in), which is what unblocks pointing `origin` at the Space.
---
## 2026-07-25 β€” Deploy surface built (`gradio_ui.py` + `src/server.py`)
The last M3 checklist item that needed code. The Space can now actually run; what remains before
a deploy is ops (secrets, enforcement) and the C4 caching gap, not app-building.
**`gradio_ui.py`** β€” three tabs (variant status / variantΓ—subtype / BYOD upload) over the existing
tools, `gr.LoginButton()` for OAuth, and three `gr.api` machine endpoints for the orchestrator.
Handler shape ported from `pdac-subtype-agent`: gate first via `access.check_access`, audit either
way, return `(caution markdown, plot, JSON)`. Refusals are **data, never exceptions** β€” a BYOD
hard-gate refusal (mouse, bad build, unparseable) surfaces as `{status: "refused", reason}` with an
explanation, and any tool blow-up becomes `{status: "error"}` rather than a crashed Space.
**`src/server.py`** β€” one sub-`FastMCP` per tool module, mounted, mirroring both siblings.
**Ported to support the handlers** (ADR-0004 explicitly deferred the sink to this step):
`audit.py` + `logging_sink.py` (`LOG_SINK` = local|hf) and `licenses.py`.
**C1 (attribution) is now discharged** β€” `licenses.py` stamps a `citation` block onto every grounded
payload with the cBioPortal papers *and* the per-study source, which is what the terms review said
belongs in the response contract. C2/C4 remain open; **C4 is the real gap**: ADR-0005 ratified
curate-and-cache, but every tool call still hits cBioPortal live on the request path.
**Three MCP/api surfaces, one set of names.** Found and fixed: gradio's MCP bridge
(`launch(mcp_server=True)`) names each tool after the **Python function**, `gradio_client`
dispatches on **`api_name`**, and `src/server.py` registers its **own** names β€” so the same tool was
appearing as `_run_variant_status`, `query_variant_status`, and `query_variant_status_tool`. The
endpoint functions are now named exactly as the contract does (tools imported under `_`-aliases to
free the names), and the FastMCP registrations pin `name=` explicitly. A test asserts the MCP names
equal the descriptor's. Verified live: all three surfaces report `query_variant_status` /
`variant_by_subtype`.
**Live finding β€” `gradio_client` sends the caller's own HF token.** A tokenless `gradio_client`
call still arrived carrying `x-hf-authorization: Bearer <the calling machine's HF token>`, which the
gate resolved to that machine's username. Two consequences, both now in a code comment: identity
locally is ambient, and per the inherited ADR-0018 finding HF Space edge infra **strips** `x-hf-*`,
so **the same call that authenticates locally will be denied in the deployed Space** unless the
orchestrator sets the custom `x-orchestrator-token`. Fail-closed either way, but it will look like a
bug if forgotten.
**Verified, not assumed:** app boots and serves HTTP 200; `/panel` answered end-to-end over
`gradio_client` (19 genes); with `ACCESS_CONTROL=enforce` and a non-matching allow-list the same
call was **denied** by name. **79 tests pass, no network** (30 new: UI gating, machine-endpoint
auth, audit-record shape, deploy-surface contract).
The audit test worth keeping honest is the negative one: sample ids, per-sample status, and
provenance strings must never reach the sink. Gene names may (a fixed public panel).
Deps pinned to what this was built against (gradio 6.18.0, fastmcp 3.2.3, huggingface_hub 1.10.1)
and `sdk_version`/`python_version` added to the README front-matter. `run_logs/` gitignored.
Still **local only β€” no remote**. Next: the ops half β€” point `origin` at the Space, set
`ALLOWED_IDS`/`ADMIN_IDS`, flip `ACCESS_CONTROL=enforce`, and close C4 (curate-and-cache) before
prod traffic.
---
## 2026-07-25 β€” C4 closed: curate-and-cache (the API is no longer on the request path)
ADR-0005 ratified "public cBioPortal + curate-and-cache" back on 2026-07-24, but the code kept
calling the API live on every request β€” the ADR and the implementation disagreed. They now agree.
**The split.** `python -m src.curate <study>` (new, `src/curate.py`) is the only thing that touches
cBioPortal: it fetches, classifies through the same M1 rules, and writes
`src/resources/curated/<study>.json`. The request path reads that artifact via
`src/workflows/curated_store.py` and **never fetches**. An uncurated study raises `NotCuratedError`
telling the operator to curate it. No fallback on purpose β€” a fallback makes the coupling
intermittent rather than absent, which is harder to debug and shows up under load.
**Artifacts carry clinical attributes, not just the matrix.** Caching the matrix alone would have
left two live calls on the path: the pancreatic-lineage filter and the subtype label-join both
queried `clinical-data` per request. Both now read the artifact. Artifacts are **sparse** (only
non-WT / non-neutral cells) β€” a dense wall of "WT" is noise in a diff.
**Four cohorts curated and committed** (real data, ~940KB total): `paad_tcga` (186 samples),
`ccle_broad_2019` (1739), `paad_qcmg_uq_2016` (456, **no CNV** β€” the coverage gate confirmed against
live data), `paad_utsw_2015` (109). The offline path reproduces the spike exactly: TCGA KRAS 136 /
TP53 104 / SMAD4 37, CCLE narrowing to 57 pancreatic lines with KRAS 84%. A live `/query_variant_status`
now answers in **0.44s**; an uncurated study refuses in 0.07s. The UI's cohort dropdown is read from
the artifacts rather than hardcoded β€” "registered" and "curated" are the same set now.
**Two bugs found by verification, not by unit tests.**
1. **A C4 leak.** `clinical_values` returned `None` both for "study not curated" and for "curated but
has no such attribute", and the second case fell through to a live call. Probing the eight known
subtype attributes against a cohort carrying none of them meant **eight live API calls per
request** β€” on real `paad_tcga`, which has no subtype attribute at all. Absence has to be an
*answer* (`{}`), not a miss. Caught by sabotaging `urllib` against the real artifacts; the unit
test missed it because the fixture study happens to carry `MOFFITT_SUBTYPE` and matched on the
first probe. Regression test added.
2. **CCLE's `SUBTYPE` is histology, not molecular subtype** β€” "Adenocarcinoma", "Melanoma", "Small
Cell Lung Cancer". `SUBTYPE` is in `SUBTYPE_ATTRIBUTES`, so auto-detection would have reported a
confident "variant Γ— subtype association" that was really variant Γ— histology across a pan-cancer
panel. Pre-existing, but curating CCLE is what made it reachable. Auto-detection now validates a
candidate's **values** against a closed PDAC subtype vocabulary (basal/classical/squamous/
progenitor/immunogenic/ADEX/…) and skips attributes that don't speak it; an explicitly named
attribute is still trusted. A plausible-looking wrong answer is worse than no answer.
**Tests: 90 pass, no network** (12 new). `patched_cbio` now patches the client *and curates from it*,
so tests exercise the offline path production actually takes; `no_curated_artifacts` isolates the
curation-time tests, since committed artifacts correctly take precedence over a patched client.
**Remaining on the source conditions:** C2 (per-study license flag) is untouched; C3 is partial
(backoff + cache, still no jitter/self-throttle/`User-Agent` contact). New backlog item: nothing
warns when an artifact goes stale β€” each carries `curated_at`, but re-curation cadence is undecided.
Still **local only β€” no remote**. Next: the ops half (point `origin` at the Space, allow-list,
`ACCESS_CONTROL=enforce`).
---
## 2026-07-25 β€” C2 + C3 done: the cBioPortal pre-prod gate is GREEN
All four source conditions from `docs/cbioportal-terms-and-deployability.md` (ratified in
ADR-0005) are now implemented rather than merely accepted. The checklist in that doc is ticked.
**C2 β€” per-study license/access, grounded in the source.** The key call: the access tier comes
from cBioPortal's own `publicStudy` / `readPermission` fields (`cbioportal_io.study_metadata`),
not from a list we maintain by hand β€” a hand-kept list is exactly the thing that goes stale
silently and is then trusted. `licenses.study_terms()` builds the block; it is stored in every
artifact and surfaced in every answer as `citation.study_terms`, because provenance that stops at
the storage layer is provenance nobody reads.
Enforced in two places: **curation refuses** a non-open study *before* fetching any variant data
(`ControlledAccessError`), and the **serve path refuses** an artifact declaring controlled access
(`ControlledAccessArtifactError`). The second should be unreachable β€” which is precisely why it
exists, since artifacts are files a human can hand-add or copy between deployments.
Commercial-use restrictions are not an API field, so they stay a hand-maintained overlay
(`licenses.COMMERCIAL_USE_RESTRICTED`) whose default is **"unreviewed", never "unrestricted"**.
Silence about terms must not read as permission.
**Schema bumped v1 β†’ v2.** A pre-C2 artifact has no terms block, and the serve gate only fires on
a *present* non-open block β€” so a v1 artifact would have served with C2 silently dropped. Refusing
it outright and re-curating was the only honest option. All four cohorts re-curated.
**C3 β€” polite client.** Descriptive `User-Agent` carrying the project URL, plus an optional
`CBIOPORTAL_CONTACT` env var (env, not hardcoded, so no personal address lands in a public repo β€”
**a real address still needs setting in the Space before prod**, now a TODO gate). ~4 req/s serial
self-throttle, backoff with **jitter** honouring `Retry-After`, and retries limited to 429/5xx: a
404 is our own bug and retrying it just repeats the mistake at their expense. All of this is
curation-time cost only (C4), so being conservative is effectively free.
**Tests: 106 pass, no network** (16 new). Includes a test that every artifact committed to this
repo is open-access β€” the C2 posture asserted, not assumed.
Two follow-ups recorded in `TODO.md`: set `CBIOPORTAL_CONTACT` at deploy, and actually review
commercial-use terms per curated study (today they all correctly read "unreviewed").
Still **local only β€” no remote**. Next: the ops half of the deploy checklist.
---
## 2026-07-25 β€” Both C-condition follow-ups closed; CCLE turns out to be commercially RESTRICTED
**Follow-up 1 β€” the contact address β€” was a requirement I invented.** Annie pushed back: why an
email? Correct. The polite-bot convention identifies via `+<URL>` (as `Googlebot/2.1;
+http://www.google.com/bot.html` does), and the Space URL we already carry *is* the contact
channel β€” the Space page has a Community tab anyone at cBioPortal can post to. No sibling repo
sets a User-Agent at all. `CBIOPORTAL_CONTACT` stays as an optional *second* channel; unset is now
documented as a complete configuration, the curation nag is gone, and the pre-deploy gate is
deleted rather than deferred. Added `.env.example` covering the auth gate, audit sink, and this.
**Follow-up 2 β€” commercial-use review β€” overturned an assumption in our own code.**
`licenses.COMMERCIAL_USE_REVIEW` now records all four curated cohorts with instrument, source URL,
confidence, and obligations:
| study | verdict | basis |
|---|---|---|
| `paad_tcga` | permitted | NIH/NCI GDS open-access tier; TCGA moratoria lifted |
| `ccle_broad_2019` | **restricted** | Broad DepMap/CCLE Portal Terms |
| `paad_qcmg_uq_2016` | no_restriction_found | ICGC E.8.1 open tier |
| `paad_utsw_2015` | permitted | CC BY 4.0 (article + supplementary) |
**CCLE is not CC BY 4.0.** That licence covers DepMap *releases*; `ccle_broad_2019` is sourced from
the Broad CCLE Portal, whose terms bar commercial use and also bar using the data to train or
enhance ML/AI models beyond internal research. We had asserted "CC-BY-4.0" in both `licenses.py`
and `CLAUDE.md` β€” wrong, now corrected in both. Our academic use stays clear either way; the flag
exists so nobody downstream reads our silence as permission.
**Two structural findings about cBioPortal licensing, worth not re-learning:** it has **no
machine-readable licence field** (`meta_study.txt` has no such key), and every datahub study ships
an *identical* ODbL boilerplate `LICENSE` file β€” so that file is not a per-study assertion. The
FAQ's promise that restrictions "will be explicitly mentioned in the study information" is not
reliably kept (CCLE's description says nothing). **Terms must always resolve to the upstream
source, never to cBioPortal's redistribution**, and where they conflict β€” they do for CCLE β€”
upstream governs.
**Vocabulary distinction that is load-bearing:** `no_restriction_found` β‰  `permitted`.
`commercial_use_permitted()` returns False for it, and for `unreviewed`. Absence of a prohibition
is not a grant, and anyone asking programmatically is about to rely on the answer.
The restriction also surfaces **in the UI**, not only in `citation.study_terms` β€” a licence
constraint nobody reads is one nobody honours.
**Two items a human must confirm before anyone relies on this commercially** (both in `TODO.md`):
read https://depmap.org/portal/terms/ in a real browser (bot-protected, so the verbatim CCLE clause
was obtained indirectly β€” substance high-confidence, wording not directly verified), and establish
whether ICGC *affirmatively permits* open-tier commercial use rather than merely not prohibiting it.
**Also rescued:** the founding seed originals were untracked in `pdac-subtype-agent` (present in one
working tree, in no commit, in a checkout several lanes share). Their content had been *merged*
here rather than copied, so the originals existed in no history at all. Archived verbatim under
`docs/design/seed/` with a map to where each one's content now lives (`bdac9d2`).
Tests: **109 pass, no network.** Still local only β€” no remote.
---
## 2026-07-25 β€” DEPLOYED: `origin` points at the Space, app is RUNNING (private)
First deploy. `origin` now **is** the HF Space (`anne-voigt/pdac-genomics-agent`), matching the
sibling model β€” there is no separate GitHub repo, and `git push origin main` rebuilds the Space.
**Merged HF's scaffold rather than force-pushing over it.** The Space carried an auto-generated
`initial commit` (a placeholder README + `.gitattributes`). Force-pushing would have discarded
`.gitattributes`, which configures LFS patterns; I merged with `--allow-unrelated-histories` and
resolved the README to ours. HF's default front-matter was wrong for us anyway β€” it pointed at
`app.py`, gradio 6.20.0 and python 3.13, against our tested `gradio_ui.py` / 6.18.0 / 3.11 (and
`sdk_version` must match the `requirements.txt` pin or the Space builds a different UI than the
tests cover). Checked first: `.gitattributes` has no `*.json` LFS rule, so the curated artifacts
stay plain git.
**Shipped an attribution file for the artifacts before pushing.** Committing the curated matrices
makes them *downloadable*, which is exactly the case the terms review singled out: serving derived
answers is a produced work, but "if the matrix is ever offered for download, ship it ODbL +
attribution". `src/resources/curated/ATTRIBUTION.md` carries the platform citation, each study's
original citation, and each study's commercial-use verdict (CCLE restricted).
**Verified live, not just reported RUNNING:** `/panel` β†’ 19 genes; `/query_variant_status` on
`paad_tcga` β†’ 186 samples with KRAS 136 / TP53 104 / SMAD4 37, identical to local and to the
original spike; CCLE β†’ 57 pancreatic lines with `commercial_use: restricted` surfacing. Response
**0.80s** β€” curate-and-cache (C4) working in the Space, no API on the request path.
**Auth state β€” deliberately incomplete.** `hf_oauth: true` is live and `ADMIN_IDS=anne-voigt` is set
as a Space secret. `ACCESS_CONTROL` is **not** flipped, so the gate is still dark-launched OFF; the
Space is **PRIVATE**, which is what contains the exposure. Two decisions left for a human, both in
`TODO.md`: who goes in `ALLOWED_IDS` (a membership question, not an ops step), and when to enforce.
**Enforcement has a known consequence, worth deciding with eyes open:** HF edge infra strips the
`x-hf-*` header `gradio_client` sends automatically, so under `ACCESS_CONTROL=enforce` the machine
API denies **every** caller until the orchestrator sends a custom `x-orchestrator-token` whose
identity is allow-listed. The OAuth UI path is unaffected. Nothing consumes the machine API yet, so
this costs nothing today β€” but it will look like a broken deploy if hit unaware.
**The one combination that must never exist is public + gate-off.** Private + gate-off is fine;
flipping the Space public requires enforcing first.
Still outstanding before this is real to users: the Rosie/Carl confirm that per-sample variant
status is the near-term need (the "should it ship at all" gate β€” never done), and merging
`deploy/orchestrator_registration.yaml` into the orchestrator's `agents.yaml`.
---
## 2026-07-27 β€” `ACCESS_CONTROL=enforce` flipped; gate verified fail-closed
The gate is on. `ACCESS_CONTROL=enforce` is set as a Space **variable** rather than a secret β€”
it carries nothing sensitive, and someone auditing "is the gate actually on?" should be able to
read that off the settings page without needing access to secrets. `ADMIN_IDS=anne-voigt` stays a
secret.
**Verified live:** the machine API (`/panel`) now returns `{"status": "denied"}` with the sign-in
message. That is the correct, predicted behaviour and it confirms the documented asymmetry β€”
HF edge infra strips the `x-hf-*` header `gradio_client` attaches automatically, so no identity
reaches the handler and the gate fails closed. **The orchestrator cannot call this Space until it
sends a custom `x-orchestrator-token` with an allow-listed identity.** Expected, not a regression.
**Two things are NOT done, and the first one matters today:**
1. **`ALLOWED_IDS` is unset, so only `anne-voigt` can use the Space.** Setting it needs the lab
members' actual HF usernames. Guessing them fails in both directions β€” a wrong guess either
locks out someone real or hands access to an unrelated account that happens to own that
username. Asked; not invented.
2. **The *allowed* path is unverified.** The denial is confirmed; nobody has signed in through
OAuth and run a query. That is the path that must work, and it cannot be tested without an
interactive browser sign-in. Rollback if the owner is locked out: set `ACCESS_CONTROL` to empty.
The Space also **slept** between the deploy and this change (idle timeout on the free tier, not an
error) β€” a restart brought it back in ~2 minutes. Worth knowing before someone reads SLEEPING as a
failure.
---
## 2026-07-27 β€” Carl allow-listed; UI gate verified BOTH ways; machine auth still failing
**`ALLOWED_IDS=anne-voigt,cpelz741`.** Carl's HF id was taken from `pdac-subtype-agent` prod
(`SHOWCASE_STATUS.md` records its live config) rather than guessed β€” a wrong username would either
lock out a colleague or hand access to an unrelated account that owns that name.
**The OAuth UI path is now VERIFIED, which was the last open deploy question.** Signed in as
`anne-voigt` in the deployed Space: a CCLE query rendered the frequency chart (KRAS ~84% across the
57 lineage-filtered pancreatic lines) *and* displayed the licence caution β€” "commercial use of this
cohort is restricted under Broad DepMap/CCLE Portal Terms". Signed out, the same query returns
`{"status":"denied"}`. Both halves of the gate confirmed end-to-end in the real UI.
Incidental: the Space **does not render inside the huggingface.co page frame** (blank), but works at
the direct host `https://anne-voigt-pdac-genomics-agent.hf.space`. Use the direct host for testing.
The cohort dropdown renders its value only on focus β€” a Gradio display quirk, not an empty default.
**Broad terms read directly** (see previous entry): confirmed, and the *Continuity of Terms* clause
means rehosting requires reposting them in full β€” done in `CCLE_BROAD_TERMS.md`.
**Open and now spun off: the machine API denies the orchestrator despite correct configuration.**
The orchestrator already sends `x-orchestrator-token` (its commit `05bda30`, built for the subtype
agent's ADR-0018 gate) and that token resolves to `anne-voigt`, which is allow-listed here. It is
still denied. What is ruled out: the header is *not* being dropped β€” a local probe confirmed
`x-orchestrator-token` arrives in `gr.Request.headers` alongside `x-hf-authorization`. So the
failure is **server-side identity resolution**: `_resolve_token_identity()` calls
`huggingface_hub.whoami()` at request time from inside the Space and returns None on any failure.
It fails closed, which is right, but silently, which is why this took a live test to notice.
The design question worth answering before patching: **should a gate depend on an outbound network
call per request at all?** That is a third-party dependency on the request path β€” the same class of
coupling ADR-0005 C4 removed for cBioPortal. A shared-secret comparison would have neither the
latency nor the failure mode.
---
## 2026-07-28 β€” ADR-0006: the variantΓ—subtype join contract (written down at last)
The agent's differentiator was the only major decision without an ADR β€” it lived as prose in
`TODO.md`, `DESIGN-pdac-genomics-agent.md` Β§4, and module docstrings, despite being the one part of
the design that **spans two Spaces**. Now `docs/adr/ADR-0006-variant-subtype-join-contract.md`.
**No behaviour changed.** This documents what M2 already ships (plus the vocabulary fix already in
code) and, crucially, states the orchestrator's obligations normatively so the other repo has a
contract to build against rather than a docstring to interpret.
What it settles:
1. **The split.** Label-join is local (cohort carries a validated subtype clinical attribute β†’ join
on sample id + test here); the live-PurIST join is orchestration (ADR-0001/0011 β€” stateless
siblings never call each other), returning `{join_available: false, route: "orchestrator"}`.
`altered` vocabularies and the Fisher-2Γ—2 / χ²-RΓ—2 rule are pinned here as the single normative
statement, because the orchestrator will implement the *same* test on its side.
2. **The routing contract** β€” the discriminated return shape (switch on `join_available`, never on
the presence of `genes`), the orchestrator's three ordered steps, and an explicit prohibition:
it may **not** synthesize a label and call back through `subtype_attribute`.
**Sample-id alignment across Spaces stays OPEN** (the spike measured 145/146 *within* one
cBioPortal cohort β€” not the cross-Space case that would actually need a map). Interim rule:
exact string equality, **report join coverage**, no fuzzy/prefix matching, and a collapsed join
is a refusal **with the numbers shown**. A silent low-coverage join is the dangerous failure.
3. **Label validation is load-bearing.** Attribute-id matching alone is unsafe *on evidence*:
`ccle_broad_2019`'s `SUBTYPE` holds histology ("Adenocarcinoma", "Melanoma"), which produced a
confident variantΓ—histology association dressed as variantΓ—subtype. Auto-detect now validates
VALUES against a **closed** PDAC subtype vocabulary (β‰₯0.5 of non-blank values); an explicitly
named attribute bypasses it (caller took responsibility) and is always visible in `subtype_source`.
4. **Statistical honesty β€” kept as-is, deliberately.** No multiple-testing correction across genes
(descriptive screen over a user-chosen panel; the right denominator is session-level knowledge a
stateless tool lacks β€” FDR is the orchestrator's call), mandatory caveat saying so, and
`testable=false` + machine-readable reason instead of a bogus p-value. Contingency table and `n`
are returned even when untestable β€” KRAS is altered in ~90%+ of PDAC, so the flagship gene is the
one that degenerates most often. No hard minimum-n refusal; show n, caveat, refuse only the
genuinely undefined.
5. **Metadata label β‰  live PurIST call.** Never interchangeable, never mixed in one contingency
table, never blended to fill gaps. `subtype_source` is required on every joined result
(`clinical_attribute:<ATTR>` vs `purist:<version>`) and may not be dropped downstream.
"Do they agree?" is a *concordance analysis*, not a licence to pool.
Cross-references `pdac-subtype-agent` **ADR-0020**, whose alignment-by-sample-id primitive this
reuses β€” variantΓ—subtype is a cross-*modality* instance of the same cross-source alignment pattern,
and inherits its align-never-pool + mandatory-caveat stance.
**Ledger protocol followed to the letter**: 0006 claimed with status `CLAIMED`, committed **alone**,
pushed to `main` (`a792086`) *before* the ADR was written β€” the step that exists because of the real
0003 collision. Flipped to `PUBLISHED` after.
**Known follow-up (not a blocker):** two association-test implementations will exist (here and
orchestrator-side) and must agree on Β§1's rules, or the same cohort answers differently by route.
---
## 2026-07-28 β€” Machine-API auth: the Space was never broken; the bug is caller-side
**The 2026-07-27 diagnosis in the entry above is WRONG and is corrected here.** It concluded the
failure was "server-side identity resolution β€” `_resolve_token_identity()` calls `whoami` per
request and returns None". Probing the deployed Space directly disproves that:
| probe (against prod, `ACCESS_CONTROL=enforce`) | result |
|---|---|
| valid HF token in `x-orchestrator-token` | **`status: ok`** β€” full panel returned |
| bogus token in `x-orchestrator-token` | denied |
| header absent entirely | denied |
So `whoami` resolves fine from inside the Space and the gate discriminates exactly as designed.
**Root cause is the caller:** `pdac-analysis-orchestrator`'s `router._build_client_kwargs()`
attaches `x-orchestrator-token` **only when its own `HF_TOKEN` is set** β€” an unset secret on *that*
Space sends no header at all. The fix is a secret on the orchestrator Space; **no code change was
needed here.**
**Why it was misdiagnosed, and the actual fix shipped.** All three failure modes returned one
message β€” *"πŸ”’ Please sign in with your HuggingFace account"* β€” which reads as a this-Space auth
bug and sent the investigation server-side, where nothing was wrong. A machine caller cannot sign
in; telling it to is a category error. Machine denials now carry a `machine_auth` diagnosis:
- `no_token_header` β€” **caller misconfigured**, message names the orchestrator's `HF_TOKEN`.
- `token_unresolved` β€” a token arrived but did not resolve (bad/expired, or an upstream blip).
- `not_allowlisted` β€” resolved fine, simply not on `ALLOWED_IDS`. The *only* one of the three that
is a real authorization decision; still names who was refused.
4 regression tests pin the three states apart (`tests/test_machine_api_auth.py`). **113 green.**
**Method note worth keeping:** the earlier conclusion came from reading the code and a local
header probe; it took ~4 curl calls against the deployed Space to overturn it. When a gate is
"failing", probe the deployed thing with a known-good credential *before* theorising about why the
resolution path is broken β€” the cheap experiment discriminates caller-side from server-side in one
step. The lookalike messages are what made theorising feel productive.
**Still open (unchanged, not urgent):** should an auth gate depend on an outbound `whoami` on the
request path at all, versus a shared-secret comparison? Same coupling class ADR-0005 C4 removed for
cBioPortal. It works today; this is a latency/failure-mode question, not a correctness one.
---
## 2026-07-28 β€” Re-curation cadence (ADR-0007): drift, visibility, and no refusal
Closed the gap ADR-0005 C4 created and left open. `curated_at` had been written into every
artifact since commit one and **read by nothing** β€” no threshold, no warning, no policy. A cohort
could drift from upstream for years and every answer would look equally confident.
**Cadence is upstream-driven, not calendar-driven.** `python -m src.curate --check` reads one
`/studies/<id>` per curated study and compares upstream's `importDate` against the stamp now
recorded at curation (`source_import_date`). Drift β†’ re-curate; no drift β†’ do nothing, however old
the file is. **The empirical confirmation is the point:** all four cohorts were last imported
upstream in **January 2026** and curated in July β€” six months of a published cohort not moving.
A "refresh monthly" policy would have rewritten four byte-identical files and spent the C3
politeness budget for zero new data. `REVIEW_AFTER_DAYS=180` / `STALE_AFTER_DAYS=365` are only a
backstop for when nobody runs the check.
**Age is visible to the reader, not just the maintainer.** Every grounded answer's `citation` block
now carries `curation` (`curated_at`, `age_days`, `level`, `source_import_date`, `drift_comparable`,
plain-language `note`); the UI renders a line past the horizon, next to the existing licence
caution. Same stance that put `terms` in the response contract β€” provenance that stops at the
storage layer is provenance nobody honours.
**Stale never refuses.** A stale-but-valid cohort is old, not wrong: its calls were correct for the
release they were curated from. Refusing would withhold a correct answer over a maintenance lapse
and would dilute what a refusal from this agent means. Refusal stays for the *incorrectness* gates
(build, species, absent modality, access tier). Pinned by
`test_stale_artifact_still_answers_and_says_so`.
**`--check` never writes**, deliberately β€” re-curation stays a reviewed commit. A checker that
repaired what it found would put the API back on an unattended path, i.e. undo C4 quietly.
Two implementation notes worth keeping:
- **No schema bump.** `source_import_date` is optional; absence degrades to an honest
`drift_comparable: false`. Bumping to v3 would have refused all four existing artifacts and
forced exactly the blanket re-curation this cadence exists to avoid.
- The four pre-existing artifacts had their `source_import_date` **backfilled once** in the same
commit from a live metadata read (metadata only β€” no status call touched), so `--check` is useful
today rather than "unknown until each happens to be re-curated".
121 tests pass (11 new, `tests/test_recuration_cadence.py`). `--check` verified live against the
public instance: all four `up_to_date`. **Not deployed** β€” the branch
`claude/infallible-kowalevski-2d4648` is pushed to origin and rebased onto `main`, but
`origin/main` IS the live Space, so promoting is a deliberate deploy decision.
**Renumbered 0006 β†’ 0007, and the near-miss is the lesson again.** A concurrent lane had already
claimed *and published* 0006 (the variantΓ—subtype join contract) on `main`. This lane's worktree
held its own copy of `RESERVED.md` and predated that claim, so the one-shared-row conflict could
not fire β€” the **third** time that root cause has bitten this family. The claim here was committed
alone and pushed, but to the feature branch rather than `main` (protocol rule 5), because
`origin/main` IS the live Space and pushing it is a deploy: rule 5 and the deploy model are in
genuine tension on this repo. **What actually caught it was reading `SHOWCASE_STATUS.md` before
updating it.** Practical check until rule 5 has a deploy-safe form here:
`git fetch origin main && git show origin/main:docs/adr/RESERVED.md` *before* claiming.
**Weekly job added the same day** (`.github/workflows/curation-freshness.yml`, Mondays 07:00 UTC +
`workflow_dispatch`, report-only). **It does not run, and the file says so in its own header rather
than looking green.** `origin` IS the HF Space and HuggingFace does not execute GitHub Actions β€”
and unlike **every** sibling (`DecoupleRpy_Agent`, `pdac-subtype-agent`,
`pdac-analysis-orchestrator`, each of which carries a second `github` remote),
**this repo has no GitHub mirror at all**, so it has never had CI of any kind. Committed anyway on
the `DecoupleRpy_Agent/.github/workflows/security.yml` precedent: the definition never forks, and
adding the mirror later is then zero work. **The one remaining ops step is creating
`Anne-Voigt/pdac-genomics-agent` on GitHub and `git push github main`** β€” that turns the check on
and gives the repo CI. A local launchd agent was deliberately NOT installed: a job that runs on one
laptop is invisible to everyone else, which is how the original gap happened. A test
(`test_weekly_job_is_report_only`) asserts the job can never gain a writing `src.curate` step β€”
that would re-establish the unattended API path C4 removed, in a file nobody reads once it is
green. 122 tests.
**Deployed 2026-07-28** (`6f23703` β†’ live; a third lane's `4b8cb52` landed on top minutes later and
the Space is RUNNING at that sha, which contains ours). Rebased onto `fdfbfb3` (the machine-auth
diagnosis lane) before pushing β€” **126 tests green** with both lanes' changes together, up from our
122. **Verified in prod, not just RUNNING:** `paad_tcga` β†’ 186 samples, KRAS 136, TP53 104 β€”
identical to local and to the original spike β€” and `citation.curation` serves
`level: current, age_days: 2, source_import_date: 2026-01-12, drift_comparable: true`, confirming
the backfill landed in the artifacts the Space actually reads.
**Fixed the same day (see the next entry):** the machine endpoints take `genes` as a
**comma-separated string** (`_split_genes`), not a list, and a list used to return
`AttributeError: 'list' object has no attribute 'split'`.
---
## 2026-07-28 β€” Cohort survey; +2 curated (6 total); per-GENE coverage gate added
Surveyed all 538 cBioPortal studies for pancreatic cohorts and curated the two that earned it.
**Curated set is now 6:** `paad_tcga`, `ccle_broad_2019`, `paad_qcmg_uq_2016`, `paad_utsw_2015`,
**`pancreas_cptac_gdc`** (new), **`pdac_msk_2024`** (new). The UI cohort list reads from the
artifacts, so both appeared with no code change.
**The scarce thing is confirmed absent.** No pancreatic study in cBioPortal carries a real
Moffitt/Bailey/Collisson/PurIST subtype clinical attribute β€” checked by sweeping all 19,136
clinical attributes portal-wide, not just the candidates. The two near-misses are both correctly
rejected by `subtype_join.looks_like_pdac_subtype`: `paad_tcga_pan_can_atlas_2018`'s `SUBTYPE` is
the literal string `"PAAD"` (a disease code, 152 patients), and `paad_iatlas_prince_2022`'s
`IMMUNE_SUBTYPE` is Thorsson C1–C6 immune clusters, not PDAC molecular subtypes.
**So `variant_by_subtype`'s in-agent label-join still has no cohort to run on, and this is now a
searched-and-settled fact rather than an untested assumption** β€” the live-PurIST route through the
orchestrator is the only path, exactly as ADR-0001 has it. Do not re-survey for this; re-check only
if cBioPortal adds a study.
**Curated, with why:**
| Study | n | Modalities | Note |
|---|---|---|---|
| `pdac_msk_2024` | 2,336 | mut + **discrete CNV** | Largest PDAC cohort available anywhere here β€” 12Γ— TCGA. Textbook frequencies (below). **Targeted MSK-IMPACT panel** β€” see the gate. Commercial use **RESTRICTED**. |
| `pancreas_cptac_gdc` | 183 | mut + **discrete CNV** + expr | CPTAC-3 pancreas, GRCh38, exome-wide, commercial use permitted. The GDC rebuild was chosen over `paad_cptac_2021` (140, same cohort) precisely because that one has **no CNV**. |
**Driver-frequency sanity check passed** (the check that a wildly-off number is a curation bug, not
biology): `pdac_msk_2024` KRAS 93.7% / TP53 76.1% / SMAD4 21.9% (+4.4% deep-del) / CDKN2A 23.4%
(+15.4% deep-del); `pancreas_cptac_gdc` KRAS 83.1% / TP53 69.4% / SMAD4 19.1% / CDKN2A 21.3%. Both
sit on the published ~90/60-70/20/20. (`paad_tcga`'s KRAS 73% remains the known outlier β€” that
cohort carries non-PDAC histologies.)
**Rejected, and why β€” more cohorts is not better, each one is committed data someone maintains:**
- `pancreas_msk_2024` (395) β€” **348 of its 395 samples are also in `pdac_msk_2024`.** Curating both
would double-count every frequency. Its 22 non-PDAC pancreatic histologies are not worth that.
- `paad_icgc` (99) β€” same ICGC PACA-AU patients as `paad_qcmg_uq_2016` (456), and mutation-only.
Strictly dominated by the cohort already curated.
- `paad_tcga_gdc` (186) / `paad_tcga_pan_can_atlas_2018` (184) β€” re-releases of `paad_tcga`.
- `pancan_pcawg_2020` (285 PAAD) β€” an *aggregation* of ICGC PACA + TCGA; overlaps two curated cohorts.
- `ccle_broad_2025` (1,981) β€” supersedes `ccle_broad_2019` on paper but ships **no discrete CNV**
(LOG2-VALUE only), so it would be a downgrade, and it duplicates the lines we have.
- `paad_iatlas_prince_2022` (93) β€” mutation-only, and KRAS 73% in a pure-PAAD *metastatic* cohort is
low enough to suggest under-powered calling. No subtype label (see above). Nothing gained.
- `pancreas_ctdna_msk_2025` (412) β€” **cfDNA, not tumour tissue.** Different detection sensitivity;
its frequencies are not comparable to the tissue cohorts and pooling them would mislead.
- `paad_cptac_2021` (140) β€” superseded by the GDC rebuild, which has CNV.
- `paad_msk_2025` (29) β€” too small to add anything.
- `paac_*` / `panet_*` / `pact_jhu_2011` β€” acinar, neuroendocrine, cystic. Not PDAC; the driver
panel does not describe them.
**New: the per-GENE assay-coverage gate** (`assayed_panel_genes` + `genes_assayed`). The existing
per-*study* modality gate does nothing for a targeted cohort. MSK-IMPACT does not sequence
**`ELAVL1`, `GATA6`, `ACVR1B`** β€” 3 of our 19 panel genes β€” so without this, `pdac_msk_2024` would
have reported GATA6 as *0% altered in 2,336 samples*: not a low frequency but no measurement,
wearing the most persuasive number in the repo. GATA6 is the one that would have hurt, since
`CLAUDE.md` names it the clearest variantΓ—subtype signal. Curation now resolves coverage from the
`GENE_PANEL` attribute (**intersection** across panel versions, not union β€” a gene on only some
samples' panel has no cohort-wide denominator), stores `genes_assayed`, and both tools return
`assayed: false` + `unassayed_genes` instead of a frequency. `genes_assayed: null` means
exome/genome-wide; all four pre-existing artifacts are, so **no re-curation was needed** and the
schema was *not* bumped (unlike v1β†’v2 for terms, a missing key here is a true statement about them).
**Licensing resolved upstream for both** (`licenses.COMMERCIAL_USE_REVIEW` + `STUDY_CITATIONS` +
`ATTRIBUTION.md`), each with its own `reviewed: 2026-07-28` β€” `study_terms` now honours a per-entry
review date instead of stamping everything with the module default.
- `pancreas_cptac_gdc` β†’ **permitted**, high confidence. NIH/NCI GDS open tier (GDC), the same
instrument as `paad_tcga`; this study is literally a GDC rebuild. CPTAC's embargo is retired.
- `pdac_msk_2024` β†’ **restricted**, and this one needs a human eye. The article
(Varghese et al., Nat Med 2025;31:466–477) is **CC BY-NC-ND 4.0** β€” non-commercial β€” while the
authors' own data-availability statement deposits the cohort in cBioPortal as "publicly
accessible and downloadable", where it carries the generic ODbL boilerplate. Both are recorded;
the stricter reading governs, consistent with "resolve upstream". **The open question is ND, not
NC:** the artifact we commit is a *derived* status matrix, arguably an adaptation, and we
redistribute it. Fine for the non-profit research this agent does; anyone going further should
resolve ND with the authors. Tracked in `TODO.md`.
**Found in passing, then fixed the same day β€” the denominator was wrong, on BOTH modalities.**
Frequencies divided by the whole cohort, but a study assays only part of what it contains, and
never-assayed samples read as `WT`/`neutral` and got counted as denominators. The headline:
> **`paad_tcga` reported KRAS at 73% (136/186). The truth is 91% (136/150) β€” 36 of its samples
> were never sequenced.** `paad_qcmg_uq_2016` likewise 75% β†’ 90%.
**This retires a "known quirk" that was actually a bug.** The 73% had been explained away as
"the TCGA cohort includes non-PDAC histologies". That explanation was wrong, and it was load-bearing
β€” it made a wrong number look understood, which is why it survived so long. All four tumour cohorts
now land KRAS at 90–94%, matching the published ~90%. Do not re-derive the histology story.
**The fix** (`profiled` block, schema **v2β†’v3**): curation records cBioPortal's own declared
per-modality sample lists (`<study>_sequenced`, `<study>_cna`) via
`cbioportal_io.profiled_sample_ids`; `StatusMatrix.denominator(modality)` intersects them with the
(possibly lineage-filtered) cohort; every frequency divides by that and reports `n_profiled`
alongside `n_altered`, so the denominator is stated rather than inferred. Unprofiled samples are
also dropped from `per_sample` (a `neutral` there reads as "measured, normal") and from
`variant_by_subtype`'s contingency table (where they had inflated the WT column and biased the test
toward "no association" β€” a denominator error that moves a p-value). The UI axis now says
"% of samples profiled for that modality", not "% of cohort".
**Bumped the schema on purpose.** A v2 artifact carries no `profiled` block, which reads as "all
samples assayed" β€” false for three of the six, and it produces a wrong number that looks right. That
must fail loudly, so v2 artifacts are refused and all six were re-curated. (Contrast `genes_assayed`,
added in the same pass *without* a bump: its absence was a true statement about every prior artifact.)
**Corrected denominators now in the artifacts:** `paad_tcga` 186 cohort / 150 sequenced / 184 CNV;
`paad_qcmg_uq_2016` 456 / 383 (no CNV); `pancreas_cptac_gdc` 183 / 181 / 149; `ccle_broad_2019` 57
pancreatic lines / 53 / 44; `paad_utsw_2015` and `pdac_msk_2024` fully profiled.
**Tests: 136 passed** (109 before; +12 `tests/test_panel_coverage.py`, +15 `tests/test_profiled_denominator.py`).
**The new literature test is the real guardrail.** `test_kras_lands_near_the_published_90_percent`
pins KRAS at ~90% in every tumour cohort. That test would have caught the denominator bug on day
one; the sanity-check-against-published-frequencies step had been done by eye and not encoded.
**Also fixed: the test suite was not actually hermetic.** `conftest.py`'s docstring claimed "NO
live network", but `patched_cbio` calls `src.curate.curate`, which fetches `study_metadata` β€” and
that function was never patched. Every fixture-using test made a real request to cbioportal.org per
fixture study. It surfaced as a 12-error, 54-second run mid-session that passed on retry: a third
party's latency reported as our bug. `study_metadata` is now patched from the fixture, and `_req`
is stubbed to `pytest.fail`, so an unanticipated call fails loudly instead of leaving CI. Verified
by running the suite with `socket.connect` blocked outright β€” **111 tests pass with no network at
all** (the three UI test modules still touch it, but that is Gradio's mocked-OAuth HF call at
import in a local dev env, not our code and not present on a Space).
---
## 2026-07-28 β€” Machine endpoints: caller-side argument errors now say so
Third instance of one lesson in two days, and the pattern is now explicit: **a message that
doesn't name the responsible side sends the reader to the wrong repo.** The auth lane fixed it for
denials (`machine_auth: no_token_header | token_unresolved | not_allowlisted`); this does the same
for arguments (`machine_input: <field>_not_a_string`), including the exact string to send instead
(`genes="KRAS,TP53"`) and an explicit "this is a caller-side argument error β€” nothing is wrong with
this Space".
**A real bug surfaced next to the message.** `registered_source(source)` is called to build the
audit descriptor *before* the auth gate β€” outside `_safe_result` β€” so a non-string `source` raised
there and became an **uncaught 500 rather than a result**. It was the one input that escaped the
catch-all. `_safe_text` now feeds the descriptor defensively; the caller gets the typed error after
the gate.
**Deliberately NOT coercing a list into a string.** Gradio publishes these parameters as `str` in
the endpoint schema (`Client.view_api()`); silently accepting a second undeclared shape would make
the published contract and the real one disagree, and the next caller reads the schema and is
misled again. One contract, with instructions when you miss it.
Ordering is gate-first: an unauthenticated caller gets `denied` and learns nothing about our
inputs β€” pinned by `test_argument_errors_are_reported_only_after_the_auth_gate`. 132 tests (7 new,
alongside the denial-diagnosis guards they mirror).
---
## 2026-07-28 β€” Hermetic is now *enforced*, and the whole suite is hermetic
Finishes the item above. That fix patched the one hole that had been found; this one removes the
reason holes could survive β€” and closes the last remaining one.
**The lesson being encoded:** the "NO live network" claim lived in a docstring for weeks while
being false. Nothing checked it. So the property is now enforced by a guard and the guard is
itself under test. Do not "simplify" either away.
- **Socket guard** (`tests/conftest.py`, module scope) β€” wraps `socket.socket.connect` /
`connect_ex` / `socket.create_connection` for the whole session and raises on any non-loopback
address, naming the host and pointing at conftest for the fix. Installed at *import* of
conftest, not as an autouse fixture, deliberately: the leaks that mattered happened at
**collection** time (a test module's `import gradio_ui` runs the app graph at module scope), and
a fixture runs too late to catch those. Loopback and AF_UNIX are allowed β€” local sockets are not
what the guard is about.
- **The three UI modules no longer touch the network.** `test_ui_handler_gating.py`,
`test_deploy_surface.py`, `test_machine_api_auth.py` all `import gradio_ui`, whose `gr.Blocks`
graph is built at module scope. Off a Space, gradio's `attach_oauth` falls back to
`_add_mocked_oauth_routes`, which calls HF `whoami` to mock a local login profile. **That is
gradio's debug convenience, not our code**, and it does not happen on the deployed Space (there
`SPACE_ID` is set and the real OAuth routes are used). conftest now stubs
`gradio.oauth._get_mocked_oauth_info`.
- **Why that seam and not the others considered:** it is the only function on that path that
touches the network, so stubbing it leaves every route gradio registers intact and the UI
tests still exercise the real graph. Restructuring `gradio_ui.py` to build Blocks inside a
function was rejected as far more invasive for a test-only problem β€” and its module-level
`demo` is what HF Spaces launches, so it must keep working. **`gradio_ui.py` is unchanged;
the entire fix is test-only.**
- **Known coupling:** `_get_mocked_oauth_info` is a gradio private. A gradio upgrade that
renames it would break the stub β€” which is why `test_hermetic.py` imports all three UI
modules explicitly, so that breakage surfaces as a named test failure rather than as a
mystery connection at collection time.
- **`tests/test_hermetic.py`** (new, 7 tests) β€” asserts the guard is installed, that it refuses a
routable address, that the message names the host *and* points at the fix (a guard with an
unhelpful message gets disabled by the next person who trips it), that loopback still works, and
that the three UI modules import cleanly.
- **Bonus: CI no longer needs an HF login.** `_get_mocked_oauth_info` raises `ValueError` when the
machine has no HF token, so those three modules could not have been imported on a clean CI box
at all. Verified with `HF_TOKEN` unset and `HF_HOME` pointed at an empty dir.
**Verified β€” 143 passed with sockets blocked outright and no `--ignore`** (136 before + 7 new),
using the same method that exposed the original bug: stub `socket.socket.connect` /
`socket.create_connection` to raise, then `pytest.main(["-q"])`. Previously 111 passed that way
with the three UI modules ignored.
**Mutation-tested, so the guard is not vacuous:** removing the `study_metadata` patch and the
`_req` stub reproduces the original bug, and the guard catches it with
`AssertionError: the test suite attempted a network connection to ('www.cbioportal.org', 443)`.
Restored after.
**Suite runtime 22s β†’ 4s** β€” that difference *was* the network, on a good day. On a bad one it was
the 54-second, 12-error run that started this.
**No ADR.** The contested part (stub a gradio private vs. restructure `gradio_ui`) is test-only,
reversible, and documented at both the seam and the test. If it should be one, **next free is
0008** β€” `main` has 0006 (variantΓ—subtype join) *and* 0007 (re-curation cadence) published β€” and
per `RESERVED.md` the claim must land on `main` alone, first.
⚠️ **This branch's `RESERVED.md` is stale: it stops at 0005 and states "next free 0006".** Both
0006 and 0007 are burned. This is the third instance of the same failure the ledger's own
postmortem describes β€” per-lane copies mean the one-shared-row conflict cannot fire between
branches, so *the ledger in your working tree is exactly the thing you cannot trust for this*.
Verify with `git show main:docs/adr/RESERVED.md` before claiming a number. (The rollup suggests
`origin/main`, but `origin` here IS the HF Space; local `main` is the reliable read.) Found by
reading `SHOWCASE_STATUS.md` before updating it β€” the same habit that caught collision #3.
**Environment note:** this repo now has a venv at `../../.venv` (Python 3.14) created for this
work β€” `gradio[oauth]==6.18.0` (the `oauth` extra is required or the UI modules fail to import at
all), plus the rest of `requirements.txt` and pytest. Previously the memory said to borrow
`DecoupleRpy_Agent/.venv`.
---
## 2026-07-28 β€” Routing leg VERIFIED end-to-end; dispatch blocked by a private-Space 404
The one thing the orchestrator registration task could not check headlessly β€” does the router
actually *classify* a DNA question and dispatch here β€” is now verified in a signed-in browser
session against the orchestrator **dev** Space (`5cbf1d9`).
Asked: *"Which samples in the TCGA pancreatic cohort carry a KRAS G12D mutation, and what is the
KRAS alteration frequency?"*
**Classification: PASS.** The orchestrator answered *"Routing to PDAC Genomics Agent for
computation."* β€” it recognised a DNA question and picked this specialist over the subtype and
DE agents. That is the registration working, including the deliberate choice to drop bare `KRAS`
from the trigger keywords (the hotspot allele `G12D` carried the intent instead).
**Dispatch: FAIL, and the error names its own cause.** The call came back:
> The specialist tool server could not be reached… `404 Client Error. Repository Not Found for
> url: https://huggingface.co/api/spaces/anne-voigt/pdac-genomics-agent`. If you are trying to
> access a private or gated repo, make sure you are authenticated…
This **Space is PRIVATE**, so without credentials the orchestrator cannot even *resolve* the repo,
let alone reach the gate. The failure is a repo-lookup 404, not an allow-list denial β€” consistent
with the caller-side root cause already found: the orchestrator attaches its token only when
`HF_TOKEN` is set, and that secret is unset on its Space.
**Nothing to change here.** The fix is one secret on the *orchestrator's* Space, and it is a
credential operation for a human. Worth noting the order of failure for whoever does it: with
`HF_TOKEN` unset the symptom is a 404 (invisible private repo); once set, the next thing exercised
is the allow-list β€” and the identity it resolves to must be on `ALLOWED_IDS` here
(`anne-voigt,cpelz741` today).
---
## 2026-07-28 β€” Repaired `main`: conflict markers were committed and deployed
`main` carried **literal `<<<<<<< HEAD` / `=======` / `>>>>>>>` markers in `memory.md`**, and they
went out to the live Space. Introduced by `b676112`, which was a cherry-pick of the hermetic-suite
commit (`dbaeaa3`) that hit an append-only collision in `memory.md`, was committed unresolved, and
was pushed. Two further things about that commit are worth knowing, because both make it hard to
find again:
- **Its message describes different work.** It reads "verify: routing leg passes; dispatch blocked
by private-Space 404", but its diff is the hermetic suite β€” `tests/conftest.py` (+109),
`tests/test_hermetic.py` (+69), plus TODO/memory. Searching the log for the socket guard will
not find it.
- **The code half landed fine.** The socket guard and `test_hermetic.py` are correct and complete
on `main`; only the prose merge was broken. So this was cosmetically invisible β€” tests pass with
conflict markers sitting in `memory.md`, because nothing tests prose.
**Repair:** kept both colliding entries (machine-endpoint argument errors, and hermetic
enforcement) in chronological order β€” an append-only log has no genuine conflict, only an ordering
question.
**Also corrected a statement `main` was making about itself.** That cherry-pick carried over a
TODO item reading "*This branch's* `RESERVED.md` is stale β€” it stops at 0005". True of the branch
it was written on; **false on `main`**, whose ledger correctly shows 0006 + 0007 published and next
free 0008. Rewritten to keep the lesson (check `git show main:docs/adr/RESERVED.md`, never your
working copy β€” the per-lane-copy failure means the shared-row conflict cannot fire) without the
false claim.
**Deliberately NOT carried over** from that branch's follow-up commit (`52bb514`): its narrative
records a *rebase* onto main, but `main` received a **merge** (`80b5e01`) instead, and it flags a
gap β€” `pancreas_cptac_gdc` / `pdac_msk_2024` missing `source_import_date` β€” that **does not exist
here**. All six artifacts on `main` carry it (verified), because the merge re-curated all six
rather than trusting git's textual merge of the JSON. Importing that entry would have written a
history that did not happen and a backlog item already closed.
**The generalizable bit:** prose is the only part of this repo nothing verifies. Code has tests,
artifacts have `--check`, licences have the review table β€” `memory.md` and `TODO.md` have nothing,
so a broken merge in them survives a fully green suite and ships. Worth a lint step (grep the tree
for conflict markers) if it happens twice.
---
## 2026-07-28 β€” Routing re-tested with `HF_TOKEN` set: still 404. It is a VISIBILITY problem.
Re-ran the signed-in dev-orchestrator test after `HF_TOKEN` was set and the Space restarted
(confirmed RUNNING, fresh container start). **Routing passed again** β€” "Routing to PDAC Genomics
Agent for computation" β€” and dispatch failed with the *same* repo-lookup 404.
**The token is set.** `router.py` prints a loud startup warning when `HF_TOKEN` is unset; the
container log for the 23:47:29 start shows no such warning. So the earlier "just set HF_TOKEN"
theory is now discharged and the remaining cause is narrower: the orchestrator has *a* token, but
it cannot **see** `anne-voigt/pdac-genomics-agent`. HF returns 404 (not 403) for a private repo the
caller cannot view, which is exactly the observed error.
**The decisive context β€” this Space is the only private specialist:**
| Space | private? |
|---|---|
| `pdac-subtype-agent-prod` | **No** |
| `Paper2Agent_decoupleRpy` | **No** |
| `pdac-genomics-agent` | **YES** |
The two specialists the orchestrator already calls successfully are public, and they are protected
by the ADR-0004 allow-list gate rather than by repo privacy. We are the outlier.
**Two ways forward, and the choice is architectural, not a bug fix:**
1. **Grant the token repo-read scope** on this Space (fine-grained tokens need explicit read on the
namespace). Keeps the Space private; keeps us different from the siblings; every future caller
hits the same wall.
2. **Make this Space public and rely on the enforced gate** β€” the family's actual convention.
`ACCESS_CONTROL=enforce` + `ALLOWED_IDS` is exactly the protection `pdac-subtype-agent-prod`
runs on, and ours is already enforced and verified in both directions. The redistribution
question this would raise was already handled: `ATTRIBUTION.md` and the full-text
`CCLE_BROAD_TERMS.md` ship beside the artifacts precisely because they are downloadable.
Not doing either unilaterally: option 2 publishes a repository, which is the owner's call.
---
## 2026-07-28 β€” GitHub mirror live (first CI ever); scope gate closed
**Two long-standing items closed, both of which had been waiting on a human.**
**1. GitHub mirror β€” `Anne-Voigt/pdac-genomics-agent`, PRIVATE.** `main` pushed; `curation-freshness`
is active and was proven with a manual `workflow_dispatch` run rather than left to fire on Monday
and be discovered broken. **This repo had never had CI of any kind** β€” unlike every sibling it had
no `github` remote at all, because `origin` IS the HF Space and HuggingFace does not run GitHub
Actions. That is why ADR-0007's weekly check shipped inert.
**PRIVATE was a deliberate call, not a default, and it should not be flipped casually.** The Space
is private on purpose, and `src/resources/curated/` redistributes two cohorts with real
restrictions: `pdac_msk_2024` under **CC BY-NC-ND** (the ND term over a *derived* status matrix is
explicitly unresolved β€” see `licenses.COMMERCIAL_USE_REVIEW`) and `ccle_broad_2019` under Broad's
terms (commercial use restricted, plus a continuity clause binding onward users). A public mirror
publishes both, to a second host, under terms nobody has cleared. **Resolve ND before going
public.**
**Remotes now mean two different things, and confusing them deploys something:**
`origin` β†’ the live HF Space (push to `main` = DEPLOY). `github` β†’ the mirror (push = CI, no
deploy). Push both; they are not interchangeable.
**2. The "should this exist at all" gate is closed.** Per-sample variant *status* is confirmed as
the near-term need. The design doc made this a **pre-M1** confirm; M1–M3 were built without it on
Carl's "build now, ship later", and it had quietly become a pre-deploy confirm that the deploy went
ahead of. Recorded with its provenance rather than as a bare tick: **this is Anne's call as the
consultant, not a direct statement from Dr. Sears or Carl Pelz**, and the original item asked for
theirs. Enough to proceed; NOT the lab having reviewed the tool. A session with Carl is still worth
having before anyone leans on this for real analysis.
**Still blocked on a human, and it is now the only thing between the orchestrator and this agent:**
`HF_TOKEN` must be set in the **orchestrator's** prod Space secrets. Not doable from here β€”
handling an API token is out of scope for an assistant, and it is the one action in this whole
sequence that genuinely requires the person who owns the credential. This agent's gate is verified
working from both sides; the orchestrator simply sends no header when that secret is unset.
---
## 2026-07-28 β€” Space made PUBLIC; the orchestrator boundary is fixed and proven
Flipped `anne-voigt/pdac-genomics-agent` to **public**, matching the two sibling specialists the
orchestrator already calls (`pdac-subtype-agent-prod`, `Paper2Agent_decoupleRpy`). The protection
model is now the family's: **repo visibility is not the boundary β€” the ADR-0004 gate is.**
**Scanned before publishing, and it caught something.** `docs/cbioportal-terms-and-deployability.md`
carried a literal personal gmail address in an example `User-Agent`. The implementation had
deliberately never hardcoded a contact (the `+URL` is the channel, with an optional
`CBIOPORTAL_CONTACT` env var); the doc had not caught up. Removed before the flip β€” publishing
would have made it world-readable and scrapeable. Also verified: no `.env`, no token-shaped
strings, no tracked `run_logs`, audit records go to a separate dataset repo.
**Verified after the flip:**
| check | result |
|---|---|
| anonymous `/panel` (no credential at all) | **denied** β€” `machine_auth: no_token_header` |
| tokenless client can *resolve* the Space | **yes** (this is exactly what 404'd while private) |
| orchestrator's pattern: resolvable repo + `x-orchestrator-token` | **ok** β€” real data returned |
`ALLOWED_IDS` remains a **secret**, not a variable β€” on a public Space, variables are world-readable,
so the sibling's `ALLOWED_IDS` (a variable) currently exposes its collaborator list. Keep ours a
secret; worth fixing there.
**A correctness signal worth noting:** the live answer is now **KRAS 136/150 = 91%**, not the
136/186 = 73% seen earlier. The difference is the denominator fix that landed from another lane β€”
frequencies are now over the *profiled* subset rather than the whole cohort. 91% is what the PDAC
literature reports for KRAS; 73% was an artifact of counting unsequenced samples as wild-type.
**Still unverified: the orchestrator's UI end-to-end.** Its chat panel returned no rendered answer
within ~20s across two attempts, and its Space restarted mid-test (fresh container starts at
23:47:29 and 23:53:05), which wipes conversation state. Routing itself was already proven twice
before the flip ("Routing to PDAC Genomics Agent for computation"), and the dispatch leg is now
proven at the API level β€” so the gap is orchestrator-side rendering/session, not this agent.
---
## 2026-07-28 β€” Pre-push hook: the mirror now follows the deploy
`scripts/hooks/pre-push` + `make install-hooks` (this repo's first Makefile; the pattern copies
`DecoupleRpy_Agent/scripts/hooks/pre-push`). Pushing `main` to `origin` β€” i.e. deploying β€” now
mirrors that same commit to `github` first, so **CI can never run against less than what is live**.
**Why "first" and not "after": git has no post-push hook.** So a failed deploy can leave the mirror
briefly AHEAD. That is deliberately the error we accept: CI running a superset of production
self-corrects on the next deploy, whereas CI running a *subset* β€” the stale-mirror case β€” reads as
a pass. Picking which way to be wrong is the whole design decision here.
**A mirror failure warns and never blocks.** The mirror is CI; `origin` is production. Refusing to
ship because a secondary remote was unreachable is disproportionate, so every path exits 0 and the
warning names the one command that fixes it (a test asserts `exit 1` appears nowhere in the hook).
Verified by driving the hook's stdin protocol directly rather than by pushing six times: non-deploy
remote, non-`main` branch, **branch delete** (all-zero local sha β€” must never mirror a deletion),
missing mirror remote, a real in-sync mirror push, and a deliberately broken remote to see the
warn-don't-block path. All six correct.
**Worktree gotcha worth keeping:** the installer uses `git rev-parse --git-path hooks`, not a
literal `.git/hooks`. Every lane in this repo works from a linked worktree where `.git` is a *file*,
so the naive path installs nothing and fails silently. Checked empirically that git honours the
resolved path from inside a worktree β€” and that it resolves to the SHARED `.git/hooks`, so one
install covers every lane. Pinned by `test_makefile_installs_the_hook_where_git_looks`.
**Still needs one manual step per clone:** hooks are not versioned by git, so `make install-hooks`
has to be run once. A lane that skips it is back to remembering `git push github main`.
Also corrected `CLAUDE.md`: the Space went **public** in `0ffadd1`, so the two-remote table's
"(private)" was already wrong. The mirror stays private.
169 tests.
---
## 2026-07-28 β€” The orchestrator β†’ this agent path is LIVE and proven in production
The three-tier architecture stopped being a design and started answering questions. Verified in the
prod orchestrator UI, signed in as `anne-voigt`:
> *"Which pancreatic cell lines carry a KRAS mutation?"* β†’ **"Routing to PDAC Genomics Agent for
> computation"** β†’ 57 pancreatic lines (lineage filter), **48/53 mutation-profiled (90.6%)
> KRAS-hotspot** (G12D dominant; MIAPACA2 = G12C; Q61H/Q61R), **13/44 CNV-profiled (29.5%) also
> amplified**, CCLE commercial-use restriction + Ghandi 2019 / cBioPortal citations in the answer.
Two things worth noting in that output. The **two denominators** (53 vs 44) are the per-study
modality-coverage gate working β€” it did not pretend 57 everywhere. And **BxPC-3 is correctly absent**
from the mutant list: the canonical KRAS-wild-type control line, so the biology is right, not just
the plumbing.
**The blocker was never auth β€” and there were TWO blockers wearing one symptom.** Four theories died
on the way here: "transient `whoami`" (wrong), "`Bearer None` broken probe" (right about that probe,
not about why prod failed), "`HF_TOKEN` unset on prod" (wrong β€” it was set all along; the prod
orchestrator's startup block carries no unset-token warning), and by then the *dev*-side failure had
already been correctly reframed by another lane as **visibility**, fixed by making this Space public
(`0ffadd1`) β€” see that entry above.
What remained, and what this entry adds: **`agents.yaml` as deployed on the orchestrator's PROD
(`f32afbd`, 07-24) contained no `pdac-genomics-agent` at all.** The registration had only ever
reached its `dev`. A router cannot dispatch to an agent it does not know exists, so prod answered
from the Claude API and recommended DepMap/COSMIC/cBioPortal β€” while this agent, built on cBioPortal,
sat one dispatch away. Fixed by promoting `dev` β†’ prod (`11452f7`, 6 commits, +1381).
So the two Spaces were failing for two unrelated reasons at once β€” dev couldn't *see* this Space,
prod didn't *know about* it β€” which is exactly why single-cause theories kept half-fitting the
evidence and dying.
**Method note, again the same lesson as the machine-auth entry above:** the theories were all built
from code and logs; the answer came from *driving the actual UI end-to-end*. When a cross-Space path
fails, exercise the real path before theorising about which layer broke β€” and check the deployed
config, not the branch you happen to have checked out.
**Bonus, relevant to ADR-0006:** the orchestrator's half of the join now exists
(`cross_modality_join.py` + its own ADR-0003), and it carries an **agreement test that imports this
repo's `contingency_and_test` and asserts identical output** β€” which is exactly the "two
implementations must agree or the same cohort answers differently by route" risk ADR-0006's
Consequences flagged. That risk is now pinned by a test rather than by prose. ⚠️ Its success path is
still fixture-only: **no cohort can actually run the live-PurIST join yet** (id-alignment failures on
every pair tried so far), so treat the first real cohort as a first run, not a regression check.
---
## 2026-07-28 β€” Mouse/KPC spike: the refusal STANDS (no code changed)
Discovery spike, no implementation: `docs/spikes/SPIKE-mouse-kpc-variant-path.md`.
**Verdict FAILED as a feature.** `byod.py`'s `non_human_species` gate and CLAUDE.md's mouse hard
gate are unchanged and should stay unchanged. `RESERVED.md`'s "Mouse/species variant boundary"
entry stays **unclaimed** β€” this spike is the evidence for *not* writing that ADR.
Four independent reasons, each sufficient:
1. **No data.** Probed the public cBioPortal: **538 studies, refGenome ∈ {hg19Γ—466, hg38Γ—72},
zero mouse builds**; the two keyword hits are human (one is a **PDX** study β€” human tumour in a
mouse, i.e. evidence *against* mouse coverage). The gene namespace rejects MGI symbols
(`Trp53`/`Kras`/`Cdkn2a`/`Smad4` β†’ `Gene not found`), so `curate_from_cbioportal` would return
an empty matrix. A registered mouse path = adopting a whole second source, not a new study id.
2. **The flagship question is tautological.** KPC is engineered (`LSL-Kras^G12D; Trp53^R172H;
Pdx1-Cre`), so "KRAS status in my KPC mice" restates the breeding record β€” and our response
shape (per-sample status + cohort frequency) is structurally indistinguishable from a genuine
somatic finding. 100% KRAS-hotspot would be a fact about the colony, not the disease.
3. **The hotspot rules silently corrupt.** Human TP53 (393 aa) vs mouse Trp53 (390 aa) is a clean
**βˆ’3 offset**: our `HOTSPOTS["TP53"]` 175/248/273/282 β†’ mouse 172/245/270/279. So the canonical
KPC allele **`Trp53 R172H` classifies as `missense`, not `hotspot`**, and a mouse variant at 175
would be *promoted* spuriously. KRAS is the trap β€” codons 12/13/61 ARE conserved (identical
first 90 aa), which generalises to the wrong conclusion.
4. **CNV has no cross-species mapping at all** β€” GISTIC calls are assembly-specific, and half the
panel's signal (CDKN2A deep-del, MYC amp) is CNV. CDKN2A also breaks orthology at the protein
level (ARF: human p14 132 aa vs mouse p19 169 aa).
**ADR-0017 is not precedent for this β€” reading it that way inverts its argument.** It permitted the
analyses with no fitted human-specific object (GSVA) and gated the one that had (PurIST). Our
hotspot table is exactly a fitted human object (human residue positions), so it lands in the PurIST
column β€” and differs only in being *worse*: PurIST on mouse is unvalidated, the TP53 rule is
arithmetically wrong. ADR-0017's 1:1 gene-level ortholog table also cannot help: expression needs
**gene**-level mapping, variants need **residue**-level equivalence per isoform.
**The real need reframed:** not per-sample mouse status but "how does my engineered model compare
to the human cohorts?" β€” mostly an *expression* question already owned by `pdac-subtype-agent`.
Spike proposes one small alternative for Carl's review, deliberately **not** built:
`compare_model_genotype_to_human_cohort`, which takes the *declared* genotype (not a MAF), maps
~5 KPC alleles to human equivalents via a tiny hand-curated table, and reports the human cohort
frequency from artifacts we already have. No mouse data, no new source, no ADR needed. Default
outcome β€” build nothing β€” remains fully defensible.
---
## 2026-08-01 β€” Branch reconciliation: seven lanes collapsed into `main`
A week of parallel worktree lanes had left **7 local branches + 5 worktrees**, several of them
rebase copies of each other, and it was no longer obvious which carried work `main` lacked. Audited
all of them; the answer was that `main` was already a superset of everything except **one** commit.
**The only unmerged work: `claude/infallible-payne-2134a8` (`e364948`)** β€” the mouse/KPC spike.
Cherry-picked onto `main` as `84001c2`. Its `docs/spikes/SPIKE-mouse-kpc-variant-path.md` and the
`RESERVED.md` annotation applied cleanly; `TODO.md` and `memory.md` conflicted because the branch
forked back at `085b9f7` and both files have been rewritten since. Resolution kept `main`'s newer
text in both and grafted in only the genuinely new spike entries. Two blocks from the branch were
**deliberately dropped as superseded, not lost**:
- its scope-confirm entry said "confirmed with Rosie/Carl"; `main`'s version records the same gate
more precisely (it was **Anne's** call as consultant, explicitly *not* the lab having reviewed the
tool). Over-reading that is the exact error `main`'s wording guards against.
- its "three operational blockers" list, plus the correction underneath saying they were already
closed. `main` records the closed state directly (`ALLOWED_IDS` set, gate verified both ways,
orchestrator reaching the machine API), so re-importing the stale-then-corrected pair would have
put a contradiction back into the file. The durable lesson from it β€” `_resolve_token_identity()`
doing an outbound `whoami` per request under a broad `except` β€” is already recorded on `main`.
**`claude/suspicious-cannon-b68c4b` looked like unmerged work and was not.** All three of its
commits (`75746cf` cohorts/denominators, `dbaeaa3` socket guard, `52bb514` memory) are content that
reached `main` by another route β€” `git cherry` flagged them `+` only because the patch-ids differ
after rebasing. Verified file-by-file rather than by subject line: `tests/conftest.py` is identical
and `main`'s `test_hermetic.py` is strictly *larger* (it has the conflict-marker test the branch
lacks). Same story for `origin/claude/infallible-kowalevski-2d4648` β€” its three "unique"
`gradio_ui.py` lines are pre-denominator-fix code that `main` superseded.
**The method worth reusing:** `git cherry` / branch names / commit subjects all lie after a rebase.
What actually settled it was `diff <(git ls-tree -r main) <(git ls-tree -r <branch>)` for unique
*files*, then a per-file `diff` of the suspects. Only one branch of seven had a unique file.
Deleted: 7 branches, 5 worktrees, and the 2 redundant `origin/claude/*` remote branches.
**Every deleted tip is preserved as an annotated `archive/<branch>` tag**, matching the convention
the orchestrator and `pdac-subtype-agent` lanes used the same day β€” pushed to the `github` mirror
(the private remote; deliberately **not** to `origin`, which is the public Space). Each tag's
message carries that branch's audit verdict, so the reasoning survives with the object instead of
only in this file:
| tag | sha | verdict |
|---|---|---|
| `archive/claude/busy-herschel-2bc3c9` | `f91fafb` | identical to `main` |
| `archive/claude/dazzling-moser-5d97a7` | `6a17ae2` | ancestor of `main` |
| `archive/claude/gallant-ellis-5ca74e` | `64d7f04` | merged via `80b5e01` |
| `archive/claude/infallible-kowalevski-2d4648` | `09cba72` | ancestor of `main` |
| `archive/claude/infallible-payne-2134a8` | `e364948` | ⚠️ **unreachable** β€” the one real branch; cherry-picked as `84001c2` |
| `archive/claude/suspicious-cannon-b68c4b` | `52bb514` | ⚠️ **unreachable** β€” no unique content (the rebase-lookalike case) |
| `archive/pick/hermetic` | `a90b9fc` | ancestor of `main` |
**Only the two marked unreachable actually needed a tag** β€” the other five are ancestors of `main`
and were never at risk. Verified rather than assumed: after `git reflog expire
--expire-unreachable=now --all && git gc --prune=now`, both `e364948` and `52bb514` still resolve.
Recorded SHAs in prose would **not** have survived that; this is the difference between the tag
habit and the note habit, and it is why the tag habit is the right one.
The `infallible-payne` tag additionally records the two blocks of `e364948` that were deliberately
**not** carried onto `main` (the over-read scope-confirm; the stale-then-corrected blockers list),
so a later reader who diffs the tag against `main` and sees content "missing" finds the reason
attached to the object rather than concluding it was lost.
174 tests pass after the merge, including the conflict-marker guard β€” the test that exists
precisely because a botched prose merge once shipped to the Space.
**Deployed:** `f91fafb..2ca2107` to prod; build watched through `RUNNING_BUILDING` β†’
`RUNNING_APP_STARTING` β†’ `RUNNING`, direct host HTTP 200. The `pre-push` hook mirrored to `github`
first, so CI tests the deployed tree. All three refs at `2ca2107`, one branch.
⚠️ **Still unverified:** the gate was not re-exercised (needs the `x-orchestrator-token`
credential) and no signed-in click-through was done. This deploy is docs/tests only over `894e710`,
so served behaviour should be unchanged β€” but `RUNNING` + HTTP 200 proves the app boots and serves,
not that it answers correctly.
---
## 2026-08-03 β€” the two remaining small code items, closed
Both were long-tail "Open β€” payload contract" / mouse-spike follow-ups; neither changes behaviour
for any deployed caller.
**1. `query_variant_status([])` now expands to the full panel** (`src/tools/query_variant_status.py`).
The machine endpoint's blank `genes` already did this via `_split_genes` in `gradio_ui.py`; calling
the Python function directly with `[]` returned an *empty* `genes` map, which reads as "nothing
altered" rather than "you asked about nothing" β€” the same class of confident-false-negative the
coverage gates exist to prevent, just aimed at a direct caller instead of an orchestrator. The two
entry points now agree. Test asserts `[]` and `load_panel()` return the identical `genes` map.
**2. The BYOD `non_human_species` refusal now names its reason** (`src/workflows/byod.py`). It said
only "not supported", which reads as an arbitrary gate. It now states the actual failure mode:
classification is keyed on **human residue coordinates**, so mouse `Trp53 R172H` β€” the ortholog of
human `R175H` β€” would score as generic missense against our human hotspot table, and CNV has no
cross-species mapping at all. Points at the mouse **expression** path in `pdac-subtype-agent` and at
`SPIKE-mouse-kpc-variant-path.md`. String change only; the refusal itself is unchanged and still
STANDS per that spike.
176 tests pass (was 174) β€” two new, one per item.
---
## 2026-08-05 β€” six backlog decisions taken, and the panel widened 19 β†’ 29
A backlog-triage session with Anne. Five open items closed by decision rather than by code, one
reopened with a real shape, and the one remaining code item β€” the panel widening β€” built.
### The decisions
**ND on `pdac_msk_2024` β€” RESOLVED, and it unblocks the mirror.** The CC BY-NC-ND licence attaches
to the *article and its supplementary material*; the authors deposited this *cohort* separately as
publicly accessible and downloadable data, so the deposition is the instrument governing the data
and a derived status matrix is not an ND-restricted adaptation. **NC is untouched** β€” commercial use
still barred, verdict stays `restricted`. Recorded as a reasoned consultant position, explicitly NOT
an author grant. This removes one of the two blockers on making the GitHub mirror public;
`ccle_broad_2019`'s Broad terms are the other and have **not** moved.
**ICGC open tier β€” `no_restriction_found` β†’ `permitted`.** `commercial_use_permitted()` now returns
True for `paad_qcmg_uq_2016`. E.4 survives the upgrade and still binds commercially (no IP claims
over primary somatic data; downstream licensing must not obstruct further research) β€” a test pins
that clause to the entry, because a grant is not the absence of obligations. ⚠️ The granting clause
itself was established off-repo and is **uncited** in the entry; that gap is recorded in the entry's
own `confidence` field, not only in `TODO.md`, since a caller can now rely on the verdict.
**`whoami` stays.** The open item asked whether the machine gate should drop its outbound `whoami`
for a shared secret. It should not, and the reasoning inverts the item: a shared secret is one
credential shared by all callers, so every machine call would audit as the same undifferentiated
principal. `principal_trace_fields` records `{principal, role}` per run precisely so that field
names a person. The coupling buys per-user attribution β€” unlike ADR-0005 C4's, which bought nothing
the artifact didn't already give us. Successor item is narrower and real: the resolution cache's TTL
and what happens on an HF outage mid-session (fail closed vs. serve on a warm cache).
**Mouse `compare_model_genotype_to_human_cohort` β€” DECLINED.** Not deferred, not blocked on Carl.
The refusal in `byod.py` is the whole answer; the species-boundary ADR stays unclaimed.
**Stub-vs-restructure ADR β€” CLOSED, not contested.** An ADR for an uncontested three-line test-only
decision is ceremony. The gradio-upgrade watch item carries the residual risk.
**`biodata-registry` β€” REOPENED, with the actual obstacle named.** Anne wants the registry's
structure reused, and the objection was never philosophical: the registry **does not model DNA**.
`VALID_MODALITIES` is expression-only, and its value-add (`data_level` β†’ path A/B/P,
`get_integration_plan`) has no meaning for a status matrix. What transfers is the *pattern*, which
this repo already reimplemented locally as `curated/*.json` + `COMMERCIAL_USE_REVIEW`. The proposal
is to extend the registry with a variant modality and move cohort semantics there β€” a cross-repo
change (new manifest kind, version bump, re-pin everywhere) needing **ADR-0008** and its own
milestone.
### The panel: 19 β†’ 29 genes, all seven artifacts re-curated
Added HR-beyond-BRCA (`RAD51C`, `RAD51D`, `BRIP1`, `BARD1`, `CHEK2`, `FANCC` β€” PARP-inhibitor
eligibility) and MMR (`MLH1`, `MSH2`, `MSH6`, `PMS2` β€” MSI-high / pembrolizumab). Both tiers are
answerable from mutation+CNV alone.
**The fusion-driven actionable genes were deliberately left off** β€” `NRG1`, `NTRK1/2/3`, `ALK`,
`ROS1`, `RET`. In PDAC these are structural-variant events, so a mutation+CNV status matrix would
report ~0% for exactly the alterations that matter, on the most clinically actionable question this
agent could be asked. They stay off the panel until a structural-variant modality exists (now a
backlog item with its own coverage-gate requirement).
New-gene frequencies are literature-plausible (0–2%; MSI-H PDAC is ~1%), and KRAS is unchanged at
**90.7% TCGA / 93.7% MSK**, so the profiled-denominator work did not regress. MSK-IMPACT assays all
ten new genes, so those MSK numbers are real rather than gated false negatives; its three unassayed
genes are still `ELAVL1`/`GATA6`/`ACVR1B`.
### The bug the widening exposed
**Widening the panel is not config-only** β€” every curated artifact bakes in the `panel_genes` it was
curated against, and `query_variant_status` filtered requested genes down to what the artifact
carried. A panel gene absent from a not-yet-re-curated artifact therefore **vanished from the
payload with no note**, and a caller reads a short `genes` map as "we looked and found nothing".
Same confident false negative the `assayed: False` gate exists to prevent, reached by another door,
and it would have fired on any future widening too.
Such a gene now returns `{curated: False, note: ...}` and is named in `uncurated_genes`. Kept
**distinct from `assayed: False` on purpose**: that one means the cohort's assay never interrogated
the gene (a fact about the data, unfixable here), this one means we have not curated it yet (a fact
about our snapshot, fixed by re-running `python -m src.curate <study>`). Collapsing them would tell
a user their cohort cannot answer a question a re-curation answers fine.
Two tests: the gap is stated rather than dropped, and every committed artifact carries the *current*
panel β€” the second fails loudly if a future widening skips the re-curation pass.
### Test-suite notes
179 pass (was 176). Three existing tests needed changing, and two of them were pinning the wrong
thing: `test_panel_endpoint_returns_the_v1_panel` hardcoded `n_genes == 19` and now asserts the
endpoint agrees with `load_panel()`, and `test_no_restriction_found_is_not_reported_as_permitted`
pinned `paad_qcmg_uq_2016` and broke when that study's verdict legitimately changed β€” it now tests
the *function's* semantics across all verdicts, which is the rule it was always guarding. The
scaffold smoke test's `len(genes) == 29` is pinned deliberately, so a panel edit that skips
re-curation cannot drift in quietly.
**Not deployed** *at the time this entry was written* β€” code, artifacts, licences and docs only.
**Superseded the same day: this shipped as `7ae3398`** β€” see the next entry.
---
## 2026-08-05 (later) β€” DEPLOYED the panel widening, then built the structural-variant modality
Two deploys today. The first shipped the morning's 19 β†’ 29 widening; the second is the SV work.
### Deploy 1 β€” the panel widening went live (`7ae3398`)
`35c0da0..7ae3398` to `origin`, hook mirrored to `github` first, all three refs aligned. Build
watched `RUNNING_BUILDING` β†’ `RUNNING_APP_STARTING` β†’ `RUNNING` at `7ae3398`, root HTTP 200.
Security scan clean (the three bandit findings β€” B104 bind-all on the MCP server, B311 jitter,
B310 urlopen β€” are pre-existing and in files this change did not touch).
**Verification note worth keeping:** the machine endpoint correctly returned `status: denied` to
an unauthenticated `gradio_client` call, which is the enforced gate working, not a deploy failure.
Since a signed-in click-through needs a browser, the deployed TREE was verified instead by
fetching files from the Space over `resolve/main`: panel 29 genes, all seven artifacts
`panel_genes=29` / schema v3 / curated 2026-08-05, and `uncurated_genes` present in the deployed
tool. **A signed-in click-through is still owed** β€” tree verification proves what is served, not
that it answers correctly.
### Deploy 2 β€” the structural-variant modality (ADR-0008)
Third modality alongside mutation/CNV. Statuses `none` / `fusion_in_frame` /
`fusion_out_of_frame` / `intragenic` / `rearrangement`, somatic only, schema **v3β†’v4**, panel
**29 β†’ 36** with the fusion tier finally added. `ADR-0008` was claimed on `main` alone and pushed
before the ADR was written, per the ledger protocol.
**Probing cBioPortal before designing was the whole game.** Three things the probe found, none of
which were guessable:
**Only 2 of 7 cohorts publish SV at all** β€” `pdac_msk_2024` and `ccle_broad_2019`. The other five
name `sv` in `unavailable_modalities` and emit no `sv` block. The per-study gate now fires far
more often than it ever did for CNV.
**The two SV cohorts differ in KIND, not just volume.** MSK states frame ("Protein Fusion: in
frame {ETV6:NTRK3}"); CCLE has `variantClass: NA` on **all 153** panel-gene rows and labels
events only as free text ("EML4-ALK fusion"). So classification reads `eventInfo`, never
`variantClass` β€” the latter describes the genomic event, not its protein consequence, and a
classifier keyed on it would have promoted every translocation to a "fusion". And a per-study
`sv_annotation` flag (`characterized` / `uncharacterized`) collapses every event in an
uncharacterized cohort to `rearrangement`, including the odd row that mentions a frame, so a
frequency is never a mixture of "genuinely in-frame" and "we could not tell". Those answers carry
a caveat saying an absence of in-frame fusions there is a fact about the annotation, not the
biology. Without it, someone comparing MSK to CCLE would read "0 in-frame fusions in cell lines"
as a finding.
**The per-gene gate was wrong for SV β€” a real bug, surfaced by building the modality.**
`genes_assayed` derives from the DNA gene panel (IMPACT341/468/505…), so it answers "was this
sequenced for mutations and copy number?" and nothing else. Fusion calling is a separate assay
with a different gene set: MSK reports **6 somatic NRG1 rearrangements while NRG1 is absent from
the DNA-panel intersection**. The old gate suppressed all six. That is the mirror image of the
failure the gate was built for β€” refusing an answer we hold, rather than inventing one we don't β€”
and both are dishonest. The gate is now modality-scoped: off the DNA panel **with** observed SV
events β‡’ `assayed: false` *and* a valid `sv` block plus a note forbidding the reader from taking
the SV frequency as a mutation frequency; off the panel with **no** events β‡’ still fully refused.
Evidence overrides the gate toward reporting, never toward a zero.
Also: `sv_provenance` is a separate dict from `provenance`. Same `(gene, sample)` key, and
TP53/CDKN2A each carry a mutation *and* a fusion in the same sample, so sharing one would have let
a rearrangement overwrite a `KRAS G12D`.
**ADR-number near-miss, checked and cleared (the fifth in this family).** `audit.py`,
`logging_sink.py` and `tests/test_audit.py` all said *"ADR-0008 parity"* before this repo had an
0008 β€” that is the SIBLINGS' numbering (`pdac-subtype-agent` and `DecoupleRpy_Agent` each numbered
their own audit-logging ADR 0008). **ADR numbers are per-repo**; only genuine cross-repo standards
burn a number family-wide, and `RESERVED.md` lists those explicitly (0014, 0015). So 0008 was free
here. The three references were reworded to name the sibling repo, since "ADR-0008 parity" now
points at the wrong decision. Recorded in the ledger with the lesson: never cite a bare `ADR-####`
for a sibling's decision.
Frequencies: NRG1 0.26%, NTRK3 0.21%, ROS1 0.21%, NTRK1 0.17%, RET 0.09%, ALK 0.04% in MSK. KRAS
SV is a real measured **0%** (on the DNA panel, so genuinely interrogated) against its 93.7%
mutation rate β€” a good illustration that the modalities are independent.
204 tests pass (was 179): 25 new in `tests/test_structural_variants.py`, most of them about what
the modality *declines* to say. Four existing tests needed shape updates (modalities dict gains
`sv`, MSK's unassayed set gains NRG1, the denominator map gains `sv`, panel count 29 β†’ 36); the
UI's `_frequency_frame` needed no change β€” its existing `"frequency" in block` guard already
skips the new mixed `assayed`/`note` entries and picks up `sv` automatically.
**Still open on this modality:** `variant_by_subtype` does not accept `modality="sv"` (per-gene
counts are 1–6 in 2,336, so nearly every cell would be `testable: false` β€” decide if that is worth
exposing), and BYOD has no SV path (an uploaded fusion table cannot answer the annotation-depth
question, so it should refuse explicitly rather than ignore an SV column).
---
## 2026-08-05 (third pass) β€” the two SV follow-ups, which turned out to be two more live bugs
Closing the two items left open by the SV modality. Both were framed as "missing features"; both
were actually **silent wrong answers** already in production. Same defect class as everything else
today: a fallback where there should have been a refusal.
### `variant_by_subtype` answered a different question than it was asked
The modality branch was `if modality == "cnv": … else: mutation`. So `modality="sv"` produced a
MUTATION association β€” and so did `"banana"`, and so did a typo like `"mutaton"` β€” returning
`join_available: true` with real p-values. The payload did say `"modality": "mutation"`, so a
scrupulous caller *could* have caught it, but the orchestrator asked about fusions and received a
confident answer about point mutations.
The modality set is now **closed** (`mutation` / `cnv` / `sv`), and an unrecognized value returns
`route: "unsupported_modality"` with `supported_modalities`. **Blank and whitespace-only still
mean "unspecified"** and default to mutation β€” the machine endpoint sends blank for an untouched
field, so refusing that would break the most ordinary call there is. (A test caught the
whitespace case: `(" " or "mutation")` is truthy, so defaulting before stripping left `""` and
refused. Strip first, then default.)
Fixing the validation is what made *not* wiring SV a safe choice rather than a silent one β€” worth
recording, because "leave it unwired for now" looked conservative and was the opposite.
### The sparse-table floor, and why it applies to everything
`MIN_ALTERED_FOR_TEST = 5`. Below it a gene reports `testable: false` **with its contingency
counts** instead of a p-value. Fisher's exact returns a number from a 2Γ—2 containing a 2, and an
odds ratio of ∞ from a single-sample cell reads as a strong finding β€” arithmetically correct,
scientifically empty. SV made this unmissable (1–6 events per gene in 2,336 samples), but the
floor is applied to **every** modality: a mutation association resting on 3 altered samples is
exactly as empty, and a rule that fired for one modality and not another would be a distinction
the reader cannot see. Nothing is hidden β€” the counts still ship, only the inference is withheld.
This changed one existing test, and the change is the lesson in miniature:
`test_variant_by_subtype_joins_existing_label` asserted that `fisher_exact` ran on a **five-sample
fixture**. That assertion was the bug written down as an expectation.
### An uploaded MAF reported fusions as missense mutations
`classify_mutation` has no fusion branch, so `Variant_Classification: Fusion` is neither silent nor
truncating and fell through to **missense**. Latent for months and harmless while the panel held
no fusion genes β€” it became serious this morning, when NRG1/NTRK1-3/ALK/ROS1/RET joined, because
an upload would then report precisely the actionable fusion genes as point mutations.
Now a `BYODRefusal("sv_not_supported")` naming why: a fusion status is only meaningful alongside an
annotation-depth claim (in frame or not) that an uploaded row cannot make (ADR-0008), and the
alternative is the missense answer above. It points at the registered cohorts that *can* answer.
**The registered path was checked and is clean** β€” cBioPortal keeps fusions in the SV profile, and
no curated artifact carries a fusion-like string in its mutation provenance. Blast radius was BYOD
only. Also added `"sv": False` to the BYOD modalities dict: the behaviour was already "no SV", but
`unavailable_modalities` is built from that dict, so a BYOD answer never *named* structural
variants as unavailable. Silence about a modality is what this whole family of gates exists to
prevent.
219 tests pass (was 204).
**Pattern worth naming, since it has now happened four times in one day:** every one of these bugs
was a default that fired where a refusal belonged β€” absent gene dropped from the payload, DNA-panel
gate applied to fusions, unknown modality falling back to mutation, fusion row falling through to
missense. None of them raised. All of them produced a well-formatted, citation-stamped answer to a
question nobody asked. **When reviewing this codebase, grep for `else:` on a branch that picks a
modality, a gene set, or a status β€” that is where they live.**
---
## 2026-08-05 β€” dev lane established (local half); mirror hook fixed
`32b56aa` on the new `dev` branch. This was the **only showcase agent repo without a dev Space** β€”
`main` IS the public prod Space, so every deploy shipped straight to production with no staging.
What landed (all local, nothing on HuggingFace):
- `hf-dev` remote β†’ `anne-voigt/pdac-genomics-agent-dev`. **The Space does not exist yet** β€” that
is Annie's call. Until it does, `hf-dev` resolves to nothing and `git fetch --all` warns on it.
- `dev` cut from `main`, tracking `hf-dev/main`; `main` tracking `origin/main`. Both branches
previously tracked *nothing*, which is why unpushed work here was invisible to `git status`
and the generated fleet table showed `(no upstream)` instead of an ahead-count.
- **`scripts/hooks/pre-push` bug, found by adding the remote and fixed in the same commit.** The
hook selected deploy pushes with `origin*|*huggingface.co*` and then acted on anything headed
for `refs/heads/main`. `git push hf-dev dev:main` satisfies BOTH β€” so the first dev-Space push
would have mirrored unreleased dev code onto the GitHub mirror's `main`, where the ADR-0007
freshness job reads it as production. Exactly the wrong-CI failure the hook exists to prevent,
inverted. Now bails on `hf-dev` (and any `*-dev` URL) before the deploy test. All four paths
exercised by piping synthetic stdin at the hook: hf-dev by name, hf-dev by URL, github, origin.
- `CLAUDE.md`: three-remote table, branch-lifecycle section, and a correction β€” the
memory-maintenance list still told agents to hand-edit `SHOWCASE_STATUS.md`, which is generated
from `status/` fragments now.
**To activate**: create the Space (private, gradio `6.18.0`, py3.11, hardware matching prod), then
copy `ACCESS_CONTROL` / `ADMIN_IDS` / `ALLOWED_IDS` / `LOG_SINK_HF_DATASET` (a **separate** dataset,
so staging runs don't pollute `anne-voigt/pdac-genomics-results`) / `LOG_SINK_HF_TOKEN` /
`CBIOPORTAL_CONTACT`. Repo is 588 KiB packed with no LFS payload, so the push is cheap.
**Update, same day β€” the dev Space is LIVE.** Created `anne-voigt/pdac-genomics-agent-dev`
(private, gradio, `cpu-basic` matching prod's free tier), pushed `dev:main`, watched
`BUILDING β†’ APP_STARTING β†’ RUNNING` at `9b5fe82`. `dev` now tracks `hf-dev/main` for real.
`build-status.sh`'s REPOS list needed the dev-ref flipped from `-` to `hf-dev/main` or the fleet
row kept printing `β€”` for a Space that exists.
Two things the wiring taught, both worth keeping:
1. **`make install-hooks` COPIES.** `.git/hooks/pre-push` was still the stale pre-fix copy after
the fix was committed. An edited hook is inert until reinstalled β€” always re-run the target.
2. **The hook fix is verified against a real push, not just synthetic stdin**: `git push hf-dev
dev:main` emitted no mirror line and left `github/main` at `40ab3dd`.
⚠️ The Space has **no secrets set yet** β€” gate dark, sink a no-op. Not a valid place to accept a
gate change until that is done (TODO).
## 2026-08-12 β€” ADR-0009: the whoami cache's TTL and outage behaviour, decided
Closed the M3 narrowed-successor TODO. `_WHOAMI_CACHE` in `gradio_ui.py` was a bare dict β€”
resolutions trusted until restart, and an HF outage served warm tokens forever while denying cold
ones, all undecided-by-default. Now (ADR-0009): entries are `(identity, monotonic-stamp)` with
**positive TTL 15 min**, **negative TTL 60 s**, and **stale-if-error grace 24 h** β€” on a
*transient* whoami failure only, a previously-positive entry is served stale so an HF blip cannot
take the orchestrator down. The deciding frame: `whoami` is **attribution, not authorization** β€”
`ALLOWED_IDS` is local, fail-closed, and re-checked per call, so the worst the cache can do is let
a *revoked HF token* keep attributing calls for the window. A definitive `HfHubHTTPError` is never
rescued by staleness; a cold token stays denied in an outage; transient failures are never cached.
Mechanics worth remembering:
- Ledger protocol followed: 0009 claimed on `main` alone (`c4f9eb5`), pushed to **`github` only**
(origin push deploys prod and this session was told not to). The claim also fixed the stale
"Next free number: 0008" line β€” 0008 was already published; next free is **0010**.
- `HfHubHTTPError` cannot be constructed bare β€” `response=` is a required kwarg now; the test
builds it around a `Mock`. Sockets are blocked in tests, so the mock seam is
`huggingface_hub.HfApi` plus a monkeypatched `time.monotonic`.
- 8 new tests in `tests/test_whoami_cache_policy.py`; full suite 245 green. NOT staged to the dev
Space (it still has no secrets β€” gate dark there anyway) and NOT pushed to origin.
## 2026-08-13 β€” stray secrets removed from the prod Space (config only, no code)
Deleted `decoupler_results_dev_token` and `decoupler_results_token` from the prod Space's
secrets at Annie's request β€” both were added there by mistake on 2026-08-13 while fixing the
*DecoupleRpy_Agent* dev Space's log-sink token (that saga lives in that repo's memory.md);
nothing in this repo ever read them. Deletion restarted prod (~90 s rebuild, back RUNNING).
Post-restart health check: tokenless `/panel` β†’ `denied / no_token_header` β€” serving, gate
fail-closed, diagnosis correct. Remaining secrets: `ADMIN_IDS`, `ALLOWED_IDS`, `HF_TOKEN`.
---
## 2026-08-13 β€” prod audit sink wired to a durable HF dataset (settings work, no code)
The prod Space `anne-voigt/pdac-genomics-agent` had **no `LOG_SINK*` settings** (verified via HF
API 2026-08-13), so `logging_sink.py` fell back to the local sink writing `./run_logs` β€” ephemeral,
lost on every restart of a free-tier Space with no persistent storage. The default dataset the HF
sink points at did not exist either. Done this session:
- **Created the private dataset `anne-voigt/pdac-genomics-results`** (the code's default repo id).
- **Set on the prod Space via API, as *variables*** (auditable without secret access, matching the
ACCESS_CONTROL choice): `LOG_SINK=hf`, `LOG_SINK_HF_DATASET=anne-voigt/pdac-genomics-results`.
The variable change restarted the Space; watched it back through `RUNNING_APP_STARTING`.
- **Safe in the interim**: `HFDatasetLogSink` no-ops without a token (checked the code before
flipping `LOG_SINK`), so audit writes cannot error while the secret is missing β€” but records are
DROPPED, not written locally, until the token lands. Strictly worse than `local` for retention
until Anne acts, strictly better after.
**Still needs Anne (Space settings UI, secret value):** set `LOG_SINK_HF_TOKEN` (the
`research_agent_token` value) on prod, then run one signed-in query and confirm a record file
lands in the dataset. Note the sink also falls back to a `research_agent_token` env var if that
name already exists as a secret on the Space.
**Discrepancy noticed while in the settings API:** prod's *variables* are only `ACCESS_CONTROL`
+ the two new `LOG_SINK*` ones β€” `ADMIN_IDS`/`ALLOWED_IDS` are evidently secrets on prod, while
the dev-Space TODO says to set them "as *variables*, matching prod". The docs disagree with the
Space; reconcile when configuring the dev Space (either flip prod's to variables or fix the TODO
wording).
---
## 2026-08-14 β€” "dead button" diagnosed; prod token secret landed on the WRONG Space; secrets decision
Anne reported the prod UI's "Query variant status" button does nothing. Reproduced and root-caused
by driving the public Space in a browser and speaking the gradio queue protocol directly:
- **The backend is healthy.** The click DOES fire (`queue/join` β†’ `process_completed`,
`success: true` in ~1 ms) β€” but a signed-out session gets the ADR-0004 denial, and that payload
updates ONLY the **collapsed "Raw result (JSON)" accordion** while hiding the caution box. So a
signed-out (or session-expired) click is **visually indistinguishable from a dead button** β€” no
toast, no chart, no visible change. The two prod restarts on 2026-08-13/14 dropped OAuth
sessions, which is almost certainly why it "stopped working". Fix for the user: sign in again.
UX finding worth a lane of its own: a denial should be visible without opening the accordion.
- **The intended prod `LOG_SINK_HF_TOKEN` was set on the DEV Space instead.** Prod's `/secrets`
API shows only `ADMIN_IDS`/`ALLOWED_IDS`/`HF_TOKEN`; dev gained `LOG_SINK_HF_TOKEN` 2026-08-13.
Confirmed live: an authorized machine query (`x-orchestrator-token`, allow-listed caller)
answered correctly (CCLE, 57 lines, n_profiled 53/44/41) and left NO record in the dataset and
NO `[logging_sink] persist_trace failed` line in the run logs β€” the exact signature of the
no-token no-op. Prod restarted 2026-08-14 to rule out stale env; still no-op. **Remaining
action is Anne's alone: add `LOG_SINK_HF_TOKEN` on PROD Space settings**, then one authorized
query and check `runs/` in the dataset.
- **Decision (Anne): `ADMIN_IDS`/`ALLOWED_IDS` stay SECRETS.** The dev-Space TODO's "as
variables, matching prod" wording was the error; docs corrected, Spaces untouched.
- **The dev Space is now fully configured** (gate + sink, separate `pdac-genomics-results-dev`
dataset, private, exists) β€” the "not a valid place to accept a gate change" caveat is lifted.
Diagnostic trick worth keeping: with gradio 6 the UI click's fn is reachable without the UI β€”
`POST /gradio_api/queue/join` with `{fn_index, trigger_id, session_hash}` + an `EventSource` on
`queue/data` shows exactly what the server yields per frame; `/gradio_api/call/<api_name>` +
`x-orchestrator-token` exercises the authorized machine path. Space secrets/variables are
enumerable (keys only) at `/api/spaces/<id>/secrets` and `/variables` β€” no more guessing what a
Space carries.
---
## 2026-08-14 β€” invisible-denial fix landed on dev (the "dead button" UX lane)
The lane the same-day diagnosis called for: an ADR-0004 denial on any of the three UI tabs now
renders in the **caution markdown box** ("β›” Access denied. …", visible), instead of only filling
the collapsed "Raw result (JSON)" accordion while hiding the caution β€” which had made a
signed-out/session-expired click visually indistinguishable from a dead button. New helper
`_denial_caution_md` in `gradio_ui.py`; the gate itself is untouched (still first, still
fail-closed). `tests/test_ui_handler_gating.py` now asserts denial visibility
(`_assert_denial_visible`: caution `visible: True` + reason text) in both denial tests, so the
regression can't come back silently. 237/237 tests pass. Merged to **dev only**; prod promotion
is Annie's call.
---
## 2026-08-14 β€” invisible-denial fix PROMOTED TO PROD (`668841c..3853e93`)
Annie's call, executed. Gates first: **245 tests** pass, gitleaks clean over 89 commits;
pip-audit flags one advisory β€” `cryptography 49.0.0` PYSEC-2026-3552 (fix 50.0.0) β€” which is a
local-venv transitive, not pinned in `requirements.txt`, so it did not block a UI-only promotion
(worth revisiting if it shows up in the Space build). Promoted with `git push origin dev:main`;
the pre-push hook mirrored `3853e93` to `github` first ("mirror in sync"). Build watched
`RUNNING_APP_STARTING β†’ RUNNING`, root HTTP 200. **Verified live via the queue protocol**
(the 2026-08-14 diagnostic trick): a signed-out click on "Query variant status" now yields
caution `{visible: true, "β›” Access denied. πŸ”’ Please sign in…"}` alongside the denied JSON β€”
the dead-button symptom is gone in production. Local `main` could not be fast-forwarded
(checked out in the `peaceful-blackburn` worktree β€” another lane); `origin/main` is the truth.
## 2026-08-14 β€” `cdaniel2008` added to `ALLOWED_IDS` (prod + dev, config only)
Fleet-wide widening (see pdac-subtype-agent memory.md): `ALLOWED_IDS` secret re-set on
`pdac-genomics-agent` and `-dev` to the prior roster (Annie + Carl) plus `cdaniel2008`, via
`HfApi.add_space_secret`. `ADMIN_IDS`/`ACCESS_CONTROL` unchanged. Spaces restarted on the write.
## 2026-08-14 (later) β€” dev `ALLOWED_IDS` narrowed to `anne-voigt`: lab members prod-only
Policy (fleet-wide, see pdac-subtype-agent memory.md): Carl + Colin removed from the dev
Space's `ALLOWED_IDS`; prod unchanged (`anne-voigt, cpelz741, cdaniel2008`). The earlier
"dev mirrors prod" posture note is superseded for the roster (posture β€” secret vs variable β€”
still matches prod).
## 2026-08-14 β€” PROMOTED TO PROD (`2e971df`): invisible-denial fix + ADR-0009 + bookkeeping
Annie's call. Merged `dev` into `main` (`--no-ff`) in the worktree lane; her concurrent
`dev:main` push had already landed `3853e93` on `origin/main` and the merge commit descends from
it, so both pushes composed cleanly β€” nothing clobbered. Gates before push: **245 tests green**
on the merged tree (repo `.venv`); pre-push hook mirrored `2e971df` to `github` first ("mirror in
sync"). Build watched to `RUNNING` at `2e971df`, root 200. **Verified live signed-out**: the
click now renders "β›” Access denied … please sign in" in the caution box on the variant-status
tab β€” the 2026-08-14 dead-button report can no longer reproduce. ADR-0009's claim + cache policy
are now on `origin/main`, closing the "reaches origin at next promotion" note.
Two restarts today dropped OAuth sessions β€” anyone who "was signed in" must sign in again.
⚠️ **`dev` NOT realigned to `main` yet** β€” the main checkout has `dev` checked out with another
active session's uncommitted `memory.md`; fast-forwarding under it was not safe. After that lane
finishes: `git switch dev && git merge --ff-only main && git push hf-dev dev:main`.
⚠️ Prod `LOG_SINK_HF_TOKEN` still missing (see 2026-08-14 entry above) β€” deploy doesn't change that.
---
## 2026-08-14 β€” PROD AUDIT SINK LIVE βœ… (the last mile, and the trap in it)
Anne set `LOG_SINK_HF_TOKEN` on prod. First verification FAILED with the exact signature of a
missing secret β€” correct answers, no record, no `persist_trace failed` line β€” because **the
secret-add restart wedged**: prod sat `RUNNING_APP_STARTING` 20+ minutes while the OLD
container (no secret in env) kept serving. Ruled out a code/token problem by querying the DEV
Space, which wrote `runs/20260814_170438_132674.json` immediately. One manual restart later prod
cut over in ~60 s and the authorized query wrote `runs/20260814_170536_091195.json` β€” full
record: `tool: variant_status:api`, `principal: anne-voigt`, `role: admin`, `enforced: true`.
**Prod audit records are now durable.** Worth keeping: a wedged Space restart serves the
pre-change environment indefinitely while reporting APP_STARTING β€” after ANY secret/variable
change, confirm the stage reaches `RUNNING`, and treat "no record + no error line" as "the
serving container never saw the config", not "the sink is broken".
Also answered for Anne: a Space secret's NAME (`LOG_SINK_HF_TOKEN`) never appears in the HF
access-token list β€” that list shows token names (`research_agent_token`); the secret is just an
env key whose value is the token string.