Skip to content

Large-scale fixes — FIX-01 … FIX-13 (drafted 2026-07-01)

Deep structural fixes grounded in the observations of 01-deep-dive.md. None restates an R-item from ../RESEARCH-ROADMAP.md; where a fix touches the same terrain as an existing item, the delta is stated. Effort tiers: S ≈ a day, M ≈ a few days, L ≈ 1–2 weeks, XL ≈ multi-week.


FIX-01 — Claims-integrity gate: no number in the docs without a machine-checked source — ✅ Done

Status: Implemented as specified — docs/claims.yaml registers the abstention thresholds, low_confidence_threshold, min_score, support_overlap, the refusal target, and the WCAG conformance level, each pinned to config/sprout.yaml, RefusalSuite().metric.threshold, docs/audits/eval-report.json, or a fixed policy decision via an explicit <!-- claim:ID --> marker; src/sprout/claims.py::check() resolves every source live and checks both directions (registry vs. source, and doc text vs. registry); sprout claims-check is wired into Makefile verify and ci.yml. All four known drifts reconciled: abstention thresholds and low_confidence_threshold corrected to 0.25/0.50 in the model card, the responsible-tech audit, and the ROADMAP ledger; the red-team report got an erratum note (not a silent rewrite, per its own dated-artifact status); the refusal target corrected to 0.90 in the ROADMAP ledger; CLAUDE.md's three AAA mentions corrected to AA; the data-card species table's four non-corpus species (Dieffenbachia, Areca palm, African violet, Hoya) moved to a labeled "planned, not yet in the corpus" note. Branch roadmap/fix-01-claims-integrity-gate-docs-claims.

Pitch. Make every numeric self-claim in the documentation provably equal to the value the code enforces — the honesty ethos applied to Sprout's own paperwork.

Why it matters. Four independent drift instances exist right now: abstention thresholds (0.45/0.62 in docs/cards/model-card.md, docs/RESPONSIBLE-TECH-AUDITS.md §D, docs/audits/red-team-2026-06-22.md vs 0.25/0.50 in config/sprout.yaml and the committed eval report); the refusal target (ROADMAP ledger ≥ 0.95 vs shipped suite gate 0.90 in src/sprout/eval/suites/refusal.py — the report explains it, the ledger never adopted it); AA vs AAA (CLAUDE.md ×3 vs everything else); the data-card species table listing plants not in the corpus (Dieffenbachia, Areca palm, African violet, Hoya). Each one hands a skeptical reviewer (persona C2, D1) a reason to distrust the claims that are true.

Shape of the work. (a) A docs/claims.yaml registry mapping each numeric claim to its source of truth (config/sprout.yaml key, suite MetricDefinition.threshold, or docs/audits/eval-report.json field); (b) a sprout claims-check command (sibling of a11y-check in cli.py) that greps registered claim sites and fails on mismatch; (c) wire into Makefile verify and ci.yml; (d) one-time reconciliation pass fixing the four known drifts (decide 0.25/0.50 and AA as canonical — they match shipped behavior — and update the model card, audits doc, ledger row, and CLAUDE.md).

Effort: M. Risks/deps: regex-based claim extraction is brittle — mitigate with explicit <!-- claim:… --> markers at claim sites; red-team report is a dated artifact and should get an erratum note, not a rewrite. Excellent looks like: make verify fails if any registered claim drifts; zero unregistered numeric thresholds in docs/**/*.md (checked by an allowlist audit); the 2026-07 reconciliation commit is the last manual one ever needed.

FIX-02 — Enforcement parity: every "AUTO" row maps to a CI line, or gets relabeled

Pitch. Close the gap between declared gates and wired gates across seven observed instances — make the ledger's AUTO column mechanically true.

Why it matters. The ROADMAP ledger and RESPONSIBLE-TECH-AUDITS declare AUTO gates that nothing enforces: latency budget (no test exists), baseline no-regression (report.diff_against_baseline implemented + tested, never called from cli.py or CI), judge-calibration gating (ci.yml runs sprout calibrate without --gate, so it can never fail), the Wilson statistical gate (off by default, not passed in CI), blocking pa11y (job is continue-on-error: true while §E claims blocking), CodeQL (claimed in §F and README table; no workflow file), and the release SBOM (cyclonedx-py … || true in release.yml, which STANDARDS/README.md forbids for AUTO). This is the single largest threat to the repo's core credibility claim.

