Skip to content
Talk to our solutions team

Field protection and data classification

compliances: is the per-field declaration of what a value is and how it must be protected. One list carries three kinds of token: read-output treatment, gates on logging/export/indexing, and at-rest treatment — plus four sensitivity classifications that expand into a default bundle of the others.

This is a compliance surface, so read this table before you rely on a directive.

DirectiveEnforced byStatus in the deployed service
noexport (explicit or classification-implied)Schema-export renderersEnforced
noindex (explicit, or implied by encryption/tokenization)Migration index emitterEnforced
encrypted blocking a WHERE/HAVING conditionCompile-time safety guardEnforced
mask/redact/encrypted/tokenize/hash/pii/phi/pci/gdpr blocking an aggregateCompile-time safety guardEnforced
access.actions.unmask / access.actions.decrypt guardsAccess hookEnforced
Classification resolution and introspectionSchema resolver, script namespacesEnforced
pii/phi/pci/gdpr selecting the columns the retention anonymise pass overwritesRetention runnerEnforced
pii/phi/pci/gdpr listed by the retention admin endpointAdmin routeEnforced
mask, redact, hidden on readRead-side compliance hookParses; hook not registered
encrypted, tokenize, hash at restWrite/read encryption hooksParses; hooks not registered
nologParses; surfaces in introspection only
retention as a field directiveParses; nothing reads it

Each entry is a map. type: is the directive; the other keys are payload read only by specific directives.

entities:
- name: patient
fields:
- name: ssn
type: string
compliances:
- type: pii
- type: mask
value: "XXX-XX-____"
KeyRead byMeaning
typeallThe directive token.
valuemask, retentionMask pattern, or retention duration.
keyencryptedName handed to the engine’s key provider; empty resolves to default.
algorithmencryptedParsed and stored; the encryption path is AES-256-GCM regardless.
categoryParsed and stored; nothing reads it.

compliance: (singular) is accepted as an alias and appends one entry to the same list. Unknown type: values are ignored silently — there is no validation pass over the vocabulary.

Fifteen tokens. Grouping is descriptive, not syntactic: they all go in the same list.

TokenGroupEffectPayload
maskoutputReplaces the value on read with a masked representationvalue: — the pattern
redactoutputReplaces the value on read with [REDACTED]
hiddenoutputRemoves the field from the row entirely
nologgateMarks the value as never-log
noexportgateRemoves the field from every schema export
noindexgateSuppresses index emission over the field
encryptedat restEncrypts the value in the columnkey:, algorithm:
tokenizeat restStores an opaque token instead of the value
hashat restStores a SHA-256 digest, one-way
retentionlifecycleMarks a retention intent on the fieldvalue: — duration
searchableopt-outSuppresses classification-implied encryption
piiclassificationPersonal data
phiclassificationProtected health data
pciclassificationCardholder data
gdprclassificationGDPR-regulated data

Two further tokens are recognised only by the compile-time safety guard, as alternatives to searchable when unblocking a filter over an encrypted column: deterministic_encrypted and blind_index. Neither participates in the classification bundle.

Declaring pii, phi, pci or gdpr fills in the protections you did not spell out. Resolution is per dimension: a classification never overrides a directive you wrote.

DimensionDefault appliedSuppressed by
Read outputmask — pattern last4 when pci is among the classifications and no pattern was given, otherwise the default strategyany of mask, redact, hidden on the field
At restencrypted with key: defaultany of encrypted, tokenize, hash on the field — or searchable
Loggingnolognothing — always applied
Exportnoexportnothing — always applied
Indexingnoindex, whenever the resolved posture ends up encrypted or tokenizeddeclaring searchable (which removes the encryption that implied it)

The output and at-rest dimensions are suppressed by any directive in their group, not by the matching one. Writing hidden on a pii field suppresses the default mask; writing hash suppresses the default encryption.

Multiple classifications on one field are additive and all are retained. Only pci changes the default mask pattern.

