avoigt1121 Claude Fable 5 commited on
Commit
2e971df
·
2 Parent(s): c4f9eb53853e93

merge: promote dev → main — invisible-denial UI fix, ADR-0009 cache policy, sink/settings bookkeeping

Browse files
CLAUDE.md CHANGED
@@ -203,6 +203,9 @@ tests/
203
  rides in every answer's `citation.curation` block, and staleness is **visible, never a refusal**.
204
  - **ADR-0008** — the structural-variant (fusion) modality: `eventInfo`-based classification, the
205
  per-study **annotation-depth** gate, and the **modality-scoped** per-gene coverage gate.
 
 
 
206
  - Originating decision: `pdac-subtype-agent` **ADR-0019** (scope, data model, sources, panel) +
207
  `SPIKE-dna-panel-feasibility.md` (cBioPortal proven).
208
 
 
203
  rides in every answer's `citation.curation` block, and staleness is **visible, never a refusal**.
204
  - **ADR-0008** — the structural-variant (fusion) modality: `eventInfo`-based classification, the
205
  per-study **annotation-depth** gate, and the **modality-scoped** per-gene coverage gate.
206
+ - **ADR-0009** — whoami resolution-cache policy: positive TTL 15 min / negative 60 s /
207
+ stale-if-error 24 h on HF outage (whoami is attribution, not authorization — the allow-list
208
+ stays local and fail-closed).
209
  - Originating decision: `pdac-subtype-agent` **ADR-0019** (scope, data model, sources, panel) +
210
  `SPIKE-dna-panel-feasibility.md` (cBioPortal proven).
211
 
TODO.md CHANGED
@@ -107,12 +107,13 @@ the design doc's M0–M4 is the intent behind it — see that doc's §0).
107
  attribution is the requirement, so the coupling is paid for, not incidental — this is
108
  NOT the same trade as ADR-0005 C4, where the coupling bought nothing the artifact
109
  didn't already give us.
110
- - [ ] **Narrowed successor: the resolution cache's TTL and its HF-outage behaviour.**
111
- Resolution is already cached per token, so the question is no longer whether to call
112
- `whoami` but how long a resolution stays good and what happens when HF is unreachable
113
- mid-session — fail closed (deny, losing availability) or serve on a warm cache (keep
114
- availability, risk a revoked token working for the TTL). Pick deliberately and write
115
- the reason down; today it is whatever the cache happens to do.
 
116
  - [x] **Confirm cBioPortal ToS for automated use; public instance vs self-host** ✅ (2026-07-24) →
117
  `docs/cbioportal-terms-and-deployability.md` + **ADR-0005**. Verdict: usable with conditions,
118
  no ToS bar on automated access, **public instance + curate-and-cache, no self-host**.
@@ -152,10 +153,32 @@ the design doc's M0–M4 is the intent behind it — see that doc's §0).
152
  Note the dev Space is one docs commit ahead of the deployed prod sha — dev is the staging lane,
153
  so being ahead is the right direction.
154
 
155
- ## Openthe dev lane cannot be click-through verified while private
156
-
157
- - [ ] **Decide: make `pdac-genomics-agent-dev` public, or accept that UI verification happens on prod
158
- only.** Found 2026-08-05 trying to verify the display fixes on dev. The Space **builds and runs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  fine** — it is *reaching* the UI that fails. Private ⇒ `anne-voigt-pdac-genomics-agent-dev.hf.space`
160
  is a **404** (the session cookie is on `huggingface.co`, not the `hf.space` origin); through the
161
  embed the app renders but sits in a cross-origin iframe that synthetic clicks do not reach; and
@@ -166,6 +189,33 @@ the design doc's M0–M4 is the intent behind it — see that doc's §0).
166
  gradio 6.18.0 — sound for client-side rendering bugs, and NOT sound for anything server-shaped
167
  (auth, secrets, artifact loading in the Space image).
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  ## Open — found in the 2026-08-05 signed-in click-through
170
 
171
  First click-through since 2026-07-27, against deployed `40ab3dd`. **Every payload was correct**;
@@ -543,12 +593,58 @@ the reasoning → `memory.md` (2026-08-05).
543
  gradio, `cpu-basic` (prod is free-tier `cpu-basic`, so the twin is free). Built and `RUNNING` at
544
  `9b5fe82`; `dev` tracks `hf-dev/main`. Closed the last no-staging-step repo in the fleet.
545
 
546
- - [ ] **Set the dev Space secrets/variables the Space is live but UNCONFIGURED.** Without them
547
- the ADR-0004 gate is dark there and the log sink is a no-op, so the dev Space is **not yet a
548
- valid place to accept a gate change**. In Space settings: `ACCESS_CONTROL`, `ADMIN_IDS`,
549
- `ALLOWED_IDS` (as *variables*, matching prod), `LOG_SINK_HF_TOKEN`, `CBIOPORTAL_CONTACT`, and
550
- `LOG_SINK_HF_DATASET` a **separate** dataset, so staging runs do not write into
551
- `anne-voigt/pdac-genomics-results`. Needs Annie (Space settings UI).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
 
553
  - [ ] **Re-run `make install-hooks` after any `scripts/hooks/pre-push` edit.** The Makefile
554
  **copies** the script into `.git/hooks/`, so an edited hook is inert until reinstalled — this bit
 
107
  attribution is the requirement, so the coupling is paid for, not incidental — this is
108
  NOT the same trade as ADR-0005 C4, where the coupling bought nothing the artifact
109
  didn't already give us.
110
+ - [x] **Narrowed successor: the resolution cache's TTL and its HF-outage behaviour.**
111
+ (2026-08-12) **ADR-0009**. Decided: positive TTL 15 min, negative TTL 60 s, and
112
+ **stale-if-error** on a *transient* HF failure a positive entry ≤24 h old is served
113
+ stale (availability wins because `whoami` is attribution, not authorization: the
114
+ `ALLOWED_IDS` check is local and re-checked every call). A definitive rejection is
115
+ never rescued by staleness and a cold token stays denied during an outage. 8 tests in
116
+ `tests/test_whoami_cache_policy.py` (clock + `huggingface_hub.HfApi` mocked).
117
  - [x] **Confirm cBioPortal ToS for automated use; public instance vs self-host** ✅ (2026-07-24) →
118
  `docs/cbioportal-terms-and-deployability.md` + **ADR-0005**. Verdict: usable with conditions,
119
  no ToS bar on automated access, **public instance + curate-and-cache, no self-host**.
 
153
  Note the dev Space is one docs commit ahead of the deployed prod sha — dev is the staging lane,
154
  so being ahead is the right direction.
155
 
