Privacy flags (pii and restricted)¶
Two per-field flags on a contract's field_bindings say that a value is
personal or sensitive. This guide covers what pycharter does about them
when records leave its control — batch loaders, streaming sinks, and
dead-letter queues — and what it deliberately does not do.
The short version:
| You declare | pycharter does |
|---|---|
pii: true on a field |
replaces the value with an HMAC token at an uncleared sink |
restricted: true on a field |
replaces the value with null at an uncleared sink |
| nothing | nothing changes |
Enforcement is opt-in per step. The default mode is detect: it
never mutates a record, it only counts and reports. Upgrading pycharter
therefore changes no pipeline's output.
1. Flag a field¶
Flags live on the contract, next to the field's binding. The contract owner declares them; a pipeline author never does.
# orders.yaml
metadata:
name: orders
version: "2.1.0"
json_schema:
type: object
properties:
order_id: {type: string}
email: {type: string, format: email}
ssn: {type: string}
field_bindings:
email:
primary: {concept: {concept: EmailAddress}, concept_scheme: "commerce:1.0.0"}
pii: true
ssn:
primary: {concept: {concept: SSN}, concept_scheme: "commerce:1.0.0"}
restricted: true
Both flags on the same field means restricted — the stricter of the
two wins, and only a restricted clearance passes it through in clear.
Flags match top-level record keys, exactly like the knowledge-base
emitter. Nested paths (customer.email) are not matched; see
What is not covered.
2. Choose a mode¶
The mode is a per-step privacy block inside the step's contract
block:
steps:
- id: load_orders
type: load
input: extract_orders
contract:
ref: {type: file, path: orders.yaml}
privacy:
mode: enforce # off | detect | enforce
clearance: [] # flags this sink may receive in clear
target:
type: postgres
db_schema: public
table: orders
| Mode | Records | Report |
|---|---|---|
off |
untouched | none — the step is not reported at all |
detect (default) |
untouched | counts every flagged value that reached the sink in clear |
enforce |
uncleared flagged values are tokenized or redacted before the write | counts what was handled |
detect is the default when the privacy block is absent, so an
existing pipeline that attaches a flagged contract starts reporting
immediately and keeps writing exactly what it wrote before.
Enforcement sits immediately before the sink write and after the knowledge-base emitter and extraction-sink tees. Those tees, and the records your transforms see, always get clear values; only what lands in the destination is handled.
clearance — the sink owner's declaration¶
clearance lists the flags this particular sink is allowed to receive
in clear. It is pipeline-side config, never inferable from the contract,
and it shows up in the run report so an auditor can see it:
clearance is only honoured at a load step's sink. Setting it anywhere
else is a preflight error ([clearance_on_non_load]).
3. What the handling actually is¶
restricted → redact. The value becomes null — not a
"__redacted__" sentinel — so a typed sink column stays loadable.
pii → tokenize. The value becomes a 64-character hex
HMAC-SHA256 token over the KB's normalized form of the value. It is:
- deterministic — the same secret and value always produce the same token, so re-running a pipeline produces identical output;
- joinable — the token equals the KB
match_tokenfor that value, so a tokenized warehouse column joins against knowledge-base records without ever holding the clear value; - not reversible — there is no un-tokenize step. Keep the clear value where it is allowed to live, not in the sink.
A value that is not a scalar (a dict, list, or bytes) cannot be
normalized into one token, so it is redacted instead and counted
separately as non_scalar_redacted. Failing closed beats raising in the
middle of a batch.
The org secret¶
Tokenization needs an HMAC key. It is never read from pipeline YAML. Supply it either way:
Every pipeline whose records should join by token must use the same
secret. If a token is needed and no secret resolves, the run does not
start — see [missing_secret] below. It never falls back to writing the
clear value.
4. The report¶
Every run that touched a flagged contract carries a report:
PipelineResult.privacy_report(andresult.to_dict()["privacy_report"])StepResult.privacy— the same per-step entry- the
pycharterPrivacyOpenLineage run facet, on the COMPLETE / FAIL event - one CLI line per load step from
pycharter pipeline run
It carries field names, flags, handling, clearance, and counts — never a value.
{
"steps": [
{
"step_id": "load_orders",
"mode": "enforce",
"contract_id": "orders",
"contract_version": "2.1.0",
"sink": "postgres:public.orders",
"clearance": [],
"records": 1204,
"fields": [
{"field": "email", "flag": "pii", "handling": "tokenize",
"cleared": false, "values": 1204, "handled": 1204,
"non_scalar_redacted": 0},
{"field": "ssn", "flag": "restricted", "handling": "redact",
"cleared": false, "values": 1204, "handled": 1204,
"non_scalar_redacted": 0}
],
"dlq_records_handled": 3,
"dlq_records_withheld": 0
}
]
}
Reading it:
values— non-null occurrences of the field at the boundary. Inenforcea cleared field is never visited, so it reportsvalues: 0withcleared: true.handled— values actually tokenized or redacted. Always0indetect.values > 0withhandled == 0andcleared == falseis the interesting line: those values reached the sink in clear.
The CLI prints the same thing on one line per load step:
privacy [enforce] load_orders → postgres:public.orders: email pii tokenized 1,204; ssn restricted redacted 1,204; dlq handled 3
The field table for the privacy block itself is in
Reference → PrivacyBlock.
5. Dead-letter queues¶
A quarantined record is a record that failed validation — its payload is
exactly the kind of thing that carries raw personal data, so under
enforce the DLQ is handled too:
- Batch step DLQ (
on_error: quarantine, file or database target) — every flagged field in the payload is redacted, andvalidation_errorscarry the offending type, never the offending value. Redact-all, not the step's handling: a quarantined record never needs to be joinable, redaction needs no secret, and it cannot fail mid-write.clearancedoes not apply to a DLQ — it is not the cleared sink. - Streaming DLQ — the same, plus: a payload that cannot be
classified at all (a decode error, an unresolvable contract) is
withheld and replaced by
{"_withheld": true, "sha256": …, "bytes": n}. If a record cannot be handled safely (no plan, or no org secret), it is routed to the DLQ withheld with reasonprivacy_errorrather than written in clear.
Under detect and off the DLQ keeps its documented raw-payload
behaviour — the change is gated behind opting in to enforce.
Counts appear in the report as dlq_records_handled and
dlq_records_withheld.
6. Preflight: what each rule code means¶
pycharter pipeline validate reports privacy problems in the
privacy_clearance category, and Pipeline.run() / arun() runs the
same pass before the first step — a pipeline never starts in a state
preflight would reject. Errors abort the run (nothing executes and
the messages come back on PipelineResult.errors); warnings are
logged at WARNING and the run proceeds.
| Code | Severity | Meaning | What to do |
|---|---|---|---|
[missing_secret] |
error | enforce must tokenize a pii field but no org secret resolves. |
Export PYCHARTER_KB_ORG_SECRET, pass privacy_secret=, or add clearance: [pii] if this sink is allowed the clear value. |
[clearance_on_non_load] |
error | clearance set on a step that is not a load step. |
Remove it, or move it to the load step that writes the records. |
[flagged_cursor] |
error | A flagged field is the incremental watermark_field. |
Pick an unflagged cursor (an id, an updated_at): watermarks persist to the run checkpoint in clear. |
[unenforceable_sink] |
error under enforce, warning under detect |
A load step has no contract block — or attaches a contract that does not flag the same fields — while an upstream step's contract flags fields. Those flags cannot be enforced at this sink. | Attach the upstream contract (or one carrying the same flags) to the load step with its own privacy block. |
[unenforceable_field] |
error under enforce, warning under detect |
A flagged binding key is nested (customer.email, tags[0]); the sink boundary matches top-level record keys only, so the value would pass in clear. |
Flatten the field, or flag its top-level parent. |
[in_clear] |
warning | detect, and flagged values reach this sink in clear. |
Expected while you are surveying. Set mode: enforce, or add the flag to clearance to say the sink is allowed it. |
[tokenized_non_string] |
warning | A tokenized field's schema type is not a string. | Tokens are 64-char strings — widen the sink column to text, or clear the sink for pii. |
[quality_check_on_redacted] |
warning | A post-load quality_check targets a field enforce redacts. |
Drop the check, or clear the sink for restricted. A null_rate check on a redacted column reads 100%. |
7. Streaming¶
Streaming takes the same privacy block, on the stream's contract:
streams:
- name: orders
contract:
ref: {type: store, name: orders}
store: {type: postgres, connection_string: ${DATABASE_URL}}
privacy:
mode: enforce
clearance: []
Semantics match the batch boundary: applied immediately before every valid-sink write (including each row a window emits), tees untouched, plans cached per contract, identical output on redelivery. Field-level details are in Reference → Streaming Worker Config.
Use ref.type: store. A ref.type: file contract resolves through
the deprecated split-directory form (schema.yaml + rules), which
carries no field_bindings and therefore no flags. The worker refuses
enforce on such a stream at build time and warns that detect —
explicit or the default when no privacy block is present — will report
nothing; the same applies to kafka.consume(contract_dir=...).
8. What is not covered¶
Known, deliberate gaps — do not read these as promises:
ai_usage_policy.pii_redaction_requiredis knowledge-base only. It governs extraction capture and embedding, not warehouse sinks. Sink behaviour keys off the per-fieldpii/restrictedflags and nothing else.- Nested paths are not matched at the sink. A binding key matches a
top-level record key (same rule as the KB emitter). A flagged nested
key is not silently skipped, though: preflight reports
[unenforceable_field](error underenforce), and validation diagnostics do withhold values for a dotted binding key. - ODCS import writes per-field flags.
classification: pii | restrictedon a foreign ODCS property lands onfield_bindings[..].pii/.restricted— the flags the runtime enforces — and no longer on the legacymetadata.governance_rules.pii_fieldslist. Export unions both, so re-export is unchanged; code that readpii_fieldsafterfrom_odcsshould read the per-field flags. pycharter stream replayis gated, but has nowhere to quarantine. Pass--contract-file <manifest.yaml>(a single-filekind: DataContractartifact) and every replayed record goes through the same gate a worker uses:--privacy-mode off|detect|enforce(defaultdetect),--clearance pii|restricted(repeatable), org secret fromPYCHARTER_KB_ORG_SECRET. The deprecated--contract-dirform carries nofield_bindings, soenforceis refused (exit 2) anddetectwarns that its report will be empty — the same refusal the worker applies to acontract.ref.type: filestream. Replay has no DLQ of its own, so a recordenforcecannot handle safely (no plan, or tokenization with no secret) is dropped rather than written in clear; it appears only as thewithheldcount on the summary line.kafka.consume()as a library appliesdetectonly when the caller's factory exposes contract metadata (built withannotate=Trueor theprivacytoggle); otherwise it isoff.- Post-load quality checks run on handled data.
uniquenesson tokens still holds;null_rateon a redacted column reads 100% (preflight warns). - No encryption, no key management, no detectors. pycharter enforces what the contract declares; it does not guess which fields are personal.
See also¶
- Reference →
PrivacyBlock— the config block's fields. - Reference → Streaming Worker Config — the streaming
privacyrow. - Admin guide — governance policy —
governance_rulesand how it relates to per-field flags.