# Resolves to: masked (default strategy), encrypted with key "default",
# nolog, noexport, noindex.
- name: date_of_birth
type: date
nullable: true
compliances:
- type: pii
# Resolves to: masked with "last4", encrypted with key "card", nolog,
# noexport, noindex. The explicit key wins; pci still sets
# the mask pattern because no output directive was written.
- name: card_number
type: string
compliances:
- type: pci
- type: encrypted
key: card

A classified field is encrypted by default, which makes it noindex and — once the at-rest hooks are wired — unusable in a plaintext filter. searchable is the only way out. It suppresses the default encryption and leaves masking, nolog and noexport in place.

- name: email
type: string
compliances:
- type: pii
- type: searchable # keep it queryable for lookups

value: on a mask entry is either a named shorthand or a character template. Detection is by exact match against the four names first; anything else containing an X or a _ is a character template, and anything else is a fixed replacement string.

PatternRuleExample
last4All but the last four characters become *; a value of four characters or fewer is fully masked4111111111111234************1234
first1First character kept, the rest become *; a one-character value is fully maskedAliceA****
emailFirst character of the local part kept, rest of the local part masked, domain kept verbatim[email protected]a****@example.com
phoneAll but the last four characters become *, when the value contains more than four digits; otherwise fully masked555-123-4567********4567

email falls back to the default strategy when the value has no @. A local part of one character yields a***@domain — the mask length is fixed, not derived.

Any other pattern is walked rune by rune against the value.

Pattern runeBehaviour
XConsumes one character of the value, emits *
_Consumes one character of the value, emits it verbatim
anything elseEmitted as a literal; consumes nothing

Two boundary rules:

  • Pattern shorter than the value — the remaining value is emitted verbatim. The pattern names the start of the mask, not the whole layout. Write a pattern at least as long as the longest expected input if you want a hard cut.
  • Value shorter than the pattern — leftover X and _ runes are dropped; leftover literals are kept.
PatternValueResult
XXX-XX-____123456789***-**-6789
XXX-XXX-____5551234567***-***-4567
XXXXXabcde*****
_____hellohello
___XXXref990ref***
XXX1234567890***4567890
XXX-XXX-____12**--
XXX@___alicedomain***@cedomain

With value: omitted — including the mask a classification supplies for a non-pci field — the default strategy applies by value length.

Value lengthResult
0unchanged
1–2every character replaced with *
3–6first character kept, rest replaced with *
7 or morefirst character kept, last four kept, everything between replaced with *

A non-string value under any mask becomes the literal ***. An empty string is never masked. redact replaces the value with [REDACTED] regardless of type.

Compliance on fields declared inside an embeddable is resolved and applied recursively through the parent field, for both object and array shapes. An embeddable stored as a JSON string is left untouched.

Protections split into irreversible and revealable:

ProtectionReversible at readReveal action
maskedyesunmask
encryptedyes, but transparently — the service holds the keynone of its own
tokenizedyes, via the tokenizernone of its own
redactedno
hashedno
hiddenno

There is exactly one per-user reveal action: unmask. Encryption and tokenization are at-rest controls — they protect the database, disk, backups and DBA from the value, not the value from an authorised reader — so the engine decrypts them transparently and exposes no per-user action for them. A field that is both encrypted and masked exposes only unmask.

Query flagRequest effectAccess rule evaluated
?unmask=trueAsks for unmasked values on a readaccess.actions.unmask
?untokenize=trueAsks for detokenized values on a readaccess.actions.decrypt

Flag names are matched case-insensitively and accept true, t, yes, y, on and 1. Both are read-only: the guards are evaluated on read operations and ignored on create, update and delete.

The extended guard is ANDed on top of the base read guard — it never replaces it. A caller who passes read but fails unmask gets 403 with action guard denied unmask on "<entity>".

entities:
- name: patient
access:
actions:
read: { language: expr, expression: '"clinician" in user.roles' }
unmask: { language: expr, expression: '"phi-reader" in user.roles' }

Once the guard passes, mask and redact are dropped from the field’s read-side treatment. hidden and nolog are not — a hidden field stays absent from the response even with ?unmask=true.