156
+ ## Donedenials are now visible in the UI (the "dead button", 2026-08-14)
157
+
158
+ - [x] **FIXED on dev 2026-08-14 — ADR-0004 denials render in the caution box, not only the
159
+ collapsed JSON accordion.** All three tabs' denied paths now show
160
+ "⛔ Access denied. <reason>" via `_denial_caution_md` (`gradio_ui.py`); previously a
161
+ signed-out/session-expired click updated only the closed "Raw result (JSON)" accordion and
162
+ hid the caution — visually a dead button (diagnosed same day, see `memory.md`). Gate order
163
+ and fail-closed behaviour unchanged; `tests/test_ui_handler_gating.py` now asserts denial
164
+ visibility so it can't regress silently. Merged to dev only; prod promotion is Annie's call.
165
+
166
+ ## Done — the dev lane is now click-through verifiable (public + enforced gate, 2026-08-13)
167
+
168
+ - [x] **RESOLVED 2026-08-13 — dev Space flipped PUBLIC with the gate enforced (Anne), all
169
+ post-flip checks pass.** Sequence held: secrets/variables first, gate verified via the
170
+ machine path while still private (see the secrets item below), THEN the flip. Verified
171
+ after the flip: `space_info` reports `private: False`;
172
+ `anne-voigt-pdac-genomics-agent-dev.hf.space` serves the app anonymously (HTTP 200, full UI,
173
+ no more 404/iframe/`__sign=` maze); a signed-out **click** on "Query variant status" returns
174
+ `{"status": "denied", "reason": "🔒 Please sign in…"}` in the Raw-result panel — the ADR-0004
175
+ UI gate, fail-closed on staging exactly as on prod; and the allow-listed path answers with
176
+ real grounded numbers (KRAS 136/150 = 90.7% on `paad_tcga`, verified same day). The staging
177
+ log sink writes to `pdac-genomics-results-dev` only. **The dev lane now verifies that the
178
+ Space ANSWERS, not merely that it boots** — the original item follows, kept for the history.
179
+
180
+ - [x] ~~Decide: make `pdac-genomics-agent-dev` public, or accept that UI verification happens on prod
181
+ only.~~ Found 2026-08-05 trying to verify the display fixes on dev. The Space **builds and runs
182
  fine** — it is *reaching* the UI that fails. Private ⇒ `anne-voigt-pdac-genomics-agent-dev.hf.space`
183
  is a **404** (the session cookie is on `huggingface.co`, not the `hf.space` origin); through the
184
  embed the app renders but sits in a cross-origin iframe that synthetic clicks do not reach; and
 
189
  gradio 6.18.0 — sound for client-side rendering bugs, and NOT sound for anything server-shaped
190
  (auth, secrets, artifact loading in the Space image).
191
 
