Spaces:
Sleeping
Sleeping
merge: promote dev → main — invisible-denial UI fix, ADR-0009 cache policy, sink/settings bookkeeping
Browse files- CLAUDE.md +3 -0
- TODO.md +112 -16
- docs/adr/ADR-0009-whoami-cache-policy.md +74 -0
- docs/adr/RESERVED.md +1 -1
- gradio_ui.py +65 -13
- memory.md +199 -0
- tests/test_ui_handler_gating.py +10 -0
- tests/test_whoami_cache_policy.py +135 -0
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 |
-
- [
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
| 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 |
-
##
|
| 156 |
-
|
| 157 |
-
- [
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
- [
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
`
|
| 550 |
-
`
|
| 551 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
## Done — denials 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 |
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 386 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
"""
|
| 388 |
-
|
| 389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 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
|
| 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
|
| 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"
|