Skip to content

feat: guardrails (PII redaction, confidence gating, refusal) - #6

Merged
div0rce merged 6 commits into
mainfrom
feat/m05-guardrails
May 29, 2026
Merged

feat: guardrails (PII redaction, confidence gating, refusal)#6
div0rce merged 6 commits into
mainfrom
feat/m05-guardrails

Conversation

@div0rce

@div0rce div0rce commented May 29, 2026

Copy link
Copy Markdown
Owner

Milestone

M5 — Guardrails

Summary

Centralized, deterministic safety layer. Adds backend/app/guardrails.py with a
five-pattern PII redaction registry (EMAIL, SSN, PHONE, CREDIT_CARD,
IPV4) plus pure helpers for confidence gating; wires redaction into the M2
ingest pipeline (pre-storage) and into the M3/M4 prompt builders (pre-LLM); surfaces
requires_review and low_confidence_fields from the M4 extraction result and
POST /extract response; documents the layer in docs/guardrails.md; and adds 29
new tests (21 unit + 8 wiring) running offline against FakeLLM/FakeEmbedder.

Definition of Done

  • All DoD items from MILESTONES.md met
  • make check passes (ruff + ruff-format + mypy strict + pytest)
  • Tests added/updated for new logic — 29 new M5 tests
  • PROGRESS.md updated — M5 marked complete on branch; M4 row updated to ☑ merged
    with PR feat: schema-constrained structured extraction #5 link; 4 new decision-log entries
  • No secrets committed; sample data is synthetic
  • Guardrails intact (citation-or-refuse, PII redaction, confidence gating, audit
    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_pii when pii_redaction_enabled=True (default-on):

    • Pre-storage in ingest.ingest_document before chunks_repo.bulk_insert. The
      document 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.
    • Pre-LLM in rag._build_user_prompt (question + chunk text) and
      extract._build_user_prompt (chunk context). Defense in depth — chunks already
      redacted at ingest are unchanged on second pass; live query input is redacted at
      LLM-call time.

    test_guardrails_integration.py asserts both surfaces with PII-laden text and
    inspects the prompt observed by FakeLLM.

  • Low-confidence extractions are flagged for review, never auto-applied
    (tested).
    ExtractionResult and ExtractResponse now carry requires_review
    and low_confidence_fields, populated via the guardrail helpers against
    settings.confidence_review_threshold. The flag is informational — extractions
    still 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_ENABLED and CONFIDENCE_REVIEW_THRESHOLD in Settings and
    .env.example. docs/guardrails.md covers patterns, algorithm, where redaction
    runs, 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:

  • PII patterns are ordered more-specific-first; replacement is [REDACTED:KIND]
    (idempotent on a second pass).
  • Document hash stays on the original text so re-ingest idempotency holds across
    toggle flips.
  • Pre-LLM redaction runs in both rag and extract for defense in depth, even when
    chunks are stored raw.
  • Confidence gating in M5 is flag-only. M6 routes; M7 audits; M5 just labels.

Other deliberate choices:

  • The five PII patterns are intentionally conservative: false positives hurt nothing
    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.
  • The toggle defaults to true. CI keeps it true. Disabling is documented as a
    local-debug-only escape hatch.
  • No M1-style schema churn: zero migrations in this PR.

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_fields ordering and threshold edge
    cases; requires_review polarities; 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_query redacts question + chunks in the prompt
    even when chunks were stored raw; extract.extract_document redacts the chunk
    context block; requires_review + low_confidence_fields correct in both
    directions.

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).

div0rce added 6 commits May 28, 2026 22:32
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).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread backend/app/ingest.py
# 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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

@div0rce
div0rce merged commit e897522 into main May 29, 2026
1 check passed
@div0rce
div0rce deleted the feat/m05-guardrails branch May 29, 2026 02:52
div0rce added a commit that referenced this pull request May 29, 2026
* 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
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.

1 participant