An impersonated request is a read-only actor by default, and unmask/decrypt are deliberately not classified as mutating actions, so an impersonating operator can still request a reveal.

A granted reveal is not written to the audit stream by the deployed service: the audit sink hook fires on writes only, and the compiler-side sink records access denials. Neither records a successful ?unmask read.

The compile-time safety guard refuses COUNT, SUM, AVG, MIN, MAX and every other aggregate over a field declaring any of encrypted, redact, mask, tokenize, hash, pii, phi, pci or gdpr, unless the principal passes the entity’s access.actions.unmask rule. COUNT(*) has no target field and is always allowed.

safetyguard: aggregation SUM(salary) refused — column carries compliance tags [pii] and principal lacks Unmask permission on entity "employee"

This check fails closed: with no unmask rule declared on the entity, no caller can aggregate over the column at all. The failure surfaces as a 500, not a 403.

noexport — declared or implied by any classification — removes the field from every variant of every schema export. See Schema export for the per-format detail and for the GraphQL SDL exception.

noindex — declared, or implied because the resolved posture is encrypted or tokenized — makes the migration planner skip any index that names the field in its columns or its covering columns. The index is dropped from the plan silently; the entity still migrates.

Scripts see the resolved posture. data.schema.field(entity, name) and data.schema.fields(entity) return a compliance object, and the pointcut binding ctx.field carries the same data on three keys:

Introspection keyContents
classificationsThe compound tags as declared — subset of pii, phi, pci, gdpr
primitivesThe atomic protections in effect, compounds already exploded: encrypted, tokenized, hashed, masked, redacted, hidden, nolog, noexport, noindex
actionsThe reveal actions available — unmask, or empty

On ctx.field the keys are complianceClassifications, compliancePrimitives and complianceActions.

GET /admin/data/retention/entities lists, for each entity with an active retention: block, the field names carrying pii, phi, pci or gdpr in pii_fields[]. The retention runner’s anonymise phase overwrites that same set and stamps anonymized_at. A nullable field is set to NULL; a NOT NULL field gets a type placeholder — [redacted] for string/text, 0 for numerics, false for bool, the Unix epoch for date/time/datetime, the all-zero UUID for uuid/ulid, and NULL for every other type. An entity with no classified field is skipped by the phase entirely.

The writeonly modifier — which strips a field from read responses — is applied by the same read-side hook as masking, so it is unwired for the same reason. It does still exclude the field from the output variant of a schema export.

entities:
- name: applicant
fields:
- name: id
type: ulid
- name: email
type: string
validations:
- type: email
compliances:
- type: pii
- type: searchable # keep it filterable
- name: ssn
type: string
compliances:
- type: pii
- type: mask
value: "XXX-XX-____" # overrides the default mask
- type: encrypted
key: pii_key
- name: card_number
type: string
compliances:
- type: pci # mask defaults to last4
- name: internal_score
type: integer
compliances:
- type: hidden # never returned, never revealable
- name: consent_ip
type: string
compliances:
- type: gdpr
- type: hash # suppresses the default encryption
access:
actions:
read: { language: expr, expression: '"underwriter" in user.roles' }
unmask: { language: expr, expression: '"pii-reader" in user.roles' }

Resolved posture, per field:

FieldPrimitivesReveal actionsIndexable
emailmasked, nolog, noexportunmaskyes
ssnencrypted, masked, nolog, noexport, noindexunmaskno
card_numberencrypted, masked (last4), nolog, noexport, noindexunmaskno
internal_scorehiddennoneyes
consent_iphashed, masked, nolog, noexportunmaskyes

ssn carries an explicit encrypted directive and no searchable, so any query with a WHERE or HAVING condition on ssn is refused at compile time; ordering or grouping by it still compiles. email is filterable because searchable removed its encryption. Every field except internal_score is refused as an aggregate target unless the caller passes the unmask rule.

Of that, the deployed service enforces the export removal, the index suppression, the filter and aggregate refusals, and the unmask guard. The masking, encryption and hashing columns of that table describe declared intent, not runtime behaviour.