Skip to content

fix(f5): clinical hand-off review remediation — grounding, contracts, security boundaries - #76

Draft
MoerAI wants to merge 24 commits into
Masterfrom
review/f5-handoff-remediation
Draft

MoerAI wants to merge 24 commits into
Masterfrom
review/f5-handoff-remediation

Conversation

@MoerAI

@MoerAI MoerAI commented Jul 22, 2026

Copy link
Copy Markdown
Member

Summary

Completes the security-critical portion of the F5 clinical hand-off report review remediation. This branch carries 24 commits over origin/Master that resolve the clinical-safety, grounding, and boundary blockers raised across the multi-round Codex + five-lane reviews.

Draft — opened for human + CODEOWNER (Platform + AI) review. The pure-refactor and documentation follow-ups (see Deferred below) are intentionally split into a separate PR to keep this one focused and low-regression-risk on the deterministic clinical report engine.

Review remediation on this branch

Grounding & contracts (prior remediation commits 0c09f7d…c921a35)

  • Official /ai/handoff/generate shared contract adopted losslessly; production A8 narrative structurally gated (422).
  • FR-018 per-field citation coverage enforced across the configured provider tiers; strictly extractive clinical claims (byte-equal user-message quote spans), duplicate message-ID rejection, direct diagnosis/treatment guard.
  • Deterministic assembly via injected generated_at (no wall-clock); type-safe RiskEvent omission; official-boundary risk validation.
  • Sanitized provider failures integrated with circuit health (no PHI in body/log/traceback; __cause__/__context__ are None).

Safety & security boundaries — completed this session (Todos 18–20 + regression)

  • Narrative confusable guard (A6→A8): rejects Unicode-confusable disease leaks — Cyrillic/Greek script when an ASCII candidate exists, interstitial combining marks, fullwidth and Cf format characters. Comparison-only; stored narrative bytes are never mutated.
  • XML 1.0 safety: invalid scalars sanitized one-for-one to U+FFFD across every FHIR Narrative and ReportLab paragraph path; the FHIR Bundle is validated before HTTP return and before persistence behind a generic, PHI-free 500 / zero-artifacts boundary (Markdown left byte-exact).
  • Exhaustive artifact cleanup: every unlink/close attempted, failed unlinks retried once after closes, the primary exception preserved with a count-only note, else a typed content-free error; extracted into a typed helper.
  • FHIR validity fix: Patient.id falls back to vp_id so sparse-persona bundles stay structurally valid — fixes an F1→F5 continuous-test regression the new pre-persistence validation surfaced (TDD RED→GREEN, new unit test).
  • Contained, exclusive (O_EXCL/O_NOFOLLOW), mode-0600 artifact writes; PHI-safe PDF/persistence failures.

Verification — candidate efea8f9

Gate Result
ai-server suite (uv run pytest -q) 2383 passed, 2 skipped, 0 failed
ruff (src tests) clean
mypy (new/changed security modules) clean
basedpyright (changed src, 8 files) 17 → 5 errors (0 added, 12 removed); new modules 0E/0W
Real-surface run-and-observe QA 28/28 — homoglyph refusal · XML-invalid→U+FFFD · malformed-FHIR reject (content-free, no files) · exhaustive cleanup (no PHI leak) · 0600 export

Deferred to a follow-up PR (tracked)

Intentionally not in this PR, to keep it focused and behavior-preserving:

  • Todos 21–24 — characterization tests + splitting the 2,505-LOC f5_report.py into typed <200-LOC modules (markdown / PDF / FHIR / thin facade). Pure structural refactor.
  • Todo 25 — SSOT / contract / prompt / project-status documentation reconciliation (the 6 touched docs remain out of this PR).
  • The 5 remaining pre-existing f5_report.py basedpyright errors (reportMissingTypeArgument / one comparison) are legacy debt cleared by the Todo 22–24 extraction — not introduced here.

Notes for reviewers

MoerAI added 24 commits July 21, 2026 14:41
… md/FHIR/PDF hardening, F5 chain-wiring, KR font portability

Remediates round-1 review blockers on the F5 clinical hand-off pipeline, preserving the original deterministic/ZERO-LLM design:

