Configuring PrivaCI (mask-rules.yaml)¶
PrivaCI is driven by a single declarative YAML file, mounted in the container at
/config/mask-rules.yaml (override with --config). The engine validates this
file strictly on boot: unknown keys, misspelled actions, and unsupported
versions fail fast with exit code 3
before any database connection is opened.
This page is the operator reference for every field. The same schema is available machine-readable for editor auto-completion:
privaci schema config > mask-rules.schema.json
Quickstart¶
Generate a starter config from your source schema with privaci init. Do
not hand-write a full schema mapping from scratch — that skips auto-detect.
export SOURCE_DB_URL=postgresql://user:pass@source-host:5432/app
privaci init --source "$SOURCE_DB_URL" --output mask-rules.yaml
privaci plan --config mask-rules.yaml --source "$SOURCE_DB_URL"
privaci validate --config mask-rules.yaml
Then set ANONYMIZATION_SALT and edit only the columns you need to change
(providers, seed_alias, table strategies). Full command docs:
privaci init · privaci plan.
A minimal hand-authored config (for docs/examples only) that masks two columns:
version: "1.0"
global_salt: "${ANONYMIZATION_SALT}"
tables:
public.users:
strategy: transform
columns:
first_name: { action: fake, provider: first_name }
email: { action: fake, provider: email }
Validate it without running a masking job:
privaci validate --config mask-rules.yaml
# Config mask-rules.yaml is valid.
A failing example — a misspelled action — exits 3 and names the column path:
privaci validate --config mask-rules.yaml
# Context: Validating mask-rules.yaml
# Cause: Config does not satisfy the schema:
# - tables.users.columns.email: ...
# Remediation: Fix the fields listed above; see docs/configuration.md.
Top-level options¶
| Key | Type | Default | Description |
|---|---|---|---|
version |
string | required | Config schema version. The v1.x engine accepts "1.0" only. |
global_salt |
string | secret URI | none | Salt literal or secret URI; resolved at run time. Never logged. |
pseudonym_key |
string | secret URI | none | HMAC key for hmac_hash / pseudonym (required when the keyed_actions capability is granted). Distinct from global_salt. |
on_existing_data |
enum | fail |
Target collision policy: fail, truncate, drop_create. Under assume_existing, fail allows empty prebuilt tables but refuses rows; see assume_existing. append is rejected in the MVP. |
schema_mode |
enum | replicate |
Who owns target DDL: replicate (engine clones schema) or assume_existing (validate prebuilt target, then load). |
passthrough_copy |
enum | auto |
Binary COPY for unmasked tables: auto (binary when column order matches, else named batch), require_binary (fail if ineligible), batch (always named batch). |
replicate_views |
bool | true |
Replicate non-elevated plain views in replicate mode (post-data). |
replicate_functions |
bool | true |
Replicate non-elevated functions/procedures (post-data, except DEFAULT/CHECK deps which hoist to pre-data). |
replicate_triggers |
bool | true |
Create user triggers in post-data (do not fire during mask load). Set false to skip. Ignored under assume_existing. |
replicate_materialized_views |
bool | false |
Create materialized-view shells with WITH NO DATA in post-data (never copy source storage). Rejected under assume_existing. |
refresh_materialized_views |
bool | false |
After masked loads, REFRESH MATERIALIZED VIEW in dependency order (requires replicate_materialized_views; replicate mode only). Rejected under assume_existing. |
elevated_objects |
map | {} |
Explicit replicate or skip for elevated views/functions. Unresolved elevated objects fail preflight. |
strict_autodetect |
bool | false |
Fail the run when auto-detect finds uncovered PII columns. |
replicate_all_indexes |
bool | false |
Replicate every source index (non-unique indexes in post-data), not just unique/PK indexes. |
batch_size |
int | 10000 |
Default streaming batch size in rows (must be ≥ 1). |
audit_log |
bool | true |
Write the per-run audit log to _privaci.audit_log. |
auto_detect |
bool | true |
Run the zero-config PII column scanner. |
implied_fk_ignore |
list[string] | [] |
Source column paths (schema.table.column) whose implied FK warnings are silenced. |
tables |
mapping | {} |
Table identifier → table config. Keys must match the catalog (schema.table, e.g. public.users). |
Target DDL that must interpolate schema/table names (truncate, drop schema,
search_path, and similar) always runs those identifiers through
quote_pg_identifier first. Semgrep auto SQL-concatenation findings on those
paths are calibrated with nosemgrep next to the SECURITY comments.
Auto-detect¶
When auto_detect: true (default), PrivaCI inspects every column name against a
built-in pattern library and assigns a confidence score:
| Confidence | Run-time behaviour |
|---|---|
high |
Auto-apply the inferred action (e.g. fake for email, ner_mask for long clinical notes). |
medium |
Flag for manual review in privaci dry-run --report; passthrough unless YAML overrides. |
low |
Passthrough. |
Explicit YAML column entries always win, including passthrough. Structured
patterns (email, ssn, phone, …) are high on name match alone. Freeform
patterns (note*, description, …) additionally require column type (text
or varchar ≥ 500) and use pg_stats.avg_width plus table-name context — see
ADR-0011.
Review detections without writing data:
privaci dry-run --config mask-rules.yaml --report review.md
With strict_autodetect: true, any high or medium finding not explicitly
listed under tables.<id>.columns fails validation (exit 3).
privaci preview and privaci dry-run --report write their review artifacts
before applying the strict check, so CI can upload a policy diff or markdown
report even when the job exits 3. Production privaci run still fails at
preflight with no writes.
Version policy. A config whose
versionis not"1.0"is rejected by the v1.x engine. A future engine reading an older config will direct you toprivaci migrate-config --from <old> --to <new> <path>. When--fromequals--to,migrate-configis a no-op and exits0.
Table configuration¶
Each entry under tables accepts:
| Key | Type | Default | Description |
|---|---|---|---|
strategy |
enum | transform |
transform (mask + copy), exclude (drop in target), empty (recreate, no rows), truncate (empty before copy). |
columns |
mapping | {} |
Column name → action. |
batch_size |
int | inherits global | Per-table override (must be ≥ 1). |
null_orphan_fks |
bool | false |
When a nullable FK references an excluded (or otherwise non-created) parent, set those FK columns to NULL on every streamed row instead of leaving dangling values. Forces the batch/cell path for that table (binary COPY is ineligible). Conflicts with passthrough_copy: require_binary at preflight. |
empty creates the table DDL on the target but streams zero rows and marks the
table checkpoint done. truncate does the same after TRUNCATE on an
existing target table — useful when you need the schema but not the data.
When null_orphan_fks: true, privaci plan / privaci dry-run still list the
child table as streamable; the FK DDL to the excluded parent is omitted either
way. Review exclude graphs before production runs.
Idempotent replicated DDL¶
In schema_mode: replicate, index creation uses IF NOT EXISTS and foreign
keys are checked by constraint name before creation. PostgreSQL's
CREATE INDEX IF NOT EXISTS compares the object name only, and the FK guard
also compares constraint names. PrivaCI does not silently replace a same-name
index or constraint whose definition differs. Use schema drift review or
remove/rename the conflicting target object before retrying.
Object replication (pg_dump-style phases)¶
In schema_mode: replicate, DDL is applied in three phases (same names as
pg_dump sections):
| Phase | Objects |
|---|---|
| pre-data | Schemas, extensions, sequences, tables, partition children, PRIMARY KEY / UNIQUE indexes, foreign keys; functions required by column DEFAULT / CHECK expressions |
| data | Masked/passthrough row stream; per-table sequence setval |
| post-data | Remaining functions/procedures, plain views, optional matview shells, optional non-unique indexes (replicate_all_indexes), triggers (replicate_triggers, default on), optional matview REFRESH |
RunStatus.SUCCEEDED means all three phases completed. Phase membership is
fixed (not configurable); use include/skip flags and assume_existing when you
own target DDL.
- Elevated objects still require an explicit disposition (see below).
- Materialized views remain opt-in definition-only shells (see below).
- Rules and publications remain skipped.
Each created object is recorded as created_object (or
definition_only_object) with ddl_phase. Skipped objects use
skipped_object with a kind and reason.
Triggers created in post-data apply to subsequent DML on the target; they
do not fire against rows inserted during the mask COPY/load. Set
replicate_triggers: false to skip (audit skipped_object with
reason: flag_disabled). When the trigger function is not replicated
(elevated skip or replicate_functions: false), the trigger is skipped with
reason: dependency_excluded.
Elevated objects¶
An object is elevated when it is a SECURITY DEFINER function/procedure, or
a view that does not use invoker rights (security_invoker). Elevated
objects are deny-by-default:
elevated_objects:
clinical.admin_v: skip
reporting.privileged_fn(bigint): replicate
replicate— create on the target; auditcreated_objectwithelevated: true.skip— do not create; auditskipped_objectwithreason: elevated_object_skipped.- Missing entry → preflight exit 2 naming the object.
privaci init scaffolds elevated_objects: {} and prints ACTION REQUIRED
listing unresolved elevated objects. privaci plan prints the same reminder
and a pre-data / post-data phase summary.
Objects still not replicated¶
Rules and logical-replication publications are never copied. Each is recorded
as skipped_object (kind: rule or publication). Triggers are skipped when
replicate_triggers: false (reason: flag_disabled) or when their function is
not replicated (reason: dependency_excluded).
Materialized views¶
Materialized-view replication is opt-in and definition-only (shell + optional refresh from masked bases — never a copy of source matview contents).
version: "1.0"
replicate_materialized_views: true # CREATE … WITH NO DATA (default false)
refresh_materialized_views: true # REFRESH after masked table loads
When replicate_materialized_views: true, PrivaCI creates the matview shell from
pg_get_viewdef and audits definition_only_object with contents_copied: false
(and refreshed: false until an optional refresh completes). It never copies stored
rows from the source matview. Optional refresh_materialized_views: true re-derives
contents from the masked base tables after streaming completes (schema_mode:
replicate only) and sets refreshed: true on the audit row. Setting refresh without
replicate is rejected at config load. Both matview flags are rejected under
schema_mode: assume_existing — pre-create and refresh shells out of band, or use
replicate.
Chained matviews are dropped in reverse dependency order before recreate so truncate
re-runs stay idempotent without CASCADE. Plain views that depend on a matview are
not yet ordered after matview shells; prefer table-backed matviews or create those
views out of band.
Source database trust boundary¶
Function bodies, index definitions, DEFAULT expressions, and similar catalog
text are copied from the source as-is when replication is enabled. Treat the
source database as a trust boundary: elevated-object dispositions
(elevated_objects) are the control plane for SECURITY DEFINER / invoker-rights
risks. PrivaCI does not rewrite or sanitize function/index bodies beyond those
gates.
schema_mode: assume_existing¶
Use when DBAs (or Flyway/Liquibase) already own the target schema and PrivaCI should validate-and-load only:
version: "1.0"
schema_mode: assume_existing
on_existing_data: truncate # typical for prebuilt staging
passthrough_copy: auto # auto | require_binary | batch
Preflight checks every in-scope table exists on the target with compatible
column names and types (physical order is not required). Extra target
columns are allowed. On success the engine writes schema.validated to
_privaci.audit_log; on refusal it writes schema.validation_failed (identifiers
and declared types only — never cell values) and exits 2.
passthrough_copy controls whole-table binary COPY for unmasked tables:
| Value | Behaviour |
|---|---|
auto (default) |
Prefer binary when source and target column names, types, and order match; otherwise use the named batch path. |
require_binary |
Fail preflight if any passthrough table is not binary-eligible. |
batch |
Never use binary COPY; always use the named batch path. |
on_existing_data: drop_create is rejected with assume_existing: deleting
customer-managed DDL would leave no schema for PrivaCI to load. Use truncate
or fail.
Loads are full reloads of source values, including primary keys and
identity/SERIAL columns (explicit insert + post-load setval). That means:
| Target state | on_existing_data: fail |
on_existing_data: truncate |
|---|---|---|
| In-scope tables empty | Allowed | Truncate (no-op) then load |
| In-scope tables have rows | Preflight exit 2 | Truncate then load |
Missing identity/SERIAL columns does not make a populated target safe without
truncate — source keys still collide on unique constraints. Use truncate for
re-runs into staging; append / upsert is not supported in this version.
Actions¶
Every column action is selected by its action discriminator. Unknown action
names and extra keys are rejected with the offending YAML path.
| Action | Required params | Optional params | Notes |
|---|---|---|---|
fake |
provider |
seed_alias, params |
Deterministic synthetic value from a registered provider. Unknown provider names fail validation (exit 3). |
regex_mask |
pattern, replace |
flags |
pattern must compile; unknown flags are rejected. |
hash |
— | — | Salted SHA-256 of raw salt concatenated with value (no delimiter between them; no column path). Same value hashes the same across tables/columns — useful when you intentionally want join/linkability. Prefer hmac_hash when column-scoped digests are required. Low-entropy inputs (phone, SSN) with a known salt are dictionary-reversible; do not treat hash as irreversible anonymization for those fields. |
hmac_hash |
— | encoding |
Keyed HMAC-SHA256 using pseudonym_key, scoped by column path. Requires a plugin granting the keyed_actions capability; rejected in community mode (exit 5). |
pseudonym |
provider |
seed_alias, params |
Keyed deterministic fake (same providers as fake). Requires the keyed_actions capability; rejected in community mode (exit 5). |
passthrough |
— | — | Copy the value unchanged. |
null |
— | — | Write NULL. Rejected at pre-flight on NOT NULL columns. |
static |
value |
— | Replace every value with a constant. |
ner_mask |
— | entities |
Level-2 SpaCy NER. entities defaults to PERSON, ORG, GPE, LOC. Requires pip install 'privaci[nlp]' and the en_core_web_sm model. Missing SpaCy fails at config validate (exit 3, explicit YAML), preflight (exit 2, including auto-detect), or runtime MaskingError (exit 1) — never silent passthrough of source text. |
ai_refine |
provider, model |
params |
Level-3 (not implemented / not wired). Rejected in community mode at config validate (exit 3). With a plugin package installed, config may accept the action, but mask time still raises L3NotInstalledError (exit 3) until connectors are wired and implemented. Do not use in production configs yet. |
Conditional masking (when:)¶
Every column action may include an optional CEL expression:
tables:
public.tickets:
columns:
notes:
action: fake
provider: company
when: "status == 'closed'"
- When
whenis omitted or empty, the action runs for every row (unchanged). - When present, the action runs only if the expression evaluates to true; otherwise the cell is left unchanged (auto-detect does not override).
- Requires the
conditional_maskinglicense capability (plugin package). Without it, config validation exits 5. - Tables with any
whenuse the batch/row path (not whole-table binary COPY).passthrough_copy: require_binarypluswhenfails preflight (exit 2). - Unsupported column types in the expression (e.g.
jsonb, arrays) fail at preflight (exit 3). Prefer bool/int/float/text/uuid/timestamp/bytea. - Unknown column identifiers in
when:fail at catalog type-check (exit 3). - Allowed forms: comparisons, logic,
size(), and stringcontains/startsWith/endsWith. Usecol != nullfor null checks (has()is rejected — activation always binds columns, so it would be always-true). Regex (matches), comprehensions (map/filter), field selection, indexing (col[i]), andtimestamp/durationare rejected at compile (exit 3). - Audit rollup: one
column.conditional_skipper guarded column withexpression_hash,skipped_rows, andevaluated_rows(no cell values). - Expression length max 512 characters; evaluation enforces a cooperative 5 ms elapsed budget per row per guarded column (celpy cannot hard-preempt; overrun fails the run with exit 1). AST depth/node limits also apply.
Built-in fake providers¶
| Provider | Output shape |
|---|---|
first_name, last_name, full_name |
Synthetic person names |
email |
local@domain using fake domains (fakedom.net, example.test, tryvault.dev by default); params.domain overrides |
phone |
E.164 test number; preserves a leading country code when detectable |
street, city, postcode, country, address |
Synthetic postal components or a one-line address |
dob |
ISO-8601 date within ±5 years of the input age bracket |
ip_address |
RFC 5737 TEST-NET address |
ssn |
000-099-XXXX SSA advertising test range |
credit_card |
Luhn-valid number from documented test BINs |
uuid |
Deterministic UUIDv4 layout |
company, job_title, username |
Synthetic org / role / handle |
password |
Always the fixed placeholder privaci-test-pw (never a real-looking hash) |
Register custom providers from plugins via privaci.contracts.register_provider.
Examples¶
tables:
public.users:
columns:
first_name: { action: fake, provider: first_name }
email: { action: fake, provider: email, seed_alias: user_email }
ssn: { action: regex_mask, pattern: "^\\d{3}-\\d{2}-\\d{4}$", replace: "000-00-0000" }
password_hash: { action: hash }
notes: { action: ner_mask } # masks PERSON/ORG/GPE/LOC
audit_internal.audit_log_events:
strategy: exclude # table omitted in target
seed_alias for foreign keys¶
Salt-hashed faking already keeps the same input → same output across columns.
When two columns hold the same logical value under different column names
(for example an FK orders.customer_email pointing at users.email), give them
a shared seed_alias so they fake to the same value and the relationship
survives:
tables:
public.users:
columns:
email: { action: fake, provider: email, seed_alias: user_email }
public.orders:
columns:
customer_email: { action: fake, provider: email, seed_alias: user_email }
Implied (soft) foreign keys¶
Postgres only enforces relationships you declare. Many real schemas carry
soft references — a column like clinical.patient_documents.referring_provider_email
that informally points at clinical.providers.email with no FOREIGN KEY
constraint. PrivaCI cannot see these in the catalog, so it would fake the two
columns independently and silently break the link.
During introspection PrivaCI flags these. A column whose name ends in a known
suffix (_email, _username, _user_id, _mrn) is matched against
single-column UNIQUE columns elsewhere in the source. When a match is
found and no catalog FK exists, the engine emits one implied_fk_warning
naming both columns and suggesting a seed_alias:
Warning: [implied_fk_warning] Table clinical.patient_documents column
referring_provider_email looks like a soft reference to
clinical.providers.email (UNIQUE) but no catalog foreign key exists. Add
'seed_alias: clinical.providers.email' on
clinical.patient_documents.referring_provider_email to keep masked values
consistent across both columns.
The warning never blocks the run. Apply the fix by giving both the referenced
column and the soft reference a shared seed_alias (see above). To silence a
warning you have reviewed and accepted, list its source column path under
implied_fk_ignore:
implied_fk_ignore:
- clinical.patient_documents.referring_provider_email
Running a mask job¶
privaci run (or bare privaci) loads this file, resolves secrets, runs
pre-flight checks, then streams masked rows to the target:
export SOURCE_DB_URL=postgresql://postgres:dev@127.0.0.1:55432/privaci_source
export TARGET_DB_URL=postgresql://postgres:dev@127.0.0.1:55433/privaci_target
export ANONYMIZATION_SALT="$(privaci gen-salt)"
privaci run --config mask-rules.yaml
privaci dry-run --config mask-rules.yaml # pre-flight only (no target wipe)
| Flag | Description |
|---|---|
--config |
Path to this file (default: /config/mask-rules.yaml). |
--source |
Source DB URL or secret URI (default: SOURCE_DB_URL). |
--target |
Target DB URL or secret URI (default: TARGET_DB_URL). |
--dry-run |
On run only: pre-flight + per-table summary, no writes. |
--no-audit-table |
Skip _privaci.audit_log writes for this run. |
Salt resolution: global_salt in this file (supports ${ANONYMIZATION_SALT}
and secret URIs), else the ANONYMIZATION_SALT environment variable.
Secret URIs (aws-sm://, azure-kv://, vault://, env://, file://) use
bounded connect/read timeouts and fail closed on backend errors. The file://
backend only reads regular files under allowed roots (default:
/run/secrets, /var/run/secrets); override with a :-separated list in
PRIVACI_SECRET_FILE_ROOTS. Symlinks and paths outside those roots are rejected.
Verifying a run¶
privaci verify audits a completed run by comparing the target against the
source. It is value-free: it reports only counts, rates, and verdicts —
never raw cell values — so it is safe to run in any environment, including CI.
Cell equality uses string forms (str(src) vs str(tgt)), so float formatting
or timestamp precision differences can produce false FAIL/WARN verdicts — treat
verify as a leak/passthrough screen, not a typed equality oracle.
privaci verify --config mask-rules.yaml
# Verification: 77 passed, 0 warning(s), 0 failure(s).
It samples rows (default 1,000 per table, --sample-size) and runs these checks:
| Check | Verdict on failure | Catches |
|---|---|---|
column.change_rate |
FAIL if 0% changed, WARN if partial | A masked column whose values didn't change (mask not applied) |
column.passthrough_drift |
FAIL | A passthrough column that changed unexpectedly |
table.row_count |
FAIL | Rows dropped or duplicated vs source |
table.uniqueness |
FAIL | Faker collisions breaking a PK/UNIQUE constraint |
table.fk_integrity |
FAIL | Orphaned foreign keys after masking |
verify exits 1 when any check FAILs (warnings do not fail the command), so
it can gate a pipeline. Tables without a single-column primary key skip the
row-level checks (reported as a WARN) but still get structural checks.
| Flag | Description |
|---|---|
--config |
Path to this file (default: /config/mask-rules.yaml). |
--source / --target |
DB URLs or secret URIs (default: SOURCE_DB_URL / TARGET_DB_URL). |
--sample-size |
Rows per table to sample for row-level checks (default: 1000). |
Resuming an interrupted run¶
After a SIGINT/SIGTERM (exit 130), checkpoints in _privaci.table_checkpoints
record per-table progress. Resume only when config, source URL, and salt are
unchanged:
privaci resume --config mask-rules.yaml
If identity fields drift, resume exits 2 with a structured message. Tables
already marked done in checkpoints are skipped; in-progress tables continue
from last_pk_value.
Generating CI workflows¶
privaci generate-ci --platform github-actions
# Writes .github/workflows/privaci-refresh.yml and docs/privaci-setup.md
privaci generate-ci --platform gitlab-ci
privaci generate-ci --platform k8s-cronjob
Use --output-dir to write files somewhere other than the current directory.
Installing a config pack¶
privaci install-pack hipaa --config mask-rules.yaml
# Preview merge, prompt for confirmation
privaci install-pack hipaa --config mask-rules.yaml --yes
privaci install-pack hipaa --local-pack-dir ./packs --config mask-rules.yaml
Manifests are verified against a trusted Ed25519 public key before any local
file is modified. Invalid signatures exit 3.
The engine ships no built-in key. You must provide the official release key
through the PRIVACI_PACK_PUBLIC_KEY environment variable (a hex-encoded 32-byte
Ed25519 public key). When it is unset, verification fails closed and
install-pack aborts before touching your config:
# Official trust anchor (v0.1.0-beta.1 and later, until rotated):
export PRIVACI_PACK_PUBLIC_KEY="cd965cb6dadcecefd508ae84a000684f431490c3d3ddae006ad5f89bf2c25978"
privaci install-pack hipaa --config mask-rules.yaml
| Situation | Result |
|---|---|
PRIVACI_PACK_PUBLIC_KEY unset or invalid |
Exit 3, no files modified |
| Key set, signature invalid/tampered | Exit 3, no files modified |
| Key set, signature valid | Merge proceeds after preview/confirmation |
The release pipeline provisions this key; operators installing official packs copy it from the published release notes. See the pack-signing runbook for how the key is generated and rotated.
Compliance reports¶
privaci report --run <run-uuid> --format json
privaci report --run <run-uuid> --format json --output report.json
Community mode emits a JSON stub; signed PDF reports require a
report_renderer plugin.
Validation rules at a glance¶
All violations below exit 3 and name the YAML path:
- Unknown top-level or per-action keys (
extra = forbid). - Missing or unsupported
version. - Misspelled
action(lists the valid action tags). regex_maskwith a non-compilablepatternor an unknown flag.on_existing_data: append(unsupported in the MVP).batch_size < 1(global or per-table).action: ai_refinewithout an LLM connector plugin installed, or with a plugin installed but Level-3 not yet wired into the masking path (mask time still raisesL3NotInstalledError, exit 3).action: nullon aNOT NULLcolumn (checked during pre-flight against the live catalog).- Configured table names absent from the source catalog (pre-flight exit
3).
Related¶
- CLI reference — every
privacisubcommand and its options. - Error codes — exit codes and message format.
- Extending PrivaCI — the
privaci.pluginsentry-point model (including reservedai_refineconnectors — not implemented yet).