feat: data classification, PHI routing and HIPAA mode (P-c, stacked on #23) #24

Merged
KofTwentyTwo merged 1 commits from feature/p3-hipaa-routing into develop 2026-10-13 23:23:37 +00:00
KofTwentyTwo commented 2026-10-13 23:20:35 +00:00 (Migrated from github.com)

P-c: data classification, PHI routing and HIPAA mode

Stacked on #23 (P-b). Merge #23 first; this PR then retargets to develop. Spec: docs/DESIGN.md part P, item 3.

Classification

  • The levels are PUBLIC < CONFIDENTIAL < PII < PHI, on tables and fields.

  • The core attribute is QRun-IO/qqq#1142 (AiTableMetaData/AiFieldMetaData.withClassification), which is not in an RC yet. Until then the qbit has a stand-in DataClassification, marked for replacement, behind a pluggable ClassificationSource:

    • The default reads AssistantQBitConfig.withDataClassification(table[, field], level).
    • A host can plug in core metadata, or qbit-secured-data's admin overrides, with withClassificationSource(...).
  • Per turn: the turn records the highest classification touched by:

    • the page context;
    • the conversation's history (once a conversation has touched PHI, it stays PHI);
    • every tool result, conservatively: the whole table unless the tool names its fields.

    This is stored on assistant_turn.classification and classification_tables, and on assistant_conversation.max_classification.

Routing

PhiRoutingGuard runs before every model call:

  • PHI goes only to hipaa_eligible provider rows. A provider registered only in code is never eligible.
  • When the guard blocks:
    • nothing is sent, including the tool result that raised the classification;
    • the turn ends PHI_BLOCKED with a reason that names the tables and the provider, but no values;
    • an audit row is written.
  • There is no fallback yet. P-e adds it, and the seam (eligibleAlternative) never crosses to a non-eligible provider.

HIPAA mode (withHipaaMode(true): on for MHP, off for kof22)

  • No non-eligible providers: they are refused at resolve and when enabled, and the code-registered fallback is not used.
  • Encrypted conversation storage:
    • AES-256-GCM in AssistantStore only, with format enc:v1: and a random IV per value. It covers message parts, titles, tool-call input, result and redactions, turn event data, and pending-action payloads.
    • The key comes from an environment-variable reference and is required at startup.
    • Tampered values fail closed, and legacy plain values stay readable.
  • Retention: PHI conversations use hipaaRetentionDays (default 6 years).
  • Logs: no provider messages, request ids or stack traces.
  • Full audit: assistant_data_audit gets one row per model call that carried PII or PHI, and one per block: who, classification, tables, provider, model, eligibility and decision. It is a child list on the conversation.

Also

System rule 11: values masked by the application (for example j***@x.com) stay masked, and the model must never ask the user to reveal them. This goes with the secured-data spec (qqq#1147).

Tests

190 in total, all green in ./mvnw verify with the JaCoCo gates. The fixture gains a classified patient table (PHI, with a PII email and a PUBLIC status).

  • Routing:
    • a PUBLIC question is allowed;
    • a PHI tool result on a non-eligible provider is blocked before the next call (the provider received exactly one request);
    • the same on an eligible provider is allowed and audited;
    • a PHI history blocks from the first call;
    • a PHI page context blocks before the first call;
    • a PII field subset is allowed and audited.
  • HIPAA mode:
    • provider refusals;
    • the encryption key is required;
    • values are enc:v1: at rest but plain through the API;
    • tamper fails closed, and legacy values stay readable.
  • Retention, logs and the source: PHI retention, log capture, and unit tests for the classification source.

🤖 Generated with Claude Code

## P-c: data classification, PHI routing and HIPAA mode **Stacked on #23 (P-b).** Merge #23 first; this PR then retargets to `develop`. Spec: `docs/DESIGN.md` part P, item 3. ### Classification - The levels are PUBLIC < CONFIDENTIAL < PII < PHI, on tables and fields. - The core attribute is QRun-IO/qqq#1142 (`AiTableMetaData`/`AiFieldMetaData.withClassification`), which is not in an RC yet. Until then the qbit has a **stand-in** `DataClassification`, marked for replacement, behind a pluggable `ClassificationSource`: - The default reads `AssistantQBitConfig.withDataClassification(table[, field], level)`. - A host can plug in core metadata, or qbit-secured-data's admin overrides, with `withClassificationSource(...)`. - **Per turn:** the turn records the highest classification touched by: - the page context; - the conversation's history (once a conversation has touched PHI, it stays PHI); - every tool result, conservatively: the whole table unless the tool names its fields. This is stored on `assistant_turn.classification` and `classification_tables`, and on `assistant_conversation.max_classification`. ### Routing `PhiRoutingGuard` runs before **every** model call: - PHI goes only to `hipaa_eligible` provider rows. A provider registered only in code is never eligible. - When the guard blocks: - nothing is sent, including the tool result that raised the classification; - the turn ends `PHI_BLOCKED` with a reason that names the tables and the provider, but no values; - an audit row is written. - There is no fallback yet. P-e adds it, and the seam (`eligibleAlternative`) never crosses to a non-eligible provider. ### HIPAA mode (`withHipaaMode(true)`: on for MHP, off for kof22) - **No non-eligible providers:** they are refused at resolve and when enabled, and the code-registered fallback is not used. - **Encrypted conversation storage:** - AES-256-GCM in `AssistantStore` only, with format `enc:v1:` and a random IV per value. It covers message parts, titles, tool-call input, result and redactions, turn event data, and pending-action payloads. - The key comes from an environment-variable reference and is required at startup. - Tampered values fail closed, and legacy plain values stay readable. - **Retention:** PHI conversations use `hipaaRetentionDays` (default 6 years). - **Logs:** no provider messages, request ids or stack traces. - **Full audit:** `assistant_data_audit` gets one row per model call that carried PII or PHI, and one per block: who, classification, tables, provider, model, eligibility and decision. It is a child list on the conversation. ### Also System rule 11: values masked by the application (for example `j***@x.com`) stay masked, and the model must never ask the user to reveal them. This goes with the secured-data spec (qqq#1147). ### Tests 190 in total, all green in `./mvnw verify` with the JaCoCo gates. The fixture gains a classified `patient` table (PHI, with a PII email and a PUBLIC status). - **Routing:** - a PUBLIC question is allowed; - a PHI tool result on a non-eligible provider is blocked before the next call (the provider received exactly one request); - the same on an eligible provider is allowed and audited; - a PHI history blocks from the first call; - a PHI page context blocks before the first call; - a PII field subset is allowed and audited. - **HIPAA mode:** - provider refusals; - the encryption key is required; - values are `enc:v1:` at rest but plain through the API; - tamper fails closed, and legacy values stay readable. - **Retention, logs and the source:** PHI retention, log capture, and unit tests for the classification source. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.