- handoff_generator: _event_risk returns MAX of parseable severity signals (risk_level + ctrs via CTRS_TO_RISK) so a contradictory pair never resolves downward (issue #21); RiskEvent-or-dict normalization via _event_view.

- schemas/handoff: new RiskEvent model — strict risk_level/ctrs_level validation at the API boundary (rejects null + unicode-confusable CTRS like '①'), extra keys preserved, max_length=100.

- f5: A6->A8 leak guard NFKC+casefold normalized (case/fullwidth dodge closed); generated_at timezone-aware.

- services/f5_report: markdown/HTML injection hardening (_md_inline/_md_cell/_md_block); PDF exporter isolation + newline-dense paragraph cap; FHIR null-primitive omission w/ dataAbsentReason, XHTML escaping, well-formed-div + null-value validation.

- services/trend_plotter: bundled SHA-pinned Korean font assets are first-class candidates (portable across macOS/slim containers).

- continuous_test: F5 runs from the post-ledger path in BOTH single- and multi-session chains (was bypassed/stale).

- tests: adversarial coverage for all of the above + test_handoff_input_validation.py.
…nsistency + PDF partial-export signaling

codex review (round-2) surfaced two P2 regressions in the round-1 remediation; both fixed while preserving the deterministic post-ledger design:

- continuous_test._main: defer F4 (like F5) out of the in-chain run so BOTH read the post-append ledger snapshot in the single-session path — previously F4 ran pre-append (N-1 entries) while F5 ran post-append (N), an inconsistent longitudinal window. Now mirrors run_multi_session_chain's append-then-F4-then-F5 ordering.

- continuous_test.run_f5_stage: report F5 'warn' (not 'pass') when PDF export failed but markdown+FHIR were written, so a missing clinical artifact is never masked as complete.

- continuous_test._run_f5_replay_cli: print an explicit WARNING and return a non-zero exit on partial export (PDF missing).

- tests: strengthen the single-session wiring test to assert F4 AND F5 observe the SAME 1-entry ledger snapshot (ordered F4->F5->F6); add PDF partial-export regression (warn when pdf absent, pass when present).
…omplete md neutralization, PDF pagination (no content drop)

codex review (round-3) surfaced 1 P1 + 2 P2, all in the round-1/2 hardening; fixed while preserving the deterministic design:

- [P1] Gate F5 on F4 =='pass' (both single- and multi-session): F4 writes its *_temporal.json ONLY on 'pass'; a 'warn'/'skip' produces no fresh longitudinal output, so gating on 'warn' let F5 silently consume a STALE temporal and emit a passing report mixing the current header with old data.

- [P2] _md_block now escapes Setext headings (=), code fences (` and ~), ordered lists (1./1)) and the inline-link seam ]( in addition to ATX/blockquote/table/bullet tokens — opt-in narrative text can no longer forge report structure.

- [P2] PDF long free-text (주호소/현병력/A8 narrative/상세 부록/슬롯 이력) is paginated across flowables via _paras/_pdf_line_chunks instead of being truncated at 40 lines with an omission marker — clinical facts are no longer dropped from the PDF while md/FHIR kept them.

- tests: F4-warn->F5-skip; _md_block Setext/fence/list/link neutralization; _pdf_line_chunks preserves every line; pypdf extraction proves a >40-line narrative's last line survives in the PDF.
…tale-PDF cleanup

Two of three codex round-3 P2 findings (the third, shared-helper PDF escaping, follows):

- [P2] continuous_test._chain_return_code: a partial F5 export (md/FHIR written, PDF missing -> F5 'warn') now makes both single- and multi-session runs exit 2 (was 0), matching the --f5-from-artifacts replay CLI, so automation detects the missing clinical artifact even without a hard stage failure. Benign non-F5 warns still exit 0.

- [P2] save_f5_result: on PDF export failure, unlink(missing_ok) the target PDF path so a same-second-prefix collision (or partial write) can't leave a previous run's stale PDF beside freshly overwritten md/FHIR.

- tests: _chain_return_code matrix (partial->2, fail->1, clean->0, benign warn->0); stale PDF removed on failed export.
…zation (no double-escape)

Third codex round-3 P2: shared render helpers (_truncate, _mse_lines, and the summary/risk-prose/risk-table chain) baked Markdown escaping (_md_cell/_md_inline) into text consumed by BOTH exporters, so the PDF double-escaped it — reportlab's own P() then produced literal &lt;… / \| artifacts.

- Thread a sanitize: Callable[[object], str] param through those helpers. Default = the Markdown escaper the helper already used, so build_markdown_report behavior is IDENTICAL (injection protection intact); build_pdf_report passes _pdf_raw (identity) so reportlab's P() is the SOLE escaper for PDF-bound text.

- _truncate anchors the full text into each renderer's own _AppendixCollector in the form correct for that renderer (md-escaped vs raw).

- test: adversarial clinical text with < and | renders clean in the PDF (no &lt;/\| artifacts); all Markdown-injection tests unchanged.
…OpenAPI enum/non-null schema

Both codex round-4 P2 findings:

- [P2] save_f5_result: append a unique per-run token to the artifact prefix (<vp>_<ts>_<8hex>) so two same-second exports of the same VP never share filenames. The round-4 on-failure unlink can no longer delete a DIFFERENT run's valid PDF, and no stale PDF is left paired with mismatched md/FHIR; the unlink now only targets THIS run's own (unique) path.

- [P2] schemas/handoff RiskEvent: publish risk_level/ctrs_level as non-nullable, enum-constrained (Literal) OpenAPI fields that remain omittable — the generated /openapi.json schema now matches the runtime validators, so schema-legal payloads no longer hit surprise 422s. The validator label sets are derived from the Literals so schema and validators cannot drift.

- tests: failed export preserves a prior run's PDF (unique prefixes); RiskEvent schema encodes enum/non-null/optional and matches validator sets; F5 naming regex updated for the per-run token.
…ent round-trip serialization

Both codex round-5 P2 findings:

- [P2] f5 A8 leak guard: match A6 candidate disease names on ASCII token boundaries (preserving the NFKC/casefold hardening) so a short Latin candidate ('AD') no longer falsely matches inside 'had' and wrongly disables the opt-in narrative; CJK names still match across a following Korean particle (우울증 in 우울증이).

- [P2] schemas/handoff RiskEvent: a model_serializer omits absent severity fields (never emits null), so an unlabeled event round-trips model_dump()->model_validate() (cache/retry/forward) without the validators rejecting a null they never should have emitted. The published schema stays enum + non-null.

- tests: leak-guard false-positive + real-leak (standalone Latin, Korean particle); unlabeled/labeled/extra-key round-trip.
codex round-6 P1: a valid A6 candidate with surrounding whitespace (e.g. 'PTSD ') had the whitespace baked into re.escape(), so the ASCII boundary anchor landed after the space and failed to match 'PTSD symptoms' — the disease name leaked into the opt-in A8 narrative (the old substring guard caught it). _disease_leaks now strips the candidate before building the pattern, restoring the A6->A8 clinical-isolation guarantee. +regression test.
Continues the F5 clinical hand-off report review remediation with three
security-hardening boundaries plus a FHIR-validity fix.

- Narrative guard (A6->A8): reject Unicode-confusable disease leaks —
  Cyrillic/Greek script when an ASCII candidate exists, interstitial
  combining marks, fullwidth and Cf format characters. Comparison-only
  normalization; stored narrative bytes are never mutated.
- XML 1.0 safety: sanitize invalid scalars one-for-one to U+FFFD across
  every FHIR Narrative and ReportLab paragraph path; validate the FHIR
  Bundle before HTTP return and before persistence behind a generic,
  PHI-free 500 / zero-artifacts boundary (Markdown left byte-exact).
- Artifact reservation cleanup: attempt every unlink/close, retry failed
  unlinks once after closes, preserve the primary exception with a
  count-only note, else raise a typed content-free error; extracted into
  a typed helper.
- Emit a valid Patient.id (fall back to vp_id) so sparse-persona bundles
  stay structurally valid FHIR — fixes an F1->F5 continuous-test
  regression the new pre-persistence validation surfaced.

Verified: ai-server suite 2383 passed / 2 skipped; ruff clean;
basedpyright changed-src errors 17->5 (0 added, 12 removed); mypy clean
on new modules; real-surface Unicode/XML/PDF/FHIR/cleanup QA 28/28.
@dhkim-kr

Copy link
Copy Markdown
Member

보안 관련 내용 수용, 브랜치 최신화 이후 조화 가능 부분들 재정합 진행 예정

@dhkim-kr

Copy link
Copy Markdown
Member

보존 안내 (filemanager, ADR-041 이후 Master 대이동)

Master가 #77#79#80#83#78#81#82 순으로 머지되어 (base였던) Master가 크게 전진했습니다. 본 PR(#76, review/f5-handoff-remediation)은 보안 하드닝 계층이 고유(대체 PR 없음, supersession 대상 아님)하여 close하지 않고 draft 상태로 보존합니다.

close 금지 확인, draft 유지.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants