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-inDataClassification, 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.
## 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)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.mdpart 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-inDataClassification, marked for replacement, behind a pluggableClassificationSource:AssistantQBitConfig.withDataClassification(table[, field], level).withClassificationSource(...).Per turn: the turn records the highest classification touched by:
This is stored on
assistant_turn.classificationandclassification_tables, and onassistant_conversation.max_classification.Routing
PhiRoutingGuardruns before every model call:hipaa_eligibleprovider rows. A provider registered only in code is never eligible.PHI_BLOCKEDwith a reason that names the tables and the provider, but no values;eligibleAlternative) never crosses to a non-eligible provider.HIPAA mode (
withHipaaMode(true): on for MHP, off for kof22)AssistantStoreonly, with formatenc: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.hipaaRetentionDays(default 6 years).assistant_data_auditgets 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 verifywith the JaCoCo gates. The fixture gains a classifiedpatienttable (PHI, with a PII email and a PUBLIC status).enc:v1:at rest but plain through the API;🤖 Generated with Claude Code