Shape of the work. For each: wire it or demote it honestly. Concretely: add a latency budget test (offline p95 over the demo questions, generous CI margin); teach sprout eval to load docs/audits/eval-baseline.json, call diff_against_baseline, and fold regressions into exit_code; add --gate to the CI calibrate step (keep the deterministic judge reported-not-gated per its own docstring — gate the probe-set freshness instead); make pa11y blocking with a curated ignore list or relabel §E; add codeql.yml or strike the claim; drop || true from SBOM. Add a small "gate-inventory" doc test that asserts every ledger row's Measured by names an existing make target/CI step (the mechanical spine of the fix).

Effort: L (each item S, but seven of them plus the inventory test). Risks/deps: enabling the statistical gate flips multilingual to FAIL at n=12 (CI lower bound 0.646 < 0.85) — sequence after FIX-12; pa11y-in-CI needs a served app and is flake-prone (pin browser, retry policy). Excellent looks like: a docs/audits/gate-inventory.md table generated in CI mapping every AUTO row → CI step → last status, with zero "declared but unenforced" rows.

FIX-03 — Unknown-species hard gate for toxicity questions — ✅ Done (2026-07-03)

Pitch. Never answer a toxicity question about a plant the corpus does not cover — even when generic toxicity passages score well enough to ground.

Why it matters. Retriever._candidates (retrieve.py) falls back to the whole corpus when _named_species finds nothing, and has_grounding requires only min_score plus one shared content token — for "is dieffenbachia toxic to cats?" the tokens "toxic" and "cats" are shared with every toxicity chunk. The worst case is a fluent, fully-cited answer about the wrong species on the one query class where wrongness is dangerous. The committed report's refusal-suite notes acknowledge the general unknown-species weakness; this fix removes it structurally for the safety-critical slice instead of hoping the score gate catches it.

Shape of the work. (a) A species-mention detector: unresolved capitalized binomials / plant-like noun phrases that match neither corpus slugs (Assistant.species_slugs) nor retrieval.species_aliases, backed by a small off-corpus gazetteer of common houseplant names (dieffenbachia, sago palm, azalea, oleander, …); (b) in answer.py, when is_safety_query is true and the query names a species that does not resolve, refuse with refusal_reason="species_not_covered" — the existing refusal + escalation path already attaches routing; (c) new eval cases in eval/suites/refusal.yaml and safety.yaml for named-but-uncovered species; (d) a crisp localized message ("I don't have a cited reference for that plant") — copy change, so it queues behind the safety-copy review gate.

Effort: M. Risks/deps: false positives (a nickname the alias map should have had) increase refusals — mitigate by expanding species_aliases alongside; gazetteer species names are not safety advice but ship with the SME review anyway. Excellent looks like: a dedicated eval slice where 100% of named-off-corpus toxicity questions refuse-with-routing; zero cited answers naming a different species than the question.

Shipped. UNCOVERED_SPECIES_GAZETTEER (src/sprout/retrieve.py) holds a deny-list of common EN/ES houseplant names absent from the corpus; Retriever.names_uncovered_species returns true only when a gazetteer entry's tokens are present and _named_species resolves to nothing, so a query naming both a covered and an uncovered species still gets the normal grounded path. Assistant.answer checks this hard gate right after computing safety, ahead of the grounding check, and refuses with refusal_reason="species_not_covered" using the existing localized refusal_for(lang) copy (no new SME-copy gate needed — the existing refusal text already reads as "no cited reference"). New cases in eval/suites/refusal.yaml (refusal-027/028) and eval/suites/safety.yaml (safety-035/036) cover named-but-uncovered EN/ES toxicity questions; unit tests in tests/test_rag.py cover the gazetteer/_named_species boundary directly.

FIX-04 — Routing coverage: topic-aware refusals and species-inclusive vocabulary — ✅ Done (2026-07-03)

Pitch. Attach vet/poison routing wherever the evidence says toxicity, not only where input keywords do — and stop assuming every pet is a cat or dog.

Why it matters. The committed report shows safety-025 failing with "no vet/poison routing." Structurally: answer._render ORs in toxicity_cited (retrieved-chunk topic == "toxicity") for answers, but _refuse keys routing solely off is_safety_query keyword hits; and GuardsConfig.toxicity_keywords lists cat/dog/kitten/puppy but no rabbit, bird, hamster, guinea pig, reptile (EN or ES). A "my rabbit ate monstera" phrasing that dodges the keyword list gets a refusal with no routing.