192
+ **RECOMMENDATION (2026-08-13, awaiting Anne's call): make dev PUBLIC with the gate enforced —
193
+ the exact model prod has run since 2026-07-28.** Analysis behind it:
194
+ - *Data exposure — dev adds none.* The restricted-license artifacts (`ccle_broad_2019` Broad
195
+ terms, `pdac_msk_2024` CC BY-NC-ND) are committed in this repo and have been served — files
196
+ tab included — by the PUBLIC prod Space since 2026-07-28. Broad's continuity clause is
197
+ discharged by `CCLE_BROAD_TERMS.md` next to the artifact; the ND question was resolved
198
+ 2026-08-05 (`licenses.py`). The "keep it private because of the artifacts" reasoning in the
199
+ GitHub-mirror item below predates both facts and no longer applies to *Space* visibility
200
+ (the mirror's own visibility is a separate decision — leave it private).
201
+ - *Secrets are not exposed by visibility* — HF secrets stay secret on a public Space.
202
+ - *Residual delta vs staying private:* a public dev Space shows unreleased code in its files
203
+ tab (dev is deliberately ahead of prod), and anonymous visitors see the UI shell (tools
204
+ deny). Both are the accepted prod model; the alternative — first exercising auth/gate
205
+ changes in production — is exactly what CLAUDE.md warns against.
206
+ - *Hard sequencing:* the gate is currently DARK on dev (secrets/variables item below).
207
+ Public-before-gate = an ungated staging app. Order is fixed: configure → verify gate via
208
+ `gradio_client` + HF token (works while private) → flip public → verify.
209
+ **Runbook for Anne** (both steps need the Space settings UI, agent cannot do them):
210
+ 1. `pdac-genomics-agent-dev` → Settings → Variables: `ACCESS_CONTROL=enforce`,
211
+ `ADMIN_IDS=anne-voigt`, `ALLOWED_IDS` (copy prod), `CBIOPORTAL_CONTACT` (copy prod),
212
+ `LOG_SINK_HF_DATASET` → a **separate dev dataset** (NOT `anne-voigt/pdac-genomics-results`).
213
+ Secrets: `LOG_SINK_HF_TOKEN`. (This closes the "dev Space secrets/variables" item below.)
214
+ 2. After the Space restarts: Settings → Change visibility → public.
215
+ 3. Then an agent verifies: `anne-voigt-pdac-genomics-agent-dev.hf.space` loads (no 404),
216
+ signed-out use is denied with the 🔒 message, signed-in allow-listed use answers, and the
217
+ staging log sink writes to the dev dataset, not prod's.
218
+
219
  ## Open — found in the 2026-08-05 signed-in click-through
220
 
221
  First click-through since 2026-07-27, against deployed `40ab3dd`. **Every payload was correct**;
 
593
  gradio, `cpu-basic` (prod is free-tier `cpu-basic`, so the twin is free). Built and `RUNNING` at
594
  `9b5fe82`; `dev` tracks `hf-dev/main`. Closed the last no-staging-step repo in the fleet.
595
 
596
+ - [x] **Set the dev Space secrets/variables** DONE 2026-08-13 (Anne, Space settings UI).
597
+ Verified via API 2026-08-14: variables `ACCESS_CONTROL=enforce`, `LOG_SINK=hf`,
598
+ `LOG_SINK_HF_DATASET=anne-voigt/pdac-genomics-results-dev` (separate dataset, exists, private);
599
+ secrets `ADMIN_IDS`, `ALLOWED_IDS`, `LOG_SINK_HF_TOKEN`, `HF_TOKEN`. **Decision (Anne,
600
+ 2026-08-14): `ADMIN_IDS`/`ALLOWED_IDS` are SECRETS** on both Spaces the earlier "as variables,
601
+ matching prod" wording here was wrong (only `ACCESS_CONTROL` is a variable) and is hereby
602
+ corrected, not the Spaces. `CBIOPORTAL_CONTACT` remains unset on dev — only needed at curation
603
+ time, which doesn't run on the Space.
604
+
605
+ - [~] **Make the PROD audit sink durable — everything set EXCEPT the prod token secret.**
606
+ Prod had no `LOG_SINK*` settings at all, so audit records went to ephemeral `./run_logs`.
607
+ Done via API: created the private dataset `anne-voigt/pdac-genomics-results` and set the
608
+ variables `LOG_SINK=hf` + `LOG_SINK_HF_DATASET=anne-voigt/pdac-genomics-results` on prod.
609
+ - [ ] **Anne: set the secret `LOG_SINK_HF_TOKEN`** (the `research_agent_token` value) in prod
610
+ Space settings — until then the HF sink **no-ops and records are dropped** (worse than
611
+ `local` for retention; safe otherwise — no errors).
612
+ - [ ] Verify: one signed-in query, then confirm a record file lands in the dataset.
613
+ - [x] **Set the dev Space secrets/variables** — DONE & VERIFIED 2026-08-13. Anne set
614
+ `ACCESS_CONTROL=enforce`, `LOG_SINK=hf` (the item above had omitted this one — without it the
615
+ dataset/token vars are inert), `LOG_SINK_HF_DATASET=anne-voigt/pdac-genomics-results-dev`
616
+ (variables) and `ADMIN_IDS` / `ALLOWED_IDS` / `LOG_SINK_HF_TOKEN` (secrets). Note the original
617
+ item's "as variables, matching prod" was wrong about prod: the allow-lists are **secrets** on
618
+ prod, and dev now matches that reality. `CBIOPORTAL_CONTACT` deliberately skipped — curation
619
+ never runs on the Space and unset is a complete configuration per `cbioportal_io.user_agent`.
620
+ Created the private dataset `anne-voigt/pdac-genomics-results-dev`. Verified live: machine API
621
+ → `no_token_header` denial with no credential, `token_unresolved` fail-closed denial on a bogus
622
+ token, resolves+answers on a valid one; audit records (`enforced: true`) land in the DEV
623
+ dataset only. The dev Space is now a valid place to accept a gate change → unblocks the
624
+ "make dev public?" decision. (A successful `panel:api` call writes no record by design — it is
625
+ a static resource; only denials are recorded on that endpoint.)
626
+ - [x] **DONE 2026-08-13 — dev Space secrets/variables set (Anne) and the gate VERIFIED both ways.**
627
+ Variables: `ACCESS_CONTROL=enforce`, `LOG_SINK=hf`, `LOG_SINK_HF_DATASET=anne-voigt/pdac-genomics-results-dev`
628
+ (separate from prod's, as required). `ADMIN_IDS`/`ALLOWED_IDS` are **secrets** (matches prod —
629
+ another lane confirmed prod holds them as secrets too, so the "as variables" wording formerly in
630
+ this item described prod wrongly, not the new config). `CBIOPORTAL_CONTACT` left unset — a
631
+ complete configuration per `cbioportal_io.user_agent()`, and inert on the request path (ADR-0005 C4).
632
+ Verified via `gradio_client` while still private: allow-listed token + `x-orchestrator-token`
633
+ header → real answer (KRAS 136/150 = 90.7% on `paad_tcga`, `grounded: true`); garbage token →
634
+ `machine_auth: token_unresolved`; no header → `no_token_header`. Audit records landed in the
635
+ **dev** dataset (6 run files), prod's dataset untouched. NOTE for the next tester: gradio 6's
636
+ `Client(token=…)` does NOT forward the token as `x-hf-authorization` — pass
637
+ `headers={"x-orchestrator-token": token}` explicitly or every call reads `no_token_header`.
638
+ **The visibility flip (runbook step 2) is now unblocked** — still Anne's, still pending.
639
+ ⚠️ 2026-08-14: the intended prod `LOG_SINK_HF_TOKEN` landed on the **dev** Space instead —
640
+ prod's secrets are still only `ADMIN_IDS`/`ALLOWED_IDS`/`HF_TOKEN` (verified via the
641
+ `/secrets` API). Confirmed live that prod's sink no-ops: an authorized machine query answered
642
+ correctly and left NO record and NO `persist_trace failed` log line. Prod was also restarted
643
+ 2026-08-14 to rule out a stale env.
644
+ - [ ] **Anne: set the secret `LOG_SINK_HF_TOKEN`** (the `research_agent_token` value) on the
645
+ **PROD** Space (`anne-voigt/pdac-genomics-agent` → Settings) — until then the HF sink
646
+ **no-ops and records are dropped** (worse than `local` for retention; safe otherwise).
647
+ - [ ] Verify: one authorized query, then confirm a `runs/<run_id>.json` lands in the dataset.
648
 
649
  - [ ] **Re-run `make install-hooks` after any `scripts/hooks/pre-push` edit.** The Makefile
650
  **copies** the script into `.git/hooks/`, so an edited hook is inert until reinstalled — this bit
docs/adr/ADR-0009-whoami-cache-policy.md ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-0009 — whoami resolution-cache policy: TTLs + stale-if-error on HF outage
2
+
3
+ **Status:** Accepted · **Date:** 2026-08-12 · **Deciders:** Annie (delegated; policy per the
4
+ M3 TODO item "the resolution cache's TTL and its HF-outage behaviour")
5
+
6
+ **Amends:** ADR-0004 (the machine half of the gate). **Relates to:** ADR-0005 C4 — this is the
7
+ one outbound call deliberately left on the request path (Anne, 2026-08-05: per-user audit
8
+ attribution requires `whoami`; a shared secret is off the table and this ADR does not reopen it).
9
+
10
+ ## Context
11
+
12
+ Machine callers are identified by resolving their HF token via `whoami`
13
+ (`gradio_ui._resolve_token_identity`). Resolution was cached in a bare dict, so both questions
14
+ this ADR answers were undecided-by-default:
15
+
16
+ 1. **TTL** — a resolution was trusted *forever* (until process restart). A revoked HF token kept
17
+ resolving indefinitely; a definitively-rejected token stayed rejected even after rotation.
18
+ 2. **HF outage mid-session** — warm tokens kept working forever (infinite cache) while cold
19
+ tokens were denied. Neither arm was chosen; both were dict behaviour.
20
+
21
+ The framing tension: ADR-0004 is a **fail-closed** gate, which argues for deny-on-outage; but
22
+ this is a low-traffic research Space whose only machine caller is the orchestrator, and taking
23
+ the orchestrator down whenever huggingface.co blips is real, recurring pain for zero attacker
24
+ model — the token's *authorization* never depended on HF in the first place.
25
+
26
+ ## Decision
27
+
28
+ The key observation: **`whoami` is attribution, not authorization.** The authorization decision
29
+ is the `ALLOWED_IDS` allow-list (ADR-0004), which is local, fail-closed, and re-checked on every
30
+ call regardless of any cache. What the cache can get wrong is therefore bounded: a *revoked HF
31
+ token* continuing to resolve to a (still-allowlisted) name for the cache window.
32
+
33
+ Three windows, constants at the top of `gradio_ui.py`:
34
+
35
+ | window | value | reason |
36
+ |---|---|---|
37
+ | positive TTL | **15 min** | bounds a revoked token's usable life; costs ≤4 whoami calls/hour/token |
38
+ | negative TTL | **60 s** | stops a bad token hammering whoami; a rotated token isn't locked out |
39
+ | stale-if-error grace | **24 h** | on a *transient* failure only, a positive entry ≤24 h old is served stale |
40
+
41
+ Outage semantics, precisely:
42
+
43
+ - Expiry always triggers a re-verification **attempt** — stale is only served after whoami
44
+ actually failed transiently.
45
+ - A **definitive** rejection (`HfHubHTTPError`) is never rescued by staleness, and it evicts any
46
+ prior positive entry (the fresh write overwrites).
47
+ - A **cold** token during an outage stays denied — stale-if-error extends trust already
48
+ established, it never creates trust.
49
+ - A transient failure is never cached, so an outage denial cannot outlive the outage.
50
+
51
+ ## Consequences
52
+
53
+ **Positive** — an HF outage no longer takes the orchestrator down (the observed availability
54
+ risk); a revoked token's window drops from "until restart" to ≤15 min normally, ≤24 h through an
55
+ outage; token rotation converges within 60 s.
56
+
57
+ **Negative** — a token revoked *during* an HF outage keeps attributing calls for up to 24 h.
58
+ Accepted: the caller was allowlisted, the calls are audited under the same identity, and
59
+ revoking *access* (allow-list edit) is instant and cache-independent.
60
+
61
+ **Neutral** — cache is per-process and unpersisted; a Space restart is a cold start (deny until
62
+ HF is reachable once). Tests: `tests/test_whoami_cache_policy.py`.
63
+
64
+ ## Alternatives considered
65
+
66
+ 1. **Fail closed on outage (no stale serving).** Purest reading of ADR-0004; rejected because it
67
+ converts every HF blip into a full orchestrator outage while defending against nothing — the
68
+ allow-list, not whoami, is the gate.
69
+ 2. **Infinite stale grace.** Rejected — "revoked token works until restart" is exactly the
70
+ undecided behaviour this ADR exists to replace; 24 h outlives any plausible HF incident.
71
+ 3. **Longer positive TTL (hours).** Rejected — the short TTL is nearly free at this traffic and
72
+ keeps the normal-operation revocation window small.
73
+ 4. **Replace whoami with a shared secret (no outbound call at all).** Out of scope by standing
74
+ decision (Anne, 2026-08-05): per-user audit attribution requires identity resolution.
docs/adr/RESERVED.md CHANGED
@@ -48,7 +48,7 @@ use) · `DROPPED` (burned).
48
  > reads as a pointer to this repo's structural-variant ADR, which is the wrong decision entirely.
