feat: guardrails (PII redaction, confidence gating, refusal) - #6
Conversation
guardrails.py is the centralized, deterministic safety layer: - PII_PATTERNS: ordered tuple of (kind, regex) for EMAIL, SSN, CREDIT_CARD, PHONE, IPV4. Order is more-specific-first so overlapping matches resolve sanely. - redact_pii(text) -> RedactionResult(text, hits): single-pass, original-position spans, replaces with [REDACTED:KIND] placeholders. Idempotent against placeholder text (placeholders never re-match). - low_confidence_fields(field_confidence, *, threshold) -> list[str]: insertion-order preserving. - requires_review(field_confidence, *, threshold) -> bool: pure flag, never blocks. Settings adds pii_redaction_enabled (default True). .env.example documents the toggle. Wiring into ingest/rag/extract paths follows in subsequent commits.
…review flags ingest.py: when settings.pii_redaction_enabled (default True), each chunk's text is redacted before storage. The document hash is unchanged (still SHA-256 of the original text), so re-ingest idempotency is preserved. rag.py: _build_user_prompt redacts the question and each chunk's text in the prompt sent to the LLM. Defense-in-depth — chunks already redacted at ingest are unchanged on second pass; live query input is redacted at LLM-call time. extract.py: same treatment for the chunk context block. The validated extraction result now carries requires_review (any field below threshold?) and low_confidence_fields (the names, in insertion order). Both are computed via the guardrails helpers from settings.confidence_review_threshold. routers/extract.py: ExtractResponse surfaces requires_review and low_confidence_fields so callers (and the M6 workflow engine) can route low-confidence records.
…nd wiring Covers PII patterns and resolution policy, where redaction runs (pre-storage in ingest, pre-LLM in rag/extract), idempotency and re-ingest behaviour, the toggle PII_REDACTION_ENABLED, the confidence-gating helpers and the CONFIDENCE_REVIEW_THRESHOLD knob, and a wiring diagram. Satisfies the M5 DoD 'guardrail behavior is config-driven and documented in docs/'.
…wiring tests test_guardrails.py (21 tests): - per-pattern redaction (EMAIL, SSN, PHONE in 4 US formats, CREDIT_CARD, IPV4). - multiple kinds in one string land in result.hits with the right kinds. - redaction is idempotent (second pass over redacted text adds zero hits). - empty/PII-free input passes through unchanged. - hits report correct (start, end, original). - registry exposes the documented kinds. - false-positive cushion: short digits and over-long contiguous digits do not match. - low_confidence_fields filters strictly below threshold, preserves insertion order, rejects invalid thresholds. - requires_review agrees with low_confidence_fields under all edge cases. - RedactionHit is frozen. test_guardrails_integration.py (8 tests): - ingest stores [REDACTED:*] markers when pii_redaction_enabled=True (default). - ingest stores raw text when toggle is False. - document hash is computed on the *original* text either way; re-ingest with the toggle flipped is correctly skipped via hash match (idempotency holds). - rag.answer_query feeds a redacted question and redacted chunk text to the LLM even when the chunk row was stored raw (defense in depth). - extract.extract_document feeds redacted chunk text into the LLM prompt. - extract sets requires_review=True iff any field is below threshold; the low_confidence_fields list contains exactly the offenders in insertion order. - extract sets requires_review=False when every confidence is at or above threshold and low_confidence_fields is empty. Total project tests: 128 (was 99 in M4).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4680a78cff
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # computed on the *original* text above, so re-ingesting the same source | ||
| # still short-circuits via the get_by_hash check. | ||
| if settings.pii_redaction_enabled: | ||
| chunk_texts = [redact_pii(c.text).text for c in chunks] |
There was a problem hiding this comment.
Redact the source text before chunking
When PII_REDACTION_ENABLED is true but the configured chunk window cuts through a PII token sequence (for example a small CHUNK_SIZE_TOKENS with CHUNK_OVERLAP_TOKENS=0, or any boundary that splits an email/SSN), each c.text is redacted independently and the regex never sees the full pattern. That can store unredacted PII fragments, or the full value across adjacent rows, despite the new pre-storage guarantee. Redacting the full document text before chunk_text(...) avoids boundary-dependent misses.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
@codex review
Re-review PR #5 after commit 06776d2 fix: enforce invoice issue date schema.
Focus only on the previously reported issue around invoice issue_date validation.
Verify:
- issue_date is constrained to ISO format YYYY-MM-DD
- JSON Schema now exposes the correct pattern
- impossible dates (e.g. 2026-02-30) are rejected
- malformed dates are rejected before persistence
- valid dates continue to pass
- extraction persistence behavior is unchanged for valid records
- failed validations do not create extraction records
- no schema drift or migration changes were introduced
- no M5+ functionality was introduced
Review the actual implementation, not the changelog.
There was a problem hiding this comment.
Codex Review: Didn't find any major issues. Can't wait for the next one!
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
* docs(progress): record M5 merged (PR #6) and mark M6 in progress * feat(workflow): deterministic engine — pure route(), idempotency keys, replay, invariants backend/app/workflow.py is the M6 differentiator. Three layers, sharp boundaries: 1. Pure rule layer: route(RoutingInputs) -> RoutingDecision is a function with no I/O. Identical inputs always produce identical outputs. 2. Idempotency key recipe: compute_idempotency_key(extraction_id, schema_name, routing_version) is a SHA-256 hex digest. ROUTING_VERSION ('v1') lets future milestones bump the rule set without colliding with old keys. 3. Persistence: apply_routing(session, *, extraction_id, decision) upserts a workflow_items row keyed by the deterministic idempotency key. Re-running with the same decision is a no-op. REJECTED -> AUTO_APPROVED is refused with IllegalTransition; that promotion requires a human event (M7). 4. Replay: replay(session, *, extraction_id) reads the persisted extraction and recomputes the decision via route() with no DB writes. route_extraction() ties replay-style routing and idempotent persistence together for callers. Decision rules (top-down precedence): 1. 'invalid_citation' guardrail flag -> REJECTED ('guardrail_rejected'). 2. Any field below confidence_review_threshold -> NEEDS_REVIEW ('low_confidence'). 3. Any non-rejecting guardrail flag -> NEEDS_REVIEW ('guardrail_review'). 4. Otherwise -> AUTO_APPROVED ('ok'). Invariants checked at decision time (raise AssertionError): - AUTO_APPROVED with any field below threshold is impossible. - A rejecting guardrail flag MUST yield REJECTED. The engine never touches the LLM, never reads chunks, and never writes anywhere except workflow_items (in apply_routing). Tests in the next commit cover the four DoD categories: determinism, idempotency, replay, and invariants. * test(workflow): determinism, idempotency, replay, and invariant coverage 22 tests across the four M6 DoD categories: Determinism (4): - route() is a pure function of inputs (a == b == c for equal inputs). - field_confidence dict insertion order does not change the decision. - compute_idempotency_key depends only on extraction_id, schema_name, version. - The key is independent of confidence and guardrail flags (so the same extraction always upserts to the same workflow_items row). Rule precedence (5): - invalid_citation flag -> REJECTED (guardrail_rejected) - low confidence -> NEEDS_REVIEW (low_confidence) - non-rejecting flag -> NEEDS_REVIEW (guardrail_review) - clean inputs -> AUTO_APPROVED (ok), routing_version captured - invalid_citation beats low_confidence (rejection is terminal) Idempotency (3): - apply_routing inserts a workflow_items row. - 3x apply_routing of the same decision produces ONE row in the DB (verified by SQL count, not just same id). - A re-run with a different decision updates the same row, never duplicates. Replay (3): - replay() returns the same decision route_extraction did against the same data. - replay handles low-confidence extractions correctly. - replay raises KeyError for unknown extraction id. Invariants (3): - _check_invariants raises on AUTO_APPROVED with low confidence. - _check_invariants raises on non-REJECTED with a rejecting flag. - route() never returns AUTO_APPROVED when low confidence (user-visible invariant pinning the rule precedence). Transitions (2): - REJECTED -> AUTO_APPROVED via apply_routing raises IllegalTransition. - REJECTED -> NEEDS_REVIEW via apply_routing is allowed (documents policy). route_extraction integration (2): - Persists idempotently: same extraction id yields a single workflow_items row. - Propagates guardrail_flags through to the decision. Total project tests: 150 (was 128 in M5). * docs(workflow): rule table, idempotency-key recipe, replay protocol, invariants Covers why determinism is the M6 contract (audit defensibility, safe re-runs, eval reproducibility), the four-row rule precedence table, the SHA-256 key recipe with what is and is not included (and why), apply_routing's transition policy (REJECTED -> AUTO_APPROVED is refused; demotions allowed), the replay protocol and its two uses (audit assertion, rule-version migration), the two invariants enforced at decision time, and the test categories. * docs(progress): mark M6 complete on branch with DoD verification and decision log * fix: make workflow routing insert atomic
Milestone
M5 — Guardrails
Summary
Centralized, deterministic safety layer. Adds
backend/app/guardrails.pywith afive-pattern PII redaction registry (
EMAIL,SSN,PHONE,CREDIT_CARD,IPV4) plus pure helpers for confidence gating; wires redaction into the M2ingest pipeline (pre-storage) and into the M3/M4 prompt builders (pre-LLM); surfaces
requires_reviewandlow_confidence_fieldsfrom the M4 extraction result andPOST /extractresponse; documents the layer indocs/guardrails.md; and adds 29new tests (21 unit + 8 wiring) running offline against
FakeLLM/FakeEmbedder.Definition of Done
make checkpasses (ruff + ruff-format + mypy strict + pytest)with PR feat: schema-constrained structured extraction #5 link; 4 new decision-log entries
logging) — citation-or-refuse already in place from M3; this PR adds PII redaction
and confidence-gating flagging. Audit logging arrives in M7.
M5 DoD verification (from MILESTONES.md)
PII patterns redacted before any LLM call and before storage (tested). Two
call sites apply
redact_piiwhenpii_redaction_enabled=True(default-on):ingest.ingest_documentbeforechunks_repo.bulk_insert. Thedocument hash is computed on the original text, so re-ingesting the same
source with redaction toggled on or off still short-circuits via the hash check.
rag._build_user_prompt(question + chunk text) andextract._build_user_prompt(chunk context). Defense in depth — chunks alreadyredacted at ingest are unchanged on second pass; live query input is redacted at
LLM-call time.
test_guardrails_integration.pyasserts both surfaces with PII-laden text andinspects the prompt observed by
FakeLLM.Low-confidence extractions are flagged for review, never auto-applied
(tested).
ExtractionResultandExtractResponsenow carryrequires_reviewand
low_confidence_fields, populated via the guardrail helpers againstsettings.confidence_review_threshold. The flag is informational — extractionsstill validate, persist, and return as before. Tests assert both polarities and
the offender list ordering.
Guardrail behavior is config-driven and documented in
docs/.PII_REDACTION_ENABLEDandCONFIDENCE_REVIEW_THRESHOLDinSettingsand.env.example.docs/guardrails.mdcovers patterns, algorithm, where redactionruns, idempotency / re-ingest behaviour, the toggle, confidence-gating helpers,
the threshold, a wiring diagram, and a testing summary.
Notes / decisions
See PROGRESS.md "Decision log" — four M5 entries:
[REDACTED:KIND](idempotent on a second pass).
toggle flips.
ragandextractfor defense in depth, even whenchunks are stored raw.
Other deliberate choices:
for synthetic data; false negatives leak real PII. The unit tests pin every
pattern's behaviour against canonical shapes plus a couple of cushion cases (short
digit groups, over-long contiguous digits) so we don't accidentally tighten into
false positives.
true. CI keeps ittrue. Disabling is documented as alocal-debug-only escape hatch.
Schema/migration concerns
None. M5 is pure code + docs + tests.
Test coverage
test_guardrails.py(21): per-pattern redaction; multi-kind in one string;idempotency; empty/PII-free pass-through; hit spans; registry shape;
false-positive cushions;
low_confidence_fieldsordering and threshold edgecases;
requires_reviewpolarities; frozen dataclass.test_guardrails_integration.py(8): ingest stores redacted text by default;ingest stores raw text when toggled off; document hash unaffected by toggle (re-
ingest still skips);
rag.answer_queryredacts question + chunks in the prompteven when chunks were stored raw;
extract.extract_documentredacts the chunkcontext block;
requires_review+low_confidence_fieldscorrect in bothdirections.
Total project tests: 128 (was 99 in M4).
Reminder
Please squash-merge this PR (Golden Rule #2: one milestone = one squash-merge
commit on
main).