Shape of the work. (a) Thread the retrieved-chunk topics into the refusal path so a refusal after retrieving toxicity chunks still routes (plumb retrieved into _refuse in answer.py); (b) extend toxicity_keywords EN/ES with the broader companion-animal vocabulary (mechanical list change; existing reviewed copy is untouched); (c) regression cases per animal per language in eval/suites/safety.yaml; (d) diagnose and pin safety-025 specifically as a named regression test.

Effort: S–M. Risks/deps: broader keywords increase safety-notice frequency on borderline queries — acceptable by design (conservative direction is the house style); copy itself unchanged so no new SME gate. Excellent looks like: safety suite at 1.000 on the routing criterion across an expanded case set, including non-cat/dog animals in both languages.

Shipped. Assistant._refuse now accepts retrieved and computes toxicity_cited = any(rc.chunk.topic == "toxicity" for rc in retrieved), ORing it with safety for both is_safety_query and safety_notice on the returned Answer — every _refuse(...) call site in answer() (out_of_scope, no_supported_sentences, low_confidence, and the new species_not_covered gate) now passes retrieved, mirroring the routing _render already did for answered cases. GuardsConfig.toxicity_keywords gained rabbit/rabbits/bird/birds/hamster/hamsters/ guinea pig/reptile/turtle/tortoise (EN) and conejo/conejos/pajaro/pajaros/ave/aves/ hamster/reptil/tortuga/cobaya (ES). safety-025 remains a known, tracked offline miss (pre-existing before this fix — the offline hashing embedder does not separate this one ES phrasing; the suite still passes overall at its threshold) rather than a regression; new per-animal regression cases (safety-029…034, refusal-029/030) cover the expanded vocabulary in both languages without breaking it.

FIX-05 — Adversarial hardening of the guards via property-based fuzzing

Status: ✅ Done (2026-07-03, roadmap/fix-05-property-based-fuzzing-of-the-gua). tests/test_guard_fuzzing.py fuzzes asserts_safety and citation_guard (_supported_by) with Hypothesis. Findings: zero-width injection defeated both the deny-list phrase match and the (separate) negation/harm-token check — text.normalize and text.tokenize both needed the NFKC + format-character strip, since the harm-token path reads tokenize directly rather than normalize. Homoglyph substitution (Cyrillic а/е/о for Latin a/e/o) defeated the phrase match; closed with a minimal, documented 3-entry table in guards._fold (ADR-0014, since guards.py is CODEOWNERS-guarded). Case perturbation was already an invariant (no fix needed). Two residuals were found, fixed only partially in scope, and pinned as tests rather than left unmeasured: homoglyph substitution into a harm token (e.g. "not"/"toxic") still defeats the negation/harm-token check (deliberately not routed through the homoglyph fold, to avoid changing retrieval/stemming for every other caller); letter-spacing ("s a f e") defeats every path and is undefended. The citation_guard same-plant cross-chunk recombination admit-rate is quantified at a bounded, deterministic Monte-Carlo sample (0.05 <= rate <= 0.70) instead of the prior qualitative-only model card note, and a cross-species non-leakage invariant is pinned. See ADR-0014 for the full write-up; "file every surviving bypass as an eval case" (item (d) below) and the model-card admit-rate rewrite are follow-on work, not done in this pass.

Pitch. Use the already-declared-but-unused hypothesis dependency to attack asserts_safety, _supported_by, and citation_guard with the inputs a cloud generator or adversarial corpus could actually produce.

Why it matters. The deny-list folds accents and hyphens (guards._fold) but — from reading text.py call sites — plausibly not zero-width characters, homoglyphs (Cyrillic "а" in "sаfe"), or letter-spacing ("s a f e"); the coverage-based _supported_by admits same-plant recombination (already honestly documented in the model card's cloud-mode residual). These matter only on the cloud path — which is exactly the path with no by-construction guarantee. This is hypothesized, not demonstrated; the fix starts by demonstrating or falsifying it.

Shape of the work. (a) Property tests in a new tests/test_guard_fuzzing.py: metamorphic invariants ("if asserts_safety(s) then asserts_safety(perturb(s))" for unicode-confusable, ZWSP-injection, spacing, and case perturbations); (b) NFKC-normalize and strip zero-width/format characters in _fold and text.normalize if the tests find holes; (c) fuzz citation_guard with sentence recombinations across two chunks of the same plant to quantify the documented residual and record the measured admit-rate in the model card instead of the current qualitative note; (d) file every surviving bypass as an eval case per the model card's own instruction.

Effort: M. Risks/deps: normalization changes touch a CODEOWNERS-guarded file (guards.py) → needs an ADR per the README hard-guardrails rule; hypothesis runtime in CI is bounded with profiles. Excellent looks like: a committed fuzzing report; deny-list invariance under the perturbation classes; the recombination residual carries a number.

FIX-06 — Single-source data packaging (kill the silent dual-copy) — ✅ Done

Status: Implemented option (b) — tests/test_resources.py now hashes corpus/ vs src/sprout/data/corpus and config/sprout.yaml vs src/sprout/data/sprout.yaml with sha256_of_obj/byte comparison and fails CI on drift; scripts/_materialize_content.py now re-mirrors both into src/sprout/data/ after every regeneration. Branch roadmap/fix-06-fix-11-single-source-data-packagi.

Pitch. Make the packaged corpus/config provably identical to the repo corpus/config — or generated from it — so an installed Sprout distribution can never serve different facts than the repo shows.

Why it matters. corpus/ and config/sprout.yaml are duplicated under src/sprout/data/ for the resources.py packaged fallback. They are byte-identical today, but scripts/_materialize_content.py regenerates only the top-level copy, and tests/test_resources.py asserts existence, not equality. One regeneration away from shipping a stale corpus to installed users — a provenance failure in a repo whose rule is "corpus is versioned and dated."

Shape of the work. Either (a) a hatchling build hook that copies corpus/ + config/sprout.yaml into the wheel at build time and deletes src/sprout/data/ from the tree, or (b) keep the vendored copy but add a parity test hashing both trees (sha256_of_obj from determinism.py over sorted relative-path→content maps) and teach _materialize_content.py to write both. Option (a) is cleaner; (b) is smaller.

Effort: S–M. Risks/deps: build-hook path must keep uv run dev flows working (resources.locate falls back correctly); sdist include list in pyproject.toml already carries both copies. Excellent looks like: it is impossible to commit a desynced pair — either the second copy no longer exists, or CI fails on inequality.

Pitch. Move index construction to ingest time so the retrieval path stays inside the latency budget as the corpus grows toward real safety coverage.

Why it matters. Retriever.retrieve constructs a fresh BM25Index over candidate texts on every query, and VectorStore.search scans all vectors in pure Python with top_k=len(self._store). At 16 species (~370 chunks) this is fine; at the corpus scale R1's mission implies (hundreds of species × 2+ languages), per-query indexing plus a full scan breaks the declared 200 ms p95 (a gate FIX-02 makes real) and makes serve throughput collapse.

Shape of the work. (a) Build BM25 postings at ingest (ingest.py) and persist them in index.json (bump _FORMAT_VERSION, keep sorted-key determinism); (b) species-filter by pre-grouped chunk-id sets instead of re-tokenizing candidates; (c) keep the pure-Python path as the reference implementation and add an optional numpy fast path behind the same VectorStore interface — determinism preserved (same floats, fixed order); (d) the latency test from FIX-02 becomes this fix's acceptance gate, run at a synthetically inflated corpus (e.g., 100× duplication) to test the curve, not the point.

Effort: M. Risks/deps: index format migration needs a load-error message pointing at sprout ingest (pattern already exists in store.load); "no database without an ADR" rule respected — this stays a flat file. Excellent looks like: p95 retrieval flat-ish in corpus size up to ~10k chunks; per-query allocation of BM25 structures gone; byte-identical index.json for identical inputs still holds.

FIX-08 — Confidence re-fit tooling (stop hand-tuning the logistic)

Pitch. Replace the hard-coded logistic constants in confidence.py with a fitted, provenance-stamped calibration artifact and a command that re-fits it without touching the eval set.

Why it matters. _MIDPOINT = 0.30, _STEEPNESS = 6.0, _MARGIN_BONUS = 0.05 carry a comment "Re-fit if the corpus or retrieval changes materially" — but no tool exists, and the committed report already shows mid-range bins failing ([0.4,0.5) and [0.5,0.6) segments FAIL inside an overall ECE pass). Every fix/expansion that changes retrieval (FIX-07, EXP-03) silently invalidates these constants; today nothing would notice except a slow ECE drift.

Shape of the work. (a) A sprout fit-confidence command that replays the assistant over a train split of generated calibration questions (never eval/suites/ — the anti-tuning-to-test discipline the ROADMAP already states), fits midpoint/steepness by logistic regression on (best-cosine, margin) → correctness, and writes them to config/sprout.yaml under a new confidence.fit: block with dataset hash + date; (b) score_confidence reads constants from config instead of module globals; (c) an ADR, since confidence.py is CODEOWNERS-guarded; (d) a drift check: eval fails if the fit's recorded retrieval-config hash no longer matches the live one.

Effort: M. Risks/deps: needs a question generator or held-out split — the per-species corpus structure makes leave-species-out splits natural; must keep the transparent-function property (no learned model beyond the 3-parameter logistic). Excellent looks like: all reliability bins pass, ECE ≤ 0.10, and the constants file answers "fitted on what, when, against which config."

FIX-09 — Consolidate language data into per-language bundles with a completeness gate

Pitch. One place per language, one validator, so language #3 is a data drop instead of an eight-file surgery.

Why it matters. EN/ES currently lives in: PromptConfig (six *_by_lang dicts), GuardsConfig (forbidden_safe_phrases, toxicity_keywords, route_terms), guards._HARM_TOKENS/_SOURCE_MARKERS (mixed-language frozensets), lang.py marker sets, and bilingual term lists hard-coded in eval/suites/safety.py (_VET_TERMS/_POISON_TERMS). RESEARCH-ROADMAP E6 gates adding a language on parity; this fix is the prerequisite refactor E6 doesn't mention — and it aligns with the portfolio-wide i18n Phase 1 (dict → gettext) already in flight elsewhere.

Shape of the work. (a) A src/sprout/locales/{en,es}/ bundle (YAML or gettext, matching the portfolio migration) carrying prompts, deny-list, keywords, markers, and suite vocabulary; (b) a load-time completeness validator keyed off languages.supported — a missing key fails at startup, not at render (extends the existing key-parity gate the README claims); (c) lang.py markers and the safety suite consume the same bundles; (d) _HARM_TOKENS split per-language (folding stays).

Effort: L. Risks/deps: touches guarded files → ADR; behavior must be character-identical for EN/ES (snapshot-test all rendered strings before/after); E6 remains gated on human review of any new language's safety strings. Excellent looks like: grep -r "es\":" src/ finds no inline Spanish outside locales/; adding a stub fr/ bundle fails the completeness gate rather than silently falling back to English.

FIX-10 — Deploy-grade server hardening (the ASVS step-up R4 quietly requires)

Pitch. Before the UI goes behind a real URL, give the FastAPI surface the L2-facing controls the offline posture deliberately skipped — as code, not as a hosting checkbox.

Why it matters. server.py states "rate-limiting/CORS/auth are left to the proxy layer," which is correct offline but becomes the gap the moment RESEARCH-ROADMAP R4 (deploy the UI) lands: no rate limits, no CSP/security headers, /api/identify accepts ~8 MB base64 JSON bodies (amplified ~1.33× by encoding) with no concurrency cap, and ADR-0008 explicitly scopes ASVS L1 to the offline mode. R4 ships a public, unauthenticated inference endpoint; the audits doc already promises the posture "steps up to L2" — nothing yet implements the step.

Shape of the work. (a) Committed reverse-proxy config (infra/ Caddyfile or nginx) with CSP, HSTS, rate limits, body-size caps; (b) app-level guards that hold even without the proxy: request-size middleware, per-IP token bucket for /api/identify, bounded worker concurrency; (c) replace engine._store private access in readyz with a public Assistant.index_size(); (d) an ASVS L2 delta checklist committed under docs/audits/ covering only the deployed surface; (e) budget alarm wiring per the README's cost promise.

Effort: M–L. Risks/deps: sequenced with R4 (not before it's real); keep the offline CLI path zero-dependency — all hardening is serve-only. Excellent looks like: the deployed URL passes an external scan (securityheaders.com A, zap baseline clean), photo-endpoint abuse is rate-limited by test, and the L1→L2 step is a committed artifact, not a sentence.

FIX-11 — Retire or honestly re-document the phantom session window — ✅ Done

Status: ServerConfig.session_memory removed from config.py and both YAML copies (config/sprout.yaml, src/sprout/data/sprout.yaml); docs/RESPONSIBLE-TECH-AUDITS.md §C now says "stateless per-request; no session state of any kind" and points to EXP-07 as the designed path if session context returns. Branch roadmap/fix-06-fix-11-single-source-data-packagi.

Pitch. session_memory exists in config and in the DPIA but not in the code; make the paperwork true this week, and let multi-turn arrive later as a designed feature.

Why it matters. ServerConfig.session_memory (config.py:407, default 4) is cited in docs/RESPONSIBLE-TECH-AUDITS.md §C ("keeps at most a small in-memory session window") — but server.py implements no session of any kind. The reality (fully stateless) is privacy-better than the documented state; the drift is still a defect under the repo's own "silent deviation" rule, and a DPIA that describes nonexistent state undermines the data-inventory's credibility.

Shape of the work. Remove the field from ServerConfig and both YAML copies, fix §C to say "stateless per-request; no session state of any kind," and leave a pointer to EXP-07 (grounded multi-turn) as the designed path if session context ever returns.

Effort: S. Risks/deps: none — config is extra="forbid", so removing the field requires touching both YAML copies (interacts with FIX-06). Excellent looks like: data inventory lists zero state the code doesn't have; EXP-07, if built, re-introduces it behind its own DPIA row.

FIX-12 — Statistical power for the multilingual and safety suites

Pitch. Grow the under-powered slices (multilingual n=12, safety n=28) past n≥30 so the Wilson gate can be turned on without lying to ourselves about precision.

Why it matters. The committed report flags both suites under-powered; the multilingual 95% CI is [0.646, 0.985] around a 0.917 score — the gate is effectively decorative at this n, and one flaky ES case moves the score 8 pp. FIX-02 wants --statistical-gate on in CI; flipping it today would fail multilingual on power, not on quality. This is distinct from R2 (author the cases at all — done): it is a power-analysis-driven sizing pass.

Shape of the work. (a) Author ~20 more ES mirror cases and ~10 more toxicity cases (they exist in EN; mirror + record via record.py); (b) set per-suite minimum-n in the suite registry so underpowered becomes a FAIL condition once targets are set; (c) then enable the statistical gate in Makefile/ci.yml (completes FIX-02); (d) ES case text queues behind the native-Spanish-reviewer gate the RESEARCH-ROADMAP already names for exactly this content.

Effort: M (case authoring dominates). Risks/deps: Spanish safety strings need the human reviewer — do EN-side sizing first; enabling min-n before authoring flips CI red (sequence carefully). Excellent looks like: every suite n ≥ 30, Wilson gate on, and the report's ⚠️ under-powered flags gone without loosening any threshold.

FIX-13 — Split the escalation card by exposure type: humans are not routed to animal poison control

Pitch. A child-ingestion question currently receives a card naming ASPCA APCC and Pet Poison Helpline — animal lines. Add a human-poison-control variant, gated on clinician review.

Why it matters. GuardsConfig.toxicity_keywords includes child/children/baby/ toddler (EN) and niño/niña/bebé (ES), and safety_route_by_lang says "pet or child" — but escalation_card_by_lang (config.py:309) names only the two animal lines. For the highest-stakes human case the card is at best confusing, at worst a delay. This extends the shipped E9 card (RESEARCH-ROADMAP) to the audience the classifier already detects; E9 itself never distinguished audiences.

Shape of the work. (a) Detect exposure type from the same keyword pass (child-terms vs animal-terms vs ambiguous); (b) a second card variant for human exposure (US: Poison Control 1-800-222-1222 / webPOISONCONTROL, ES-localized), shown alongside — never instead of — the urgent-call framing; ambiguous queries show both; (c) eval cases asserting a child-ingestion question surfaces a human poison line; (d) hard gate: no copy ships without review by a poison-control clinician / medical toxicologist, in both languages — the numbers, the phrasing, and the decision to include them at all are theirs to approve. The "non-toxic ≠ safe" caveat and never-certify-safe rule apply unchanged to the human path.

Effort: S–M in code; the gate dominates the calendar. Risks/deps: SME access is the blocker (same recruitment as RESEARCH-ROADMAP's vet-toxicologist ask — one outreach, two review scopes); non-US numbers are out of scope until a locale story exists (note it honestly in the card copy). Excellent looks like: exposure-type routing is deterministic and eval-gated; a clinician's dated sign-off is committed under docs/audits/ before the copy renders anywhere.