avoigt1121 commited on
Commit
5b9f592
·
2 Parent(s): d5af68a97e7d80

merge: feat/phase1-knowledge-specialist-s1 — the knowledge specialist's governed ToolUniverse core (ADR-0001): 28-tool allow-list with exactly 12 loaded, no-model-key and load_workspace=False startup guards, 10-field provenance, pinned output schemas, retrieved_at-preserving cache, per-source rate limits, identifier validator, annotation payloads; 117 passed / 1 strict xfail; ADR-0014 scan clean

Browse files
.gitattributes ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Bookkeeping files are union-merged so concurrent lanes' entries both survive
2
+ # (fleet convention). NEVER set merge=union on code or lockfiles.
3
+ memory.md merge=union
4
+ TODO.md merge=union
.github/workflows/tests.yml ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The test suite and the formatting check, run on the GitHub mirror (copied from pdac-spatial-agent,
2
+ # where a repo with no workflow let an unformatted file sit for three days).
3
+ #
4
+ # WHERE IT RUNS. Once the Spaces exist, `origin` IS the HuggingFace prod Space and `hf-dev` the dev
5
+ # Space; HuggingFace does not execute GitHub Actions, so this only ever runs via the `github` mirror,
6
+ # which deploys nothing.
7
+ #
8
+ # WHY IT NEEDS NO SECRETS. `tests/conftest.py` blocks outbound sockets before collection and removes
9
+ # every model-provider variable, so the suite is hermetic by enforcement. Install-time network is
10
+ # still required (PyPI). If this job ever starts needing a token, that is a regression in the guard,
11
+ # not a missing secret.
12
+ name: tests
13
+
14
+ on:
15
+ pull_request:
16
+ push:
17
+ branches: [main, dev]
18
+ workflow_dispatch:
19
+
20
+ permissions:
21
+ contents: read
22
+
23
+ concurrency:
24
+ group: tests-${{ github.ref }}
25
+ cancel-in-progress: true
26
+
27
+ jobs:
28
+ # Separate from pytest on purpose: a style failure and a test failure are different news, and
29
+ # folding them together hides one behind the other. Safe to gate only because the repo is
30
+ # formatted from its first commit — a check that fails on day one is one people learn to ignore.
31
+ format:
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - uses: actions/checkout@v7
35
+ - uses: actions/setup-python@v6
36
+ with:
37
+ python-version: "3.12"
38
+ - name: Install ruff (pinned — see requirements-dev.txt)
39
+ run: pip install "$(grep '^ruff==' requirements-dev.txt)"
40
+ - name: Check formatting
41
+ # `format --check` only. `ruff check` (lint) is not gated: it still has pre-existing
42
+ # findings across files no single lane owns, and gating that would fail on day one.
43
+ run: ruff format --check .
44
+
45
+ pytest:
46
+ runs-on: ubuntu-latest
47
+ steps:
48
+ - uses: actions/checkout@v7
49
+
50
+ - uses: actions/setup-python@v6
51
+ with:
52
+ # Matches `python_version` in the README front-matter — CI should run what the Space
53
+ # runs. Note this is 3.12 where most of the fleet is 3.11; that is this Space's setting,
54
+ # not a copy-paste slip.
55
+ python-version: "3.12"
56
+ cache: pip
57
+ cache-dependency-path: |
58
+ requirements.txt
59
+ requirements-dev.txt
60
+
61
+ - name: Install deps
62
+ # The whole pinned set plus pytest. ToolUniverse (~100 packages) makes this the slow step; the
63
+ # pip cache above is what keeps it from being slow every time.
64
+ run: |
65
+ python -m pip install --upgrade pip
66
+ pip install -r requirements-dev.txt
67
+
68
+ - name: Run the suite
69
+ run: python -m pytest tests/ -q
.gitleaks.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Gitleaks config — ADR-0014 secret scan (this repo's copy of the shared definition
2
+ # authored in DecoupleRpy_Agent, ported via pdac-subtype-agent).
3
+ #
4
+ # Extends the upstream default ruleset (do not replace it). Real secrets (HF_TOKEN,
5
+ # `research_agent_token`, `ANTHROPIC_API_KEY`) must NEVER be committed — they live in HF
6
+ # Space secrets. Runs over the working tree AND full git history, because tokens have
7
+ # flowed through these repos.
8
+ #
9
+ # Run: gitleaks detect --config .gitleaks.toml --redact --no-banner
10
+
11
+ [extend]
12
+ useDefault = true
13
+
14
+ [[allowlists]]
15
+ description = "known-safe paths and placeholder tokens"
16
+ paths = [
17
+ '''\.venv/''',
18
+ '''(^|/)node_modules/''',
19
+ '''security/sbom\.json''',
20
+ '''security/pip-audit-.*\.(json|txt)''',
21
+ '''\.gitleaks\.toml''',
22
+ ]
23
+ # Docs and ADRs reference token *names* as identifiers, never their values.
24
+ regexes = [
25
+ '''research_agent_token''',
26
+ '''ANTHROPIC_API_KEY''',
27
+ '''(?i)your[-_]?(hf|api|anthropic)[-_]?(token|key)[-_]?here''',
28
+ '''(?i)example[-_]?(token|key|secret)''',
29
+ '''xxx+|placeholder|dummy|fake[-_]?(token|key|secret)''',
30
+ ]
31
+ stopwords = ["example", "placeholder", "changeme"]
.pre-commit-config.yaml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pre-commit hooks — shared lint/format baseline across the PDAC-system repos.
2
+ # Install once per clone: pip install pre-commit && pre-commit install
3
+ # Run on all files: pre-commit run --all-files
4
+ #
5
+ # NOTE: this is separate from the ADR-0014 *security* pre-push hook
6
+ # (scripts/hooks/pre-push, installed via `make install-hooks`). This one runs
7
+ # ruff at commit time for style/correctness; that one runs the security scan at
8
+ # push time.
9
+ repos:
10
+ - repo: https://github.com/astral-sh/ruff-pre-commit
11
+ rev: v0.16.9
12
+ hooks:
13
+ - id: ruff # lint (uses ruff.toml / [tool.ruff]); --fix applies safe fixes
14
+ args: [--fix]
15
+ - id: ruff-format # formatter (line-length + quote style from config)
16
+ - repo: https://github.com/pre-commit/pre-commit-hooks
17
+ rev: v5.0.0
18
+ hooks:
19
+ - id: end-of-file-fixer
20
+ - id: trailing-whitespace
21
+ - id: check-yaml
22
+ - id: check-added-large-files
23
+ args: [--maxkb=2048]
Makefile ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # pdac-knowledge-agent — developer entry points. The security targets are this repo's copy
2
+ # of the shared surface ADR-0014 standardizes across the fleet.
3
+ .PHONY: help security-scan security-baseline install-hooks test
4
+
5
+ help:
6
+ @echo "make install-hooks Install the pre-push hook (security gate + GitHub mirror sync on prod deploys)."
7
+ @echo "make security-scan Run the security scan (pip-audit, bandit, gitleaks; trivy N/A)."
8
+ @echo "make security-baseline Regenerate the accepted-findings bandit baseline."
9
+ @echo "make test Run the test suite."
10
+
11
+ # One scan definition; the pre-push hook and a manual run both call it, so the check
12
+ # cannot drift between entry points.
13
+ security-scan:
14
+ @bash scripts/security_scan.sh
15
+
16
+ # Only after intentionally accepting a new finding. Review the diff before committing —
17
+ # a new entry is a new accepted suppression and must be justified in
18
+ # security/ACCEPTED-FINDINGS.md. Targets match the scan: src/ + root-level modules.
19
+ security-baseline:
20
+ @if command -v bandit >/dev/null 2>&1; then B="bandit"; else B="uvx bandit"; fi; \
21
+ $$B -r src $$(git ls-files '*.py' | grep -v /) -ll -c bandit.yaml \
22
+ -f json -o security/bandit-baseline.json -q; \
23
+ echo "Wrote security/bandit-baseline.json — review the diff and document any new suppression."
24
+
25
+ # Git never commits .git/hooks, so this installs one (idempotent).
26
+ # `--git-path hooks` resolves correctly from a linked worktree, where .git is a file.
27
+ # It installs a SHIM, not a copy: scripts/hooks/pre-push-shim runs the tracked hook as it is
28
+ # on `dev`, so an edited gate never needs re-installing (a stale copy silently disabled a gate
29
+ # in pdac-analysis-orchestrator for six days). Run once per clone.
30
+ install-hooks:
31
+ @hooks_dir="$$(git rev-parse --git-path hooks)"; mkdir -p "$$hooks_dir"; \
32
+ cp scripts/hooks/pre-push-shim "$$hooks_dir/pre-push"; chmod +x "$$hooks_dir/pre-push"; \
33
+ echo "Installed $$hooks_dir/pre-push — every push now runs dev's security gate on the pushed commit."
34
+
35
+ test:
36
+ @python -m pytest tests/ -q
README.md ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: PDAC Knowledge Agent
3
+ emoji: 📚
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: gradio
7
+ sdk_version: 6.28.0
8
+ python_version: "3.12"
9
+ app_file: gradio_ui.py
10
+ hf_oauth: true
11
+ pinned: false
12
+ ---
13
+
14
+ # PDAC Knowledge Agent
15
+
16
+ The knowledge specialist of the PDAC Research Agent System. It looks up what public knowledge
17
+ bases say about named genes, drugs, diseases and trials, and returns **annotation, never
18
+ analysis**: it reads no dataset and computes no statistic. Built per
19
+ [ADR-0001](docs/adr/ADR-0001-knowledge-specialist-contract.md) on a pinned, allow-listed subset
20
+ of [ToolUniverse](https://github.com/mims-harvard/ToolUniverse) (Apache-2.0).
21
+
22
+ > **Status: Phase 1, slice s1 — the governed core only.** The endpoints, the Gradio app and the
23
+ > deploy descriptor arrive in s2; there is no Space yet. See
24
+ > [the plan](docs/plans/PLAN-phase1-knowledge-specialist.md).
25
+
26
+ ## What it will answer (s2)
27
+
28
+ | Endpoint | What | Sources |
29
+ |---|---|---|
30
+ | `drugs_for_targets` | Drugs and clinical candidates per target, with mechanism and maximum clinical stage | HGNC → Open Targets; ChEMBL |
31
+ | `trials_for_genes` | ClinicalTrials.gov studies whose intervention matches the gene | ClinicalTrials.gov |
32
+ | `annotate_genes` | Approved name, UniProt accession, protein name, function, subcellular location | HGNC, UniProt |
33
+ | `pathways_for_genes` | Reactome pathways each gene belongs to (membership, not enrichment) | UniProt → Reactome |
34
+ | `targets_for_disease` | Top associated targets, with Open Targets' own association score | Open Targets |
35
+
36
+ Which TFs or pathways are active, which genes are differentially expressed and which gene sets
37
+ are enriched in a dataset are DecoupleRpy's questions; the literature corpus is lit-agent's.
38
+
39
+ ## The rules, enforced in code
40
+
41
+ - **Provenance on every row:** source, ToolUniverse tool and version, the query sent, the
42
+ source's own id and URL, when the source answered, cache hit or miss, licence and licence URL
43
+ (`src/provenance.py`). A row missing any of them cannot reach a payload.
44
+ - **Annotation, not analysis:** every ok payload is `kind: "annotation"` and carries no
45
+ `n_for_inference`; a row count is never a sample size (`src/payloads.py`).
46
+ - **Allow-list by construction:** only the tools the endpoints use are loaded, from the approved
47
+ 28 in `config/tools_allowlist.yaml`. Agentic/LLM-backed tools, tool finders, remote MCP
48
+ servers, web search and key-gated tools can never load, ToolUniverse's workspace and profile
49
+ are never read, and startup refuses if any model-provider key is set (`src/tu_client.py`).
50
+ - **Pinned output schemas:** a source response that drifts from its schema becomes
51
+ `upstream_schema_drift`, never a guessed row (`src/schemas/`).
52
+ - **Identifiers only leave the Space:** gene symbols, Ensembl/UniProt/EFO/MONDO/NCT ids and a
53
+ bounded disease name. Free text, sample ids and numbers are refused before any call
54
+ (`src/identifiers.py`).
55
+ - **Declared hosts:** every host the loaded tools can reach is in `config/sources.yaml`, checked
56
+ offline against the installed package. This is a behaviour contract, not an egress firewall:
57
+ Hugging Face Spaces have no egress allow-list.
58
+ - **Cache and limits:** ephemeral SQLite cache with a TTL per source that keeps the original
59
+ `retrieved_at`; a token bucket per source; timeouts and limits become structured errors.
60
+
61
+ ## Development
62
+
63
+ ```bash
64
+ uv venv --python 3.12 .venv && uv pip install -r requirements-dev.txt
65
+ python -m pytest # hermetic: outbound sockets are blocked
66
+ make security-scan # ADR-0014: pip-audit, bandit, gitleaks (trivy N/A)
67
+ make install-hooks # the pre-push security gate, once per clone
68
+ ```
69
+
70
+ `dev` is the integration branch and `main` is production (once the Spaces exist, `main` IS the
71
+ prod Space). Lanes merge to `dev` only.
access.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """App-layer identity & authorization — the family allow-list gate.
2
+
3
+ Deliberately mirrors the sibling specialists' ``access.py`` env contract
4
+ (pdac-genomics-agent, pdac-subtype-agent ADR-0012, DecoupleRpy_Agent
5
+ ``access_control``) so all Spaces share ONE allow-list model:
6
+
7
+ - ``ADMIN_IDS`` — comma-separated admin identities (``UPLOAD_ADMIN_IDS`` honored
8
+ for back-compat with the family contract).
9
+ - ``ALLOWED_IDS`` — comma-separated regular-user identities. Admins are allowed too.
10
+ - ``ACCESS_CONTROL`` — dark-launch switch. OFF unless one of ``1/on/true/enforce/yes``.
11
+ It is set to enforce on both Spaces before either is made public (plan D2-C). When
12
+ ON, fail-closed: an empty list denies everyone; unknown/anonymous identities are refused.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+
19
+ ADMIN_ROLE = "admin"
20
+ USER_ROLE = "user"
21
+
22
+ _ADMIN_ENV_VARS = ("ADMIN_IDS", "UPLOAD_ADMIN_IDS")
23
+ _ALLOWED_ENV_VAR = "ALLOWED_IDS"
24
+ _ENFORCE_VALUES = {"1", "on", "true", "enforce", "yes"}
25
+
26
+
27
+ def _ids_from_env(*env_vars: str) -> set[str]:
28
+ ids: set[str] = set()
29
+ for var in env_vars:
30
+ raw = os.environ.get(var, "")
31
+ ids.update(part.strip() for part in raw.split(",") if part.strip())
32
+ return ids
33
+
34
+
35
+ def access_enforced() -> bool:
36
+ return os.environ.get("ACCESS_CONTROL", "").strip().lower() in _ENFORCE_VALUES
37
+
38
+
39
+ def role_for(identity: str | None) -> str | None:
40
+ if not identity:
41
+ return None
42
+ identity = identity.strip()
43
+ if not identity:
44
+ return None
45
+ if identity in _ids_from_env(*_ADMIN_ENV_VARS):
46
+ return ADMIN_ROLE
47
+ if identity in _ids_from_env(_ALLOWED_ENV_VAR):
48
+ return USER_ROLE
49
+ return None
50
+
51
+
52
+ def is_authorized(identity: str | None) -> bool:
53
+ return role_for(identity) is not None
54
+
55
+
56
+ def check_access(username: str | None) -> tuple[bool, str | None]:
57
+ """(allowed, denial_message). Enforcement off ⇒ everyone allowed."""
58
+ if not access_enforced():
59
+ return True, None
60
+ if not username:
61
+ return False, (
62
+ "🔒 Please sign in with your HuggingFace account (button above) to use this tool."
63
+ )
64
+ if not is_authorized(username):
65
+ return False, (
66
+ f"🔒 Access denied — '{username}' is not on the authorized-users list "
67
+ "for this deployment. Contact the study admin to request access."
68
+ )
69
+ return True, None
audit.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal run audit — one record per call, results NEVER stored.
2
+
3
+ Records who called which endpoint, with how many identifiers and what status. Neither the
4
+ identifiers nor the payload are written (the ADR-0008 posture of the sibling
5
+ specialists — orchestrator persistence stores results in ITS private index; the
6
+ specialist's sink stays result-free). Failure to write never fails a call.
7
+
8
+ Destination is the configured sink (``LOG_SINK``, see :mod:`logging_sink`): ``local`` by
9
+ default, ``hf`` on the deployed Spaces so the trail survives a rebuild (2026-10-13; the
10
+ temp-dir JSONL this replaced was erased by every rebuild).
11
+
12
+ The ``hf`` upload runs on a daemon thread: the answer never waits on HuggingFace. A slow or
13
+ failing hub (HF git returned 500s for minutes on 2026-10-13) must not push a call past the
14
+ orchestrator's 120 s budget. The cost is that a record still uploading when the container is
15
+ killed is lost — seconds, against the whole trail before.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import datetime
21
+ import secrets
22
+ import threading
23
+
24
+ from access import access_enforced, role_for
25
+ from logging_sink import get_log_sink, persist_trace_safe
26
+
27
+
28
+ def record_run(
29
+ tool: str,
30
+ *,
31
+ username: str | None,
32
+ n_identifiers: int | None,
33
+ status: str,
34
+ run_id: str | None = None,
35
+ ) -> threading.Thread | None:
36
+ """Build and persist one audit record. Returns the upload thread for the ``hf`` sink.
37
+
38
+ ``run_id`` is the payload's own id (None for denials and input errors). The record's
39
+ storage key is that id, or a fresh one when there is none, so no record overwrites
40
+ another. Fail-open: any error building or writing the record is swallowed.
41
+ """
42
+ try:
43
+ now = datetime.datetime.now(datetime.UTC)
44
+ key = run_id or f"{now:%Y%m%d_%H%M%S}_{secrets.token_hex(4)}_{status}"
45
+ record = {
46
+ "run_id": run_id,
47
+ "timestamp": now.isoformat(),
48
+ "tool": tool,
49
+ "status": status,
50
+ "n_identifiers": n_identifiers,
51
+ "principal": username or "anonymous",
52
+ "role": role_for(username),
53
+ "enforced": access_enforced(),
54
+ }
55
+ sink = get_log_sink()
56
+ if sink.name != "hf":
57
+ persist_trace_safe(sink, key, record)
58
+ return None
59
+ upload = threading.Thread(
60
+ target=persist_trace_safe, args=(sink, key, record), name="audit-upload", daemon=True
61
+ )
62
+ upload.start()
63
+ return upload
64
+ except Exception as e: # noqa: BLE001 — auditing must never crash a request
65
+ print(f"[audit] record_run failed: {e}")
66
+ return None
bandit.yaml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Bandit config — ADR-0014 static-analysis scan (this repo's copy of the shared
2
+ # definition authored in DecoupleRpy_Agent, ported via pdac-subtype-agent).
3
+ #
4
+ # Scope: the app code — `src/` plus every root-level module (scripts/security_scan.sh
5
+ # discovers them). Tests use synthetic data and assert on error paths, so scanning them
6
+ # produces noise without signal. Accepted findings live in security/bandit-baseline.json
7
+ # (the scan fails only on NEW ones) and each is justified in
8
+ # security/ACCEPTED-FINDINGS.md. `-ll` = medium severity and above.
9
+
10
+ exclude_dirs:
11
+ - tests
12
+ - .venv
13
+ - scripts
14
+ - docker
15
+ - .claude
config/sources.yaml ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Every external source this Space may call, with the hosts its loaded tools reach (ADR-0001 §6).
2
+ #
3
+ # This file is the honest egress statement, not an egress firewall: Hugging Face Spaces have no
4
+ # egress allow-list, so what this lists is "the hosts this code is built to call", enforced by
5
+ # tests/test_sources_hosts.py (every loaded tool's host must appear here) and by the allow-list
6
+ # loader. The security-review addendum's host table is generated from it (s4), and on any future
7
+ # AWS footprint this list becomes the VPC egress allow-list.
8
+ #
9
+ # Per source:
10
+ # hosts hosts the loaded tools call (found in ToolUniverse 1.5.6's tool code/config)
11
+ # licence the licence string stamped on every row from this source
12
+ # licence_url where that licence is stated
13
+ # licence_checked date + how it was checked; "partial" says what could not be confirmed
14
+ # ttl_seconds cache lifetime (ADR-0001 §8): 7 days; ClinicalTrials.gov 24 h (recruitment moves)
15
+ # rate_per_second token-bucket rate (plan default: at most 3 requests/s per source)
16
+ # burst bucket size
17
+ # timeout_seconds per-call limit; a timeout becomes a `source_unavailable` entry
18
+ # coverage_notes deterministic caveats every payload from this source carries
19
+ # citation the attribution line for `attribution[]`
20
+ #
21
+ # PubChem and DGIdb have no Phase 1 endpoint, so none of their tools is loaded; they are listed
22
+ # because they are on the approved allow-list, and their hosts are declared ahead of use.
23
+
24
+ sources:
25
+ opentargets:
26
+ name: "Open Targets Platform"
27
+ hosts: [api.platform.opentargets.org]
28
+ licence: "CC0 1.0"
29
+ licence_url: "https://platform-docs.opentargets.org/licence"
30
+ licence_checked: "2026-10-20: the licence page states the Platform is marked with CC0 1.0"
31
+ ttl_seconds: 604800
32
+ rate_per_second: 3
33
+ burst: 3
34
+ timeout_seconds: 30
35
+ coverage_notes:
36
+ - "Open Targets lists drugs and clinical candidates with trial or approval evidence; an investigational compound with no trial record (for example MRTX1133 against KRAS G12D) may be absent."
37
+ - "Open Targets association scores are Open Targets' own scores, relayed as such; they are not a statistic computed here."
38
+ citation: "Open Targets Platform (platform.opentargets.org)"
39
+
40
+ chembl:
41
+ name: "ChEMBL"
42
+ hosts: [www.ebi.ac.uk]
43
+ licence: "CC BY-SA 3.0"
44
+ licence_url: "https://chembl.gitbook.io/chembl-interface-documentation/about"
45
+ licence_checked: "2026-10-20: 'made available on a Creative Commons Attribution-Share Alike 3.0 Unported License'"
46
+ ttl_seconds: 604800
47
+ rate_per_second: 3
48
+ burst: 3
49
+ timeout_seconds: 30
50
+ coverage_notes:
51
+ - "ChEMBL data are share-alike (CC BY-SA 3.0): a redistributed table built from these rows must carry the same licence."
52
+ citation: "ChEMBL, EMBL-EBI (www.ebi.ac.uk/chembl)"
53
+
54
+ uniprot:
55
+ name: "UniProt"
56
+ hosts: [rest.uniprot.org]
57
+ licence: "CC BY 4.0"
58
+ licence_url: "https://www.uniprot.org/help/license"
59
+ licence_checked: "2026-10-20 partial: the licence page is script-rendered and did not load; every UniProtKB record's copyright line states CC BY 4.0 and points to uniprot.org/terms"
60
+ ttl_seconds: 604800
61
+ rate_per_second: 3
62
+ burst: 3
63
+ timeout_seconds: 30
64
+ coverage_notes:
65
+ - "Only reviewed (Swiss-Prot) human entries are used; a symbol with no reviewed entry (for example a pseudogene such as SORD2P) is reported as unresolved, not guessed."
66
+ citation: "The UniProt Consortium, UniProtKB (www.uniprot.org)"
67
+
68
+ clinicaltrials:
69
+ name: "ClinicalTrials.gov"
70
+ hosts: [clinicaltrials.gov]
71
+ licence: "Public (U.S. National Library of Medicine); NLM terms and conditions apply"
72
+ licence_url: "https://clinicaltrials.gov/about-site/terms-conditions"
73
+ licence_checked: "2026-10-20 partial: the terms page did not render; NLM policy (ncbi.nlm.nih.gov/home/about/policies) puts government-created information in the public domain and asks for acknowledgment"
74
+ ttl_seconds: 86400
75
+ rate_per_second: 3
76
+ burst: 3
77
+ timeout_seconds: 30
78
+ coverage_notes:
79
+ - "ToolUniverse narrows an intervention search to trials whose intervention matches the gene (Phase 0 finding 5: 1 pancreas-specific CD40 trial, where a broad API query also returns 2 solid-tumour basket trials). The payload's query_semantics states this."
80
+ citation: "ClinicalTrials.gov, U.S. National Library of Medicine"
81
+
82
+ reactome:
83
+ name: "Reactome"
84
+ hosts: [reactome.org]
85
+ licence: "CC0 1.0"
86
+ licence_url: "https://reactome.org/license"
87
+ licence_checked: "2026-10-20: the License Agreement §1(c) releases data under the Creative Commons Public Domain Dedication (CC0). The plan had assumed CC BY 4.0; corrected here."
88
+ ttl_seconds: 604800
89
+ rate_per_second: 3
90
+ burst: 3
91
+ timeout_seconds: 30
92
+ coverage_notes:
93
+ - "Pathway rows are membership (this gene is annotated to this pathway), never enrichment of a gene list."
94
+ citation: "Reactome Knowledgebase (reactome.org)"
95
+
96
+ hgnc:
97
+ name: "HGNC"
98
+ hosts: [rest.genenames.org]
99
+ licence: "No restrictions on access or use (HGNC data release policy); citation requested"
100
+ licence_url: "https://www.genenames.org/about/"
101
+ licence_checked: "2026-10-20: 'No restrictions are imposed on access to, or use of, the data provided by the HGNC'; asks users to cite the HUGO Gene Nomenclature Committee (RRID:SCR_002827)"
102
+ ttl_seconds: 604800
103
+ rate_per_second: 3
104
+ burst: 3
105
+ timeout_seconds: 30
106
+ coverage_notes: []
107
+ citation: "HUGO Gene Nomenclature Committee (HGNC), www.genenames.org"
108
+
109
+ pubchem:
110
+ name: "PubChem"
111
+ hosts: [pubchem.ncbi.nlm.nih.gov]
112
+ licence: "NCBI places no restrictions on the data; some depositor content may carry its own rights"
113
+ licence_url: "https://www.ncbi.nlm.nih.gov/home/about/policies/"
114
+ licence_checked: "2026-10-20: NCBI policy page; PubChem is named among resources that may include third-party material"
115
+ ttl_seconds: 604800
116
+ rate_per_second: 3
117
+ burst: 3
118
+ timeout_seconds: 30
119
+ coverage_notes: []
120
+ citation: "PubChem, U.S. National Library of Medicine"
121
+
122
+ dgidb:
123
+ name: "DGIdb"
124
+ hosts: [dgidb.org]
125
+ licence: "Unconfirmed: DGIdb aggregates sources that carry their own terms; rows must name the interaction's source"
126
+ licence_url: "https://dgidb.org/about"
127
+ licence_checked: "2026-10-20 partial: no data licence found on dgidb.org; must be confirmed before any DGIdb endpoint ships"
128
+ ttl_seconds: 604800
129
+ rate_per_second: 3
130
+ burst: 3
131
+ timeout_seconds: 30
132
+ coverage_notes: []
133
+ citation: "DGIdb, the Drug Gene Interaction Database (dgidb.org)"
config/tools_allowlist.yaml ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The ToolUniverse allow-list — the ONLY tools this Space may ever load.
2
+ #
3
+ # Source of truth: the Phase 0 evaluation memo's 28-tool allow-list across 8 keyless public
4
+ # sources, approved by the project lead 2026-10-13 (memo decision 2: "additions go by config
5
+ # with a review note"). The memo described the tools by function; the exact names below were
6
+ # resolved against ToolUniverse 1.5.6's own tool configs on 2026-10-20 (s1), and every name
7
+ # exists in that version (tests/test_allowlist.py asserts it).
8
+ #
9
+ # `endpoints` names which BCC endpoints use a tool (ADR-0001 §4). Only tools with at least one
10
+ # endpoint are LOADED (ADR-0001 §7); the rest are approved but wait for an endpoint that needs
11
+ # them. Adding a tool here is a reviewed change: give it a `review` note saying who approved it
12
+ # and why. Never add an agentic/LLM-backed tool, the tool finder, a remote MCP server, a
13
+ # web-search backend or a key-gated tool — tests/test_allowlist.py refuses each of those.
14
+ #
15
+ # The five endpoints are built in s2; their tool sets are fixed here so the loaded surface is
16
+ # reviewable before any endpoint code exists.
17
+
18
+ tooluniverse_version: "1.5.6"
19
+
20
+ tools:
21
+ # --- Open Targets (6) ---
22
+ - name: OpenTargets_get_disease_id_description_by_name
23
+ source: opentargets
24
+ endpoints: [targets_for_disease]
25
+ review: "memo 2026-10-12, approved 2026-10-13 — disease name to EFO/MONDO id"
26
+ - name: OpenTargets_get_associated_targets_by_disease_efoId
27
+ source: opentargets
28
+ endpoints: [targets_for_disease]
29
+ review: "memo 2026-10-12, approved 2026-10-13 — targets by disease (Phase 0 Q8)"
30
+ - name: OpenTargets_get_associated_drugs_by_target_ensemblID
31
+ source: opentargets
32
+ endpoints: [drugs_for_targets]
33
+ review: "memo 2026-10-12, approved 2026-10-13 — drugs by target (Phase 0 Q1, Q2, Q7)"
34
+ - name: OpenTargets_get_associated_drugs_by_disease_efoId
35
+ source: opentargets
36
+ endpoints: []
37
+ review: "memo 2026-10-12, approved 2026-10-13 — drugs by disease"
38
+ - name: OpenTargets_get_drug_mechanisms_of_action_by_chemblId
39
+ source: opentargets
40
+ endpoints: []
41
+ review: "memo 2026-10-12, approved 2026-10-13 — drug mechanisms"
42
+ - name: OpenTargets_get_drug_description_by_chemblId
43
+ source: opentargets
44
+ endpoints: []
45
+ review: "memo 2026-10-12, approved 2026-10-13 — drug description"
46
+
47
+ # --- ChEMBL (4) ---
48
+ - name: ChEMBL_search_molecules
49
+ source: chembl
50
+ endpoints: []
51
+ review: "memo 2026-10-12, approved 2026-10-13 — molecule search (Phase 0 found MRTX-1133 only here)"
52
+ - name: ChEMBL_get_drug
53
+ source: chembl
54
+ endpoints: []
55
+ review: "memo 2026-10-12, approved 2026-10-13 — drug record"
56
+ - name: ChEMBL_get_drug_mechanisms
57
+ source: chembl
58
+ endpoints: [drugs_for_targets]
59
+ review: "memo 2026-10-12, approved 2026-10-13 — mechanism of action per drug (ADR-0001 §4)"
60
+ - name: ChEMBL_search_targets
61
+ source: chembl
62
+ endpoints: []
63
+ review: "memo 2026-10-12, approved 2026-10-13 — target search"
64
+
65
+ # --- UniProt (4) ---
66
+ - name: UniProt_search
67
+ source: uniprot
68
+ endpoints: [annotate_genes, pathways_for_genes]
69
+ review: "memo 2026-10-12, approved 2026-10-13 — symbol to reviewed human accession (Phase 0 Q4)"
70
+ - name: UniProt_get_function_by_accession
71
+ source: uniprot
72
+ endpoints: [annotate_genes]
73
+ review: "memo 2026-10-12, approved 2026-10-13 — function (Phase 0 Q3)"
74
+ - name: UniProt_get_recommended_name_by_accession
75
+ source: uniprot
76
+ endpoints: [annotate_genes]
77
+ review: "memo 2026-10-12, approved 2026-10-13 — recommended protein name"
78
+ - name: UniProt_get_subcellular_location_by_accession
79
+ source: uniprot
80
+ endpoints: [annotate_genes]
81
+ review: "memo 2026-10-12, approved 2026-10-13 — subcellular location"
82
+
83
+ # --- ClinicalTrials.gov (3) ---
84
+ - name: ClinicalTrials_search_studies
85
+ source: clinicaltrials
86
+ endpoints: [trials_for_genes]
87
+ review: "memo 2026-10-12, approved 2026-10-13 — interactive trials lookups (memo decision 3; Phase 0 Q5)"
88
+ - name: ClinicalTrials_get_study
89
+ source: clinicaltrials
90
+ endpoints: []
91
+ review: "memo 2026-10-12, approved 2026-10-13 — one study by NCT id"
92
+ - name: ClinicalTrials_search_by_intervention
93
+ source: clinicaltrials
94
+ endpoints: []
95
+ review: "memo 2026-10-12, approved 2026-10-13 — search by intervention"
96
+
97
+ # --- PubChem (3) ---
98
+ - name: PubChem_get_CID_by_compound_name
99
+ source: pubchem
100
+ endpoints: []
101
+ review: "memo 2026-10-12, approved 2026-10-13 — compound name to CID (Phase 0 Q10)"
102
+ - name: PubChem_get_compound_properties_by_CID
103
+ source: pubchem
104
+ endpoints: []
105
+ review: "memo 2026-10-12, approved 2026-10-13 — compound properties (Phase 0 Q10)"
106
+ - name: PubChem_get_compound_xrefs_by_CID
107
+ source: pubchem
108
+ endpoints: []
109
+ review: "memo 2026-10-12, approved 2026-10-13 — cross-references"
110
+
111
+ # --- Reactome (3) ---
112
+ - name: Reactome_map_uniprot_to_pathways
113
+ source: reactome
114
+ endpoints: [pathways_for_genes]
115
+ review: "memo 2026-10-12, approved 2026-10-13 — protein to pathways (Phase 0 Q9)"
116
+ - name: Reactome_get_pathway
117
+ source: reactome
118
+ endpoints: []
119
+ review: "memo 2026-10-12, approved 2026-10-13 — one pathway"
120
+ - name: Reactome_get_pathways_low_entity
121
+ source: reactome
122
+ endpoints: [pathways_for_genes]
123
+ review: "memo 2026-10-12, approved 2026-10-13 — lowest-level pathways for an entity (ADR-0001 §4)"
124
+
125
+ # --- DGIdb (3) ---
126
+ - name: DGIdb_get_drug_gene_interactions
127
+ source: dgidb
128
+ endpoints: []
129
+ review: "memo 2026-10-12, approved 2026-10-13 — drug–gene interactions (Phase 0 Q6)"
130
+ - name: DGIdb_get_gene_druggability
131
+ source: dgidb
132
+ endpoints: []
133
+ review: "memo 2026-10-12, approved 2026-10-13 — druggability categories"
134
+ - name: DGIdb_get_drug_info
135
+ source: dgidb
136
+ endpoints: []
137
+ review: "memo 2026-10-12, approved 2026-10-13 — drug info"
138
+
139
+ # --- HGNC (2) ---
140
+ - name: HGNC_fetch_gene_by_symbol
141
+ source: hgnc
142
+ endpoints: [drugs_for_targets, annotate_genes]
143
+ review: "memo 2026-10-12, approved 2026-10-13 — approved symbol, name and Ensembl id"
144
+ - name: HGNC_search_genes
145
+ source: hgnc
146
+ endpoints: []
147
+ review: "memo 2026-10-12, approved 2026-10-13 — gene search"
docs/adr/ADR-0001-knowledge-specialist-contract.md ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR-0001 — The knowledge specialist: contract, boundary, and what a lookup may claim
2
+
3
+ **Status:** Accepted 2026-10-20 (project lead: D1-A of `docs/plans/PLAN-phase1-knowledge-specialist.md`, five endpoints
4
+ as drafted).
5
+ Access is set by D2-C: public Spaces behind the allow-list gate.
6
+ **Numbering:** drafted beside the roadmap's planning ADRs and moved into this repo at s1 (2026-10-20); this repo's
7
+ `docs/adr/RESERVED.md` owns the number. No orchestrator ADR number is claimed for Phase 1.
8
+ **Rests on:** the Phase 0 evaluation memo (kept with the roadmap, outside this repo), all four of whose decisions were approved
9
+ 2026-10-13: borrow, wrapped; the 28-tool allow-list; ToolUniverse for interactive trials lookups (lit-agent's
10
+ harvester keeps the digest); knowledge lookups joined by the orchestrator, never called from inside DecoupleRpy's
11
+ code agent. It also rests on `pdac-analysis-orchestrator` ADR-0001 (tool_server dispatch), ADR-0002 (machine auth)
12
+ and ADR-0006 (a run card before every tool-server run), and on `pdac-spatial-agent` ADR-0001, the template.
13
+
14
+ ## Context
15
+
16
+ After any result, scientists ask the same next question: what is known about these genes, and is anything
17
+ targeting them? Today the model answers that from memory, and memory is the one source the rest of the architecture
18
+ refuses to rely on. The Phase 0 test showed that ToolUniverse 1.5.6 answers the ten PDAC lookups as well as direct
19
+ API calls do (9 of 10 equal), keeps up with upstream schema changes we would otherwise maintain, and returns
20
+ structured errors. But it stamps no provenance or licence, it silently rewrites some queries (ClinicalTrials.gov
21
+ "CD40"), it carries LLM-backed and hosted-service tools that must never load, and it changes often (six releases in
22
+ two weeks). The fleet's contract has to sit between ToolUniverse and the orchestrator.
23
+
24
+ ## Decisions
25
+
26
+ 1. **Repo `pdac-knowledge-agent`, agent id `pdac_knowledge_agent`.** Spaces are `anne-voigt/pdac-knowledge-agent`
27
+ (prod; `main` is prod) and `anne-voigt/pdac-knowledge-agent-dev`, with a GitHub mirror at
28
+ `Anne-Voigt/pdac-knowledge-agent`. It uses the standard remotes: `origin`, `hf-dev`, `github`.
29
+ 2. **Dispatch kind `tool_server`.** Deterministic `gr.api` endpoints, plain strings in and JSON strings out.
30
+ `mcp_server=True` and the `x-orchestrator-token` machine gate work as they do in the siblings. It is not a
31
+ CodeAgent, and **it makes no model call.** No LLM-provider key may be set; startup refuses if one is.
32
+ 3. **The boundary sentence**, reused verbatim in the descriptor and the routing prompt: *"Looks up what public
33
+ knowledge bases say about named genes, drugs, diseases and trials (Open Targets, ChEMBL, UniProt,
34
+ ClinicalTrials.gov, Reactome, HGNC). It returns annotation, never analysis: it reads no dataset and computes no
35
+ statistic. Which genes are differentially expressed, which TFs or pathways are active, and which gene sets are
36
+ enriched in a dataset are DecoupleRpy's questions. The literature corpus belongs to lit-agent."*
37
+ 4. **Five endpoints, frozen once the descriptor exists:**
38
+
39
+ | Endpoint | Input | What it returns | Sources |
40
+ |---|---|---|---|
41
+ | `drugs_for_targets` | gene symbols or Ensembl ids | drugs and clinical candidates per target, with mechanism and maximum clinical stage | HGNC → Open Targets; ChEMBL for mechanism |
42
+ | `trials_for_genes` | gene symbols, optional condition and status | ClinicalTrials.gov studies whose intervention matches the gene, with NCT id, phase, status | ClinicalTrials.gov |
43
+ | `annotate_genes` | gene symbols | approved name, UniProt accession, recommended protein name, function, subcellular location; unresolved symbols listed | HGNC, UniProt |
44
+ | `pathways_for_genes` | gene symbols or UniProt accessions | Reactome lowest-level pathways each gene belongs to (membership, not enrichment) | UniProt → Reactome |
45
+ | `targets_for_disease` | disease name or EFO/MONDO id | top associated targets, with Open Targets' association score labelled as its score | Open Targets |
46
+
47
+ No endpoint passes through a raw ToolUniverse tool name. A ToolUniverse version bump may change the code behind
48
+ an endpoint, but never the endpoint's name or its payload shape.
49
+ 5. **The payload contract.** The family invariants apply: `status` is one of `ok`, `refused`, `error` or `denied`,
50
+ with a `reason` on anything but `ok`. Refusals are data. `run_id` is on everything except denials. The gate runs
51
+ first, then argument validation. Beyond those:
52
+ - **`kind: "annotation"` is on every ok payload.** Ok payloads carry no `hierarchy` and no `n_for_inference`. A row
53
+ count is not a sample size and must never be rendered as one.
54
+ - **Every row carries `provenance`:** `source`, `source_tool` (the ToolUniverse tool name), `tooluniverse_version`,
55
+ `query` (the arguments actually sent), `source_ref` (the source's own id: ChEMBL, NCT, UniProt, Reactome or
56
+ Ensembl), `source_url`, `retrieved_at` (when the source answered, which is not when the cache served it),
57
+ `cache` (`hit` or `miss`), `licence` and `licence_url`.
58
+ - **Payload-level fields:** `unresolved[]` (each input not found, with its reason) and `source_errors[]` (each with
59
+ `source` and one of `source_unavailable`, `rate_limited` or `upstream_schema_drift`). `coverage_notes[]` come
60
+ from config (for example, that Open Targets may omit investigational compounds that have no trial record, such
61
+ as MRTX1133). `attribution[]` holds one entry per source used. A deterministic `narrative` is generated from the
62
+ rows and always says "according to <source> (queried <date>)".
63
+ - **Output schema check.** Each source's raw response is validated against a pinned schema before any row is
64
+ built. A mismatch gives `upstream_schema_drift` for that source. Rows are never guessed from a partial match.
65
+ Phase 0 saw Open Targets rename `knownDrugs` while the test ran; this check is what catches that.
66
+ 6. **What may leave the Space: identifiers only.** Allowed inputs are HGNC symbols, Ensembl gene ids, UniProt
67
+ accessions, EFO/MONDO ids, NCT ids, and disease names as a bounded string (80 characters; letters, digits,
68
+ spaces, hyphens, commas and apostrophes). At most 100 identifiers per call. Free text, sample ids, expression
69
+ values and file contents are refused as `invalid_identifier` before any outbound call. Every outbound host is
70
+ named in `config/sources.yaml`. The Space calls those hosts and nothing else that is ours to configure; this is a
71
+ behaviour contract, not an egress firewall, and Hugging Face Spaces have no egress allow-list. The security-review
72
+ wording must say exactly that.
73
+ 7. **Allow-list by construction.** `config/tools_allowlist.yaml` holds the approved 28. Only the tools the five
74
+ endpoints use are loaded. Agentic or LLM-backed tools, the tool finder, remote MCP servers, web-search backends
75
+ and any key-gated tool can never load. ToolUniverse's own result cache and any download of an embedding model
76
+ or tool index are switched off. `tooluniverse==1.5.6` is pinned. A version bump is its own lane:
77
+ `tooluniverse-doctor`, re-recorded fixtures with the diff reviewed, the schema tests, and the security scan.
78
+ Adding a tool is a config change with a review note.
79
+ 8. **Cache and rate limits.** The cache is SQLite inside the container and ephemeral, with a TTL per source from
80
+ `config/sources.yaml`. A per-source token-bucket limit and a per-call timeout also come from that file. A timeout
81
+ or limit becomes a structured `source_errors` entry, never a retry loop that outlasts the orchestrator's call
82
+ limit.
83
+
84
+ ## Knowledge is annotation, never analysis
85
+
86
+ - The specialist computes nothing across sources: no combined scores, no rankings of its own, no p-values. When a
87
+ source has its own score (Open Targets' association score), it is relayed as that source's score.
88
+ - Knowledge payloads never enter `biodata-registry`, any statistical step, or the orchestrator's significance
89
+ chain. In Phase 1 the orchestrator registration declares no `cross_modality_join` partner, and a test asserts
90
+ that. The Phase 2 recipes may join a knowledge payload to a data result **by identifier only**, and must label the
91
+ join. That join rule gets its own orchestrator ADR when Phase 2 is shaped.
92
+ - Summaries say "according to <source> (queried <date>)" and never state an external fact flat. A deterministic
93
+ backstop in the orchestrator flags any drug name, NCT id or ChEMBL id in a summary that is absent from the
94
+ payload.
95
+
96
+ ## Consequences
97
+
98
+ - The fleet gets a fifth Space with a 266 MB, 106-package dependency. The memo accepted that under the security scan
99
+ (ADR-0014 standard), with mandatory pinning and recorded fixtures.
100
+ - **The Space adds runtime calls to eight public hosts.** The OHSU security review's "Where data comes from" lists
101
+ only GEO, GDC, ICGC, CPTAC and cBioPortal, so the review needs an addendum naming the component and its hosts.
102
+ Plan slice s4 drafts it in Phase 1. Filing it is the project lead's call, and lab access does not wait for it (plan D2-C).
103
+ - Recorded-fixture tests block outbound sockets, so drift in a source shows up only at a deliberate re-record or as
104
+ `upstream_schema_drift` in production, never as a silently different answer.
105
+ - DecoupleRpy's `mcp_config.yaml` remote-server path stays unused for knowledge tools (memo decision 4).
106
+
107
+ ## Alternatives rejected
108
+
109
+ - **Expose `tooluniverse-smcp` directly with category filters.** It has no contract of our own, no provenance, and
110
+ a version bump would change what the orchestrator sees. Rejected by memo decision 1.
111
+ - **Build thin clients for five sources.** That was the desk review's lean. The hands-on test showed our own Open
112
+ Targets client breaking on an upstream schema change that ToolUniverse had already absorbed.
113
+ - **Add the endpoints to an existing specialist.** That puts external calls and a large dependency inside a Space
114
+ that holds data and computes on it, and it blurs the line this ADR exists to draw.
115
+ - **Let the DecoupleRpy code agent call knowledge tools.** Rejected by memo decision 4, for the same provenance
116
+ reasons as re-mining's chain-first design.
docs/adr/RESERVED.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ADR number ledger — pdac-knowledge-agent
2
+
3
+ Claim-first discipline (the fleet pattern): to claim a number, add a CLAIMED row and commit it to
4
+ **`dev`** BEFORE writing the ADR. Here, as in pdac-spatial-agent, `main` IS production once the
5
+ Spaces exist, so a claim on `main` would either strand unpushed or ship a docs-only prod deploy to
6
+ announce an intention; lanes branch from `dev`, which is where a claim is visible to the collision it
7
+ exists to prevent. Statuses: `PUBLISHED`, `CLAIMED`, `EXTERNAL`, `DROPPED`. Family-wide standards
8
+ owned elsewhere are EXTERNAL here, and cross-repo citations are always repo-qualified.
9
+
10
+ | ADR | Title | Status | Notes |
11
+ |---|---|---|---|
12
+ | 0001 | Knowledge specialist: contract, boundary, and what a lookup may claim | PUBLISHED | accepted 2026-10-20 (project lead, plan D1-A); drafted beside the roadmap's planning ADRs, moved in at s1 |
13
+ | — | Specialist dispatch contracts (tool_server) | EXTERNAL | pdac-analysis-orchestrator ADR-0001 |
14
+ | — | Machine auth (`x-orchestrator-token`) | EXTERNAL | pdac-analysis-orchestrator ADR-0002 |
15
+ | — | A run card before every tool-server run | EXTERNAL | pdac-analysis-orchestrator ADR-0006 |
16
+ | — | Family security scan | EXTERNAL | ADR-0014 (fleet-wide, authored in DecoupleRpy_Agent) |
17
+
18
+ **Next free number: 0002.**
19
+
20
+ The orchestrator-side ADR for joining annotation to data results by identifier (Phase 2's recipes)
21
+ belongs to pdac-analysis-orchestrator's ledger, not this one, and is claimed when Phase 2 is shaped.
docs/plans/PLAN-phase1-knowledge-specialist.md ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PLAN — A fifth specialist answers "what is known about these genes, and what targets them?" from named public sources, with provenance on every row
2
+
3
+ **Status:** Approved 2026-10-20 (project lead): D1–D4 decided · **Owner repo:** `pdac-knowledge-agent` (this repo; s1 created
4
+ it and moved this plan in) · **Touches:** pdac-analysis-orchestrator, the fleet status rollup and skills ·
5
+ **Asked:** project lead, 2026-10-20 (roadmap Plan 4 / index plan 3, Phase 1) · **Gate on starting:** none since D4-A
6
+ (2026-10-20): s1 starts now, in parallel with re-mining.
7
+
8
+ **Why it lives here.** The specialist owns the user-facing outcome, not the orchestrator, so a plan in the
9
+ orchestrator's `docs/design/` would be the copy that drifts. It was drafted beside the roadmap's planning ADRs while
10
+ this repo did not exist, and moved in at s1 with `docs/adr/ADR-0001-knowledge-specialist-contract.md`. This copy is
11
+ the canonical one; the planning folder keeps a pointer.
12
+
13
+ **Settled before this plan; cited, not re-asked.** All four memo decisions were approved 2026-10-13
14
+ (the Phase 0 evaluation memo, kept with the roadmap): borrow ToolUniverse, wrapped behind BCC endpoints; the 28-tool
15
+ allow-list, with additions by config and a review note; ToolUniverse for interactive trials lookups while
16
+ lit-agent's harvester keeps the digest; knowledge lookups joined by the orchestrator, never called from inside
17
+ DecoupleRpy's code agent. The memo's acceptance of the dependency footprint under the security scan is also settled.
18
+
19
+ ## Brief
20
+
21
+ - **The ask:** build Phase 1 of the ToolUniverse plan, the skeleton of a knowledge specialist. It wraps the approved
22
+ allow-list behind the fleet's contract: provenance on every row, output schema checks, caching, rate limits, and
23
+ external knowledge as annotation, never analysis.
24
+ - **The problem behind it:** after any result, a scientist's next question is what is known about these genes and
25
+ whether anything targets them. Today the model answers from memory, which is the one source the architecture
26
+ otherwise refuses to rely on. Phase 2's three recipes (activity → targets, DE genes → trials,
27
+ signature → annotation) need a governed source to chain to. This phase builds that source and proves it on its
28
+ own.
29
+ - **Who meets it, and where:** lab members, in orchestrator chat, asking a direct lookup ("which drugs target JAK1
30
+ and JAK2?"). They get a table with a source, a query date and a licence on each row, after the run card
31
+ (orchestrator ADR-0006). Later callers are Phase 2's recipes, the knowledge graph (index plan 5's `targets` and
32
+ `tested_in` edges) and re-mining's optional enrichment.
33
+ - **The boundary:** the sentence in ADR-0001 §3, reused verbatim. The specialist reads no dataset and computes no
34
+ statistic. Activity, DE and enrichment belong to DecoupleRpy, the literature corpus to lit-agent, and the
35
+ chained recipes to Phase 2.
36
+
37
+ ## Done when
38
+
39
+ | # | Check | How |
40
+ |---|---|---|
41
+ | C1 | **The project lead's click-through on the dev orchestrator.** (a) "Which drugs target JAK1, JAK2 or STAT1, and what is their maximum clinical stage?" gives a table with ruxolitinib among the rows. Every row shows its source, query date and licence, and the narrative reads "According to Open Targets (queried …)". (b) "Are there recruiting pancreatic cancer trials with a CD40 intervention?" gives NCT ids plus the sentence stating the query semantics. (c) "Annotate KRAS, TP53 and SORD2P" lists SORD2P as unresolved, not guessed. (d) "Which TFs are most active in basal-like TCGA-PAAD samples?" goes to DecoupleRpy, not here | ticked with screenshots |
42
+ | C2 | **Allow-list by construction.** The loaded tool set equals the five endpoints' set, which is a subset of the 28 in `config/tools_allowlist.yaml`. No agentic, tool-finder, remote-MCP, web-search or key-gated tool can load. Startup refuses if any LLM-provider key is set. `tooluniverse.__version__ == "1.5.6"` at runtime. Loading tools opens no socket | `tests/test_allowlist.py`, `tests/test_no_llm_keys.py` |
43
+ | C3 | **Provenance on every row.** For every endpoint and every fixture, each row carries all ten provenance fields (ADR-0001 §5), and every ok payload has `kind: "annotation"` and no `n_for_inference` | `tests/test_provenance.py` (property test over all fixtures) |
44
+ | C4 | **Output schema checks.** Every recorded response validates against its pinned schema. A fixture mutated the way Open Targets changed during Phase 0 (`knownDrugs` → `drugAndClinicalCandidates`) returns `upstream_schema_drift` for that source, with no rows built from it | `tests/test_output_schemas.py` |
45
+ | C5 | **Cache and rate limits.** A repeated call is a `hit` that keeps the original `retrieved_at`, and the TTL expires per source. A 55-gene burst (the pORG list) never exceeds a source's configured rate. A source timeout becomes a `source_unavailable` entry within the per-call limit | `tests/test_cache.py`, `tests/test_ratelimit.py` |
46
+ | C6 | **Hermetic Phase 0 replay.** Tests block outbound sockets. The ten Phase 0 lookups, recorded once, replay to their expected entities: KRAS G12C gives sotorasib and adagrasib; DR5 gives all 7 agents; pORG gives 54 of 55 with SORD2P unresolved; the CD40 trial semantics are pinned; ISG15, RSAD2 and OAS1 are in Interferon alpha/beta signaling; KRAS G12D has no MRTX1133 from Open Targets, with the coverage note present | `tests/conftest.py` socket block, `tests/test_phase0_replay.py` |
47
+ | C7 | **Refusals are data.** Free text, sample ids, numbers or more than 100 identifiers are refused as `invalid_identifier` or `too_many_identifiers` before any outbound call. A call without the token is `denied`. The gate runs before validation on every handler | `tests/test_refusals_and_payloads.py`, `tests/test_access_gate.py` |
48
+ | C8 | **Hosts are declared.** Each loaded tool's configured endpoint URL has its host in `config/sources.yaml`, and every source there has a licence string and licence URL, checked against the source's own page | `tests/test_sources_hosts.py` |
49
+ | C9 | **Deploy surface.** Descriptor endpoint names equal the live `gr.api` surface on the dev Space. An unauthenticated call to the dev Space is `denied` (the gate fails closed). The prod Space is RUNNING at the verified sha (runtime API) | `tests/test_deploy_surface.py`; runtime sha in the s3 packet |
50
+ | C10 | **Orchestrator registration.** The `agents.yaml` block mirrors the descriptor. It is a tool_server pointing at the prod Space, with the dev override `HF_SPACE_PDAC_KNOWLEDGE_AGENT`. There is no `cross_modality_join` or significance-chain partner, so knowledge never enters analysis. The routing bank has boundary cases going to DecoupleRpy ("which pathways are enriched in…") and to the direct route. `make eval-cheap` is green, and `scripts/build_tool_catalog.py --check` exits 0 with the five rows | `tests/test_knowledge_registration.py`, `eval/routing_cases.json`, `tests/test_tool_catalog.py` |
51
+ | C11 | **Summary backstop.** The rows table and the narrative are rendered from the payload, not from model prose. A summary that names a drug, NCT id or ChEMBL id absent from the payload is flagged. On the recorded KRAS G12D payload, a summary that adds MRTX1133 is caught | `tests/test_knowledge_backstop.py` |
52
+ | C12 | **End to end.** One judged eval case, dev orchestrator to dev knowledge Space, judged PASS, with the trace checked for the payload itself (rows plus provenance). That is one live question, per the credit rule | `eval/` judged case `KNOW-001`; packet cites the run id |
53
+ | C13 | **Security-review addendum drafted** in the review's own language. The component, its eight hosts (generated from `config/sources.yaml`), keyless access, no model calls, the identifiers-only boundary, and the egress sentence from ADR-0001 §6. Filing it with OHSU is the project lead's act, and it does not gate lab access (D2-C) | `scripts/render_host_table.py --check`; the project lead ticks "filed" |
54
+
55
+ ## Options and recommendation
56
+
57
+ The memo settled the approach. The only real choice left is where the endpoints live:
58
+
59
+ - **A. A new tool_server specialist from the spatial template (recommended).** Six slices, each S or M. It keeps
60
+ the 266 MB dependency and the outbound calls out of every Space that holds data, and it is the contract the Phase 2
61
+ joins and the knowledge graph will consume.
62
+ - **B. Expose `tooluniverse-smcp` with category filters.** It is quick, but it has no contract of our own, and a
63
+ version bump changes what the router sees. Memo decision 1 rejects it.
64
+ - **C. Put the endpoints in DecoupleRpy or the orchestrator.** It adds no Space, but it puts external calls inside a
65
+ Space that computes on data, and memo decision 4 rejects calls from inside the code agent.
66
+
67
+ ## Decisions
68
+
69
+ ### Decision D1: accept the knowledge-specialist contract (ADR-0001) as drafted?
70
+ - **Door:** one-way. It freezes the repo, the Space names, five endpoint names and the payload contract that Phase 2,
71
+ the knowledge graph and re-mining will consume.
72
+ - **Options:**
73
+ - A) accept as drafted: `pdac-knowledge-agent` / `pdac_knowledge_agent`; the five endpoints from the memo
74
+ (`drugs_for_targets`, `trials_for_genes`, `annotate_genes`, `pathways_for_genes`, `targets_for_disease`);
75
+ `kind: annotation` with ten provenance fields per row;
76
+ - B) only the three endpoints the Phase 2 recipes need (`drugs_for_targets`, `trials_for_genes`,
77
+ `annotate_genes`), with pathways and disease targets deferred. This removes the boundary risk that
78
+ `pathways_for_genes` gets mistaken for enrichment;
79
+ - C) accept with changes she names.
80
+ - **Recommend:** A. The memo named all five. The pathways boundary is held by the routing sentence and a C10 boundary
81
+ case, and `targets_for_disease` is the lookup Phase 0 found most useful as a sanity check.
82
+ - **Blocks:** s1, s2.
83
+ - **Status:** DECIDED 2026-10-20 (project lead): A — the five endpoints as drafted. ADR-0001 is accepted.
84
+
85
+ ### Decision D2: who may use the new Spaces, and does lab access wait for the security-review addendum?
86
+ - **Door:** one-way. It sets access and visibility. The Space adds runtime calls to eight hosts that OHSU's security
87
+ review does not list (the review names GEO, GDC, ICGC, CPTAC and cBioPortal), and the review is what a reviewer sees.
88
+ - **Options:**
89
+ - A) both Spaces private with `ACCESS_CONTROL=enforce` from the first deploy, and admin only until the addendum
90
+ (C13) is filed with OHSU. After that, the lab allow-list goes on prod, followed by the orchestrator's prod
91
+ registration (orchestrator-forwarded principals must be on the specialist's `ALLOWED_IDS`);
92
+ - B) both private and gated, with the lab allow-list on prod as soon as the specialist is verified there, and the
93
+ addendum in Phase 3 as the roadmap first planned;
94
+ - C) public Spaces behind the allow-list gate, the older sibling pattern.
95
+ - **Recommend:** A. The addendum is an S slice that s4 drafts in parallel, so the wait costs little. It also keeps
96
+ the review's "where data comes from" true on the day lab members can reach the new hosts.
97
+ - **Blocks:** s3 (Space settings), and the prod half of s5.
98
+ - **Status:** DECIDED 2026-10-20 (project lead): C — public Spaces behind the allow-list gate, the sibling pattern.
99
+ - What follows for s3: `ACCESS_CONTROL=enforce` with `ADMIN_IDS` and `ALLOWED_IDS` set before either Space is made
100
+ public. The gate is verified to fail closed on the dev Space while it is still private (`new-specialist-agent`
101
+ §5). Every handler calls the access check itself, so an anonymous `gr.api` call is `denied`.
102
+ - **Lab access is not tied to filing the addendum.** Only option A tied the two, so that is how this answer is
103
+ read; if the project lead meant otherwise, this line changes and nothing else does.
104
+ - s4 still drafts the addendum in Phase 1, and when to file it is her call.
105
+
106
+ ### Decision D3: what does the router advertise in Phase 1?
107
+ - **Door:** one-way. It sets what the router offers lab users.
108
+ - **Options:**
109
+ - A) the five direct lookups go to orchestrator prod once the specialist is verified on its own prod, C11's
110
+ backstop and C12's judged case pass, and D2's access condition is met;
111
+ - B) registration on orchestrator dev only, with prod waiting for Phase 2's recipes;
112
+ - C) no orchestrator work in Phase 1; the specialist is proven on its own Spaces only.
113
+ - **Recommend:** A. Direct lookups are useful on their own. The deterministic table plus the backstop makes the
114
+ "memory blended with tool results" risk testable now. And Phase 2 then only adds joins on a route that is already
115
+ proven.
116
+ - **Blocks:** s5.
117
+ - **Status:** DECIDED 2026-10-20 (project lead): A. Under D2-C, "D2's condition" means the lab allow-list is set on the
118
+ knowledge prod Space (orchestrator-forwarded principals must be on it).
119
+
120
+ ### Decision D4: what gates the first slice, given that re-mining has not shown anything visible yet?
121
+ - **Door:** one-way, in schedule. The roadmap index says the build "waits until Plan 1 has produced something
122
+ visible", and re-mining Phase 1 had not started. On 2026-10-20 the project lead chose to start this plan in
123
+ parallel with two others.
124
+ - **Options:**
125
+ - A) start s1 now. Her 2026-10-20 call supersedes the README order. s1, s2 and s4 are offline, cost zero credits
126
+ and show nothing to users, and D2 and D3 already hold anything user-facing back;
127
+ - B) s1, s2 and s4 now, but no Space (s3) until re-mining has something visible (re-mining's start, or Plan 1's
128
+ first dry-run digest);
129
+ - C) nothing until re-mining Phase 1 shows its dry-run digest (the README's original order).
130
+ - **Recommend:** A. The README's reason was not to put a new capability in front of the lab ahead of re-mining. D2 and
131
+ D3 already make every user-facing step the project lead's call, so holding the offline slices back only spends calendar
132
+ time.
133
+ - **Blocks:** s1 (and through it every slice).
134
+ - **Status:** DECIDED 2026-10-20 (project lead): A — start s1 now.
135
+
136
+ **Two-way defaults** (DECIDED by default; overrule in one line):
137
+ - The plan and ADR-0001 stay here until s1 moves them into the new repo.
138
+ - **No orchestrator ADR number is claimed in Phase 1.** The knowledge-join ADR (annotation inside chains, the recipe
139
+ backstop, BYOD-derived gene lists) is claimed when Phase 2 is shaped. That leaves `0008` free for the audit-trail
140
+ plan.
141
+ - The repo mirrors `pdac-spatial-agent`: Python 3.12, gradio 6.28.0, `access.py`/`audit.py` copied from the newest
142
+ sibling, the keep-local `.gitignore` from the first commit, the ADR-0014 security scan, and a `dev` branch.
143
+ It has **no `biodata-registry` dependency**, because it reads no dataset. `src/registry_gate.py` and `src/fetch.py`
144
+ are replaced by `src/tu_client.py` (the only module that imports `tooluniverse`), `src/provenance.py`,
145
+ `src/schemas/`, `src/cache.py`, `src/ratelimit.py` and `src/identifiers.py`.
146
+ - Only the tools the five endpoints use are loaded; PubChem and DGIdb wait for an endpoint that needs them.
147
+ ToolUniverse's own cache is off. Our SQLite cache lives in `/tmp` and is ephemeral, with a 7-day TTL (24 h for
148
+ ClinicalTrials.gov).
149
+ - Limits per source: at most 3 requests/s, 30 s per call, an endpoint budget of 240 s, and 100 identifiers per call.
150
+ The orchestrator sets `timeout_seconds: 300` on `annotate_genes`, since the 55 pORG genes took 75 s unbatched in
151
+ Phase 0. UniProt lookups are batched where its query syntax allows.
152
+ - Trials semantics follow ToolUniverse's intervention-narrowed query, stated in `query_semantics` and pinned by a
153
+ fixture (memo finding 5). The defaults are condition "pancreatic cancer" and status RECRUITING.
154
+ - Fixtures are recorded once from the ten Phase 0 lookups (live, keyless, from the Mac) in s2. They are re-recorded
155
+ only in a version-bump lane.
156
+ - Licences per source, as checked in s1 (2026-10-20, `config/sources.yaml`): Open Targets CC0 1.0, ChEMBL CC BY-SA 3.0,
157
+ UniProt CC BY 4.0, **Reactome CC0 1.0** (not CC BY 4.0 as first assumed), HGNC unrestricted with citation requested,
158
+ ClinicalTrials.gov public under NLM terms (terms page did not render: partial check). DGIdb's data licence could not
159
+ be confirmed, so no DGIdb endpoint ships until it is.
160
+ - The specialist writes the narrative. The orchestrator relays it and renders the rows table from the payload. The
161
+ model adds framing only, and C11's backstop checks it.
162
+ - On the orchestrator side: `own_dataset_universe: true`, with a clarification saying it takes gene, drug, disease
163
+ or trial names rather than datasets. The run card shows "lookup, not analysis". Catalog rows come through the
164
+ existing `tool_catalog` annotations.
165
+ - Slice ids `phase1-knowledge-specialist-s<n>` go in branch names, with a `Slice:` trailer on commits.
166
+
167
+ ## Slices
168
+
169
+ | id | repo | goal (one sentence) | depends on | gates | size | status |
170
+ |---|---|---|---|---|---|---|
171
+ | s1 | pdac-knowledge-agent (new, local only) | Create the repo from the spatial template and build the governed core: the allow-list loader, the no-LLM-key guard, provenance stamping, schema validation, cache, rate limiter, identifier validator and hermetic test harness, behind the security gate; move ADR-0001 and this plan in | D1, D4 (decided) | C2, C5, C8, C3/C4 (core, on synthetic responses) | M | READY |
172
+ | s2 | pdac-knowledge-agent | The five endpoints on recorded Phase 0 fixtures, with deterministic narratives, refusals, `gradio_ui.py` (gate, `mcp_server=True`) and the frozen `deploy/orchestrator_registration.yaml` | s1 | C3, C4, C6, C7 | M | BLOCKED |
173
+ | s4 | pdac-knowledge-agent | Security-review addendum, with its host table generated from `config/sources.yaml`, written in the review's own language | s1 | C13 (drafted) | S | BLOCKED |
174
+ | s3 | pdac-knowledge-agent + HF | Dev and prod Spaces and the GitHub mirror (the project lead creates or approves), each created private. Set the secrets and gate variables, verify the gate fails closed on dev, then make the Spaces public (D2-C). Check the deploy surface on dev, then go to prod through `promote-to-prod` | s2 | C9 | S | BLOCKED |
175
+ | s5 | pdac-analysis-orchestrator | Registration: the `agents.yaml` block, the `prompts.yaml` boundary, routing cases, the registration test, catalog rows, the deterministic table and narrative relay with the entity backstop, and one judged case on dev | s3 on **prod**; prod half also needs the lab allow-list on the knowledge prod Space | C10, C11, C12, C1 | M | BLOCKED |
176
+ | s6 | fleet | Fleet wiring: a status fragment and `build-status.sh` row, plus the repo added to the skills and checks that list repos by name (`promote-to-prod`, `pre-showing-repo-cleanup`, `hf-allowlist`, the weekday health check, the Friday report, the hook fleet path list) | s3 | `grep -l pdac-knowledge-agent` hits each listed file | S | BLOCKED |
177
+
178
+ - **Order and parallelism:** s1, then s2. s4 can run beside s2 once s1 lands (it needs only `config/sources.yaml`).
179
+ s3 needs s2 and D2. s5 and s6 can run in parallel once the specialist is on its own prod. Orchestrator prod is
180
+ last, through `promote-to-prod`, once D2's condition holds.
181
+ - **Fleet rule** (`new-specialist-agent` §8): the specialist reaches its dev Space, then its prod at a verified
182
+ commit, then the orchestrator's dev registration and a real question end to end, and only then orchestrator prod.
183
+ - **Credits:** s1–s4 and s6 spend none. s5 spends one judged question on dev (C12) and, at promotion, at most one
184
+ live prod question.
185
+
186
+ ## Out of scope (Phase 2 and later)
187
+
188
+ - The three chained recipes and their joins. These include the orchestrator ADR on joining annotation to data by
189
+ identifier, and the 20-question knowledge bank (roadmap §7, Phase 2).
190
+ - **Whether gene lists derived from BYOD or unpublished uploads may be sent to external sources.** Phase 1 has no
191
+ chain, so its only inputs are identifiers a user types. This becomes a Phase 2 one-way decision.
192
+ - Scheduled smoke runs, the ≥ 95% reliability week, the p95 latency report and the cost report (Phase 3).
193
+ - PubChem and DGIdb endpoints, compound identifiers, and any tool beyond the five endpoints' set.
194
+ - Outbound registration of BCC methods in ToolUniverse (Phase 4, after Plan 5).
195
+ - lit-agent's ClinicalTrials.gov harvester, which stays as it is (memo decision 3).
196
+
197
+ ## Kickoffs and pointers
198
+
199
+ - Kickoffs are keep-local (`docs/tasks/`, gitignored). s1's was written beside the roadmap's planning ADRs; s2's and
200
+ s4's are written into this repo's `docs/tasks/` when s1 merges. s5's goes in the orchestrator's `docs/tasks/`.
201
+ - The roadmap's Plan 4 file points at this plan. DecoupleRpy is not touched by Phase 1 (memo decision 4).
logging_sink.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configurable sink for audit-record persistence.
2
+
3
+ Copied from `pdac-spatial-agent/logging_sink.py` (genomics → subtype → spatial) so every
4
+ Space in the family shares one audit-sink contract. The destination is a CONFIG value (the
5
+ `LOG_SINK` env var), not a hardcoded path — prep for a future migration to OHSU-managed AWS.
6
+
7
+ Why it exists in the family (2026-10-13): a sink that wrote JSONL to the container's temp dir
8
+ lost the whole trail on every rebuild, because the Spaces have no persistent storage.
9
+
10
+ **No redaction layer, deliberately.** Runs are structured tool calls and `audit.py` builds a
11
+ record that by construction holds no results and no query identifiers, only a count. The
12
+ record is safe *by shape*.
13
+
14
+ Every sink implements one method::
15
+
16
+ persist_trace(run_id: str, trace: dict) -> str | None
17
+
18
+ returning a location string on success, or None on failure/no-op. Callers use
19
+ :func:`persist_trace_safe` so a logging failure NEVER crashes a user request.
20
+
21
+ Env vars (all optional):
22
+
23
+ LOG_SINK local | hf (default: local)
24
+ LOG_SINK_LOCAL_DIR output directory (default: AUDIT_DIR, else <tmp>/pdac-knowledge-agent-audit)
25
+ LOG_SINK_HF_DATASET dataset repo id (default: anne-voigt/pdac-knowledge-results)
26
+ LOG_SINK_HF_TOKEN HF write token (falls back to research_agent_token;
27
+ no token => no-op)
28
+
29
+ **Never co-mingle with a sibling's results dataset** — this Space writes to its own
30
+ `pdac-knowledge-results` repo.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import os
37
+ import tempfile
38
+ from pathlib import Path
39
+
40
+ DEFAULT_HF_DATASET = "anne-voigt/pdac-knowledge-results"
41
+
42
+
43
+ class LogSink:
44
+ """Common interface for audit-record persistence."""
45
+
46
+ name = "base"
47
+
48
+ def persist_trace(self, run_id: str, trace: dict) -> str | None:
49
+ raise NotImplementedError
50
+
51
+
52
+ class LocalLogSink(LogSink):
53
+ """Write the record JSON to a directory on local disk (the default).
54
+
55
+ On a Space this directory is ephemeral: it is for local runs and tests, not an audit trail.
56
+ """
57
+
58
+ name = "local"
59
+
60
+ def __init__(self, directory: str | None = None):
61
+ self.directory = (
62
+ directory
63
+ or os.environ.get("LOG_SINK_LOCAL_DIR")
64
+ or os.environ.get("AUDIT_DIR") # the pre-2026-10-13 variable, still honoured
65
+ or str(Path(tempfile.gettempdir()) / "pdac-knowledge-agent-audit")
66
+ )
67
+
68
+ def persist_trace(self, run_id: str, trace: dict) -> str | None:
69
+ os.makedirs(self.directory, exist_ok=True)
70
+ path = os.path.join(self.directory, f"{run_id}.json")
71
+ with open(path, "w", encoding="utf-8") as fh:
72
+ json.dump(trace, fh, indent=2)
73
+ return path
74
+
75
+
76
+ class HFDatasetLogSink(LogSink):
77
+ """Write the record to a private HuggingFace dataset repo (the deployed-Space posture).
78
+
79
+ One file per record (``runs/<run_id>.json``), so concurrent calls never contend for a
80
+ shared file. No-ops (returns None) when no write token is configured, so a Space without
81
+ the secret degrades to "not logged" rather than erroring on every request.
82
+ """
83
+
84
+ name = "hf"
85
+
86
+ def __init__(self, repo_id: str | None = None, token: str | None = None):
87
+ self.repo_id = repo_id or os.environ.get("LOG_SINK_HF_DATASET", DEFAULT_HF_DATASET)
88
+ self.token = (
89
+ token or os.environ.get("LOG_SINK_HF_TOKEN") or os.environ.get("research_agent_token")
90
+ )
91
+
92
+ def persist_trace(self, run_id: str, trace: dict) -> str | None:
93
+ if not self.token:
94
+ return None
95
+ from huggingface_hub import HfApi
96
+
97
+ path_in_repo = f"runs/{run_id}.json"
98
+ HfApi(token=self.token).upload_file(
99
+ path_or_fileobj=json.dumps(trace, indent=2).encode("utf-8"),
100
+ path_in_repo=path_in_repo,
101
+ repo_id=self.repo_id,
102
+ repo_type="dataset",
103
+ commit_message=f"audit: {trace.get('tool', '?')} {trace.get('status', '?')}",
104
+ )
105
+ return f"{self.repo_id}/{path_in_repo}"
106
+
107
+
108
+ _SINKS = {"local": LocalLogSink, "hf": HFDatasetLogSink}
109
+
110
+
111
+ def get_log_sink() -> LogSink:
112
+ """The sink selected by the `LOG_SINK` env var (default `local`)."""
113
+ choice = os.environ.get("LOG_SINK", "local").strip().lower()
114
+ return _SINKS.get(choice, LocalLogSink)()
115
+
116
+
117
+ def persist_trace_safe(sink: LogSink, run_id: str, trace: dict) -> str | None:
118
+ """Persist via `sink`, swallowing any failure (auditing must never break a request)."""
119
+ try:
120
+ return sink.persist_trace(run_id, trace)
121
+ except Exception as e: # noqa: BLE001
122
+ print(f"[logging_sink] persist_trace failed ({sink.name}): {e}")
123
+ return None
pytest.ini ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ [pytest]
2
+ # `tests/` only, so a hand-run script under scripts/ can never be collected (the DecoupleRpy
3
+ # 2026-09 lesson), and the repo root on sys.path so `src` and the root modules import.
4
+ testpaths = tests
5
+ pythonpath = .
6
+ addopts = -q
requirements-dev.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Test-only dependencies. Deliberately NOT in requirements.txt: that file IS the Space's build
2
+ # input, so anything added there is installed into production.
3
+ #
4
+ # It layers on requirements.txt rather than restating it — one pinned runtime set, extended, so
5
+ # CI resolves the same versions the Space builds from.
6
+ -r requirements.txt
7
+
8
+ pytest==9.1.1
9
+
10
+ # Pinned, not floating: the formatter's output may change between ruff releases. Same version
11
+ # the rest of the fleet pins.
12
+ ruff==0.16.9
requirements.in ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Curated top-level dependencies for pdac-knowledge-agent (HF gradio Space, py3.12).
2
+ # Same convention as the sibling specialists: THIS file is edited by hand; requirements.txt is
3
+ # the full lock HF actually installs, regenerated via:
4
+ #
5
+ # uv pip compile requirements.in -o requirements.txt \
6
+ # --python-version 3.12 --python-platform linux
7
+ #
8
+ # uv prefers the versions already in requirements.txt, so a recompile only moves what a changed
9
+ # line forces. Check `git diff requirements.txt` shows only what you meant to move. Because
10
+ # requirements.txt is a compiled lock, scripts/security_scan.sh audits it as-is
11
+ # (pip-audit --no-deps), transitive dependencies included.
12
+ #
13
+ # There is NO biodata-registry line: this specialist reads no dataset (ADR-0001 §3).
14
+
15
+ # --- UI / MCP ---
16
+ # Pinned to the Space's sdk_version (README front matter). HF's generated Dockerfile installs
17
+ # `gradio[oauth,mcp]==<sdk_version>` in the same pip command as this lock, so the extras are named
18
+ # here too and the audit sees exactly what the Space installs.
19
+ gradio[oauth,mcp]==6.28.0
20
+
21
+ # --- ToolUniverse (ADR-0001 §7) ---
22
+ # Pinned EXACTLY. config/tools_allowlist.yaml carries the same version and src/tu_client.py
23
+ # refuses to start on a mismatch. A bump is its own lane: tooluniverse-doctor, re-recorded
24
+ # fixtures with the diff reviewed, the schema tests, and this repo's security scan. The memo
25
+ # accepted its footprint (~100 packages) under that scan, 2026-10-13.
26
+ tooluniverse==1.5.6
27
+
28
+ # --- Config and output-schema checks ---
29
+ PyYAML==6.0.3
30
+ jsonschema==4.26.0
requirements.txt ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This file was autogenerated by uv via the following command:
2
+ # uv pip compile requirements.in -o requirements.txt --python-version 3.12 --python-platform linux
3
+ aiofile==3.12.3
4
+ # via py-key-value-aio
5
+ annotated-doc==0.0.5
6
+ # via
7
+ # fastapi
8
+ # typer
9
+ annotated-types==0.8.0
10
+ # via pydantic
11
+ anyio==4.15.1
12
+ # via
13
+ # boltz-api
14
+ # gradio
15
+ # httpx
16
+ # mcp
17
+ # py-key-value-aio
18
+ # sse-starlette
19
+ # starlette
20
+ # watchfiles
21
+ attrs==26.1.0
22
+ # via
23
+ # cyclopts
24
+ # jsonschema
25
+ # jsonschema-path
26
+ # referencing
27
+ authlib==1.9.1
28
+ # via
29
+ # fastmcp-slim
30
+ # gradio
31
+ beartype==0.23.1
32
+ # via py-key-value-aio
33
+ beautifulsoup4==4.15.0
34
+ # via tooluniverse
35
+ boltz-api==0.46.0
36
+ # via tooluniverse
37
+ brotli==1.2.0
38
+ # via gradio
39
+ cachetools==7.2.4
40
+ # via py-key-value-aio
41
+ caio==0.12.9
42
+ # via aiofile
43
+ certifi==2026.10.19
44
+ # via
45
+ # httpcore
46
+ # httpx
47
+ # requests
48
+ cffi==2.1.1
49
+ # via cryptography
50
+ charset-normalizer==3.5.2
51
+ # via
52
+ # pdfminer-six
53
+ # requests
54
+ click==8.5.0
55
+ # via
56
+ # huggingface-hub
57
+ # uvicorn
58
+ cryptography==50.0.2
59
+ # via
60
+ # authlib
61
+ # joserfc
62
+ # pdfminer-six
63
+ # pyjwt
64
+ # secretstorage
65
+ cyclopts==5.2.0
66
+ # via fastmcp-slim
67
+ distro==1.9.0
68
+ # via boltz-api
69
+ dnspython==2.9.0
70
+ # via email-validator
71
+ docstring-parser==0.18.0
72
+ # via cyclopts
73
+ email-validator==2.3.0
74
+ # via pydantic
75
+ et-xmlfile==2.0.0
76
+ # via openpyxl
77
+ exceptiongroup==1.3.1
78
+ # via fastmcp-slim
79
+ faiss-cpu==1.12.0
80
+ # via tooluniverse
81
+ fastapi==0.156.2
82
+ # via
83
+ # gradio
84
+ # tooluniverse
85
+ fastmcp==3.4.10
86
+ # via tooluniverse
87
+ fastmcp-slim==3.4.10
88
+ # via fastmcp
89
+ filelock==4.2.4
90
+ # via huggingface-hub
91
+ fsspec==2026.9.0
92
+ # via
93
+ # gradio-client
94
+ # huggingface-hub
95
+ gradio==6.28.0
96
+ # via -r requirements.in
97
+ gradio-client==2.7.1
98
+ # via
99
+ # gradio
100
+ # hf-gradio
101
+ graphql-core==3.3.0
102
+ # via
103
+ # rcsb-api
104
+ # tooluniverse
105
+ griffelib==2.4.0
106
+ # via fastmcp-slim
107
+ groovy==0.1.2
108
+ # via gradio
109
+ h11==0.16.0
110
+ # via
111
+ # httpcore
112
+ # uvicorn
113
+ hf-gradio==0.4.1
114
+ # via gradio
115
+ hf-xet==1.7.0
116
+ # via huggingface-hub
117
+ httpcore==1.0.9
118
+ # via httpx
119
+ httpx==0.28.1
120
+ # via
121
+ # boltz-api
122
+ # fastmcp-slim
123
+ # gradio
124
+ # gradio-client
125
+ # huggingface-hub
126
+ # mcp
127
+ # rcsb-api
128
+ # safehttpx
129
+ httpx-sse==0.4.3
130
+ # via mcp
131
+ huggingface-hub==1.33.0
132
+ # via
133
+ # gradio
134
+ # gradio-client
135
+ # tooluniverse
136
+ idna==3.20
137
+ # via
138
+ # anyio
139
+ # email-validator
140
+ # httpx
141
+ # requests
142
+ itsdangerous==2.2.0
143
+ # via gradio
144
+ jaraco-classes==3.4.0
145
+ # via keyring
146
+ jaraco-context==6.1.2
147
+ # via keyring
148
+ jaraco-functools==4.6.0
149
+ # via keyring
150
+ jeepney==0.9.0
151
+ # via
152
+ # keyring
153
+ # secretstorage
154
+ jinja2==3.1.6
155
+ # via gradio
156
+ joserfc==1.7.7
157
+ # via
158
+ # authlib
159
+ # fastmcp-slim
160
+ jsonpath-ng==1.11.0
161
+ # via tooluniverse
162
+ jsonref==1.1.0
163
+ # via fastmcp-slim
164
+ jsonschema==4.26.0
165
+ # via
166
+ # -r requirements.in
167
+ # mcp
168
+ # tooluniverse
169
+ jsonschema-path==0.5.0
170
+ # via fastmcp-slim
171
+ jsonschema-specifications==2025.9.1
172
+ # via jsonschema
173
+ keyring==25.7.0
174
+ # via py-key-value-aio
175
+ lxml==6.1.3
176
+ # via tooluniverse
177
+ markdown-it-py==4.2.0
178
+ # via rich
179
+ markupsafe==3.0.4
180
+ # via
181
+ # gradio
182
+ # jinja2
183
+ mcp==1.30.0
184
+ # via
185
+ # fastmcp-slim
186
+ # gradio
187
+ # tooluniverse
188
+ mdurl==0.1.2
189
+ # via markdown-it-py
190
+ more-itertools==11.2.1
191
+ # via
192
+ # jaraco-classes
193
+ # jaraco-functools
194
+ nest-asyncio==1.6.0
195
+ # via rcsb-api
196
+ numpy==2.5.4
197
+ # via
198
+ # faiss-cpu
199
+ # gradio
200
+ # pandas
201
+ # rustworkx
202
+ # tooluniverse
203
+ openapi-pydantic==0.6.0
204
+ # via fastmcp-slim
205
+ openpyxl==3.1.5
206
+ # via tooluniverse
207
+ opentelemetry-api==1.45.1
208
+ # via
209
+ # fastapi
210
+ # fastmcp-slim
211
+ orjson==3.13.0
212
+ # via gradio
213
+ packaging==26.3
214
+ # via
215
+ # faiss-cpu
216
+ # fastmcp-slim
217
+ # gradio
218
+ # gradio-client
219
+ # huggingface-hub
220
+ pandas==3.0.6
221
+ # via
222
+ # gradio
223
+ # tooluniverse
224
+ pathable==0.6.0
225
+ # via jsonschema-path
226
+ pdfminer-six==20260107
227
+ # via pdfplumber
228
+ pdfplumber==0.11.10
229
+ # via tooluniverse
230
+ pillow==12.3.0
231
+ # via
232
+ # gradio
233
+ # pdfplumber
234
+ pip==26.2.2
235
+ # via tooluniverse
236
+ platformdirs==4.13.5
237
+ # via fastmcp-slim
238
+ py-key-value-aio==0.4.6
239
+ # via fastmcp-slim
240
+ pycparser==3.11
241
+ # via cffi
242
+ pydantic==2.12.5
243
+ # via
244
+ # boltz-api
245
+ # fastapi
246
+ # fastmcp-slim
247
+ # gradio
248
+ # mcp
249
+ # openapi-pydantic
250
+ # pydantic-settings
251
+ # tooluniverse
252
+ pydantic-core==2.41.5
253
+ # via pydantic
254
+ pydantic-settings==2.16.1
255
+ # via
256
+ # fastmcp-slim
257
+ # mcp
258
+ pydub==0.25.1
259
+ # via gradio
260
+ pygments==2.21.0
261
+ # via
262
+ # rich
263
+ # rich-rst
264
+ pyjwt==2.15.1
265
+ # via mcp
266
+ pypdfium2==5.14.0
267
+ # via pdfplumber
268
+ pyperclip==1.11.0
269
+ # via fastmcp-slim
270
+ python-dateutil==2.9.0.post0
271
+ # via pandas
272
+ python-dotenv==1.2.4
273
+ # via
274
+ # fastmcp-slim
275
+ # mcp
276
+ # pydantic-settings
277
+ # tooluniverse
278
+ python-multipart==0.0.32
279
+ # via
280
+ # fastmcp-slim
281
+ # gradio
282
+ # mcp
283
+ pytz==2026.5
284
+ # via gradio
285
+ pyyaml==6.0.3
286
+ # via
287
+ # -r requirements.in
288
+ # fastmcp-slim
289
+ # gradio
290
+ # huggingface-hub
291
+ # jsonschema-path
292
+ # tooluniverse
293
+ rcsb-api==1.7.3
294
+ # via tooluniverse
295
+ referencing==0.37.0
296
+ # via
297
+ # jsonschema
298
+ # jsonschema-path
299
+ # jsonschema-specifications
300
+ requests==2.34.2
301
+ # via tooluniverse
302
+ rich==15.0.0
303
+ # via
304
+ # cyclopts
305
+ # fastmcp-slim
306
+ # rich-rst
307
+ # typer
308
+ rich-rst==2.2.0
309
+ # via cyclopts
310
+ rpds-py==2026.9.1
311
+ # via
312
+ # jsonschema
313
+ # referencing
314
+ rustworkx==0.18.1
315
+ # via rcsb-api
316
+ safehttpx==0.1.7
317
+ # via gradio
318
+ secretstorage==3.5.0
319
+ # via keyring
320
+ semantic-version==2.10.0
321
+ # via gradio
322
+ shellingham==1.5.4
323
+ # via typer
324
+ six==1.17.0
325
+ # via python-dateutil
326
+ sniffio==1.3.1
327
+ # via boltz-api
328
+ soupsieve==3.0.3
329
+ # via beautifulsoup4
330
+ sse-starlette==3.5.0
331
+ # via mcp
332
+ starlette==1.9.0
333
+ # via
334
+ # fastapi
335
+ # fastmcp-slim
336
+ # gradio
337
+ # mcp
338
+ # sse-starlette
339
+ tomlkit==0.14.0
340
+ # via gradio
341
+ tooluniverse==1.5.6
342
+ # via -r requirements.in
343
+ tqdm==4.70.1
344
+ # via
345
+ # huggingface-hub
346
+ # rcsb-api
347
+ typer==0.27.3
348
+ # via
349
+ # gradio
350
+ # hf-gradio
351
+ # mcp
352
+ typing-extensions==4.16.0
353
+ # via
354
+ # anyio
355
+ # beautifulsoup4
356
+ # boltz-api
357
+ # exceptiongroup
358
+ # fastapi
359
+ # fastmcp-slim
360
+ # gradio
361
+ # gradio-client
362
+ # huggingface-hub
363
+ # mcp
364
+ # opentelemetry-api
365
+ # py-key-value-aio
366
+ # pydantic
367
+ # pydantic-core
368
+ # pydantic-settings
369
+ # referencing
370
+ # starlette
371
+ # typing-inspection
372
+ typing-inspection==0.4.4
373
+ # via
374
+ # fastapi
375
+ # mcp
376
+ # pydantic
377
+ # pydantic-settings
378
+ uncalled-for==0.4.1
379
+ # via fastmcp-slim
380
+ urllib3==2.8.0
381
+ # via
382
+ # requests
383
+ # tooluniverse
384
+ uvicorn==0.54.0
385
+ # via
386
+ # fastmcp-slim
387
+ # gradio
388
+ # mcp
389
+ # tooluniverse
390
+ watchfiles==1.3.0
391
+ # via fastmcp-slim
392
+ websockets==17.3
393
+ # via
394
+ # fastmcp-slim
395
+ # tooluniverse
396
+ xmltodict==1.0.4
397
+ # via tooluniverse
ruff.toml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ruff configuration. Its job today is to pin the line length, because without a config file every
2
+ # contributor and every CI runner silently uses ruff's default of 88 while this fleet writes at 100.
3
+ #
4
+ # That is not theoretical here. On 2026-09-25, porting `memory.py` into this repo, a reflexive
5
+ # `ruff format` reformatted the shared builder to 88 — breaking the byte-identity with the other
6
+ # five copies that is the entire point of sharing it. Caught and reverted, but the next person gets
7
+ # no warning either. This file is the warning.
8
+
9
+ line-length = 100
10
+
11
+ # **py312, unlike the siblings' py311** — deliberately, and not a copy-paste slip: this Space runs
12
+ # `python_version: "3.12"` (README front-matter). target-version tells ruff which syntax is valid,
13
+ # so it should describe the interpreter the code actually runs on, not the one next door.
14
+ target-version = "py312"
15
+
16
+ exclude = [".venv", "run_logs", "security"]
17
+
18
+ [format]
19
+ quote-style = "double"
20
+
21
+ [lint]
22
+ # Ruff's defaults. Turning on more rules is a separate decision from writing down a line length,
23
+ # and would arrive as findings across files this lane does not own.
scripts/hooks/pre-push ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Two jobs, in this order (the genomics-agent pattern):
3
+ # 1. ADR-0014 security gate — runs on EVERY push. Blocks on findings.
4
+ # 2. GitHub mirror sync — only on a PROD deploy (`main` to `origin`). Warns, never blocks.
5
+ # The gate runs first, so a blocked deploy can never have already updated the mirror.
6
+ #
7
+ # --- 1. ADR-0014 pre-push security gate ----------------------------------------------------
8
+ #
9
+ # `origin` and `hf-dev` ARE HuggingFace Spaces — a push builds a live Space — so there is
10
+ # no CI merge gate in front of them. This hook is the enforced check: it runs the shared
11
+ # scan (scripts/security_scan.sh) before anything leaves the machine.
12
+ #
13
+ # It scans the commit being PUSHED, not the working tree it happens to run from. The
14
+ # sibling hooks scan the working tree, and on 2026-09-21 that went wrong in the
15
+ # orchestrator: pushed from a checkout parked on another lane's branch, the scan audited
16
+ # THAT branch's requirements.txt instead of the commit being shipped. Here each pushed sha
17
+ # is checked out into a throwaway worktree and scanned there; reports (and the cached
18
+ # audit venv) are written to this checkout's security/ so they outlive it.
19
+ #
20
+ # Install with: make install-hooks
21
+ # Bypass (e.g. a docs-only push, knowingly): git push --no-verify
22
+ # Set SECURITY_SCAN_STRICT=1 to fail on skipped stages.
23
+ set -euo pipefail
24
+
25
+ # The mirror push below sets this, so that nested push is neither rescanned nor re-mirrored.
26
+ [[ -n "${SECURITY_SCAN_DONE:-}" ]] && exit 0
27
+
28
+ remote_name="${1:-}"
29
+ remote_url="${2:-}"
30
+ mirror_remote="${MIRROR_REMOTE:-github}"
31
+ dev_remote="${DEV_REMOTE:-hf-dev}"
32
+ repo_root="$(git rev-parse --show-toplevel)"
33
+
34
+ # stdin: "<local ref> <local sha> <remote ref> <remote sha>" per ref being pushed. Git sends it
35
+ # once and both jobs need it, so it is read here and replayed from the variable.
36
+ push_lines="$(cat)"
37
+ shas=()
38
+ while read -r _lref lsha _rref _rsha; do
39
+ [[ -n "${lsha:-}" ]] || continue
40
+ case "$lsha" in *[!0]*) ;; *) continue ;; esac # all-zero = a delete; nothing to scan
41
+ [[ " ${shas[*]-} " == *" $lsha "* ]] || shas+=("$lsha")
42
+ done <<< "$push_lines"
43
+ [[ ${#shas[@]} -gt 0 ]] || exit 0
44
+
45
+ # Sweep leaks from earlier interrupted pushes. Named (not `mktemp -d`) so a leak is
46
+ # identifiable, and keyed on LIVENESS rather than age: each scan dir records its hook's PID
47
+ # and its scan child's, so a dir whose hook is gone is swept at once while a CONCURRENT
48
+ # push's dir is never touched. A dir with no .hookpid predates this and is by definition a
49
+ # leak. (2026-09-23: a leaked worktree was found with `git worktree add`'s inner
50
+ # `git reset --hard` still sleeping after 2 days 7 hours, in a directory macOS had reaped.)
51
+ for stale in "${TMPDIR:-/tmp}"/pre-push-scan.*; do
52
+ [[ -d "$stale" ]] || continue
53
+ stale_pid="$(cat "$stale/.hookpid" 2>/dev/null || true)"
54
+ if [[ -z "$stale_pid" ]] || ! kill -0 "$stale_pid" 2>/dev/null; then
55
+ stale_scan="$(cat "$stale/.scanpid" 2>/dev/null || true)"
56
+ if [[ -n "$stale_scan" ]] && kill -0 "$stale_scan" 2>/dev/null; then
57
+ kill -TERM "-$stale_scan" 2>/dev/null || kill -TERM "$stale_scan" 2>/dev/null || true
58
+ sleep 1
59
+ if kill -0 "$stale_scan" 2>/dev/null; then
60
+ kill -KILL "-$stale_scan" 2>/dev/null || kill -KILL "$stale_scan" 2>/dev/null || true
61
+ fi
62
+ fi
63
+ for wt in "$stale"/wt-*; do
64
+ [[ -d "$wt" ]] && git worktree remove --force "$wt" >/dev/null 2>&1
65
+ done
66
+ rm -rf "$stale"
67
+ echo "[pre-push] swept a leaked scan directory from an interrupted push: $(basename "$stale")"
68
+ fi
69
+ done
70
+ git worktree prune >/dev/null 2>&1 || true
71
+
72
+ tmp="$(mktemp -d "${TMPDIR:-/tmp}/pre-push-scan.XXXXXX")"
73
+ echo "$$" > "$tmp/.hookpid"
74
+ scan_pid=""
75
+ cleanup() {
76
+ # Kill the scan BEFORE its directory disappears: deleting the tree under a live child is
77
+ # how a gitleaks or `git reset --hard` ends up running in a path that no longer exists.
78
+ if [[ -n "$scan_pid" ]] && kill -0 "$scan_pid" 2>/dev/null; then
79
+ # Negative pid = the whole process group. run_scan starts the job with `set -m` so it
80
+ # gets its own group; signalling the group takes gitleaks / pip-audit / bandit down with
81
+ # the shell that launched them, instead of reparenting them to init.
82
+ kill -TERM "-$scan_pid" 2>/dev/null || kill -TERM "$scan_pid" 2>/dev/null || true
83
+ wait "$scan_pid" 2>/dev/null || true
84
+ fi
85
+ for wt in "$tmp"/wt-*; do [[ -d "$wt" ]] && git worktree remove --force "$wt" >/dev/null 2>&1; done
86
+ git worktree prune >/dev/null 2>&1 || true
87
+ rm -rf "$tmp"
88
+ }
89
+ # EXIT alone is not enough: a non-interactive bash killed by an UNTRAPPED signal dies
90
+ # without running its EXIT trap, so Ctrl-C, a closed terminal or a terminated agent session
91
+ # left the worktree registered and its directory behind. `trap - EXIT` stops a double run.
92
+ # Nothing in-process survives SIGKILL — that case is what the sweep above is for.
93
+ on_signal() {
94
+ cleanup
95
+ trap - EXIT
96
+ echo "[pre-push] interrupted — scan worktree cleaned up; push aborted." >&2
97
+ exit 130
98
+ }
99
+ trap cleanup EXIT
100
+ trap on_signal INT TERM HUP
101
+
102
+ # Run the scan as a background job and `wait`: bash defers a trap handler until the running
103
+ # FOREGROUND command returns, so a Ctrl-C during a multi-minute scan would not be acted on
104
+ # until the scan finished. `wait` IS interruptible, so cleanup runs at once.
105
+ run_scan() {
106
+ set -m # give the job its own process group, so cleanup can signal the tree
107
+ "$@" &
108
+ scan_pid=$!
109
+ set +m
110
+ echo "$scan_pid" > "$tmp/.scanpid"
111
+ local rc=0
112
+ wait "$scan_pid" || rc=$?
113
+ scan_pid=""
114
+ rm -f "$tmp/.scanpid"
115
+ return "$rc"
116
+ }
117
+
118
+ for sha in "${shas[@]}"; do
119
+ short="${sha:0:7}"
120
+ # Branches that predate the scan have no script — skip them cleanly rather than block.
121
+ if ! git cat-file -e "$sha:scripts/security_scan.sh" 2>/dev/null; then
122
+ echo "[pre-push] $short has no scripts/security_scan.sh — skipping scan for it."
123
+ continue
124
+ fi
125
+ echo "[pre-push] running ADR-0014 security scan on the pushed commit $short (git push --no-verify to skip)…"
126
+ wt="$tmp/wt-$short"
127
+ git worktree add --detach --quiet "$wt" "$sha"
128
+ # Git exports GIT_DIR & co. into hooks; left set, the scan's own git calls (bandit's
129
+ # target discovery, gitleaks) would read the wrong checkout.
130
+ scan_job() {
131
+ env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_PREFIX \
132
+ SECURITY_SCAN_OUT="$repo_root/security" bash "$wt/scripts/security_scan.sh"
133
+ }
134
+ if run_scan scan_job; then
135
+ echo "[pre-push] $short: security scan clean."
136
+ else
137
+ echo "[pre-push] $short: security scan reported findings — push blocked." >&2
138
+ echo "[pre-push] triage the reports in security/, or 'git push --no-verify' to override." >&2
139
+ exit 1
140
+ fi
141
+ done
142
+ echo "[pre-push] security scan clean — proceeding."
143
+
144
+ # --- 2. GitHub mirror sync -----------------------------------------------------------------
145
+ # `origin` IS the prod Space and `github` is a source mirror that only runs CI; nothing links
146
+ # them, so the mirror fell behind prod by hand-sync on 2026-10-01 more than once. A stale mirror
147
+ # means CI runs a SUBSET of production, which reads as a pass. This syncs the mirror's `main`
148
+ # at the only moment git offers: when a prod deploy is about to go out.
149
+ #
150
+ # Two honest limitations (as in pdac-genomics-agent):
151
+ # 1. The mirror is pushed FIRST: git has no post-push hook. If the `origin` push then fails,
152
+ # `github` is briefly AHEAD of prod. That self-corrects on the next deploy, and CI running a
153
+ # superset of production is the milder error.
154
+ # 2. A mirror failure WARNS; it does not block. The mirror is CI; `origin` is production. It is
155
+ # pushed without force, so a mirror that has moved on (another lane synced it) is left alone.
156
+ #
157
+ # Only `main` on `origin` is mirrored. `git push hf-dev dev:main` also targets refs/heads/main on
158
+ # a huggingface.co URL, so the dev Space is excluded by name AND by URL first — mirroring it
159
+ # would put dev code on the mirror's `main`, where CI reads it as production.
160
+ case "$remote_name$remote_url" in
161
+ "$dev_remote"*|*-dev|*-dev.git|*-dev/) exit 0 ;;
162
+ esac
163
+ case "$remote_name$remote_url" in
164
+ origin*|*huggingface.co/spaces/anne-voigt/pdac-knowledge-agent) ;;
165
+ *) exit 0 ;;
166
+ esac
167
+
168
+ main_sha=""
169
+ while read -r _lref lsha rref _rsha; do
170
+ [[ "${rref:-}" == "refs/heads/main" ]] || continue
171
+ case "${lsha:-}" in *[!0]*) main_sha="$lsha" ;; esac # all-zero = a DELETE; never mirror it
172
+ done <<< "$push_lines"
173
+ [[ -n "$main_sha" ]] || exit 0 # nothing headed for main: not a deploy
174
+
175
+ if ! git remote get-url "$mirror_remote" >/dev/null 2>&1; then
176
+ echo "[pre-push] no '$mirror_remote' remote — the GitHub mirror will fall behind this deploy." >&2
177
+ echo "[pre-push] git remote add $mirror_remote https://github.com/Anne-Voigt/pdac-knowledge-agent.git" >&2
178
+ exit 0
179
+ fi
180
+
181
+ echo "[pre-push] mirroring ${main_sha:0:7} to '$mirror_remote' main so the mirror matches prod…"
182
+ if SECURITY_SCAN_DONE=1 git push --quiet "$mirror_remote" "$main_sha:refs/heads/main"; then
183
+ echo "[pre-push] mirror in sync — proceeding with the deploy."
184
+ else
185
+ echo "[pre-push] WARNING: mirror push failed (often: it already moved on). The deploy proceeds." >&2
186
+ echo "[pre-push] Check it after the deploy: git fetch $mirror_remote && git log --oneline -1 $mirror_remote/main" >&2
187
+ fi
188
+ exit 0
scripts/hooks/pre-push-shim ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Installed as .git/hooks/pre-push by `make install-hooks`. It never needs re-installing.
3
+ #
4
+ # The real gate is the TRACKED scripts/hooks/pre-push. Installing a COPY freezes whatever
5
+ # version was current on install day — in pdac-analysis-orchestrator that silently disabled a
6
+ # gate for six days (the installed copy predated it). This shim instead runs the tracked hook
7
+ # as it is on `dev` (the integration branch), so an edited gate is live on the next push from
8
+ # any checkout or worktree, including one parked on an older branch. The gate LOGIC comes from
9
+ # `dev`; the CODE it judges is still the pushed commit (the hook scans each pushed sha).
10
+ # Falls back to this checkout's copy if `dev` has none.
11
+ set -euo pipefail
12
+
13
+ hook="$(mktemp)"
14
+ refs="$(mktemp)"
15
+ trap 'rm -f "$hook" "$refs"' EXIT
16
+ if ! git show dev:scripts/hooks/pre-push > "$hook" 2>/dev/null; then
17
+ cp "$(git rev-parse --show-toplevel)/scripts/hooks/pre-push" "$hook"
18
+ fi
19
+
20
+ # Capture git's ref lines BEFORE backgrounding the gate. Not optional plumbing: POSIX gives an
21
+ # asynchronous command's stdin /dev/null in a non-interactive shell, so a backgrounded gate reads
22
+ # NO ref lines, concludes "no commits to scan" and exits 0 — a gate that passes every push while
23
+ # checking nothing. Found in DecoupleRpy_Agent on 2026-09-23 while adding the forwarding below,
24
+ # and only because the test asserted the gate had SCANNED rather than that the hook exited 0.
25
+ cat > "$refs"
26
+
27
+ # Run the gate as a background job and FORWARD signals to it. The shim is the process git starts,
28
+ # so it is the one a terminating session signals — and the gate's own cleanup traps cannot help if
29
+ # the shim dies and leaves the gate reparented to init with a scan worktree open, which is the
30
+ # 2026-09-23 leak one layer up. (Ctrl-C alone is fine: the terminal signals the whole foreground
31
+ # group. A plain TERM to the hook is not.) `wait` is interruptible, so the forward is immediate.
32
+ bash "$hook" "$@" < "$refs" & # "$@" is git's remote name + URL
33
+ gate=$!
34
+ forward() { kill -TERM "$gate" 2>/dev/null || true; }
35
+ trap forward INT TERM HUP
36
+ rc=0
37
+ wait "$gate" || rc=$?
38
+ exit "$rc"
scripts/security_scan.sh ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # ADR-0014 security scan — this repo's copy of the shared definition (authored in
3
+ # DecoupleRpy_Agent; ported from pdac-subtype-agent). Same stages everywhere, run by
4
+ # the pre-push hook and `make security-scan`:
5
+ #
6
+ # 1. Dependencies — pip-audit over the FULL resolved dependency tree (+ SBOM)
7
+ # 2. Static — bandit over the app code (baseline-suppressed accepted findings)
8
+ # 3. Secrets — gitleaks over the working tree AND full git history
9
+ # 4. Sandbox image — trivy over docker/sandbox.Dockerfile (skips where absent)
10
+ #
11
+ # Differences from the sibling copies, both deliberate:
12
+ #
13
+ # - Stage 1 audits the RESOLVED tree, not the lines in requirements.txt. The
14
+ # siblings run `pip-audit --no-deps`, which is right for them because their
15
+ # requirements.txt is a compiled lockfile pinning every transitive dependency.
16
+ # Here it is a short hand-written list, so --no-deps would audit only the top-level
17
+ # pins and never see a transitive CVE — exactly the class that hit anyio on
18
+ # 2026-09-21. So a hand-written file is first resolved with uv for the Space's own
19
+ # python_version, plus the gradio HF installs from README `sdk_version`; a file that
20
+ # is already a uv lock is audited as-is.
21
+ # - bandit targets are discovered, not listed: `src/` plus every root-level module,
22
+ # so a new top-level file cannot silently fall outside the scan.
23
+ #
24
+ # Not audited: HF's generic SDK base layer (datasets, huggingface-hub, …), which HF
25
+ # installs and caches outside this repo. Anything we depend on through it that
26
+ # matters must be floored in requirements.in (see its security-floors block).
27
+ #
28
+ # Design goals (unchanged from the shared definition):
29
+ # - No global installs, no repo mutation beyond security/ artifacts.
30
+ # - Graceful skip (not failure) when an optional scanner is absent; loud about it.
31
+ # SECURITY_SCAN_STRICT=1 turns skips into failures.
32
+ # - Artifacts land in $SECURITY_SCAN_OUT (default: security/). The pre-push hook
33
+ # scans a throwaway checkout of the PUSHED commit and points this at the real
34
+ # repo, so reports and the cached audit venv survive the checkout's removal.
35
+ #
36
+ # Exit code: non-zero if ANY enabled stage reports findings.
37
+ # Usage: scripts/security_scan.sh [requirements.txt]
38
+ set -uo pipefail
39
+
40
+ cd "$(dirname "$0")/.."
41
+ REQ="${1:-requirements.txt}"
42
+ OUT="${SECURITY_SCAN_OUT:-security}"
43
+ mkdir -p "$OUT"
44
+ STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
45
+ STRICT="${SECURITY_SCAN_STRICT:-0}"
46
+
47
+ RC=0
48
+ SKIPS=()
49
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
50
+
51
+ note() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
52
+ fail() { printf '\033[31mFINDINGS:\033[0m %s\n' "$*"; RC=1; }
53
+ skip() { printf '\033[33mSKIP:\033[0m %s\n' "$*"; SKIPS+=("$*"); [[ "$STRICT" == "1" ]] && RC=1; return 0; }
54
+ ok() { printf '\033[32mOK:\033[0m %s\n' "$*"; }
55
+
56
+ [[ -f "$REQ" ]] || { echo "error: '$REQ' not found" >&2; exit 2; }
57
+
58
+ # README front matter is what HF builds from — read it rather than restating it.
59
+ front() { awk '/^---$/{n++; next} n==1' README.md 2>/dev/null | sed -n "s/^$1:[[:space:]]*//p" | tr -d '"' | head -1; }
60
+ PYV="$(front python_version)"; PYV="${PYV:-3.10}" # HF's default when unset
61
+ SDKV="$(front sdk_version)"
62
+
63
+ # --------------------------------------------------------------------------- #
64
+ # Stage 1a — the full dependency lock that stage 1 audits
65
+ # --------------------------------------------------------------------------- #
66
+ note "dependency tree (python $PYV, linux${SDKV:+, gradio $SDKV from sdk_version})"
67
+ LOCK="$TMP/lock.txt"
68
+ if head -3 "$REQ" | grep -q 'uv pip compile'; then
69
+ cp "$REQ" "$LOCK"; ok "$REQ is already a compiled lock — auditing it as-is"
70
+ elif command -v uv >/dev/null 2>&1; then
71
+ { cat "$REQ"; [[ -n "$SDKV" ]] && echo "gradio[oauth,mcp]==$SDKV"; } > "$TMP/in.txt"
72
+ if uv pip compile "$TMP/in.txt" -o "$LOCK" --python-version "$PYV" \
73
+ --python-platform linux --no-header --quiet 2>"$TMP/uv.err"; then
74
+ ok "resolved $(grep -cE '^[A-Za-z0-9_.-]+==' "$LOCK") packages (incl. transitive)"
75
+ else
76
+ fail "could not resolve $REQ for python $PYV:"; sed 's/^/ /' "$TMP/uv.err"; LOCK=""
77
+ fi
78
+ else
79
+ skip "dependency resolution — uv not found; transitive deps will NOT be audited"
80
+ cp "$REQ" "$LOCK"
81
+ fi
82
+
83
+ # --------------------------------------------------------------------------- #
84
+ # Stage 1b — CycloneDX SBOM (of the resolved tree)
85
+ # --------------------------------------------------------------------------- #
86
+ note "SBOM (CycloneDX) -> $OUT/sbom.json"
87
+ if [[ -z "$LOCK" ]]; then
88
+ skip "SBOM — no resolved dependency tree"
89
+ elif command -v uvx >/dev/null 2>&1; then
90
+ uvx --from cyclonedx-bom cyclonedx-py requirements "$LOCK" --of JSON -o "$OUT/sbom.json" \
91
+ >/dev/null 2>&1 && ok "SBOM written" || fail "SBOM generation errored"
92
+ else
93
+ skip "SBOM — uvx not found"
94
+ fi
95
+
96
+ # --------------------------------------------------------------------------- #
97
+ # Stage 1c — pip-audit (known CVEs)
98
+ # --------------------------------------------------------------------------- #
99
+ # biodata-registry is a first-party wheel hosted on HF, not a PyPI package, so it has
100
+ # no advisories to look up; strip it (and any URL/git line) before auditing.
101
+ note "pip-audit (known CVEs; first-party HF-wheel dep excluded)"
102
+ PYBIN=""
103
+ for cand in python3.13 python3.12 python3.11 python3; do
104
+ command -v "$cand" >/dev/null 2>&1 || continue
105
+ if "$cand" -m venv "$TMP/probe-$cand" >/dev/null 2>&1; then PYBIN="$cand"; break; fi
106
+ done
107
+ if [[ -z "$LOCK" ]]; then
108
+ : # already reported as a finding above
109
+ elif [[ -z "$PYBIN" ]]; then
110
+ skip "pip-audit — no Python >=3.11 with a working venv found"
111
+ else
112
+ grep -vE 'git\+|biodata-registry|https?://' "$LOCK" > "$TMP/audit.txt"
113
+ VENV="$OUT/.audit-venv"
114
+ if [[ ! -x "$VENV/bin/pip-audit" ]]; then
115
+ echo " setting up audit venv ($PYBIN)"
116
+ "$PYBIN" -m venv "$VENV" && "$VENV/bin/python" -m pip install -q --upgrade pip pip-audit
117
+ fi
118
+ "$VENV/bin/python" -m pip_audit --requirement "$TMP/audit.txt" --no-deps --disable-pip \
119
+ --format columns 2>/dev/null | tee "$OUT/pip-audit-$STAMP.txt"
120
+ AUDIT_RC=${PIPESTATUS[0]}
121
+ if [[ "$AUDIT_RC" -eq 0 ]]; then
122
+ ok "pip-audit — no known vulnerabilities in the resolved tree"
123
+ else
124
+ fail "pip-audit reported vulnerabilities (see $OUT/pip-audit-$STAMP.txt)."
125
+ echo " Fix by pinning or flooring the package in requirements.txt, or record a"
126
+ echo " justified acceptance in security/ACCEPTED-FINDINGS.md."
127
+ fi
128
+ fi
129
+
130
+ # --------------------------------------------------------------------------- #
131
+ # Stage 2 — bandit (static analysis, baseline-suppressed)
132
+ # --------------------------------------------------------------------------- #
133
+ TARGETS=(); [[ -d src ]] && TARGETS+=(src)
134
+ while IFS= read -r f; do TARGETS+=("$f"); done < <(git ls-files '*.py' 2>/dev/null | grep -v /)
135
+ note "bandit (static analysis over: ${TARGETS[*]})"
136
+ BASELINE="security/bandit-baseline.json"
137
+ BANDIT=(); if command -v bandit >/dev/null 2>&1; then BANDIT=(bandit); \
138
+ elif command -v uvx >/dev/null 2>&1; then BANDIT=(uvx bandit); fi
139
+ if [[ ${#BANDIT[@]} -eq 0 ]]; then
140
+ skip "bandit — not installed and uvx unavailable"
141
+ elif [[ ${#TARGETS[@]} -eq 0 ]]; then
142
+ skip "bandit — no Python code found"
143
+ else
144
+ BARGS=(-r "${TARGETS[@]}" -ll -c bandit.yaml)
145
+ [[ -f "$BASELINE" ]] && BARGS+=(-b "$BASELINE")
146
+ "${BANDIT[@]}" "${BARGS[@]}" -f json -o "$OUT/bandit-$STAMP.json" -q 2>/dev/null
147
+ BRC=$?
148
+ "${BANDIT[@]}" "${BARGS[@]}" -q 2>/dev/null | tail -n 25 || true
149
+ if [[ "$BRC" -eq 0 ]]; then
150
+ ok "bandit — no new findings beyond the accepted baseline"
151
+ else
152
+ fail "bandit reported NEW findings (see $OUT/bandit-$STAMP.json). Fix, or if"
153
+ echo " intended, annotate inline (# nosec Bxxx), document it in"
154
+ echo " security/ACCEPTED-FINDINGS.md, and run: make security-baseline"
155
+ fi
156
+ fi
157
+
158
+ # --------------------------------------------------------------------------- #
159
+ # Stage 3 — gitleaks (secrets: working tree AND full history)
160
+ # --------------------------------------------------------------------------- #
161
+ note "gitleaks (secrets — working tree + full git history)"
162
+ if command -v gitleaks >/dev/null 2>&1; then
163
+ gitleaks detect --config .gitleaks.toml --redact --no-banner --log-level warn \
164
+ --report-format json --report-path "$OUT/gitleaks-$STAMP.json"
165
+ if [[ $? -eq 0 ]]; then
166
+ ok "gitleaks — no secrets detected in tree or history"
167
+ else
168
+ fail "gitleaks detected potential secrets (see $OUT/gitleaks-$STAMP.json)."
169
+ echo " If a real secret leaked, ROTATE it (HF_TOKEN / research_agent_token /"
170
+ echo " ANTHROPIC_API_KEY are HF Space secrets) — removing the commit is not enough."
171
+ fi
172
+ else
173
+ skip "gitleaks — not installed (brew install gitleaks)"
174
+ fi
175
+
176
+ # --------------------------------------------------------------------------- #
177
+ # Stage 4 — trivy (sandbox image) — N/A unless the repo has one
178
+ # --------------------------------------------------------------------------- #
179
+ note "trivy (sandbox Dockerfile)"
180
+ DOCKERFILE="docker/sandbox.Dockerfile"
181
+ if [[ ! -f "$DOCKERFILE" ]]; then
182
+ skip "trivy — $DOCKERFILE not found (no sandboxed-exec image in this repo)"
183
+ elif command -v trivy >/dev/null 2>&1; then
184
+ trivy config --severity HIGH,CRITICAL --exit-code 1 \
185
+ --format json --output "$OUT/trivy-$STAMP.json" "$DOCKERFILE"
186
+ [[ $? -eq 0 ]] && ok "trivy — no HIGH/CRITICAL Dockerfile misconfigurations" \
187
+ || fail "trivy flagged the sandbox Dockerfile (see $OUT/trivy-$STAMP.json)."
188
+ else
189
+ skip "trivy — not installed (brew install trivy)"
190
+ fi
191
+
192
+ # --------------------------------------------------------------------------- #
193
+ # Summary
194
+ # --------------------------------------------------------------------------- #
195
+ note "summary"
196
+ if [[ ${#SKIPS[@]} -gt 0 ]]; then
197
+ echo "Skipped stages:"; for s in "${SKIPS[@]}"; do echo " - $s"; done
198
+ fi
199
+ if [[ "$RC" -eq 0 ]]; then
200
+ ok "security scan passed (all enabled stages clean)."
201
+ else
202
+ echo "Security scan FAILED (rc=$RC) — triage above. Artifacts in $OUT/."
203
+ fi
204
+ exit "$RC"
security/.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ephemeral scan outputs — reproducible via `make security-scan`, so not tracked.
2
+ .audit-venv/
3
+ bandit-2*.json
4
+ pip-audit-*.json
5
+ pip-audit-*.txt
6
+ gitleaks-*.json
7
+ trivy-*.json
8
+
9
+ # CycloneDX SBOM — regenerable, carries a per-run timestamp+uuid, so not tracked.
10
+ sbom.json
security/ACCEPTED-FINDINGS.md ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Accepted security-scan findings — pdac-knowledge-agent
2
+
3
+ Triage record for the ADR-0014 security scan (`make security-scan`, and the pre-push hook installed by
4
+ `make install-hooks`). Every suppression would be a deliberate, justified acceptance, not an ignored finding.
5
+ There are none.
6
+
7
+ ## §0 — First full run (2026-10-20, slice s1)
8
+
9
+ | Stage | Result |
10
+ |-------|--------|
11
+ | **pip-audit** (compiled lock vs OSV/PyPI advisories) | **clean** — `requirements.txt` is a uv lock of `requirements.in` for python 3.12 / linux: 119 pinned packages, including gradio 6.28.0 from `sdk_version` and ToolUniverse 1.5.6 with every transitive dependency. The memo accepted that footprint under this scan (2026-10-13). |
12
+ | **bandit** (static, medium+, `src/` + `access.py`, `audit.py`, `logging_sink.py`) | **clean** — 0 findings, so there is no baseline file. |
13
+ | **gitleaks** (working tree + full git history) | **clean**. The first run flagged `src/identifiers.py:4` under `generic-api-key`: the docstring phrase "UniProt accessions, EFO/MONDO/Orphanet" matched the rule's keyword-plus-token shape. Not a secret; fixed by rewording the docstring and amending the unpushed commit, **not** by an allowlist entry. |
14
+ | **trivy** (sandbox image) | **N/A** — no sandboxed-exec Dockerfile in this repo; the stage skips. |
15
+ | **SBOM** (CycloneDX, of the lock) | written to `security/sbom.json` (not tracked — regenerable, as in pdac-spatial-agent). |
16
+
17
+ ### What a ToolUniverse bump must re-run
18
+
19
+ A version bump is its own lane (ADR-0001 §7): `tooluniverse-doctor`, re-recorded fixtures with the diff reviewed,
20
+ the schema tests, and this scan on the recompiled lock. `tests/test_no_llm_keys.py` also fails if the lock ever
21
+ gains an LLM client library (openai, anthropic, google-genai, smolagents, litellm, langchain, vllm), so a new
22
+ transitive of that kind is a reviewed change, not a silent one.
src/__init__.py ADDED
File without changes
src/cache.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Response cache — SQLite inside the container, ephemeral, TTL per source (ADR-0001 §8).
2
+
3
+ ToolUniverse's own result cache is switched off (``src.tu_client``) so this is the only one, and
4
+ it keeps the one fact a cache usually hides: ``retrieved_at`` is when the SOURCE answered, and a
5
+ hit returns that original time, never the time the cache served it. Only responses that passed
6
+ their schema check are stored, so a drifted response is never replayed.
7
+
8
+ The file lives in the temp dir by default (``KNOWLEDGE_CACHE_PATH`` overrides; ``:memory:`` for
9
+ tests). Nothing here is persistent on a Space, by design: a rebuild empties it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import sqlite3
18
+ import tempfile
19
+ import threading
20
+ import time
21
+ from collections.abc import Callable
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from src import config
26
+
27
+ DEFAULT_PATH = str(Path(tempfile.gettempdir()) / "pdac-knowledge-cache.sqlite3")
28
+
29
+
30
+ def cache_key(tool: str, args: dict[str, Any]) -> str:
31
+ canonical = json.dumps({"tool": tool, "args": args}, sort_keys=True, separators=(",", ":"))
32
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
33
+
34
+
35
+ class ResponseCache:
36
+ def __init__(self, path: str | None = None, *, clock: Callable[[], float] = time.time):
37
+ self.path = path or os.environ.get("KNOWLEDGE_CACHE_PATH") or DEFAULT_PATH
38
+ self._clock = clock
39
+ self._lock = threading.Lock()
40
+ self._db = sqlite3.connect(self.path, check_same_thread=False)
41
+ self._db.execute(
42
+ "CREATE TABLE IF NOT EXISTS responses ("
43
+ " key TEXT PRIMARY KEY, source TEXT NOT NULL, tool TEXT NOT NULL,"
44
+ " data TEXT NOT NULL, retrieved_at REAL NOT NULL)"
45
+ )
46
+ self._db.commit()
47
+
48
+ def get(self, source: str, tool: str, args: dict[str, Any]) -> tuple[Any, float] | None:
49
+ """``(data, retrieved_at)`` for a live entry, else None. Expired entries are dropped."""
50
+ key = cache_key(tool, args)
51
+ ttl = config.source(source)["ttl_seconds"]
52
+ with self._lock:
53
+ row = self._db.execute(
54
+ "SELECT data, retrieved_at FROM responses WHERE key = ?", (key,)
55
+ ).fetchone()
56
+ if row is None:
57
+ return None
58
+ data, retrieved_at = row
59
+ if self._clock() - retrieved_at >= ttl:
60
+ self._db.execute("DELETE FROM responses WHERE key = ?", (key,))
61
+ self._db.commit()
62
+ return None
63
+ return json.loads(data), retrieved_at
64
+
65
+ def put(
66
+ self, source: str, tool: str, args: dict[str, Any], data: Any, retrieved_at: float
67
+ ) -> None:
68
+ key = cache_key(tool, args)
69
+ with self._lock:
70
+ self._db.execute(
71
+ "INSERT OR REPLACE INTO responses (key, source, tool, data, retrieved_at)"
72
+ " VALUES (?, ?, ?, ?, ?)",
73
+ (key, source, tool, json.dumps(data), retrieved_at),
74
+ )
75
+ self._db.commit()
76
+
77
+ def clear(self) -> None:
78
+ with self._lock:
79
+ self._db.execute("DELETE FROM responses")
80
+ self._db.commit()
src/config.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The two config files every other module reads: the tool allow-list and the source list.
2
+
3
+ `config/tools_allowlist.yaml` is the approved surface (ADR-0001 §7): which ToolUniverse tools may
4
+ ever load, and which BCC endpoint uses each. `config/sources.yaml` is the honest egress statement
5
+ (ADR-0001 §6): the hosts each source's tools reach, the licence stamped on its rows, and its cache
6
+ and rate settings. Both are validated here once, so a malformed file stops the Space at import
7
+ rather than surfacing as a half-stamped row later.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import functools
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+
18
+ ROOT = Path(__file__).resolve().parent.parent
19
+ CONFIG_DIR = ROOT / "config"
20
+
21
+ # Frozen by ADR-0001 §4 (accepted 2026-10-20). s2 builds them; the names are fixed now so the
22
+ # loaded tool set can be checked before any endpoint code exists.
23
+ ENDPOINTS = (
24
+ "drugs_for_targets",
25
+ "trials_for_genes",
26
+ "annotate_genes",
27
+ "pathways_for_genes",
28
+ "targets_for_disease",
29
+ )
30
+
31
+ _TOOL_FIELDS = ("name", "source", "endpoints", "review")
32
+ _SOURCE_FIELDS = {
33
+ "name": str,
34
+ "hosts": list,
35
+ "licence": str,
36
+ "licence_url": str,
37
+ "licence_checked": str,
38
+ "ttl_seconds": int,
39
+ "rate_per_second": (int, float),
40
+ "burst": int,
41
+ "timeout_seconds": (int, float),
42
+ "coverage_notes": list,
43
+ "citation": str,
44
+ }
45
+
46
+
47
+ class ConfigError(ValueError):
48
+ """A config file is malformed. Raised at import, never at request time."""
49
+
50
+
51
+ def _read(name: str) -> dict[str, Any]:
52
+ with open(CONFIG_DIR / name, encoding="utf-8") as fh:
53
+ data = yaml.safe_load(fh)
54
+ if not isinstance(data, dict):
55
+ raise ConfigError(f"{name}: expected a mapping at the top level")
56
+ return data
57
+
58
+
59
+ @functools.cache
60
+ def sources() -> dict[str, dict[str, Any]]:
61
+ """Every declared source, keyed by its short id (`opentargets`, `chembl`, …)."""
62
+ data = _read("sources.yaml").get("sources")
63
+ if not isinstance(data, dict) or not data:
64
+ raise ConfigError("sources.yaml: `sources` must be a non-empty mapping")
65
+ for sid, entry in data.items():
66
+ for field, kind in _SOURCE_FIELDS.items():
67
+ if not isinstance(entry.get(field), kind):
68
+ raise ConfigError(f"sources.yaml: {sid}.{field} missing or not {kind}")
69
+ if not entry["hosts"] or not all(isinstance(h, str) and h for h in entry["hosts"]):
70
+ raise ConfigError(f"sources.yaml: {sid}.hosts must list at least one host")
71
+ if not entry["licence"].strip() or not entry["licence_url"].startswith("https://"):
72
+ raise ConfigError(f"sources.yaml: {sid} needs a licence and an https licence_url")
73
+ if entry["rate_per_second"] <= 0 or entry["burst"] < 1 or entry["ttl_seconds"] <= 0:
74
+ raise ConfigError(f"sources.yaml: {sid} has a non-positive rate, burst or TTL")
75
+ return data
76
+
77
+
78
+ def source(source_id: str) -> dict[str, Any]:
79
+ try:
80
+ return sources()[source_id]
81
+ except KeyError:
82
+ raise ConfigError(f"unknown source {source_id!r}") from None
83
+
84
+
85
+ @functools.cache
86
+ def allowlist() -> dict[str, Any]:
87
+ """The parsed allow-list: `tooluniverse_version` plus `tools` keyed by tool name."""
88
+ data = _read("tools_allowlist.yaml")
89
+ version = data.get("tooluniverse_version")
90
+ if not isinstance(version, str) or not version:
91
+ raise ConfigError("tools_allowlist.yaml: `tooluniverse_version` must be a string")
92
+ tools: dict[str, dict[str, Any]] = {}
93
+ for entry in data.get("tools") or []:
94
+ if not all(field in entry for field in _TOOL_FIELDS):
95
+ raise ConfigError(f"tools_allowlist.yaml: entry missing {_TOOL_FIELDS}: {entry!r}")
96
+ name = entry["name"]
97
+ if name in tools:
98
+ raise ConfigError(f"tools_allowlist.yaml: {name} is listed twice")
99
+ if entry["source"] not in sources():
100
+ raise ConfigError(
101
+ f"tools_allowlist.yaml: {name} names undeclared source {entry['source']!r}"
102
+ )
103
+ unknown = set(entry["endpoints"]) - set(ENDPOINTS)
104
+ if unknown:
105
+ raise ConfigError(
106
+ f"tools_allowlist.yaml: {name} names unknown endpoints {sorted(unknown)}"
107
+ )
108
+ if not str(entry["review"]).strip():
109
+ raise ConfigError(f"tools_allowlist.yaml: {name} has no review note")
110
+ tools[name] = entry
111
+ if not tools:
112
+ raise ConfigError("tools_allowlist.yaml: no tools")
113
+ return {"tooluniverse_version": version, "tools": tools}
114
+
115
+
116
+ def pinned_tooluniverse_version() -> str:
117
+ return allowlist()["tooluniverse_version"]
118
+
119
+
120
+ def allowed_tool_names() -> frozenset[str]:
121
+ """Every approved tool (the memo's 28), loaded or not."""
122
+ return frozenset(allowlist()["tools"])
123
+
124
+
125
+ def endpoint_tool_names() -> frozenset[str]:
126
+ """The tools at least one endpoint uses: exactly the set that is loaded (ADR-0001 §7)."""
127
+ return frozenset(name for name, t in allowlist()["tools"].items() if t["endpoints"])
128
+
129
+
130
+ def tools_for_endpoint(endpoint: str) -> tuple[str, ...]:
131
+ if endpoint not in ENDPOINTS:
132
+ raise ConfigError(f"unknown endpoint {endpoint!r}")
133
+ return tuple(
134
+ sorted(name for name, t in allowlist()["tools"].items() if endpoint in t["endpoints"])
135
+ )
136
+
137
+
138
+ def source_of(tool: str) -> str:
139
+ try:
140
+ return allowlist()["tools"][tool]["source"]
141
+ except KeyError:
142
+ raise ConfigError(f"{tool!r} is not on the allow-list") from None
src/identifiers.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What may leave the Space: identifiers only (ADR-0001 §6).
2
+
3
+ Every endpoint argument passes through here BEFORE any outbound call. Allowed are HGNC symbols,
4
+ Ensembl gene ids, UniProt accession numbers, disease ids from EFO, MONDO or Orphanet, NCT ids, and
5
+ a disease name as a bounded string. Free text, sample ids, numbers (expression values) and file contents are
6
+ refused as data — ``invalid_identifier`` — and more than 100 identifiers as
7
+ ``too_many_identifiers``. A refusal never echoes more than a short, truncated token back.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass, field
14
+
15
+ MAX_IDENTIFIERS = 100
16
+ MAX_DISEASE_NAME = 80
17
+ _ECHO = 24 # longest token a refusal message repeats back
18
+
19
+ # HGNC approved symbols start with a letter and use letters, digits and hyphens (C1orf112,
20
+ # HLA-DRB1, NKX2-1; immunoglobulin segments such as IGHV1-69-2 carry two hyphens).
21
+ SYMBOL = re.compile(r"^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+){0,2}$")
22
+ ENSEMBL_GENE = re.compile(r"^ENSG\d{11}(?:\.\d+)?$")
23
+ # UniProt's published accession pattern.
24
+ UNIPROT = re.compile(
25
+ r"^(?:[OPQ][0-9][A-Z0-9]{3}[0-9]|[A-NR-Z][0-9](?:[A-Z][A-Z0-9]{2}[0-9]){1,2})$"
26
+ )
27
+ DISEASE_ID = re.compile(r"^(?:EFO|MONDO|Orphanet)_\d{1,10}$")
28
+ NCT = re.compile(r"^NCT\d{8}$")
29
+ DISEASE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ,'\-]*$")
30
+
31
+ # Shapes that would pass as a symbol but are sample or run identifiers (TCGA barcodes, GEO and
32
+ # SRA accessions, CPTAC case ids, ICGC donor/sample ids). They never leave the Space: a sample id
33
+ # is study data, not a public identifier. Every prefix here is checked against real symbols that
34
+ # share its letters (CPT1A, ERRFI1, DOK1, SAA1 stay valid; tests/test_identifiers.py).
35
+ _SAMPLE_ID = re.compile(
36
+ r"^(?:TCGA-|GSM\d|GSE\d|SRR\d|ERR\d|DRR\d|SRS\d|SAMN\d|C3[LN]-|DO\d|SA\d)",
37
+ re.IGNORECASE,
38
+ )
39
+ _SPLIT = re.compile(r"[\s,;]+")
40
+
41
+ GENE_KINDS = frozenset({"symbol", "ensembl"})
42
+ PROTEIN_OR_GENE_KINDS = frozenset({"symbol", "ensembl", "uniprot"})
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Identifier:
47
+ value: str
48
+ kind: str # symbol | ensembl | uniprot | disease_id | disease_name | nct
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Parsed:
53
+ """Either a list of identifiers or a refusal (``reason`` + ``message``), never both."""
54
+
55
+ identifiers: tuple[Identifier, ...] = ()
56
+ reason: str | None = None
57
+ message: str | None = None
58
+ duplicates_dropped: int = field(default=0)
59
+
60
+ @property
61
+ def ok(self) -> bool:
62
+ return self.reason is None
63
+
64
+
65
+ def _echo(token: str) -> str:
66
+ return token if len(token) <= _ECHO else token[:_ECHO] + "…"
67
+
68
+
69
+ def _refuse(reason: str, message: str) -> Parsed:
70
+ return Parsed(reason=reason, message=message)
71
+
72
+
73
+ def _dedupe_key(token: str) -> str:
74
+ """Symbols compare case-insensitively (kras == KRAS); Ensembl ids exactly."""
75
+ return token if token.startswith("ENSG") else token.upper()
76
+
77
+
78
+ def classify_gene_token(token: str, *, allow_uniprot: bool = False) -> str | None:
79
+ """The identifier kind for one gene/protein token, or None if it is not one."""
80
+ if _SAMPLE_ID.match(token):
81
+ return None
82
+ if ENSEMBL_GENE.match(token):
83
+ return "ensembl"
84
+ if allow_uniprot and UNIPROT.match(token):
85
+ return "uniprot"
86
+ if len(token) <= 25 and SYMBOL.match(token):
87
+ return "symbol"
88
+ return None
89
+
90
+
91
+ def parse_gene_list(raw: str | list[str] | None, *, allow_uniprot: bool = False) -> Parsed:
92
+ """Split, de-duplicate (order kept) and validate a gene list."""
93
+ if isinstance(raw, list):
94
+ tokens = [str(t).strip() for t in raw]
95
+ else:
96
+ tokens = _SPLIT.split((raw or "").strip())
97
+ tokens = [t for t in tokens if t]
98
+ if not tokens:
99
+ return _refuse("invalid_identifier", "No gene identifiers were given.")
100
+ unique = {_dedupe_key(t) for t in tokens}
101
+ if len(unique) > MAX_IDENTIFIERS:
102
+ return _refuse(
103
+ "too_many_identifiers",
104
+ f"{len(unique)} identifiers given; at most {MAX_IDENTIFIERS} per call.",
105
+ )
106
+ bad = [t for t in tokens if classify_gene_token(t, allow_uniprot=allow_uniprot) is None]
107
+ if bad:
108
+ shown = ", ".join(_echo(t) for t in bad[:5])
109
+ more = f" and {len(bad) - 5} more" if len(bad) > 5 else ""
110
+ accepted = "HGNC symbols or Ensembl gene ids" + (
111
+ " or UniProt accessions" if allow_uniprot else ""
112
+ )
113
+ return _refuse(
114
+ "invalid_identifier",
115
+ f"Not a gene identifier: {shown}{more}. Only {accepted} are sent to external sources; "
116
+ "free text, sample ids and numbers are not.",
117
+ )
118
+ out: list[Identifier] = []
119
+ emitted: set[str] = set()
120
+ for t in tokens:
121
+ key = _dedupe_key(t)
122
+ if key in emitted:
123
+ continue
124
+ emitted.add(key)
125
+ out.append(Identifier(t, classify_gene_token(t, allow_uniprot=allow_uniprot) or "symbol"))
126
+ return Parsed(identifiers=tuple(out), duplicates_dropped=len(tokens) - len(out))
127
+
128
+
129
+ def parse_disease(raw: str | None) -> Parsed:
130
+ """One disease: an EFO/MONDO/Orphanet id, or a bounded name."""
131
+ text = (raw or "").strip()
132
+ if not text:
133
+ return _refuse("invalid_identifier", "No disease was given.")
134
+ if DISEASE_ID.match(text):
135
+ return Parsed(identifiers=(Identifier(text, "disease_id"),))
136
+ if (
137
+ len(text) <= MAX_DISEASE_NAME
138
+ and DISEASE_NAME.match(text)
139
+ and any(c.isalpha() for c in text)
140
+ and not _SAMPLE_ID.match(text)
141
+ ):
142
+ return Parsed(identifiers=(Identifier(" ".join(text.split()), "disease_name"),))
143
+ return _refuse(
144
+ "invalid_identifier",
145
+ f"Not a disease name or id: {_echo(text)}. Give an EFO/MONDO id or a name of at most "
146
+ f"{MAX_DISEASE_NAME} characters (letters, digits, spaces, hyphens, commas, apostrophes).",
147
+ )
148
+
149
+
150
+ def parse_nct(raw: str | None) -> Parsed:
151
+ text = (raw or "").strip().upper()
152
+ if NCT.match(text):
153
+ return Parsed(identifiers=(Identifier(text, "nct"),))
154
+ return _refuse("invalid_identifier", f"Not an NCT id: {_echo(text)}.")
src/payloads.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Payload assembly — every answer this Space returns goes through here.
2
+
3
+ The contract (ADR-0001 §5, accepted 2026-10-20), on top of the family invariants:
4
+
5
+ - Every payload is a dict with ``status`` in {"ok", "refused", "error", "denied"} plus a
6
+ human-readable ``reason`` on every non-ok status. Refusals are answers, never exceptions.
7
+ - Every payload except a denial carries a ``run_id`` (the gate mints none for a caller it
8
+ turned away).
9
+ - Every ``ok`` payload carries ``kind: "annotation"`` and **no** ``hierarchy`` or
10
+ ``n_for_inference``. This is the deliberate break from the data specialists: a row count here
11
+ is not a sample size, and nothing downstream may render it as one.
12
+ - Every row carries a complete ``provenance`` block (:mod:`src.provenance`); :func:`ok` refuses to
13
+ build a payload around a row that does not.
14
+ - ``coverage_notes`` and ``attribution`` come from config for every source the payload used, and
15
+ the deterministic ``narrative`` says "according to <source> (queried <date>)".
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import datetime
21
+ import json
22
+ import secrets
23
+ from collections.abc import Iterable
24
+ from typing import Any
25
+
26
+ from src import config
27
+ from src.provenance import missing_fields
28
+
29
+ STATUSES = ("ok", "refused", "error", "denied")
30
+ SOURCE_ERROR_REASONS = ("source_unavailable", "rate_limited", "upstream_schema_drift")
31
+ KIND = "annotation"
32
+ DISCLAIMER = (
33
+ "External annotation, not analysis: these rows are what the named public sources say, "
34
+ "as of the query date on each row. Nothing here was computed from a dataset, and no "
35
+ "statistic was run."
36
+ )
37
+
38
+
39
+ def mint_run_id() -> str:
40
+ """A sortable, collision-resistant run id (sibling-agent format)."""
41
+ stamp = datetime.datetime.now(datetime.UTC).strftime("%Y%m%d_%H%M%S")
42
+ return f"{stamp}_{secrets.randbelow(10**6):06d}"
43
+
44
+
45
+ def source_error(source: str, reason: str, detail: str) -> dict[str, str]:
46
+ if reason not in SOURCE_ERROR_REASONS:
47
+ raise ValueError(f"source error reason must be one of {SOURCE_ERROR_REASONS}")
48
+ return {"source": source, "reason": reason, "detail": detail}
49
+
50
+
51
+ def _attribution(source_ids: Iterable[str]) -> list[dict[str, str]]:
52
+ out = []
53
+ for sid in sorted(set(source_ids)):
54
+ src = config.source(sid)
55
+ out.append(
56
+ {
57
+ "source": sid,
58
+ "name": src["name"],
59
+ "citation": src["citation"],
60
+ "licence": src["licence"],
61
+ "licence_url": src["licence_url"],
62
+ }
63
+ )
64
+ return out
65
+
66
+
67
+ def _coverage_notes(source_ids: Iterable[str]) -> list[dict[str, str]]:
68
+ return [
69
+ {"source": sid, "note": note}
70
+ for sid in sorted(set(source_ids))
71
+ for note in config.source(sid)["coverage_notes"]
72
+ ]
73
+
74
+
75
+ def ok(
76
+ endpoint: str,
77
+ *,
78
+ query: dict[str, Any],
79
+ rows: list[dict[str, Any]],
80
+ sources_used: Iterable[str],
81
+ narrative: str,
82
+ unresolved: Iterable[dict[str, str]] = (),
83
+ source_errors: Iterable[dict[str, str]] = (),
84
+ run_id: str | None = None,
85
+ **extra: Any,
86
+ ) -> dict[str, Any]:
87
+ """An ok payload. Raises ValueError (a programming error) on an unstamped row."""
88
+ for i, row in enumerate(rows):
89
+ gaps = missing_fields(row)
90
+ if gaps:
91
+ raise ValueError(f"row {i} is missing provenance fields {gaps}")
92
+ if not narrative.strip():
93
+ raise ValueError("an ok payload needs its deterministic narrative")
94
+ used = list(sources_used)
95
+ forbidden = {"hierarchy", "n_for_inference"} & set(extra)
96
+ if forbidden:
97
+ raise ValueError(f"annotation payloads never carry {sorted(forbidden)}")
98
+ payload: dict[str, Any] = {
99
+ "status": "ok",
100
+ "run_id": run_id or mint_run_id(),
101
+ "kind": KIND,
102
+ "endpoint": endpoint,
103
+ "query": query,
104
+ "rows": rows,
105
+ "unresolved": list(unresolved),
106
+ "source_errors": list(source_errors),
107
+ "coverage_notes": _coverage_notes(used),
108
+ "attribution": _attribution(used),
109
+ "narrative": narrative,
110
+ "disclaimer": DISCLAIMER,
111
+ }
112
+ payload.update(extra)
113
+ return payload
114
+
115
+
116
+ def refused(endpoint: str, reason: str, message: str, *, run_id: str | None = None) -> dict:
117
+ return {
118
+ "status": "refused",
119
+ "run_id": run_id or mint_run_id(),
120
+ "endpoint": endpoint,
121
+ "reason": reason,
122
+ "message": message,
123
+ }
124
+
125
+
126
+ def error(
127
+ endpoint: str,
128
+ reason: str,
129
+ message: str,
130
+ *,
131
+ source_errors: Iterable[dict[str, str]] = (),
132
+ run_id: str | None = None,
133
+ ) -> dict:
134
+ return {
135
+ "status": "error",
136
+ "run_id": run_id or mint_run_id(),
137
+ "endpoint": endpoint,
138
+ "reason": reason,
139
+ "message": message,
140
+ "source_errors": list(source_errors),
141
+ }
142
+
143
+
144
+ def denied(message: str) -> dict:
145
+ return {"status": "denied", "reason": "access_denied", "message": message}
146
+
147
+
148
+ def to_json(payload: dict[str, Any]) -> str:
149
+ if payload.get("status") not in STATUSES:
150
+ raise ValueError(f"unknown status {payload.get('status')!r}")
151
+ return json.dumps(payload, ensure_ascii=False)
src/provenance.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provenance on every row (ADR-0001 §5).
2
+
3
+ ToolUniverse returns each source's raw response with no provenance or licence (Phase 0 governance
4
+ finding), so every row this Space returns is stamped here with the ten fields below. A row that is
5
+ missing any of them is a bug, and :func:`src.payloads.ok` refuses to build a payload around it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime
11
+ from typing import Any
12
+
13
+ from src import config
14
+
15
+ PROVENANCE_FIELDS = (
16
+ "source", # short source id from config/sources.yaml (opentargets, chembl, …)
17
+ "source_tool", # the ToolUniverse tool name that produced the row
18
+ "tooluniverse_version", # the pinned version that ran it
19
+ "query", # the arguments actually sent
20
+ "source_ref", # the source's own id for the row (ChEMBL, NCT, UniProt, Reactome, Ensembl)
21
+ "source_url", # where a reader can look the row up
22
+ "retrieved_at", # when the SOURCE answered — survives a cache hit unchanged
23
+ "cache", # "hit" or "miss"
24
+ "licence",
25
+ "licence_url",
26
+ )
27
+
28
+ CACHE_STATES = ("hit", "miss")
29
+
30
+
31
+ def iso_utc(epoch_seconds: float) -> str:
32
+ """``2026-10-20T15:37:49Z`` — second resolution, always UTC."""
33
+ moment = datetime.datetime.fromtimestamp(epoch_seconds, tz=datetime.UTC)
34
+ return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
35
+
36
+
37
+ def stamp(
38
+ row: dict[str, Any],
39
+ *,
40
+ source: str,
41
+ source_tool: str,
42
+ tooluniverse_version: str,
43
+ query: dict[str, Any],
44
+ source_ref: str,
45
+ source_url: str,
46
+ retrieved_at: float,
47
+ cache: str,
48
+ ) -> dict[str, Any]:
49
+ """A copy of ``row`` carrying a complete ``provenance`` block. Licence comes from config."""
50
+ if cache not in CACHE_STATES:
51
+ raise ValueError(f"cache must be one of {CACHE_STATES}, not {cache!r}")
52
+ src = config.source(source)
53
+ stamped = dict(row)
54
+ stamped["provenance"] = {
55
+ "source": source,
56
+ "source_tool": source_tool,
57
+ "tooluniverse_version": tooluniverse_version,
58
+ "query": dict(query),
59
+ "source_ref": source_ref,
60
+ "source_url": source_url,
61
+ "retrieved_at": iso_utc(retrieved_at),
62
+ "cache": cache,
63
+ "licence": src["licence"],
64
+ "licence_url": src["licence_url"],
65
+ }
66
+ return stamped
67
+
68
+
69
+ def missing_fields(row: dict[str, Any]) -> list[str]:
70
+ """Provenance fields absent or empty on ``row`` (empty list = complete)."""
71
+ prov = row.get("provenance")
72
+ if not isinstance(prov, dict):
73
+ return list(PROVENANCE_FIELDS)
74
+ return [f for f in PROVENANCE_FIELDS if prov.get(f) in (None, "", {}, [])]
src/ratelimit.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-source rate limits — a token bucket per source, settings from config/sources.yaml.
2
+
3
+ A caller that would have to wait longer than its budget does not wait: it gets ``False`` and the
4
+ client turns that into a ``rate_limited`` source error. A limit must never become a retry loop
5
+ that outlasts the orchestrator's call limit (ADR-0001 §8).
6
+
7
+ Tokens are reserved under the lock and slept for outside it, so concurrent callers queue in
8
+ order without holding the lock while they sleep.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+ import time
15
+ from collections.abc import Callable
16
+
17
+ from src import config
18
+
19
+
20
+ class TokenBucket:
21
+ def __init__(
22
+ self,
23
+ rate_per_second: float,
24
+ burst: int,
25
+ *,
26
+ clock: Callable[[], float] = time.monotonic,
27
+ sleep: Callable[[float], None] = time.sleep,
28
+ ):
29
+ if rate_per_second <= 0 or burst < 1:
30
+ raise ValueError("rate must be positive and burst at least 1")
31
+ self.rate = float(rate_per_second)
32
+ self.burst = int(burst)
33
+ self._clock = clock
34
+ self._sleep = sleep
35
+ self._tokens = float(burst)
36
+ self._last = clock()
37
+ self._lock = threading.Lock()
38
+
39
+ def acquire(self, max_wait: float) -> bool:
40
+ """Take one token, waiting at most ``max_wait`` seconds. False if that is not enough."""
41
+ with self._lock:
42
+ now = self._clock()
43
+ self._tokens = min(self.burst, self._tokens + (now - self._last) * self.rate)
44
+ self._last = now
45
+ wait = 0.0 if self._tokens >= 1 else (1 - self._tokens) / self.rate
46
+ if wait > max_wait:
47
+ return False
48
+ self._tokens -= 1 # reserve now; may go negative while the caller sleeps
49
+ if wait > 0:
50
+ self._sleep(wait)
51
+ return True
52
+
53
+
54
+ class RateLimiters:
55
+ """One bucket per declared source, built lazily from config."""
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ clock: Callable[[], float] = time.monotonic,
61
+ sleep: Callable[[float], None] = time.sleep,
62
+ ):
63
+ self._clock = clock
64
+ self._sleep = sleep
65
+ self._buckets: dict[str, TokenBucket] = {}
66
+ self._lock = threading.Lock()
67
+
68
+ def bucket(self, source: str) -> TokenBucket:
69
+ with self._lock:
70
+ if source not in self._buckets:
71
+ cfg = config.source(source)
72
+ self._buckets[source] = TokenBucket(
73
+ cfg["rate_per_second"], cfg["burst"], clock=self._clock, sleep=self._sleep
74
+ )
75
+ return self._buckets[source]
76
+
77
+ def acquire(self, source: str, max_wait: float) -> bool:
78
+ return self.bucket(source).acquire(max_wait)
src/schemas/OpenTargets_get_associated_drugs_by_target_ensemblID.schema.json ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$comment": "Pinned 2026-10-20 (s1) from the Phase 0 response recorded through ToolUniverse 1.5.6 on 2026-10-12 (TNFRSF10B). Only the fields drugs_for_targets reads are required. A null target means the id is unknown to Open Targets: an unresolved input, not drift. `target` is object-or-null in one schema rather than a oneOf, so a drift error names the field that moved instead of 'not valid under any of the given schemas' (JSON Schema's required/properties only apply to objects).",
4
+ "type": "object",
5
+ "required": [
6
+ "target"
7
+ ],
8
+ "properties": {
9
+ "target": {
10
+ "type": [
11
+ "object",
12
+ "null"
13
+ ],
14
+ "required": [
15
+ "id",
16
+ "approvedSymbol",
17
+ "drugAndClinicalCandidates"
18
+ ],
19
+ "properties": {
20
+ "id": {
21
+ "type": "string",
22
+ "pattern": "^ENSG\\d{11}$"
23
+ },
24
+ "approvedSymbol": {
25
+ "type": "string",
26
+ "minLength": 1
27
+ },
28
+ "drugAndClinicalCandidates": {
29
+ "type": "object",
30
+ "required": [
31
+ "count",
32
+ "rows"
33
+ ],
34
+ "properties": {
35
+ "count": {
36
+ "type": "integer",
37
+ "minimum": 0
38
+ },
39
+ "rows": {
40
+ "type": "array",
41
+ "items": {
42
+ "type": "object",
43
+ "required": [
44
+ "drug",
45
+ "maxClinicalStage"
46
+ ],
47
+ "properties": {
48
+ "drug": {
49
+ "type": "object",
50
+ "required": [
51
+ "id",
52
+ "name"
53
+ ],
54
+ "properties": {
55
+ "id": {
56
+ "type": "string",
57
+ "pattern": "^CHEMBL\\d+$"
58
+ },
59
+ "name": {
60
+ "type": "string",
61
+ "minLength": 1
62
+ }
63
+ }
64
+ },
65
+ "maxClinicalStage": {
66
+ "type": [
67
+ "string",
68
+ "null"
69
+ ]
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
78
+ }
79
+ }
src/schemas/__init__.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pinned output schemas — the check that stands between a source's response and a row.
2
+
3
+ ADR-0001 §5: each source's raw response is validated against a pinned schema BEFORE any row is
4
+ built, and a mismatch is ``upstream_schema_drift`` for that source. Rows are never guessed from a
5
+ partial match. Phase 0 is why: while the test ran, Open Targets renamed ``knownDrugs`` to
6
+ ``drugAndClinicalCandidates`` and a hand-written client broke. A schema here pins the fields the
7
+ row builders READ, not ToolUniverse's whole declared ``return_schema``, so a harmless addition
8
+ upstream passes and a rename of something we rely on fails loudly.
9
+
10
+ One file per tool: ``<tool name>.schema.json`` (JSON Schema draft 2020-12), describing the tool's
11
+ ``data`` after ToolUniverse's ``{"status": "success", "data": …}`` envelope is removed.
12
+
13
+ **No schema means no rows.** A loaded tool without a pinned schema is refused the same way as a
14
+ drifted one. That keeps the contract fail-closed while s2 is still recording fixtures and
15
+ pinning the remaining schemas.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import functools
21
+ import json
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from jsonschema import Draft202012Validator
26
+ from jsonschema.exceptions import best_match
27
+
28
+ SCHEMA_DIR = Path(__file__).resolve().parent
29
+
30
+
31
+ class SchemaDrift(ValueError):
32
+ """The response does not match the pinned schema (or there is none to check against)."""
33
+
34
+ def __init__(self, tool: str, detail: str):
35
+ super().__init__(f"{tool}: {detail}")
36
+ self.tool = tool
37
+ self.detail = detail
38
+
39
+
40
+ @functools.cache
41
+ def _validator(tool: str) -> Draft202012Validator | None:
42
+ path = SCHEMA_DIR / f"{tool}.schema.json"
43
+ if not path.is_file():
44
+ return None
45
+ schema = json.loads(path.read_text(encoding="utf-8"))
46
+ Draft202012Validator.check_schema(schema)
47
+ return Draft202012Validator(schema)
48
+
49
+
50
+ def pinned_tools() -> frozenset[str]:
51
+ """Tools that have a pinned schema file."""
52
+ return frozenset(p.name.removesuffix(".schema.json") for p in SCHEMA_DIR.glob("*.schema.json"))
53
+
54
+
55
+ def validate(tool: str, data: Any) -> None:
56
+ """Raise :class:`SchemaDrift` unless ``data`` matches the tool's pinned schema."""
57
+ validator = _validator(tool)
58
+ if validator is None:
59
+ raise SchemaDrift(tool, "no pinned schema for this tool, so no rows can be built from it")
60
+ error = best_match(validator.iter_errors(data))
61
+ if error is not None:
62
+ where = "/".join(str(p) for p in error.absolute_path) or "(root)"
63
+ raise SchemaDrift(
64
+ tool, f"response does not match the pinned schema at {where}: {error.message}"
65
+ )
src/tu_client.py ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The governed ToolUniverse client — the ONLY module that imports ``tooluniverse``.
2
+
3
+ ADR-0001 §7, enforced in code rather than by convention:
4
+
5
+ - **Allow-list by construction.** ToolUniverse is built with ``keep_default_tools=False`` and
6
+ ``load_workspace=False``, then ``load_tools(include_tools=<the endpoints' set>)``, and startup
7
+ is REFUSED unless the loaded set equals that set exactly. With these arguments 1.5.6 loads the
8
+ named tools and nothing else: no special tools, no tool finder, no agentic tools, no remote MCP
9
+ servers (measured 2026-10-20, with outbound sockets blocked, so loading opens no connection).
10
+ - **No workspace.** ``load_workspace=False`` is not optional. With a workspace directory present
11
+ (``TOOLUNIVERSE_HOME``, or ``./.tooluniverse``), 1.5.6's constructor writes a default
12
+ ``profile.yaml`` into it and applies it, which loads ALL 2,691 tools before ``load_tools`` runs;
13
+ it also reads that workspace's ``.env`` and ``~/.tooluniverse/.env`` into the environment, which
14
+ could bring a model key in after the check below. Found by this repo's own tests on 2026-10-20,
15
+ when an earlier draft pointed ``TOOLUNIVERSE_HOME`` at an empty temp dir.
16
+ - **No model provider keys.** ToolUniverse's agentic tools and tool finder can reach OpenAI, Azure
17
+ OpenAI, Gemini, OpenRouter or a vLLM server when keys are set. None of those tools can load
18
+ here, and startup still refuses if any key is present: the Space makes no model call
19
+ (ADR-0001 §2). ``HF_TOKEN`` is allowed — the machine gate needs it — and in 1.5.6 only
20
+ Hugging Face inference and embedding tools read it, none of which is on the allow-list.
21
+ - **Exactly one cache.** ToolUniverse's own result cache is disabled through its environment
22
+ switches and every call passes ``use_cache=False``; :mod:`src.cache` is the only cache, because
23
+ it is the one that keeps ``retrieved_at`` honest.
24
+ - **Pinned.** The installed version must equal ``config/tools_allowlist.yaml``'s
25
+ ``tooluniverse_version``. A bump is its own lane (doctor, re-recorded fixtures, schema tests,
26
+ security scan) and never changes an endpoint's name or payload shape.
27
+
28
+ :class:`ToolClient` is what the endpoints call. It returns a :class:`SourceResult` or raises a
29
+ :class:`SourceError` whose ``reason`` is one of the three payload source-error reasons, so a
30
+ timeout, a limit or a drifted response is always structured, never a guess.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import ast
36
+ import concurrent.futures
37
+ import inspect
38
+ import os
39
+ import re
40
+ import threading
41
+ import time
42
+ from collections.abc import Callable, Mapping
43
+ from dataclasses import dataclass
44
+ from typing import Any
45
+
46
+ from src import config, schemas
47
+ from src.cache import ResponseCache
48
+ from src.ratelimit import RateLimiters
49
+
50
+ # Every variable through which ToolUniverse 1.5.6 (or a library it can load) reaches a model.
51
+ LLM_KEY_VARS = (
52
+ "OPENAI_API_KEY",
53
+ "OPENAI_BASE_URL",
54
+ "AZURE_OPENAI_API_KEY",
55
+ "AZURE_OPENAI_ENDPOINT",
56
+ "GEMINI_API_KEY",
57
+ "GOOGLE_API_KEY",
58
+ "OPENROUTER_API_KEY",
59
+ "ANTHROPIC_API_KEY",
60
+ "VLLM_SERVER_URL",
61
+ )
62
+
63
+ # Tool types that must never load, whatever the allow-list says: LLM-backed agents, the tool
64
+ # finders (they can call a model or download an embedding index), remote MCP auto-loaders and
65
+ # web-search backends. A tool whose config names `required_api_keys` is key-gated and refused too.
66
+ FORBIDDEN_TOOL_TYPES = frozenset(
67
+ {
68
+ "AgenticTool",
69
+ "SmolAgentTool",
70
+ "MCPAutoLoaderTool",
71
+ "MCPClientTool",
72
+ "ToolFinderEmbedding",
73
+ "ToolFinderLLM",
74
+ "ToolFinderKeyword",
75
+ "ToolFinderJev",
76
+ "WebSearchTool",
77
+ "WebAPIDocumentationSearchTool",
78
+ }
79
+ )
80
+
81
+ # ToolUniverse's own switches: its result cache off. The workspace and profile variables are
82
+ # REMOVED rather than set, because any workspace widens what loads (see the module docstring).
83
+ _TU_ENV = {
84
+ "TOOLUNIVERSE_CACHE_ENABLED": "false",
85
+ "TOOLUNIVERSE_CACHE_PERSIST": "false",
86
+ "TOOLUNIVERSE_QUIET": "1",
87
+ }
88
+ _TU_ENV_REMOVED = ("TOOLUNIVERSE_PROFILE", "TOOLUNIVERSE_HOME")
89
+
90
+ _HOST = re.compile(r"https?://([A-Za-z0-9.-]+)")
91
+ _build_lock = threading.Lock()
92
+
93
+
94
+ class StartupRefused(RuntimeError):
95
+ """The Space must not start: a key is set, the version drifted, or the tool set is wrong."""
96
+
97
+
98
+ class SourceError(Exception):
99
+ """A structured failure from one source call (``reason`` ∈ payload SOURCE_ERROR_REASONS)."""
100
+
101
+ def __init__(self, source: str, reason: str, detail: str):
102
+ super().__init__(f"{source}: {reason}: {detail}")
103
+ self.source = source
104
+ self.reason = reason
105
+ self.detail = detail
106
+
107
+ def as_payload(self) -> dict[str, str]:
108
+ return {"source": self.source, "reason": self.reason, "detail": self.detail}
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class SourceResult:
113
+ tool: str
114
+ source: str
115
+ args: dict[str, Any]
116
+ data: Any
117
+ retrieved_at: float # epoch seconds when the SOURCE answered
118
+ cache: str # "hit" | "miss"
119
+ tooluniverse_version: str
120
+
121
+
122
+ def assert_no_llm_keys(environ: Mapping[str, str] | None = None) -> None:
123
+ env = os.environ if environ is None else environ
124
+ present = [var for var in LLM_KEY_VARS if (env.get(var) or "").strip()]
125
+ if present:
126
+ raise StartupRefused(
127
+ f"Model-provider settings are present ({', '.join(present)}). This Space makes no "
128
+ "model call and must run without them (ADR-0001 §2); remove them from the Space's "
129
+ "secrets and variables."
130
+ )
131
+
132
+
133
+ def configure_tooluniverse_env() -> None:
134
+ """Set ToolUniverse's environment before an instance is built (idempotent)."""
135
+ for var, value in _TU_ENV.items():
136
+ os.environ[var] = value
137
+ for var in _TU_ENV_REMOVED:
138
+ os.environ.pop(var, None)
139
+
140
+
141
+ def installed_tooluniverse_version() -> str:
142
+ import tooluniverse
143
+
144
+ return str(getattr(tooluniverse, "__version__", ""))
145
+
146
+
147
+ def build_tooluniverse(include: frozenset[str] | None = None):
148
+ """A ToolUniverse holding exactly ``include`` (default: the endpoints' set), or refuse."""
149
+ include = config.endpoint_tool_names() if include is None else include
150
+ if not include <= config.allowed_tool_names():
151
+ raise StartupRefused(
152
+ f"not on the allow-list: {sorted(include - config.allowed_tool_names())}"
153
+ )
154
+ assert_no_llm_keys()
155
+ configure_tooluniverse_env()
156
+ version = installed_tooluniverse_version()
157
+ if version != config.pinned_tooluniverse_version():
158
+ raise StartupRefused(
159
+ f"ToolUniverse {version!r} is installed; config pins "
160
+ f"{config.pinned_tooluniverse_version()!r}. A version bump is its own reviewed lane."
161
+ )
162
+ from tooluniverse import ToolUniverse
163
+
164
+ tu = ToolUniverse(keep_default_tools=False, load_workspace=False)
165
+ if tu.all_tool_dict:
166
+ raise StartupRefused(
167
+ f"ToolUniverse loaded {len(tu.all_tool_dict)} tools before the allow-list was applied"
168
+ )
169
+ tu.load_tools(include_tools=sorted(include), quiet=True)
170
+ assert_no_llm_keys() # again: nothing during construction may have brought one in
171
+ loaded = frozenset(tu.all_tool_dict)
172
+ if loaded != include:
173
+ raise StartupRefused(
174
+ f"loaded tool set differs from the allow-listed set: extra {sorted(loaded - include)}, "
175
+ f"missing {sorted(include - loaded)}"
176
+ )
177
+ unsafe = sorted(
178
+ name
179
+ for name, cfg in tu.all_tool_dict.items()
180
+ if cfg.get("type") in FORBIDDEN_TOOL_TYPES or cfg.get("required_api_keys")
181
+ )
182
+ if unsafe:
183
+ raise StartupRefused(f"agentic, finder, remote-MCP, web-search or key-gated: {unsafe}")
184
+ cache_manager = getattr(tu, "cache_manager", None)
185
+ if cache_manager is not None and getattr(cache_manager, "enabled", False):
186
+ raise StartupRefused("ToolUniverse's own result cache is enabled; it must be off")
187
+ return tu
188
+
189
+
190
+ def tool_hosts(tu, tool: str) -> set[str]:
191
+ """Every host a loaded tool can reach: URLs in its config plus its class's code.
192
+
193
+ Read offline from the installed package: the tool's config dict, the source of its class and
194
+ ToolUniverse base classes, and any module-level URL constant those classes name (HGNC keeps
195
+ its base URL in one). So the egress statement in config/sources.yaml can be checked without a
196
+ network call (tests/test_sources_hosts.py).
197
+ """
198
+ from tooluniverse.tool_registry import get_tool_class_lazy
199
+
200
+ cfg = tu.all_tool_dict[tool]
201
+ hosts = set(_HOST.findall(repr(cfg)))
202
+ # The lazy lookup imports the class's module (offline); the plain registry is empty until a
203
+ # tool instance has been created, which would make this check silently find nothing.
204
+ cls = get_tool_class_lazy(cfg["type"])
205
+ if cls is None:
206
+ raise StartupRefused(f"{tool}: tool type {cfg['type']!r} has no class to inspect")
207
+ for klass in inspect.getmro(cls):
208
+ if not klass.__module__.startswith("tooluniverse"):
209
+ continue
210
+ try:
211
+ class_source = inspect.getsource(klass)
212
+ module_source = inspect.getsource(inspect.getmodule(klass))
213
+ except (OSError, TypeError):
214
+ continue
215
+ hosts.update(_HOST.findall(class_source))
216
+ for name, value in _module_url_constants(module_source).items():
217
+ if re.search(rf"\b{re.escape(name)}\b", class_source):
218
+ hosts.update(_HOST.findall(value))
219
+ return hosts
220
+
221
+
222
+ def _module_url_constants(module_source: str) -> dict[str, str]:
223
+ """Top-level ``NAME = "https://…"`` assignments in a module."""
224
+ found: dict[str, str] = {}
225
+ for node in ast.parse(module_source).body:
226
+ if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant):
227
+ value = node.value.value
228
+ if isinstance(value, str) and _HOST.search(value):
229
+ for target in node.targets:
230
+ if isinstance(target, ast.Name):
231
+ found[target.id] = value
232
+ return found
233
+
234
+
235
+ def _unwrap(tool: str, source: str, raw: Any) -> Any:
236
+ """ToolUniverse's envelope → the source's data, or a structured error."""
237
+ if raw is None:
238
+ raise SourceError(source, "source_unavailable", f"{tool} returned nothing")
239
+ if isinstance(raw, dict):
240
+ status = raw.get("status")
241
+ if status == "success" and "data" in raw:
242
+ return raw["data"]
243
+ if status == "error" or ("error" in raw and raw.get("error")):
244
+ detail = str(raw.get("error") or raw.get("message") or "error")[:200]
245
+ raise SourceError(source, "source_unavailable", f"{tool}: {detail}")
246
+ if isinstance(raw, str) and raw.lower().startswith("error"):
247
+ raise SourceError(source, "source_unavailable", f"{tool}: {raw[:200]}")
248
+ return raw
249
+
250
+
251
+ class ToolClient:
252
+ """Cache → rate limit → call with a deadline → schema check → cache.
253
+
254
+ ``runner(tool, args)`` performs the actual call. By default it is ToolUniverse's
255
+ ``run_one_function`` on a lazily built, allow-listed instance; tests inject a fake runner
256
+ with synthetic or recorded responses, so the suite never reaches the network.
257
+ """
258
+
259
+ def __init__(
260
+ self,
261
+ *,
262
+ runner: Callable[[str, dict[str, Any]], Any] | None = None,
263
+ cache: ResponseCache | None = None,
264
+ limiters: RateLimiters | None = None,
265
+ clock: Callable[[], float] = time.time,
266
+ tooluniverse_version: str | None = None,
267
+ ):
268
+ self._runner = runner
269
+ self._tu = None
270
+ self.cache = cache if cache is not None else ResponseCache()
271
+ self.limiters = limiters if limiters is not None else RateLimiters()
272
+ self._clock = clock
273
+ self._version = tooluniverse_version
274
+ self._pool = concurrent.futures.ThreadPoolExecutor(
275
+ max_workers=8, thread_name_prefix="tu-call"
276
+ )
277
+
278
+ @property
279
+ def tooluniverse_version(self) -> str:
280
+ if self._version is None:
281
+ self._version = installed_tooluniverse_version()
282
+ return self._version
283
+
284
+ def _default_runner(self, tool: str, args: dict[str, Any]) -> Any:
285
+ with _build_lock:
286
+ if self._tu is None:
287
+ self._tu = build_tooluniverse()
288
+ return self._tu.run_one_function({"name": tool, "arguments": args}, use_cache=False)
289
+
290
+ def call(self, tool: str, args: dict[str, Any], *, max_wait: float = 10.0) -> SourceResult:
291
+ if tool not in config.endpoint_tool_names():
292
+ # A programming error, not a user input: endpoints only ever name loaded tools.
293
+ raise ValueError(f"{tool!r} is not an endpoint tool")
294
+ source = config.source_of(tool)
295
+ cfg = config.source(source)
296
+
297
+ hit = self.cache.get(source, tool, args)
298
+ if hit is not None:
299
+ data, retrieved_at = hit
300
+ return SourceResult(
301
+ tool, source, dict(args), data, retrieved_at, "hit", self.tooluniverse_version
302
+ )
303
+
304
+ if not self.limiters.acquire(source, max_wait):
305
+ raise SourceError(
306
+ source, "rate_limited", f"{cfg['name']} limit of {cfg['rate_per_second']}/s reached"
307
+ )
308
+
309
+ runner = self._runner or self._default_runner
310
+ future = self._pool.submit(runner, tool, dict(args))
311
+ try:
312
+ raw = future.result(timeout=cfg["timeout_seconds"])
313
+ except concurrent.futures.TimeoutError:
314
+ future.cancel()
315
+ raise SourceError(
316
+ source,
317
+ "source_unavailable",
318
+ f"{cfg['name']} did not answer within {cfg['timeout_seconds']} s",
319
+ ) from None
320
+ except SourceError:
321
+ raise
322
+ except Exception as e:
323
+ raise SourceError(source, "source_unavailable", f"{tool}: {type(e).__name__}") from e
324
+ retrieved_at = self._clock()
325
+
326
+ data = _unwrap(tool, source, raw)
327
+ try:
328
+ schemas.validate(tool, data)
329
+ except schemas.SchemaDrift as drift:
330
+ raise SourceError(source, "upstream_schema_drift", drift.detail) from drift
331
+
332
+ self.cache.put(source, tool, args, data, retrieved_at)
333
+ return SourceResult(
334
+ tool, source, dict(args), data, retrieved_at, "miss", self.tooluniverse_version
335
+ )
tests/__init__.py ADDED
File without changes
tests/conftest.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hermetic test setup — outbound network is BLOCKED before collection.
2
+
3
+ The block is enforced, not asserted (the genomics/spatial pattern): the socket patch is in place
4
+ at conftest import time, before any test module loads, so a test that would reach Open Targets,
5
+ UniProt or any other source fails loudly instead of quietly depending on the network. Responses
6
+ come from recorded Phase 0 fixtures (``tests/fixtures/``) or synthetic dicts, through
7
+ ``ToolClient(runner=...)``.
8
+
9
+ Model-provider variables are removed for the session as well: this Space must start without them
10
+ (ADR-0001 §2), and a developer shell that happens to export ``ANTHROPIC_API_KEY`` must not turn
11
+ every startup test into a refusal. Tests that need one set it themselves.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import socket
19
+ from pathlib import Path
20
+
21
+ import pytest
22
+
23
+ os.environ.setdefault("HF_HUB_OFFLINE", "1")
24
+ os.environ.pop("ACCESS_CONTROL", None) # gate dark-launched off unless a test sets it
25
+
26
+ from src.tu_client import LLM_KEY_VARS
27
+
28
+ for _var in LLM_KEY_VARS:
29
+ os.environ.pop(_var, None)
30
+
31
+ _real_connect = socket.socket.connect
32
+ OUTBOUND_ATTEMPTS: list = []
33
+
34
+
35
+ def _blocked_connect(self, address):
36
+ host = address[0] if isinstance(address, tuple) else address
37
+ # AF_UNIX (a filesystem path) is local IPC.
38
+ if isinstance(address, (str, bytes)) or str(host) in ("127.0.0.1", "::1", "localhost"):
39
+ return _real_connect(self, address)
40
+ OUTBOUND_ATTEMPTS.append(address)
41
+ raise RuntimeError(f"Outbound network blocked in tests (attempted {address!r})")
42
+
43
+
44
+ socket.socket.connect = _blocked_connect # type: ignore[method-assign]
45
+
46
+ FIXTURES = Path(__file__).resolve().parent / "fixtures"
47
+
48
+
49
+ def load_fixture(relpath: str) -> dict:
50
+ return json.loads((FIXTURES / relpath).read_text(encoding="utf-8"))
51
+
52
+
53
+ class FakeClock:
54
+ """A clock that only moves when told to; ``sleep`` advances it."""
55
+
56
+ def __init__(self, start: float = 1_760_000_000.0):
57
+ self.now = start
58
+ self.sleeps: list[float] = []
59
+
60
+ def __call__(self) -> float:
61
+ return self.now
62
+
63
+ def sleep(self, seconds: float) -> None:
64
+ self.sleeps.append(seconds)
65
+ self.now += seconds
66
+
67
+ def advance(self, seconds: float) -> None:
68
+ self.now += seconds
69
+
70
+
71
+ class FakeRunner:
72
+ """Stands in for ToolUniverse's ``run_one_function``: canned responses, every call recorded."""
73
+
74
+ def __init__(self, responses: dict[str, object] | None = None):
75
+ self.responses = dict(responses or {})
76
+ self.calls: list[tuple[str, dict]] = []
77
+
78
+ def __call__(self, tool: str, args: dict):
79
+ self.calls.append((tool, dict(args)))
80
+ response = self.responses[tool]
81
+ if isinstance(response, BaseException):
82
+ raise response
83
+ if callable(response):
84
+ return response(args)
85
+ return response
86
+
87
+
88
+ @pytest.fixture()
89
+ def clock() -> FakeClock:
90
+ return FakeClock()
91
+
92
+
93
+ @pytest.fixture()
94
+ def memory_cache(clock):
95
+ from src.cache import ResponseCache
96
+
97
+ return ResponseCache(":memory:", clock=clock)
98
+
99
+
100
+ @pytest.fixture()
101
+ def tnfrsf10b_response() -> dict:
102
+ """The real Open Targets response for DR5, recorded through ToolUniverse 1.5.6 in Phase 0."""
103
+ return load_fixture("phase0/opentargets_drugs_TNFRSF10B.json")["response"]
tests/fixtures/phase0/opentargets_drugs_TNFRSF10B.json ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_recorded": {
3
+ "tool": "OpenTargets_get_associated_drugs_by_target_ensemblID",
4
+ "arguments": {
5
+ "ensemblId": "ENSG00000120889",
6
+ "size": 100
7
+ },
8
+ "tooluniverse_version": "1.5.6",
9
+ "recorded_on": "2026-10-12",
10
+ "origin": "Phase 0 evaluation run (run2_results.json, Q7_tu); each row's diseases list trimmed to two entries",
11
+ "licence": "Open Targets Platform, CC0 1.0"
12
+ },
13
+ "response": {
14
+ "status": "success",
15
+ "data": {
16
+ "target": {
17
+ "id": "ENSG00000120889",
18
+ "approvedSymbol": "TNFRSF10B",
19
+ "drugAndClinicalCandidates": {
20
+ "count": 7,
21
+ "rows": [
22
+ {
23
+ "drug": {
24
+ "id": "CHEMBL2109247",
25
+ "name": "LBY-135",
26
+ "maximumClinicalStage": "PHASE_1"
27
+ },
28
+ "maxClinicalStage": "PHASE_1",
29
+ "diseases": []
30
+ },
31
+ {
32
+ "drug": {
33
+ "id": "CHEMBL2107846",
34
+ "name": "DULANERMIN",
35
+ "maximumClinicalStage": "PHASE_2"
36
+ },
37
+ "maxClinicalStage": "PHASE_2",
38
+ "diseases": [
39
+ {
40
+ "disease": {
41
+ "id": "MONDO_0056806",
42
+ "name": "non-small cell squamous lung carcinoma"
43
+ }
44
+ },
45
+ {
46
+ "disease": {
47
+ "id": "MONDO_0005233",
48
+ "name": "non-small cell lung carcinoma"
49
+ }
50
+ }
51
+ ]
52
+ },
53
+ {
54
+ "drug": {
55
+ "id": "CHEMBL1743008",
56
+ "name": "DROZITUMAB",
57
+ "maximumClinicalStage": "PHASE_2"
58
+ },
59
+ "maxClinicalStage": "PHASE_2",
60
+ "diseases": [
61
+ {
62
+ "disease": {
63
+ "id": "MONDO_0005233",
64
+ "name": "non-small cell lung carcinoma"
65
+ }
66
+ },
67
+ {
68
+ "disease": {
69
+ "id": "MONDO_0018906",
70
+ "name": "follicular lymphoma"
71
+ }
72
+ }
73
+ ]
74
+ },
75
+ {
76
+ "drug": {
77
+ "id": "CHEMBL1743080",
78
+ "name": "TIGATUZUMAB",
79
+ "maximumClinicalStage": "PHASE_2"
80
+ },
81
+ "maxClinicalStage": "PHASE_2",
82
+ "diseases": [
83
+ {
84
+ "disease": {
85
+ "id": "MONDO_0007254",
86
+ "name": "breast cancer"
87
+ }
88
+ },
89
+ {
90
+ "disease": {
91
+ "id": "MONDO_0005575",
92
+ "name": "colorectal cancer"
93
+ }
94
+ }
95
+ ]
96
+ },
97
+ {
98
+ "drug": {
99
+ "id": "CHEMBL2109248",
100
+ "name": "APOMAB",
101
+ "maximumClinicalStage": "PHASE_1"
102
+ },
103
+ "maxClinicalStage": "PHASE_1",
104
+ "diseases": [
105
+ {
106
+ "disease": {
107
+ "id": "MONDO_0005575",
108
+ "name": "colorectal cancer"
109
+ }
110
+ }
111
+ ]
112
+ },
113
+ {
114
+ "drug": {
115
+ "id": "CHEMBL1743036",
116
+ "name": "LEXATUMUMAB",
117
+ "maximumClinicalStage": "PHASE_2"
118
+ },
119
+ "maxClinicalStage": "PHASE_2",
120
+ "diseases": [
121
+ {
122
+ "disease": {
123
+ "id": "MONDO_0005072",
124
+ "name": "neuroblastoma"
125
+ }
126
+ },
127
+ {
128
+ "disease": {
129
+ "id": "MONDO_0012817",
130
+ "name": "Ewing sarcoma"
131
+ }
132
+ }
133
+ ]
134
+ },
135
+ {
136
+ "drug": {
137
+ "id": "CHEMBL1743003",
138
+ "name": "CONATUMUMAB",
139
+ "maximumClinicalStage": "PHASE_2"
140
+ },
141
+ "maxClinicalStage": "PHASE_2",
142
+ "diseases": [
143
+ {
144
+ "disease": {
145
+ "id": "MONDO_0005575",
146
+ "name": "colorectal cancer"
147
+ }
148
+ },
149
+ {
150
+ "disease": {
151
+ "id": "HP_0012531",
152
+ "name": "Pain"
153
+ }
154
+ }
155
+ ]
156
+ }
157
+ ]
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
tests/test_allowlist.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """C2 — the allow-list holds by construction (ADR-0001 §7).
2
+
3
+ What these tests pin, against the INSTALLED ToolUniverse (offline: loading reads the package's
4
+ own JSON configs and opens no socket, which the conftest block would turn into a failure):
5
+
6
+ - the config is the memo's 28 tools across 8 sources, and every name exists in 1.5.6;
7
+ - the Space loads exactly the five endpoints' set (12 tools), nothing more;
8
+ - no agentic, tool-finder, remote-MCP, web-search or key-gated tool is or can be loaded;
9
+ - the installed version is the pinned one, and a mismatch refuses startup;
10
+ - ToolUniverse's own result cache is off.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections import Counter
16
+
17
+ import pytest
18
+
19
+ from src import config, tu_client
20
+ from src.tu_client import FORBIDDEN_TOOL_TYPES, StartupRefused, build_tooluniverse
21
+ from tests.conftest import OUTBOUND_ATTEMPTS
22
+
23
+ MEMO_COUNTS = {
24
+ "opentargets": 6,
25
+ "chembl": 4,
26
+ "uniprot": 4,
27
+ "clinicaltrials": 3,
28
+ "pubchem": 3,
29
+ "reactome": 3,
30
+ "dgidb": 3,
31
+ "hgnc": 2,
32
+ }
33
+
34
+
35
+ @pytest.fixture(scope="module")
36
+ def loaded_tu():
37
+ return build_tooluniverse()
38
+
39
+
40
+ def test_allowlist_is_the_memos_28_across_8_sources():
41
+ counts = Counter(config.source_of(name) for name in config.allowed_tool_names())
42
+ assert dict(counts) == MEMO_COUNTS
43
+ assert len(config.allowed_tool_names()) == 28
44
+
45
+
46
+ def test_every_allowlisted_name_exists_in_the_installed_version():
47
+ tu = build_tooluniverse(include=config.allowed_tool_names())
48
+ assert frozenset(tu.all_tool_dict) == config.allowed_tool_names()
49
+
50
+
51
+ def test_the_space_loads_exactly_the_endpoints_set(loaded_tu):
52
+ loaded = frozenset(loaded_tu.all_tool_dict)
53
+ assert loaded == config.endpoint_tool_names()
54
+ assert len(loaded) == 12
55
+ assert loaded <= config.allowed_tool_names()
56
+
57
+
58
+ def test_loading_opens_no_socket():
59
+ before = len(OUTBOUND_ATTEMPTS)
60
+ build_tooluniverse()
61
+ assert OUTBOUND_ATTEMPTS[before:] == []
62
+
63
+
64
+ def test_no_loaded_tool_is_agentic_finder_mcp_websearch_or_key_gated(loaded_tu):
65
+ for name, cfg in loaded_tu.all_tool_dict.items():
66
+ assert cfg.get("type") not in FORBIDDEN_TOOL_TYPES, name
67
+ assert not cfg.get("required_api_keys"), name
68
+
69
+
70
+ def test_no_allowlisted_tool_is_of_a_forbidden_kind():
71
+ tu = build_tooluniverse(include=config.allowed_tool_names())
72
+ for name, cfg in tu.all_tool_dict.items():
73
+ assert cfg.get("type") not in FORBIDDEN_TOOL_TYPES, name
74
+ assert not cfg.get("required_api_keys"), name
75
+
76
+
77
+ @pytest.mark.parametrize("name", ["Tool_Finder_LLM", "Tool_RAG", "web_search", "Finish"])
78
+ def test_a_tool_off_the_allowlist_cannot_be_requested(name):
79
+ with pytest.raises(StartupRefused, match="not on the allow-list"):
80
+ build_tooluniverse(include=frozenset({name}))
81
+
82
+
83
+ def test_a_forbidden_type_refuses_startup_even_if_it_loaded(monkeypatch):
84
+ """Defence in depth: if an allow-list edit ever let one in, the Space still will not start."""
85
+ from tooluniverse import ToolUniverse
86
+
87
+ real_load = ToolUniverse.load_tools
88
+
89
+ def load_with_an_agent(self, *args, **kwargs):
90
+ real_load(self, *args, **kwargs)
91
+ name = next(iter(self.all_tool_dict))
92
+ self.all_tool_dict[name] = {**self.all_tool_dict[name], "type": "AgenticTool"}
93
+
94
+ monkeypatch.setattr(ToolUniverse, "load_tools", load_with_an_agent)
95
+ with pytest.raises(StartupRefused, match="agentic"):
96
+ build_tooluniverse()
97
+
98
+
99
+ def test_an_extra_loaded_tool_refuses_startup(monkeypatch):
100
+ from tooluniverse import ToolUniverse
101
+
102
+ real_load = ToolUniverse.load_tools
103
+
104
+ def load_one_more(self, *args, include_tools=None, **kwargs):
105
+ real_load(
106
+ self,
107
+ *args,
108
+ include_tools=[*include_tools, "PubChem_get_CID_by_compound_name"],
109
+ **kwargs,
110
+ )
111
+
112
+ monkeypatch.setattr(ToolUniverse, "load_tools", load_one_more)
113
+ with pytest.raises(StartupRefused, match="differs from the allow-listed set"):
114
+ build_tooluniverse()
115
+
116
+
117
+ def test_a_workspace_cannot_widen_what_loads_or_bring_in_a_key(monkeypatch, tmp_path):
118
+ """The 2026-10-20 trap: with any workspace present, 1.5.6's constructor seeds and applies a
119
+ default profile (all 2,691 tools) and reads the workspace's and ~/.tooluniverse's .env."""
120
+ home = tmp_path / "home"
121
+ home.mkdir()
122
+ local = tmp_path / "cwd" / ".tooluniverse"
123
+ local.mkdir(parents=True)
124
+ (local / ".env").write_text("OPENAI_API_KEY=planted-in-a-workspace\n")
125
+ (home / ".env").write_text("GEMINI_API_KEY=planted-in-home\n")
126
+ monkeypatch.setenv("TOOLUNIVERSE_HOME", str(home))
127
+ monkeypatch.setenv("TOOLUNIVERSE_PROFILE", str(home / "profile.yaml"))
128
+ monkeypatch.chdir(tmp_path / "cwd")
129
+ tu = build_tooluniverse()
130
+ assert frozenset(tu.all_tool_dict) == config.endpoint_tool_names()
131
+ import os
132
+
133
+ assert "OPENAI_API_KEY" not in os.environ and "GEMINI_API_KEY" not in os.environ
134
+ assert not (home / "profile.yaml").exists()
135
+
136
+
137
+ def test_installed_version_is_the_pinned_one():
138
+ assert config.pinned_tooluniverse_version() == "1.5.6"
139
+ assert tu_client.installed_tooluniverse_version() == "1.5.6"
140
+
141
+
142
+ def test_a_version_mismatch_refuses_startup(monkeypatch):
143
+ monkeypatch.setattr(tu_client, "installed_tooluniverse_version", lambda: "1.5.7")
144
+ with pytest.raises(StartupRefused, match="1.5.7"):
145
+ build_tooluniverse()
146
+
147
+
148
+ def test_tooluniverse_result_cache_is_off(loaded_tu):
149
+ assert loaded_tu.cache_manager.enabled is False
150
+ assert loaded_tu.cache_manager.persistent is None
151
+
152
+
153
+ def test_only_tu_client_imports_tooluniverse():
154
+ from pathlib import Path
155
+
156
+ root = Path(__file__).resolve().parent.parent
157
+ offenders = [
158
+ str(p.relative_to(root))
159
+ for p in [*root.glob("src/**/*.py"), *root.glob("*.py")]
160
+ if p.name != "tu_client.py"
161
+ and any(
162
+ line.lstrip().startswith(("import tooluniverse", "from tooluniverse"))
163
+ for line in p.read_text(encoding="utf-8").splitlines()
164
+ )
165
+ ]
166
+ assert offenders == []
tests/test_audit.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The audit record is result-free and query-free: who, which endpoint, how many ids, status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import audit
8
+
9
+
10
+ def test_local_record_holds_a_count_never_the_identifiers(monkeypatch, tmp_path):
11
+ monkeypatch.delenv("LOG_SINK", raising=False)
12
+ monkeypatch.setenv("LOG_SINK_LOCAL_DIR", str(tmp_path))
13
+ assert (
14
+ audit.record_run(
15
+ "drugs_for_targets",
16
+ username="someone",
17
+ n_identifiers=3,
18
+ status="ok",
19
+ run_id="20261020_120000_000001",
20
+ )
21
+ is None
22
+ )
23
+ [path] = tmp_path.glob("*.json")
24
+ record = json.loads(path.read_text())
25
+ assert record["tool"] == "drugs_for_targets"
26
+ assert record["n_identifiers"] == 3
27
+ assert record["principal"] == "someone"
28
+ assert set(record) == {
29
+ "run_id",
30
+ "timestamp",
31
+ "tool",
32
+ "status",
33
+ "n_identifiers",
34
+ "principal",
35
+ "role",
36
+ "enforced",
37
+ }
38
+
39
+
40
+ def test_a_failing_sink_never_fails_the_call(monkeypatch):
41
+ import logging_sink
42
+
43
+ class Broken(logging_sink.LogSink):
44
+ name = "local"
45
+
46
+ def persist_trace(self, run_id, trace):
47
+ raise OSError("disk full")
48
+
49
+ monkeypatch.setattr(audit, "get_log_sink", lambda: Broken())
50
+ assert (
51
+ audit.record_run("annotate_genes", username=None, n_identifiers=0, status="denied") is None
52
+ )
53
+
54
+
55
+ def test_the_hf_sink_defaults_to_this_spaces_own_dataset():
56
+ import logging_sink
57
+
58
+ assert logging_sink.DEFAULT_HF_DATASET == "anne-voigt/pdac-knowledge-results"
tests/test_cache.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """C5 — the cache keeps retrieved_at honest and expires per source (ADR-0001 §8)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from src import config
8
+ from src.cache import cache_key
9
+ from src.tu_client import SourceError, ToolClient
10
+ from tests.conftest import FakeRunner
11
+
12
+ OT_DRUGS = "OpenTargets_get_associated_drugs_by_target_ensemblID"
13
+ ARGS = {"ensemblId": "ENSG00000120889", "size": 100}
14
+
15
+
16
+ def _client(runner, memory_cache, clock):
17
+ return ToolClient(runner=runner, cache=memory_cache, clock=clock, tooluniverse_version="1.5.6")
18
+
19
+
20
+ def test_a_repeat_call_is_a_hit_with_the_original_retrieved_at(
21
+ memory_cache, clock, tnfrsf10b_response
22
+ ):
23
+ runner = FakeRunner({OT_DRUGS: tnfrsf10b_response})
24
+ client = _client(runner, memory_cache, clock)
25
+ first = client.call(OT_DRUGS, ARGS)
26
+ clock.advance(3600)
27
+ second = client.call(OT_DRUGS, ARGS)
28
+ assert (first.cache, second.cache) == ("miss", "hit")
29
+ assert second.retrieved_at == first.retrieved_at
30
+ assert second.data == first.data
31
+ assert len(runner.calls) == 1
32
+
33
+
34
+ def test_an_entry_expires_at_the_sources_ttl(memory_cache, clock, tnfrsf10b_response):
35
+ runner = FakeRunner({OT_DRUGS: tnfrsf10b_response})
36
+ client = _client(runner, memory_cache, clock)
37
+ client.call(OT_DRUGS, ARGS)
38
+ clock.advance(config.source("opentargets")["ttl_seconds"] - 1)
39
+ assert client.call(OT_DRUGS, ARGS).cache == "hit"
40
+ clock.advance(2)
41
+ again = client.call(OT_DRUGS, ARGS)
42
+ assert again.cache == "miss"
43
+ assert again.retrieved_at == clock.now
44
+ assert len(runner.calls) == 2
45
+
46
+
47
+ def test_ttls_are_seven_days_and_one_day_for_trials():
48
+ assert config.source("clinicaltrials")["ttl_seconds"] == 86_400
49
+ for sid in ("opentargets", "chembl", "uniprot", "reactome", "hgnc"):
50
+ assert config.source(sid)["ttl_seconds"] == 7 * 86_400
51
+
52
+
53
+ def test_store_and_read_directly(memory_cache, clock):
54
+ memory_cache.put("chembl", "ChEMBL_get_drug_mechanisms", {"x": 1}, {"ok": True}, clock.now - 10)
55
+ data, retrieved_at = memory_cache.get("chembl", "ChEMBL_get_drug_mechanisms", {"x": 1})
56
+ assert data == {"ok": True}
57
+ assert retrieved_at == clock.now - 10
58
+ assert memory_cache.get("chembl", "ChEMBL_get_drug_mechanisms", {"x": 2}) is None
59
+
60
+
61
+ def test_the_key_ignores_argument_order():
62
+ assert cache_key("T", {"a": 1, "b": 2}) == cache_key("T", {"b": 2, "a": 1})
63
+ assert cache_key("T", {"a": 1}) != cache_key("U", {"a": 1})
64
+
65
+
66
+ def test_a_drifted_response_is_never_cached(memory_cache, clock):
67
+ drifted = {"status": "success", "data": {"target": {"id": "ENSG00000120889"}}}
68
+ runner = FakeRunner({OT_DRUGS: drifted})
69
+ client = _client(runner, memory_cache, clock)
70
+ for _ in range(2):
71
+ with pytest.raises(SourceError, match="upstream_schema_drift"):
72
+ client.call(OT_DRUGS, ARGS)
73
+ assert memory_cache.get("opentargets", OT_DRUGS, ARGS) is None
74
+ assert len(runner.calls) == 2
tests/test_identifiers.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What may leave the Space: identifiers only (ADR-0001 §6). Refusals are data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from src.identifiers import MAX_IDENTIFIERS, parse_disease, parse_gene_list, parse_nct
8
+
9
+ # Real HGNC symbols chosen to exercise the edges of the pattern and of the sample-id prefixes.
10
+ VALID_SYMBOLS = [
11
+ "KRAS",
12
+ "TP53",
13
+ "ELAVL1",
14
+ "C1orf112",
15
+ "HLA-DRB1",
16
+ "NKX2-1",
17
+ "IGHV1-69-2",
18
+ "CPT1A",
19
+ "ERRFI1",
20
+ "DOK1",
21
+ "SAA1",
22
+ "SORD2P",
23
+ ]
24
+
25
+
26
+ @pytest.mark.parametrize("symbol", VALID_SYMBOLS)
27
+ def test_real_symbols_pass(symbol):
28
+ parsed = parse_gene_list(symbol)
29
+ assert parsed.ok, parsed.message
30
+ assert parsed.identifiers[0].kind == "symbol"
31
+
32
+
33
+ def test_a_list_splits_on_commas_spaces_and_semicolons_and_keeps_order():
34
+ parsed = parse_gene_list("JAK1, JAK2;STAT1 jak1")
35
+ assert parsed.ok
36
+ assert [i.value for i in parsed.identifiers] == ["JAK1", "JAK2", "STAT1"]
37
+ assert parsed.duplicates_dropped == 1
38
+
39
+
40
+ def test_ensembl_ids_are_genes_and_uniprot_only_when_allowed():
41
+ assert parse_gene_list("ENSG00000133703").identifiers[0].kind == "ensembl"
42
+ assert parse_gene_list("P05161", allow_uniprot=True).identifiers[0].kind == "uniprot"
43
+ assert parse_gene_list("P05161").identifiers[0].kind == "symbol"
44
+
45
+
46
+ @pytest.mark.parametrize(
47
+ "raw",
48
+ [
49
+ "what drugs target KRAS?",
50
+ "TCGA-2J-AAB1-01A",
51
+ "GSM1234567",
52
+ "C3L-00017",
53
+ "12.5",
54
+ "KRAS 3.2 1.1",
55
+ "<script>",
56
+ "SELECT * FROM genes",
57
+ ],
58
+ )
59
+ def test_free_text_sample_ids_and_numbers_are_refused(raw):
60
+ parsed = parse_gene_list(raw)
61
+ assert not parsed.ok
62
+ assert parsed.reason == "invalid_identifier"
63
+ assert "free text, sample ids and numbers are not" in parsed.message
64
+
65
+
66
+ def test_an_empty_list_is_refused():
67
+ assert parse_gene_list(" ").reason == "invalid_identifier"
68
+ assert parse_gene_list([]).reason == "invalid_identifier"
69
+
70
+
71
+ def test_more_than_the_cap_is_refused_with_the_cap():
72
+ raw = ",".join(f"GENE{i}" for i in range(MAX_IDENTIFIERS + 1))
73
+ parsed = parse_gene_list(raw)
74
+ assert parsed.reason == "too_many_identifiers"
75
+ assert str(MAX_IDENTIFIERS) in parsed.message
76
+ assert parse_gene_list(",".join(f"GENE{i}" for i in range(MAX_IDENTIFIERS))).ok
77
+
78
+
79
+ def test_a_refusal_never_echoes_a_long_token():
80
+ parsed = parse_gene_list("x" * 200 + "!")
81
+ assert len(parsed.message) < 250
82
+ assert "x" * 30 not in parsed.message
83
+
84
+
85
+ @pytest.mark.parametrize(
86
+ "raw,kind",
87
+ [
88
+ ("EFO_0002618", "disease_id"),
89
+ ("MONDO_0005192", "disease_id"),
90
+ ("pancreatic ductal adenocarcinoma", "disease_name"),
91
+ ("Crohn's disease", "disease_name"),
92
+ ("type 2 diabetes mellitus", "disease_name"),
93
+ ],
94
+ )
95
+ def test_diseases(raw, kind):
96
+ parsed = parse_disease(raw)
97
+ assert parsed.ok and parsed.identifiers[0].kind == kind
98
+
99
+
100
+ @pytest.mark.parametrize(
101
+ "raw", ["", "a" * 81, "patient 12; DOB 1950-01-01?", "1234", "TCGA-2J-AAB1"]
102
+ )
103
+ def test_bad_diseases_are_refused(raw):
104
+ assert parse_disease(raw).reason == "invalid_identifier"
105
+
106
+
107
+ def test_nct_ids():
108
+ assert parse_nct("nct04635059").identifiers[0].value == "NCT04635059"
109
+ assert parse_nct("NCT123").reason == "invalid_identifier"
tests/test_no_llm_keys.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """C2 — the Space makes no model call, so it refuses to start with any model-provider setting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from src.tu_client import LLM_KEY_VARS, StartupRefused, assert_no_llm_keys, build_tooluniverse
8
+
9
+
10
+ def test_a_clean_environment_passes():
11
+ assert_no_llm_keys({})
12
+ assert_no_llm_keys({"HF_TOKEN": "hf_test", "ACCESS_CONTROL": "enforce"})
13
+
14
+
15
+ @pytest.mark.parametrize("var", LLM_KEY_VARS)
16
+ def test_each_model_provider_setting_refuses(var):
17
+ with pytest.raises(StartupRefused, match=var):
18
+ assert_no_llm_keys({var: "set-in-a-test"})
19
+
20
+
21
+ def test_blank_values_do_not_count():
22
+ assert_no_llm_keys({"OPENAI_API_KEY": "", "ANTHROPIC_API_KEY": " "})
23
+
24
+
25
+ @pytest.mark.parametrize("var", ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY"])
26
+ def test_startup_refuses_before_tooluniverse_is_built(monkeypatch, var):
27
+ monkeypatch.setenv(var, "set-in-a-test")
28
+ with pytest.raises(StartupRefused, match=var):
29
+ build_tooluniverse()
30
+
31
+
32
+ def test_the_message_names_the_variable_never_its_value():
33
+ with pytest.raises(StartupRefused) as excinfo:
34
+ assert_no_llm_keys({"OPENROUTER_API_KEY": "sk-should-not-appear"})
35
+ assert "sk-should-not-appear" not in str(excinfo.value)
36
+
37
+
38
+ # Client libraries through which a model could be called. The memo found that, as installed,
39
+ # ToolUniverse's LLM-backed tools cannot load because none of these is present; the lock keeps
40
+ # it that way, so adding one is a reviewed change to this test rather than a silent transitive.
41
+ LLM_CLIENT_LIBRARIES = {
42
+ "openai",
43
+ "anthropic",
44
+ "google-genai",
45
+ "google-generativeai",
46
+ "smolagents",
47
+ "litellm",
48
+ "langchain",
49
+ "langchain-core",
50
+ "vllm",
51
+ }
52
+
53
+
54
+ def test_the_lock_installs_no_llm_client_library():
55
+ import re
56
+ from pathlib import Path
57
+
58
+ lock = (Path(__file__).resolve().parent.parent / "requirements.txt").read_text()
59
+ pinned = {m.group(1).lower() for m in re.finditer(r"^([A-Za-z0-9_.-]+)==", lock, re.MULTILINE)}
60
+ assert pinned, "requirements.txt holds no pins"
61
+ assert not pinned & LLM_CLIENT_LIBRARIES
tests/test_output_schemas.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """C4 (core) — a response that drifts from its pinned schema builds no rows (ADR-0001 §5).
2
+
3
+ The drift case is the one Phase 0 actually saw: Open Targets replaced ``knownDrugs`` (rows of
4
+ ``drug{name maximumClinicalTrialPhase} phase``) with ``drugAndClinicalCandidates`` while the test
5
+ ran, and a hand-written client broke. Here the old shape and a future rename both come back as
6
+ ``upstream_schema_drift``, never as partial rows.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import copy
12
+
13
+ import pytest
14
+
15
+ from src import config, schemas
16
+ from src.tu_client import SourceError, ToolClient
17
+ from tests.conftest import FakeRunner
18
+
19
+ OT_DRUGS = "OpenTargets_get_associated_drugs_by_target_ensemblID"
20
+ ARGS = {"ensemblId": "ENSG00000120889", "size": 100}
21
+
22
+ # The pre-2026 Open Targets shape that the Phase 0 direct client was written against.
23
+ KNOWN_DRUGS_SHAPE = {
24
+ "status": "success",
25
+ "data": {
26
+ "target": {
27
+ "id": "ENSG00000120889",
28
+ "approvedSymbol": "TNFRSF10B",
29
+ "knownDrugs": {
30
+ "count": 1,
31
+ "rows": [
32
+ {"drug": {"name": "CONATUMUMAB", "maximumClinicalTrialPhase": 2}, "phase": 2}
33
+ ],
34
+ },
35
+ }
36
+ },
37
+ }
38
+
39
+
40
+ def _client(response, memory_cache, clock):
41
+ return ToolClient(
42
+ runner=FakeRunner({OT_DRUGS: response}),
43
+ cache=memory_cache,
44
+ clock=clock,
45
+ tooluniverse_version="1.5.6",
46
+ )
47
+
48
+
49
+ def test_the_recorded_phase0_response_validates(tnfrsf10b_response):
50
+ schemas.validate(OT_DRUGS, tnfrsf10b_response["data"])
51
+
52
+
53
+ def test_an_unknown_target_is_valid_not_drift():
54
+ schemas.validate(OT_DRUGS, {"target": None})
55
+
56
+
57
+ def test_the_old_known_drugs_shape_is_drift(memory_cache, clock):
58
+ with pytest.raises(SourceError) as excinfo:
59
+ _client(KNOWN_DRUGS_SHAPE, memory_cache, clock).call(OT_DRUGS, ARGS)
60
+ assert excinfo.value.reason == "upstream_schema_drift"
61
+ assert excinfo.value.source == "opentargets"
62
+ assert "drugAndClinicalCandidates" in excinfo.value.detail
63
+
64
+
65
+ def test_a_future_rename_is_drift(memory_cache, clock, tnfrsf10b_response):
66
+ mutated = copy.deepcopy(tnfrsf10b_response)
67
+ target = mutated["data"]["target"]
68
+ target["knownDrugsAndCandidates"] = target.pop("drugAndClinicalCandidates")
69
+ with pytest.raises(SourceError) as excinfo:
70
+ _client(mutated, memory_cache, clock).call(OT_DRUGS, ARGS)
71
+ assert excinfo.value.reason == "upstream_schema_drift"
72
+
73
+
74
+ def test_a_renamed_field_inside_a_row_is_drift(memory_cache, clock, tnfrsf10b_response):
75
+ mutated = copy.deepcopy(tnfrsf10b_response)
76
+ row = mutated["data"]["target"]["drugAndClinicalCandidates"]["rows"][2]
77
+ row["maximumStage"] = row.pop("maxClinicalStage")
78
+ with pytest.raises(SourceError) as excinfo:
79
+ _client(mutated, memory_cache, clock).call(OT_DRUGS, ARGS)
80
+ assert excinfo.value.reason == "upstream_schema_drift"
81
+ assert "rows/2" in excinfo.value.detail
82
+
83
+
84
+ def test_an_added_field_upstream_is_not_drift(memory_cache, clock, tnfrsf10b_response):
85
+ mutated = copy.deepcopy(tnfrsf10b_response)
86
+ mutated["data"]["target"]["newUpstreamField"] = {"anything": True}
87
+ result = _client(mutated, memory_cache, clock).call(OT_DRUGS, ARGS)
88
+ assert result.cache == "miss"
89
+
90
+
91
+ def test_a_tool_with_no_pinned_schema_builds_no_rows(memory_cache, clock):
92
+ client = ToolClient(
93
+ runner=FakeRunner({"UniProt_search": {"status": "success", "data": {"results": []}}}),
94
+ cache=memory_cache,
95
+ clock=clock,
96
+ tooluniverse_version="1.5.6",
97
+ )
98
+ with pytest.raises(SourceError) as excinfo:
99
+ client.call("UniProt_search", {"query": "gene_exact:KRAS"})
100
+ assert excinfo.value.reason == "upstream_schema_drift"
101
+ assert "no pinned schema" in excinfo.value.detail
102
+
103
+
104
+ def test_every_pinned_schema_belongs_to_an_endpoint_tool():
105
+ assert schemas.pinned_tools() <= config.endpoint_tool_names()
106
+
107
+
108
+ @pytest.mark.xfail(
109
+ strict=True,
110
+ reason="s2 records the Phase 0 fixtures and pins the remaining 11 schemas; when it does, "
111
+ "this XPASSes and strict mode fails the suite until the marker is removed",
112
+ )
113
+ def test_every_endpoint_tool_has_a_pinned_schema():
114
+ assert schemas.pinned_tools() == config.endpoint_tool_names()
tests/test_provenance.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """C3 (core) — provenance on every row, and the annotation payload shape (ADR-0001 §5).
2
+
3
+ s1 runs these on the Phase 0 recorded Open Targets response through a stub row builder; s2 reruns
4
+ the same assertions over every endpoint and every recorded fixture.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import pytest
10
+
11
+ from src import payloads
12
+ from src.provenance import PROVENANCE_FIELDS, missing_fields, stamp
13
+ from src.tu_client import SourceResult, ToolClient
14
+ from tests.conftest import FakeRunner
15
+
16
+ OT_DRUGS = "OpenTargets_get_associated_drugs_by_target_ensemblID"
17
+ ARGS = {"ensemblId": "ENSG00000120889", "size": 100}
18
+
19
+
20
+ def _rows_from(result: SourceResult) -> list[dict]:
21
+ """A minimal stand-in for s2's drugs_for_targets row builder."""
22
+ target = result.data["target"]
23
+ return [
24
+ stamp(
25
+ {
26
+ "target": target["approvedSymbol"],
27
+ "drug": r["drug"]["name"],
28
+ "max_clinical_stage": r["maxClinicalStage"],
29
+ },
30
+ source=result.source,
31
+ source_tool=result.tool,
32
+ tooluniverse_version=result.tooluniverse_version,
33
+ query=result.args,
34
+ source_ref=r["drug"]["id"],
35
+ source_url=f"https://platform.opentargets.org/drug/{r['drug']['id']}",
36
+ retrieved_at=result.retrieved_at,
37
+ cache=result.cache,
38
+ )
39
+ for r in target["drugAndClinicalCandidates"]["rows"]
40
+ ]
41
+
42
+
43
+ @pytest.fixture()
44
+ def result(memory_cache, clock, tnfrsf10b_response):
45
+ client = ToolClient(
46
+ runner=FakeRunner({OT_DRUGS: tnfrsf10b_response}),
47
+ cache=memory_cache,
48
+ clock=clock,
49
+ tooluniverse_version="1.5.6",
50
+ )
51
+ return client.call(OT_DRUGS, ARGS)
52
+
53
+
54
+ def test_there_are_exactly_ten_provenance_fields():
55
+ assert len(PROVENANCE_FIELDS) == 10
56
+
57
+
58
+ def test_every_row_carries_all_ten_fields(result):
59
+ rows = _rows_from(result)
60
+ assert len(rows) == 7 # the seven DR5 agents Phase 0 found
61
+ for row in rows:
62
+ assert missing_fields(row) == []
63
+ prov = row["provenance"]
64
+ assert prov["source"] == "opentargets"
65
+ assert prov["source_tool"] == OT_DRUGS
66
+ assert prov["tooluniverse_version"] == "1.5.6"
67
+ assert prov["query"] == ARGS
68
+ assert prov["source_ref"].startswith("CHEMBL")
69
+ assert prov["licence"] == "CC0 1.0"
70
+ assert prov["retrieved_at"].endswith("Z")
71
+
72
+
73
+ def test_a_cache_hit_keeps_the_original_retrieved_at(memory_cache, clock, tnfrsf10b_response):
74
+ client = ToolClient(
75
+ runner=FakeRunner({OT_DRUGS: tnfrsf10b_response}),
76
+ cache=memory_cache,
77
+ clock=clock,
78
+ tooluniverse_version="1.5.6",
79
+ )
80
+ miss = _rows_from(client.call(OT_DRUGS, ARGS))
81
+ clock.advance(86_400)
82
+ hit = _rows_from(client.call(OT_DRUGS, ARGS))
83
+ assert [r["provenance"]["cache"] for r in miss + hit] == ["miss"] * 7 + ["hit"] * 7
84
+ assert {r["provenance"]["retrieved_at"] for r in hit} == {miss[0]["provenance"]["retrieved_at"]}
85
+
86
+
87
+ def test_an_ok_payload_is_annotation_with_no_sample_size(result):
88
+ payload = payloads.ok(
89
+ "drugs_for_targets",
90
+ query={"genes": ["TNFRSF10B"]},
91
+ rows=_rows_from(result),
92
+ sources_used=["opentargets"],
93
+ narrative="According to Open Targets (queried 2025-10-09), 7 drugs target TNFRSF10B.",
94
+ )
95
+ assert payload["status"] == "ok"
96
+ assert payload["kind"] == "annotation"
97
+ assert "n_for_inference" not in payload
98
+ assert "hierarchy" not in payload
99
+ assert payload["run_id"]
100
+ assert [a["source"] for a in payload["attribution"]] == ["opentargets"]
101
+ assert any("MRTX1133" in n["note"] for n in payload["coverage_notes"])
102
+ assert "not analysis" in payload["disclaimer"]
103
+ payloads.to_json(payload)
104
+
105
+
106
+ def test_an_unstamped_row_cannot_reach_a_payload(result):
107
+ rows = _rows_from(result)
108
+ del rows[3]["provenance"]["licence"]
109
+ with pytest.raises(ValueError, match="row 3 is missing provenance fields \\['licence'\\]"):
110
+ payloads.ok(
111
+ "drugs_for_targets",
112
+ query={},
113
+ rows=rows,
114
+ sources_used=["opentargets"],
115
+ narrative="x",
116
+ )
117
+
118
+
119
+ @pytest.mark.parametrize("key", ["n_for_inference", "hierarchy"])
120
+ def test_an_annotation_payload_refuses_a_sample_size(result, key):
121
+ with pytest.raises(ValueError, match="never carry"):
122
+ payloads.ok(
123
+ "drugs_for_targets",
124
+ query={},
125
+ rows=_rows_from(result),
126
+ sources_used=["opentargets"],
127
+ narrative="x",
128
+ **{key: 7},
129
+ )
130
+
131
+
132
+ def test_non_ok_payloads():
133
+ refused = payloads.refused("annotate_genes", "invalid_identifier", "Not a gene identifier: x")
134
+ assert refused["status"] == "refused" and refused["run_id"] and refused["reason"]
135
+ err = payloads.error(
136
+ "annotate_genes",
137
+ "all_sources_failed",
138
+ "UniProt did not answer",
139
+ source_errors=[payloads.source_error("uniprot", "source_unavailable", "timeout")],
140
+ )
141
+ assert err["status"] == "error" and err["source_errors"][0]["reason"] == "source_unavailable"
142
+ denied = payloads.denied("sign in")
143
+ assert denied["status"] == "denied" and "run_id" not in denied
144
+ with pytest.raises(ValueError):
145
+ payloads.source_error("uniprot", "guessed", "no")
tests/test_ratelimit.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """C5 — per-source rate limits and timeouts become structured errors, never long waits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+
8
+ import pytest
9
+
10
+ from src import config
11
+ from src.ratelimit import RateLimiters, TokenBucket
12
+ from src.tu_client import SourceError, ToolClient
13
+ from tests.conftest import FakeClock, FakeRunner
14
+
15
+ OT_DRUGS = "OpenTargets_get_associated_drugs_by_target_ensemblID"
16
+ UNIPROT_SEARCH = "UniProt_search"
17
+
18
+
19
+ def test_every_source_is_limited_to_at_most_three_per_second():
20
+ for sid, cfg in config.sources().items():
21
+ assert 0 < cfg["rate_per_second"] <= 3, sid
22
+ assert cfg["burst"] >= 1, sid
23
+
24
+
25
+ def test_a_55_call_burst_never_exceeds_the_configured_rate():
26
+ """The pORG list is 55 genes; annotating it is a burst of one call per gene."""
27
+ clock = FakeClock(start=0.0)
28
+ cfg = config.source("uniprot")
29
+ bucket = TokenBucket(cfg["rate_per_second"], cfg["burst"], clock=clock, sleep=clock.sleep)
30
+ times = []
31
+ for _ in range(55):
32
+ assert bucket.acquire(max_wait=60)
33
+ times.append(clock.now)
34
+ rate, burst = cfg["rate_per_second"], cfg["burst"]
35
+ for start in times:
36
+ in_window = sum(1 for t in times if start <= t < start + 1.0)
37
+ assert in_window <= rate + burst
38
+ # Sustained: 55 calls at 3/s with a burst of 3 cannot finish before (55 - 3) / 3 s.
39
+ assert times[-1] >= (55 - burst) / rate - 1e-9
40
+
41
+
42
+ def test_waiting_longer_than_the_budget_is_refused_not_slept():
43
+ clock = FakeClock(start=0.0)
44
+ bucket = TokenBucket(1, 1, clock=clock, sleep=clock.sleep)
45
+ assert bucket.acquire(max_wait=0)
46
+ assert bucket.acquire(max_wait=0.5) is False
47
+ assert clock.sleeps == []
48
+
49
+
50
+ def test_a_limited_call_becomes_a_rate_limited_source_error(
51
+ memory_cache, clock, tnfrsf10b_response
52
+ ):
53
+ limiters = RateLimiters(clock=clock, sleep=clock.sleep)
54
+ client = ToolClient(
55
+ runner=FakeRunner({OT_DRUGS: tnfrsf10b_response}),
56
+ cache=memory_cache,
57
+ limiters=limiters,
58
+ clock=clock,
59
+ tooluniverse_version="1.5.6",
60
+ )
61
+ burst = config.source("opentargets")["burst"]
62
+ for i in range(burst):
63
+ client.call(OT_DRUGS, {"ensemblId": f"ENSG0000012088{i}", "size": 100}, max_wait=0)
64
+ with pytest.raises(SourceError) as excinfo:
65
+ client.call(OT_DRUGS, {"ensemblId": "ENSG00000120889", "size": 50}, max_wait=0)
66
+ assert excinfo.value.reason == "rate_limited"
67
+ assert excinfo.value.source == "opentargets"
68
+
69
+
70
+ def test_concurrent_callers_share_one_bucket():
71
+ clock = FakeClock(start=0.0)
72
+ lock = threading.Lock()
73
+
74
+ def sleep(seconds):
75
+ with lock:
76
+ clock.sleeps.append(seconds)
77
+
78
+ bucket = TokenBucket(3, 3, clock=clock, sleep=sleep)
79
+ granted = []
80
+
81
+ def worker():
82
+ granted.append(bucket.acquire(max_wait=100))
83
+
84
+ threads = [threading.Thread(target=worker) for _ in range(9)]
85
+ for t in threads:
86
+ t.start()
87
+ for t in threads:
88
+ t.join()
89
+ assert all(granted)
90
+ # 3 from the burst, then 6 reservations each waiting a strictly later slot.
91
+ assert sorted(clock.sleeps) == pytest.approx([i / 3 for i in range(1, 7)])
92
+
93
+
94
+ def test_a_source_timeout_is_source_unavailable_within_the_limit(monkeypatch, memory_cache, clock):
95
+ monkeypatch.setitem(config.source("uniprot"), "timeout_seconds", 0.2)
96
+
97
+ def hangs(args):
98
+ time.sleep(2)
99
+
100
+ client = ToolClient(
101
+ runner=FakeRunner({UNIPROT_SEARCH: hangs}),
102
+ cache=memory_cache,
103
+ clock=clock,
104
+ tooluniverse_version="1.5.6",
105
+ )
106
+ started = time.monotonic()
107
+ with pytest.raises(SourceError) as excinfo:
108
+ client.call(UNIPROT_SEARCH, {"query": "gene_exact:KRAS"})
109
+ assert time.monotonic() - started < 1.5
110
+ assert excinfo.value.reason == "source_unavailable"
111
+ assert "did not answer within" in excinfo.value.detail
112
+
113
+
114
+ def test_an_exception_inside_a_source_is_source_unavailable(memory_cache, clock):
115
+ client = ToolClient(
116
+ runner=FakeRunner({UNIPROT_SEARCH: ConnectionError("reset by peer")}),
117
+ cache=memory_cache,
118
+ clock=clock,
119
+ tooluniverse_version="1.5.6",
120
+ )
121
+ with pytest.raises(SourceError) as excinfo:
122
+ client.call(UNIPROT_SEARCH, {"query": "gene_exact:KRAS"})
123
+ assert excinfo.value.reason == "source_unavailable"
124
+ assert "ConnectionError" in excinfo.value.detail
125
+
126
+
127
+ def test_a_tooluniverse_error_envelope_is_source_unavailable(memory_cache, clock):
128
+ envelope = {"status": "error", "error": "Read timed out. (read timeout=30)"}
129
+ client = ToolClient(
130
+ runner=FakeRunner({UNIPROT_SEARCH: envelope}),
131
+ cache=memory_cache,
132
+ clock=clock,
133
+ tooluniverse_version="1.5.6",
134
+ )
135
+ with pytest.raises(SourceError) as excinfo:
136
+ client.call(UNIPROT_SEARCH, {"query": "gene_exact:KRAS"})
137
+ assert excinfo.value.reason == "source_unavailable"
138
+
139
+
140
+ def test_a_tool_off_the_endpoint_set_is_a_programming_error(memory_cache, clock):
141
+ client = ToolClient(runner=FakeRunner(), cache=memory_cache, clock=clock)
142
+ with pytest.raises(ValueError, match="not an endpoint tool"):
143
+ client.call("PubChem_get_CID_by_compound_name", {"name": "gemcitabine"})
tests/test_sources_hosts.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """C8 — every host a loaded tool can reach is declared in config/sources.yaml (ADR-0001 §6).
2
+
3
+ The hosts are read offline from the installed ToolUniverse: URLs in each tool's config and in the
4
+ source of its tool class. This is what makes the security-review addendum's host table honest:
5
+ it is generated from the same file this test checks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+
12
+ from src import config
13
+ from src.tu_client import build_tooluniverse, tool_hosts
14
+
15
+
16
+ @pytest.fixture(scope="module")
17
+ def loaded_tu():
18
+ return build_tooluniverse()
19
+
20
+
21
+ def test_every_loaded_tool_has_at_least_one_host(loaded_tu):
22
+ for name in loaded_tu.all_tool_dict:
23
+ assert tool_hosts(loaded_tu, name), name
24
+
25
+
26
+ def test_every_loaded_tools_hosts_are_declared_for_its_source(loaded_tu):
27
+ for name in loaded_tu.all_tool_dict:
28
+ declared = set(config.source(config.source_of(name))["hosts"])
29
+ undeclared = tool_hosts(loaded_tu, name) - declared
30
+ assert not undeclared, f"{name} reaches undeclared hosts {sorted(undeclared)}"
31
+
32
+
33
+ def test_no_stale_host_for_a_source_in_use(loaded_tu):
34
+ """A used source declares no host its loaded tools never reach."""
35
+ reached: dict[str, set[str]] = {}
36
+ for name in loaded_tu.all_tool_dict:
37
+ reached.setdefault(config.source_of(name), set()).update(tool_hosts(loaded_tu, name))
38
+ for sid, hosts in reached.items():
39
+ assert set(config.source(sid)["hosts"]) == hosts, sid
40
+
41
+
42
+ def test_the_six_phase1_sources_are_the_ones_in_use():
43
+ used = {config.source_of(t) for t in config.endpoint_tool_names()}
44
+ assert used == {"opentargets", "chembl", "uniprot", "clinicaltrials", "reactome", "hgnc"}
45
+
46
+
47
+ @pytest.mark.parametrize("sid", sorted(config.sources()))
48
+ def test_every_source_has_a_licence_url_and_check_note(sid):
49
+ src = config.source(sid)
50
+ assert src["licence"].strip()
51
+ assert src["licence_url"].startswith("https://")
52
+ assert src["licence_checked"].startswith("2026-")
53
+ assert src["citation"].strip()
54
+
55
+
56
+ def test_licences_as_checked_on_2026_10_20():
57
+ expected = {
58
+ "opentargets": "CC0 1.0",
59
+ "chembl": "CC BY-SA 3.0",
60
+ "uniprot": "CC BY 4.0",
61
+ "reactome": "CC0 1.0",
62
+ }
63
+ for sid, licence in expected.items():
64
+ assert config.source(sid)["licence"] == licence, sid
65
+
66
+
67
+ def test_a_source_without_a_confirmed_licence_has_no_endpoint():
68
+ """DGIdb's data licence could not be confirmed, so no loaded tool may come from it."""
69
+ unconfirmed = {
70
+ sid for sid, src in config.sources().items() if src["licence"].startswith("Unconfirmed")
71
+ }
72
+ assert unconfirmed == {"dgidb"}
73
+ assert not {config.source_of(t) for t in config.endpoint_tool_names()} & unconfirmed