49
  > **The lesson: a bare `ADR-####` in prose is ambiguous the moment two repos share a number —
50
  > always name the repo when citing a sibling's.**
51
- | 0009 | CLAIMED | whoami resolution-cache policy — TTL + stale-if-error on HF outage | *(in flight)* |
52
  | **0014** | **EXTERNAL** | Shared security-scan standard (pip-audit + bandit + gitleaks + trivy) — authored in `DecoupleRpy_Agent`. **Do not use;** adopt it, don't renumber it. Adopted here as `security/scan.sh`. | *(sibling repo)* |
53
  | **0015** | **EXTERNAL** | `data_level` / registry `modality` semantics — authored in `biodata-registry`. **Do not use.** A DNA `modality` extension would be an amendment *there*, under that owner. | *(sibling repo)* |
54
 
 
48
  > reads as a pointer to this repo's structural-variant ADR, which is the wrong decision entirely.
49
  > **The lesson: a bare `ADR-####` in prose is ambiguous the moment two repos share a number —
50
  > always name the repo when citing a sibling's.**
51
+ | 0009 | PUBLISHED | whoami resolution-cache policy — TTL + stale-if-error on HF outage | `ADR-0009-whoami-cache-policy.md` |
52
  | **0014** | **EXTERNAL** | Shared security-scan standard (pip-audit + bandit + gitleaks + trivy) — authored in `DecoupleRpy_Agent`. **Do not use;** adopt it, don't renumber it. Adopted here as `security/scan.sh`. | *(sibling repo)* |
53
  | **0015** | **EXTERNAL** | `data_level` / registry `modality` semantics — authored in `biodata-registry`. **Do not use.** A DNA `modality` extension would be an amendment *there*, under that owner. | *(sibling repo)* |
54
 
gradio_ui.py CHANGED
@@ -29,6 +29,7 @@ from __future__ import annotations
29
  import json
30
  import os
31
  import sys
 
32
  from pathlib import Path
33
 
34
  import gradio as gr
@@ -75,6 +76,17 @@ def _denied_json(message: str):
75
  return None, json.dumps({"status": "denied", "reason": message}, indent=2)
76
 
77
 
 
 
 
 
 
 
 
 
 
 
 
78
  # The clearing frame (see `_ui_variant_status`): blank each modality chart's VALUE, and do not
79
  # touch its visibility. Hiding here instead produced a hide→show flip on every query, and the
80
  # third chart lost that race about half the time — it stayed unmounted while the browser threw
@@ -350,7 +362,34 @@ def _subtype_caution_md(result):
350
  # Consequence worth remembering: a call that authenticates locally may be denied in the Space.
351
  # That asymmetry is safe (fail-closed both ways) but will look like a bug if you forget it.
352
  _MACHINE_TOKEN_HEADERS = ("x-orchestrator-token", "x-hf-authorization")
353
- _WHOAMI_CACHE: dict[str, str | None] = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
 
355
  # Machine-caller denial text, per diagnosis. Deliberately NOT the UI's "please sign in": a
356
  # machine caller cannot sign in, and telling it to is what made this look like a Space-side
@@ -380,13 +419,22 @@ def _header_value(headers, name: str) -> str | None:
380
 
381
 
382
  def _resolve_token_identity(token: str) -> str | None:
383
- """Resolve an HF token to a username via `whoami` (cached). None = unusable token.
384
-
385
- Only definitive rejections are cached as None; a transient failure denies *this* call but
386
- is not remembered, so an upstream blip cannot lock the orchestrator out until restart.
 
 
 
 
387
  """
388
- if token in _WHOAMI_CACHE:
389
- return _WHOAMI_CACHE[token]
 
 
 
 
 
390
  try:
391
  from huggingface_hub import HfApi
392
  from huggingface_hub.utils import HfHubHTTPError
@@ -394,11 +442,15 @@ def _resolve_token_identity(token: str) -> str | None:
394
  try:
395
  identity = HfApi().whoami(token=token).get("name")
396
  except HfHubHTTPError:
397
- _WHOAMI_CACHE[token] = None # definitive: the token is bad
398
  return None
399
- except Exception: # noqa: BLE001 — transient/import failure: deny, do not cache
 
 
 
 
400
  return None
401
- _WHOAMI_CACHE[token] = identity
402
  return identity
403
 
404
 
@@ -598,7 +650,7 @@ def _ui_variant_status(source, genes, profile: gr.OAuthProfile | None = None):
598
  if not allowed:
599
  record_run("variant_status", username=username, source=src, status="denied")
600
  _, js = _denied_json(denial)
601
- yield (gr.update(value="", visible=False), *_CLEAR_PLOTS, js)
602
  return
603
  yield (gr.update(), *_CLEAR_PLOTS, gr.update())
604
  result = _safe_result(lambda: _query_variant_status(list(genes) or list(PANEL), source))
@@ -615,7 +667,7 @@ def _ui_variant_by_subtype(
615
  if not allowed:
616
  record_run("variant_by_subtype", username=username, source=src, status="denied")
617
  plot, js = _denied_json(denial)
618
- return gr.update(value="", visible=False), plot, js
619
  result = _safe_result(
620
  lambda: _variant_by_subtype(
621
  list(genes) or list(PANEL),
@@ -653,7 +705,7 @@ def _ui_byod(
653
  if not allowed:
654
  record_run("variant_status:byod", username=username, source=src, status="denied")
655
  plot, js = _denied_json(denial)
656
- return gr.update(value="", visible=False), plot, js
657
 
658
  def _run():
659
  if not maf_file and not cnv_file:
 
29
  import json
30
  import os
31
  import sys
32
+ import time
33
  from pathlib import Path
34
 
35
  import gradio as gr
 
76
  return None, json.dumps({"status": "denied", "reason": message}, indent=2)
77
 
78
 
79
+ def _denial_caution_md(message: str):
80
+ """The denial reason as a VISIBLE caution, not just JSON in the collapsed accordion.
81
+
82
+ Before this, a denied click updated only the closed "Raw result (JSON)" accordion and hid
83
+ the caution box — on screen, nothing happened, indistinguishable from a dead button
84
+ (diagnosed 2026-08-14). The gate stays exactly where ADR-0004 put it; only where the
85
+ refusal is *shown* changes.
86
+ """
87
+ return gr.update(value=f"⛔ **Access denied.** {message}", visible=True)
88
+
89
+
90
  # The clearing frame (see `_ui_variant_status`): blank each modality chart's VALUE, and do not
91
  # touch its visibility. Hiding here instead produced a hide→show flip on every query, and the
92
  # third chart lost that race about half the time — it stayed unmounted while the browser threw
 
362
  # Consequence worth remembering: a call that authenticates locally may be denied in the Space.
363
  # That asymmetry is safe (fail-closed both ways) but will look like a bug if you forget it.
364
  _MACHINE_TOKEN_HEADERS = ("x-orchestrator-token", "x-hf-authorization")
365
+
366
+ # Resolution-cache policy (ADR-0009). Each entry is (identity, monotonic-timestamp); the three
367
+ # windows below are the deliberate answers to "how long is a whoami result trusted?" and "what
368
+ # happens when huggingface.co is unreachable mid-session?" — previously both were whatever a bare
369
+ # dict happened to do (trust forever; outage serves warm tokens forever, denies cold ones).
370
+ #
371
+ # _WHOAMI_TTL_S — a POSITIVE resolution is fresh for 15 min. This bounds only how long a
372
+ # *revoked HF token* keeps resolving to a name; the actual authorization
373
+ # decision (ALLOWED_IDS) is local and re-checked on every call, so
374
+ # de-authorizing a caller is instant regardless of this window. Cost of
375
+ # the short TTL is ≤4 whoami calls/hour per token — nothing, at this
376
+ # Space's traffic.
377
+ # _WHOAMI_NEG_TTL_S — a DEFINITIVE rejection (HfHubHTTPError) is remembered for 60 s: enough
378
+ # to stop a misconfigured caller hammering whoami in a burst, short
379
+ # enough that a freshly-rotated token is not locked out.
380
+ # _WHOAMI_STALE_OK_S — on a TRANSIENT failure (HF outage / network error), a previously
381
+ # POSITIVE entry up to 24 h old is served stale. Availability wins here:
382
+ # identity resolution exists for audit attribution, not authorization,
383
+ # and the fail-closed gate (ADR-0004) is the allow-list check that never
384
+ # leaves this process. Worst case is a token revoked *during* an HF
385
+ # outage still attributing calls for ≤24 h — acceptable on a low-traffic
386
+ # research Space; the alternative is the orchestrator going down whenever
387
+ # HF blips. Stale entries are never served when HF is reachable, and a
388
+ # cold or definitively-rejected token stays denied during an outage.
389
+ _WHOAMI_TTL_S = 15 * 60
390
+ _WHOAMI_NEG_TTL_S = 60
391
+ _WHOAMI_STALE_OK_S = 24 * 60 * 60
392
+ _WHOAMI_CACHE: dict[str, tuple[str | None, float]] = {}
393
 
394
  # Machine-caller denial text, per diagnosis. Deliberately NOT the UI's "please sign in": a
395
  # machine caller cannot sign in, and telling it to is what made this look like a Space-side
 
419
 
420
 
421
  def _resolve_token_identity(token: str) -> str | None:
422
+ """Resolve an HF token to a username via `whoami` (TTL-cached). None = unusable token.
423
+
424
+ Cache policy is ADR-0009 (see the `_WHOAMI_*` constants above): positive resolutions are
425
+ fresh for `_WHOAMI_TTL_S`, definitive rejections for `_WHOAMI_NEG_TTL_S`, and on a
426
+ *transient* failure only (HF unreachable) a positive entry up to `_WHOAMI_STALE_OK_S` old is
427
+ served stale — so an upstream outage cannot take the orchestrator down, while the allow-list
428
+ gate itself stays local and fail-closed. A transient failure with no stale positive entry
429
+ denies this call and is not remembered.
430
  """
431
+ now = time.monotonic()
432
+ entry = _WHOAMI_CACHE.get(token)
433
+ if entry is not None:
434
+ identity, stamp = entry
435
+ ttl = _WHOAMI_TTL_S if identity is not None else _WHOAMI_NEG_TTL_S
436
+ if now - stamp < ttl:
437
+ return identity
438
  try:
439
  from huggingface_hub import HfApi
440
  from huggingface_hub.utils import HfHubHTTPError
 
442
  try:
443
  identity = HfApi().whoami(token=token).get("name")
444
  except HfHubHTTPError:
445
+ _WHOAMI_CACHE[token] = (None, now) # definitive: the token is bad
446
  return None
447
+ except Exception: # noqa: BLE001 — transient/import failure: stale-if-error, else deny
448
+ if entry is not None:
449
+ identity, stamp = entry
450
+ if identity is not None and now - stamp < _WHOAMI_STALE_OK_S:
451
+ return identity
452
  return None
453
+ _WHOAMI_CACHE[token] = (identity, now)
454
  return identity
455
 
456
 
 
650
  if not allowed:
651
  record_run("variant_status", username=username, source=src, status="denied")
652
  _, js = _denied_json(denial)
653
+ yield (_denial_caution_md(denial), *_CLEAR_PLOTS, js)
654
  return
655
  yield (gr.update(), *_CLEAR_PLOTS, gr.update())
656
  result = _safe_result(lambda: _query_variant_status(list(genes) or list(PANEL), source))
 
667
  if not allowed:
668
  record_run("variant_by_subtype", username=username, source=src, status="denied")
669
  plot, js = _denied_json(denial)
670
+ return _denial_caution_md(denial), plot, js
671
  result = _safe_result(
672
  lambda: _variant_by_subtype(
673
  list(genes) or list(PANEL),
 
705
  if not allowed:
706
  record_run("variant_status:byod", username=username, source=src, status="denied")
707
  plot, js = _denied_json(denial)
708
+ return _denial_caution_md(denial), plot, js
709
 
710
  def _run():
711
  if not maf_file and not cnv_file:
memory.md CHANGED
@@ -4,6 +4,98 @@ Current status + history. Architecture → `CLAUDE.md`. Backlog → `TODO.md`.
4
 
5
  ---
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  ## 2026-08-06 — legibility: the header names SV, and the chart no longer stacks (`408908b`, NOT deployed)
8
 
9
  **Header.** Title and the variant-status blurb both said "mutation and copy-number status". SV has
@@ -1964,3 +2056,110 @@ Two things the wiring taught, both worth keeping:
1964
 
1965
  ⚠️ The Space has **no secrets set yet** — gate dark, sink a no-op. Not a valid place to accept a
1966
  gate change until that is done (TODO).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  ---
6
 
7
+ ## 2026-08-13 — dev Space configured & verified: the gate is live on staging (no code change)
8
+
9
+ The dev Space `anne-voigt/pdac-genomics-agent-dev` went from "RUNNING but unconfigured" to a
10
+ valid staging target for gate changes. Anne entered the settings; this lane prepared the exact
11
+ values and verified afterward.
12
+
13
+ **Settings now on dev** — variables: `ACCESS_CONTROL=enforce`, `LOG_SINK=hf`,
14
+ `LOG_SINK_HF_DATASET=anne-voigt/pdac-genomics-results-dev`; secrets: `ADMIN_IDS`, `ALLOWED_IDS`,
15
+ `LOG_SINK_HF_TOKEN`. Two corrections to the TODO item as written:
16
+
17
+ 1. **It omitted `LOG_SINK=hf`** — the dataset/token vars are inert while the sink defaults to
18
+ `local` (`logging_sink.get_log_sink`).
19
+ 2. **"`ADMIN_IDS`/`ALLOWED_IDS` as variables, matching prod" was wrong about prod** — checked via
20
+ the HF API: prod holds them as *secrets* (only `ACCESS_CONTROL` is a visible variable). Dev now
21
+ matches prod's actual posture.
22
+
23
+ Also found while checking prod: **prod had no `LOG_SINK*` settings at all and its default dataset
24
+ `anne-voigt/pdac-genomics-results` did not exist** — prod audit records were going to ephemeral
25
+ `./run_logs` and dying on every restart. Spun off to a separate lane (which created the prod
26
+ dataset and set the prod variables; its `LOG_SINK_HF_TOKEN` secret was still pending Anne at the
27
+ time of writing — see the status fragment).
28
+
29
+ **Verification (all live against the dev Space):** machine API with no credential →
30
+ `machine_auth: no_token_header` denial; bogus token → `token_unresolved` fail-closed denial;
31
+ valid token → resolves and returns the 36-gene panel. Audit records with `enforced: true` land in
32
+ `pdac-genomics-results-dev` under `runs/`, and none in prod's dataset. Signed-out UI access is
33
+ moot while the Space is private (HF's own wall precedes ours), and the anonymous-denial records
34
+ prove the gate's fail-closed path regardless. Note learned: a *successful* `panel:api` call
35
+ writes no audit record by design — `panel()` returns after the gate without `record_run`; only
36
+ denials are recorded on that endpoint.
37
+
38
+ Created the private dataset `anne-voigt/pdac-genomics-results-dev`. This item gated the
39
+ "make the dev Space public?" decision — that is now unblocked.
40
+ ## 2026-08-13 (latest) — dev Space PUBLIC; the staging lane is whole
41
+
42
+ Anne flipped visibility the same day. Post-flip verification, all green:
43
+
44
+ - `space_info` (anonymous) → `private: False`; `anne-voigt-pdac-genomics-agent-dev.hf.space`
45
+ returns HTTP 200 with no cookies and renders the full app — the 404 / cross-origin-iframe /
46
+ `__sign=` password-form maze from 2026-08-05 is gone.
47
+ - Signed-out **click-through** on "Query variant status" → `{"status": "denied", "reason":
48
+ "🔒 Please sign in…"}`. The ADR-0004 UI gate is enforced and fail-closed on staging, matching
49
+ prod's public+gated model.
50
+ - Allow-listed access answers with grounded numbers (machine path, same day: KRAS 136/150 =
51
+ 90.7% on `paad_tcga`); audit records land in `pdac-genomics-results-dev` only.
52
+
53
+ **Consequence: the dev lane can now verify that the Space answers, not merely that it boots.**
54
+ Server-shaped changes (auth, secrets, artifact loading) no longer take their first real
55
+ click-through in production. The 2026-08-05 caveat about local-run verification being the only
56
+ option is retired.
57
+
58
+ ---
59
+
60
+ ## 2026-08-13 (later) — dev Space configured; gate verified pre-flip; only the public flip remains
61
+
62
+ Anne set the dev Space settings the same day. Verified from here while the Space is still private
63
+ (machine path works through privacy; only browser click-through doesn't):
64
+
65
+ - Variables read via `HfApi.get_space_variables`: `ACCESS_CONTROL=enforce`, `LOG_SINK=hf`,
66
+ `LOG_SINK_HF_DATASET=anne-voigt/pdac-genomics-results-dev`. `ADMIN_IDS`/`ALLOWED_IDS` are
67
+ secrets — and that MATCHES prod (another lane checked prod the same day); the old TODO wording
68
+ "as variables, matching prod" misdescribed prod. `CBIOPORTAL_CONTACT` deliberately unset.
69
+ - Gate, three ways: allow-listed token in `x-orchestrator-token` → real grounded answer
70
+ (KRAS 136/150 = 90.7% on `paad_tcga` — the post-denominator-fix number); garbage token →
71
+ `token_unresolved`; no header → `no_token_header`. Fail-closed confirmed on staging.
72
+ - Sink separation: the test runs wrote 6 `runs/*.json` into `pdac-genomics-results-dev`;
73
+ prod's `pdac-genomics-results` has none.
74
+ - ⚠️ Tooling trap worth remembering: gradio 6's `Client(token=…)` does **not** send
75
+ `x-hf-authorization` — without an explicit `headers={"x-orchestrator-token": …}` even a valid
76
+ admin token reads as `no_token_header`. The 2026-07-28 "gradio_client attaches it automatically"
77
+ note no longer holds on the pinned client.
78
+
79
+ Remaining: Anne flips visibility → public (Settings UI), then the browser-level checks
80
+ (`*.hf.space` loads, signed-out UI denied, signed-in allow-listed answers).
81
+
82
+ ---
83
+
84
+ ## 2026-08-13 — dev-Space visibility: recommendation written, flip awaits Anne
85
+
86
+ The "public vs prod-only click-through" decision (TODO, open since 2026-08-05) now carries a
87
+ worked recommendation + runbook in TODO.md: **make dev public with `ACCESS_CONTROL=enforce`**,
88
+ matching prod's model. The load-bearing findings: the licensing argument for privacy is stale —
89
+ `ccle_broad_2019` and `pdac_msk_2024` have been served by the *public* prod Space since
90
+ 2026-07-28, with the Broad continuity clause discharged in-repo and the ND question resolved
91
+ 2026-08-05, so a public dev Space adds no new data exposure; secrets are unaffected by
92
+ visibility; the only genuine precondition is sequencing — the gate is DARK on dev, so secrets/
93
+ variables must be configured (and gate-verified via `gradio_client` + token, which works while
94
+ private) BEFORE the flip. Both the secret-setting and the visibility flip need the Space settings
95
+ UI, so this is Annie's action; verification steps are in the runbook. No push made from this lane.
96
+
97
+ ---
98
+
99
  ## 2026-08-06 — legibility: the header names SV, and the chart no longer stacks (`408908b`, NOT deployed)
100
 
101
  **Header.** Title and the variant-status blurb both said "mutation and copy-number status". SV has
 
2056
 
2057
  ⚠️ The Space has **no secrets set yet** — gate dark, sink a no-op. Not a valid place to accept a
2058
  gate change until that is done (TODO).
2059
+
2060
+ ## 2026-08-12 — ADR-0009: the whoami cache's TTL and outage behaviour, decided
2061
+
2062
+ Closed the M3 narrowed-successor TODO. `_WHOAMI_CACHE` in `gradio_ui.py` was a bare dict —
2063
+ resolutions trusted until restart, and an HF outage served warm tokens forever while denying cold
2064
+ ones, all undecided-by-default. Now (ADR-0009): entries are `(identity, monotonic-stamp)` with
2065
+ **positive TTL 15 min**, **negative TTL 60 s**, and **stale-if-error grace 24 h** — on a
2066
+ *transient* whoami failure only, a previously-positive entry is served stale so an HF blip cannot
2067
+ take the orchestrator down. The deciding frame: `whoami` is **attribution, not authorization** —
2068
+ `ALLOWED_IDS` is local, fail-closed, and re-checked per call, so the worst the cache can do is let
2069
+ a *revoked HF token* keep attributing calls for the window. A definitive `HfHubHTTPError` is never
2070
+ rescued by staleness; a cold token stays denied in an outage; transient failures are never cached.
2071
+
2072
+ Mechanics worth remembering:
2073
+ - Ledger protocol followed: 0009 claimed on `main` alone (`c4f9eb5`), pushed to **`github` only**
2074
+ (origin push deploys prod and this session was told not to). The claim also fixed the stale
2075
+ "Next free number: 0008" line — 0008 was already published; next free is **0010**.
2076
+ - `HfHubHTTPError` cannot be constructed bare — `response=` is a required kwarg now; the test
2077
+ builds it around a `Mock`. Sockets are blocked in tests, so the mock seam is
2078
+ `huggingface_hub.HfApi` plus a monkeypatched `time.monotonic`.
2079
+ - 8 new tests in `tests/test_whoami_cache_policy.py`; full suite 245 green. NOT staged to the dev
2080
+ Space (it still has no secrets — gate dark there anyway) and NOT pushed to origin.
2081
+
2082
+ ## 2026-08-13 — stray secrets removed from the prod Space (config only, no code)
2083
+
2084
+ Deleted `decoupler_results_dev_token` and `decoupler_results_token` from the prod Space's
2085
+ secrets at Annie's request — both were added there by mistake on 2026-08-13 while fixing the
2086
+ *DecoupleRpy_Agent* dev Space's log-sink token (that saga lives in that repo's memory.md);
2087
+ nothing in this repo ever read them. Deletion restarted prod (~90 s rebuild, back RUNNING).
2088
+ Post-restart health check: tokenless `/panel` → `denied / no_token_header` — serving, gate
2089
+ fail-closed, diagnosis correct. Remaining secrets: `ADMIN_IDS`, `ALLOWED_IDS`, `HF_TOKEN`.
2090
+ ---
2091
+
2092
+ ## 2026-08-13 — prod audit sink wired to a durable HF dataset (settings work, no code)
2093
+
2094
+ The prod Space `anne-voigt/pdac-genomics-agent` had **no `LOG_SINK*` settings** (verified via HF
2095
+ API 2026-08-13), so `logging_sink.py` fell back to the local sink writing `./run_logs` — ephemeral,
2096
+ lost on every restart of a free-tier Space with no persistent storage. The default dataset the HF
2097
+ sink points at did not exist either. Done this session:
2098
+
2099
+ - **Created the private dataset `anne-voigt/pdac-genomics-results`** (the code's default repo id).
2100
+ - **Set on the prod Space via API, as *variables*** (auditable without secret access, matching the
2101
+ ACCESS_CONTROL choice): `LOG_SINK=hf`, `LOG_SINK_HF_DATASET=anne-voigt/pdac-genomics-results`.
2102
+ The variable change restarted the Space; watched it back through `RUNNING_APP_STARTING`.
2103
+ - **Safe in the interim**: `HFDatasetLogSink` no-ops without a token (checked the code before
2104
+ flipping `LOG_SINK`), so audit writes cannot error while the secret is missing — but records are
2105
+ DROPPED, not written locally, until the token lands. Strictly worse than `local` for retention
2106
+ until Anne acts, strictly better after.
2107
+
2108
+ **Still needs Anne (Space settings UI, secret value):** set `LOG_SINK_HF_TOKEN` (the
2109
+ `research_agent_token` value) on prod, then run one signed-in query and confirm a record file
2110
+ lands in the dataset. Note the sink also falls back to a `research_agent_token` env var if that
2111
+ name already exists as a secret on the Space.
2112
+
2113
+ **Discrepancy noticed while in the settings API:** prod's *variables* are only `ACCESS_CONTROL`
2114
+ + the two new `LOG_SINK*` ones — `ADMIN_IDS`/`ALLOWED_IDS` are evidently secrets on prod, while
2115
+ the dev-Space TODO says to set them "as *variables*, matching prod". The docs disagree with the
2116
+ Space; reconcile when configuring the dev Space (either flip prod's to variables or fix the TODO
2117
+ wording).
2118
+
2119
+ ---
2120
+
2121
+ ## 2026-08-14 — "dead button" diagnosed; prod token secret landed on the WRONG Space; secrets decision
2122
+
2123
+ Anne reported the prod UI's "Query variant status" button does nothing. Reproduced and root-caused
2124
+ by driving the public Space in a browser and speaking the gradio queue protocol directly:
2125
+
2126
+ - **The backend is healthy.** The click DOES fire (`queue/join` → `process_completed`,
2127
+ `success: true` in ~1 ms) — but a signed-out session gets the ADR-0004 denial, and that payload
2128
+ updates ONLY the **collapsed "Raw result (JSON)" accordion** while hiding the caution box. So a
2129
+ signed-out (or session-expired) click is **visually indistinguishable from a dead button** — no
2130
+ toast, no chart, no visible change. The two prod restarts on 2026-08-13/14 dropped OAuth
2131
+ sessions, which is almost certainly why it "stopped working". Fix for the user: sign in again.
2132
+ UX finding worth a lane of its own: a denial should be visible without opening the accordion.
2133
+ - **The intended prod `LOG_SINK_HF_TOKEN` was set on the DEV Space instead.** Prod's `/secrets`
2134
+ API shows only `ADMIN_IDS`/`ALLOWED_IDS`/`HF_TOKEN`; dev gained `LOG_SINK_HF_TOKEN` 2026-08-13.
2135
+ Confirmed live: an authorized machine query (`x-orchestrator-token`, allow-listed caller)
2136
+ answered correctly (CCLE, 57 lines, n_profiled 53/44/41) and left NO record in the dataset and
2137
+ NO `[logging_sink] persist_trace failed` line in the run logs — the exact signature of the
2138
+ no-token no-op. Prod restarted 2026-08-14 to rule out stale env; still no-op. **Remaining
2139
+ action is Anne's alone: add `LOG_SINK_HF_TOKEN` on PROD Space settings**, then one authorized
2140
+ query and check `runs/` in the dataset.
2141
+ - **Decision (Anne): `ADMIN_IDS`/`ALLOWED_IDS` stay SECRETS.** The dev-Space TODO's "as
2142
+ variables, matching prod" wording was the error; docs corrected, Spaces untouched.
2143
+ - **The dev Space is now fully configured** (gate + sink, separate `pdac-genomics-results-dev`
2144
+ dataset, private, exists) — the "not a valid place to accept a gate change" caveat is lifted.
2145
+
2146
+ Diagnostic trick worth keeping: with gradio 6 the UI click's fn is reachable without the UI —
2147
+ `POST /gradio_api/queue/join` with `{fn_index, trigger_id, session_hash}` + an `EventSource` on
2148
+ `queue/data` shows exactly what the server yields per frame; `/gradio_api/call/<api_name>` +
2149
+ `x-orchestrator-token` exercises the authorized machine path. Space secrets/variables are
2150
+ enumerable (keys only) at `/api/spaces/<id>/secrets` and `/variables` — no more guessing what a
2151
+ Space carries.
2152
+
2153
+ ---
2154
+
2155
+ ## 2026-08-14 — invisible-denial fix landed on dev (the "dead button" UX lane)
2156
+
2157
+ The lane the same-day diagnosis called for: an ADR-0004 denial on any of the three UI tabs now
2158
+ renders in the **caution markdown box** ("⛔ Access denied. …", visible), instead of only filling
2159
+ the collapsed "Raw result (JSON)" accordion while hiding the caution — which had made a
2160
+ signed-out/session-expired click visually indistinguishable from a dead button. New helper
2161
+ `_denial_caution_md` in `gradio_ui.py`; the gate itself is untouched (still first, still
2162
+ fail-closed). `tests/test_ui_handler_gating.py` now asserts denial visibility
2163
+ (`_assert_denial_visible`: caution `visible: True` + reason text) in both denial tests, so the
2164
+ regression can't come back silently. 237/237 tests pass. Merged to **dev only**; prod promotion
2165
+ is Annie's call.
tests/test_ui_handler_gating.py CHANGED
@@ -72,6 +72,14 @@ def _final(outputs):
72
  return outputs
73
 
74
 
 
 
 
 
 
 
 
 
75
  UI_CASES = [
76
  ("_ui_variant_status", ("cbioportal:paad_tcga", ["KRAS"])),
77
  ("_ui_variant_by_subtype", ("cbioportal:paad_tcga", ["KRAS"], "mutation", "")),
@@ -92,6 +100,7 @@ def test_ui_handler_denies_anonymous(monkeypatch, handler_name, args):
92
  body = json.loads(outputs[-1])
93
  assert body["status"] == "denied"
94
  assert "sign in" in body["reason"].lower()
 
95
 
96
 
97
  @pytest.mark.parametrize("handler_name,args", UI_CASES)
@@ -109,6 +118,7 @@ def test_ui_handler_denies_unlisted_user(monkeypatch, handler_name, args):
109
  body = json.loads(outputs[-1])
110
  assert body["status"] == "denied"
111
  assert "someone-else" in body["reason"]
 
112
 
113
 
114
  def test_ui_handler_allows_listed_user(monkeypatch):
 
72
  return outputs
73
 
74
 
75
+ def _assert_denial_visible(caution, reason):
76
+ """A denial must land in the caution box the user can actually see, not only in the
77
+ collapsed JSON accordion — the invisible-denial bug (2026-08-14) looked like a dead button."""
78
+ assert isinstance(caution, dict)
79
+ assert caution.get("visible") is True
80
+ assert reason in caution["value"]
81
+
82
+
83
  UI_CASES = [
84
  ("_ui_variant_status", ("cbioportal:paad_tcga", ["KRAS"])),
85
  ("_ui_variant_by_subtype", ("cbioportal:paad_tcga", ["KRAS"], "mutation", "")),
 
100
  body = json.loads(outputs[-1])
101
  assert body["status"] == "denied"
102
  assert "sign in" in body["reason"].lower()
103
+ _assert_denial_visible(outputs[0], body["reason"])
104
 
105
 
106
  @pytest.mark.parametrize("handler_name,args", UI_CASES)
 
118
  body = json.loads(outputs[-1])
119
  assert body["status"] == "denied"
120
  assert "someone-else" in body["reason"]
121
+ _assert_denial_visible(outputs[0], body["reason"])
122
 
123
 
124
  def test_ui_handler_allows_listed_user(monkeypatch):
tests/test_whoami_cache_policy.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ADR-0009 — the whoami resolution-cache policy: TTLs + HF-outage behaviour.
2
+
3
+ Before this policy the cache was a bare dict: a resolution was trusted forever, and an HF
4
+ outage served warm tokens forever while denying cold ones — both undecided-by-default. These
5
+ tests pin the deliberate answers:
6
+
7
+ - positive resolutions expire after ``_WHOAMI_TTL_S`` (bounds a revoked token's lifetime);
8
+ - definitive rejections expire after ``_WHOAMI_NEG_TTL_S`` (a rotated token isn't locked out);
9
+ - on a *transient* failure only, a positive entry ≤ ``_WHOAMI_STALE_OK_S`` old is served stale
10
+ (availability through an HF outage; the allow-list gate itself is local and unaffected);
11
+ - a definitive rejection is never rescued by staleness, and a cold token stays denied.
12
+
13
+ The suite is hermetic (sockets blocked in conftest), so the whoami layer is mocked at
14
+ ``huggingface_hub.HfApi``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import time
20
+ from unittest.mock import Mock
21
+
22
+ import pytest
23
+ from huggingface_hub.utils import HfHubHTTPError
24
+
25
+ import gradio_ui
26
+
27
+
28
+ def _rejection() -> HfHubHTTPError:
29
+ # `response` is a required kwarg; a bare Mock stands in for the HTTP 401.
30
+ return HfHubHTTPError(
31
+ "401 invalid token", response=Mock(status_code=401, headers={}, text="", json=lambda: {})
32
+ )
33
+
34
+
35
+ class _Clock:
36
+ """Controllable stand-in for time.monotonic."""
37
+
38
+ def __init__(self):
39
+ self.now = 1000.0
40
+
41
+ def __call__(self):
42
+ return self.now
43
+
44
+ def advance(self, seconds):
45
+ self.now += seconds
46
+
47
+
48
+ class _FakeApi:
49
+ """One shared behaviour per test: 'ok', 'reject', or 'outage'."""
50
+
51
+ behaviour = "ok"
52
+ calls = 0
53
+
54
+ def whoami(self, token=None):
55
+ _FakeApi.calls += 1
56
+ if _FakeApi.behaviour == "reject":
57
+ raise _rejection()
58
+ if _FakeApi.behaviour == "outage":
59
+ raise ConnectionError("huggingface.co unreachable")
60
+ return {"name": "orchestrator-bot"}
61
+
62
+
63
+ @pytest.fixture()
64
+ def clock(monkeypatch):
65
+ clk = _Clock()
66
+ monkeypatch.setattr(time, "monotonic", clk)
67
+ monkeypatch.setattr("huggingface_hub.HfApi", _FakeApi)
68
+ _FakeApi.behaviour = "ok"
69
+ _FakeApi.calls = 0
70
+ gradio_ui._WHOAMI_CACHE.clear()
71
+ yield clk
72
+ gradio_ui._WHOAMI_CACHE.clear()
73
+
74
+
75
+ def test_fresh_positive_resolution_is_served_from_cache(clock):
76
+ assert gradio_ui._resolve_token_identity("tok") == "orchestrator-bot"
77
+ clock.advance(gradio_ui._WHOAMI_TTL_S - 1)
78
+ assert gradio_ui._resolve_token_identity("tok") == "orchestrator-bot"
79
+ assert _FakeApi.calls == 1 # second hit never left the cache
80
+
81
+
82
+ def test_positive_resolution_expires_and_is_re_verified(clock):
83
+ gradio_ui._resolve_token_identity("tok")
84
+ clock.advance(gradio_ui._WHOAMI_TTL_S + 1)
85
+ # token was revoked upstream in the meantime
86
+ _FakeApi.behaviour = "reject"
87
+ assert gradio_ui._resolve_token_identity("tok") is None
88
+ assert _FakeApi.calls == 2
89
+
90
+
91
+ def test_definitive_rejection_is_cached_briefly_then_retried(clock):
92
+ _FakeApi.behaviour = "reject"
93
+ assert gradio_ui._resolve_token_identity("tok") is None
94
+ assert gradio_ui._resolve_token_identity("tok") is None
95
+ assert _FakeApi.calls == 1 # within the negative TTL: no second whoami
96
+ clock.advance(gradio_ui._WHOAMI_NEG_TTL_S + 1)
97
+ _FakeApi.behaviour = "ok" # token rotated to a valid one
98
+ assert gradio_ui._resolve_token_identity("tok") == "orchestrator-bot"
99
+
100
+
101
+ def test_outage_serves_a_stale_positive_entry(clock):
102
+ """HF down mid-session: a previously-resolved token keeps working (stale-if-error)."""
103
+ gradio_ui._resolve_token_identity("tok")
104
+ clock.advance(gradio_ui._WHOAMI_TTL_S + 1) # fresh window over → whoami is attempted
105
+ _FakeApi.behaviour = "outage"
106
+ assert gradio_ui._resolve_token_identity("tok") == "orchestrator-bot"
107
+ assert _FakeApi.calls == 2 # it DID try to re-verify before falling back
108
+
109
+
110
+ def test_outage_stale_grace_is_bounded(clock):
111
+ gradio_ui._resolve_token_identity("tok")
112
+ clock.advance(gradio_ui._WHOAMI_STALE_OK_S + 1)
113
+ _FakeApi.behaviour = "outage"
114
+ assert gradio_ui._resolve_token_identity("tok") is None
115
+
116
+
117
+ def test_outage_never_rescues_a_cold_token(clock):
118
+ _FakeApi.behaviour = "outage"
119
+ assert gradio_ui._resolve_token_identity("never-seen") is None
120
+
121
+
122
+ def test_outage_never_rescues_a_definitively_rejected_token(clock):
123
+ _FakeApi.behaviour = "reject"
124
+ gradio_ui._resolve_token_identity("bad")
125
+ clock.advance(gradio_ui._WHOAMI_NEG_TTL_S + 1)
126
+ _FakeApi.behaviour = "outage"
127
+ assert gradio_ui._resolve_token_identity("bad") is None
128
+
129
+
130
+ def test_transient_failure_is_not_cached(clock):
131
+ """An outage denial must not poison the cache once HF recovers."""
132
+ _FakeApi.behaviour = "outage"
133
+ assert gradio_ui._resolve_token_identity("tok") is None
134
+ _FakeApi.behaviour = "ok"
135
+ assert gradio_ui._resolve_token_identity("tok") == "orchestrator-bot"