Registry stack documentation: machine-readable Markdown.
Index of all pages: https://docs.registrystack.org/llms.txt
Full corpus: https://docs.registrystack.org/llms-full.txt

# Encrypt a field

> Encrypt a restricted field so the database holds only ciphertext, bind the key the runtime needs, and turn encryption on for a field that already holds data.

If your registry holds a national identifier, a licence number, or another value your rules call
restricted, encrypt that field. The database then stores only ciphertext, and Base Registry Engine
(BReg) decrypts the value only when it answers a caller allowed to read it.

## Declare the field

Set `encrypted: true` on the field in `registry.yaml`. The compiler accepts it only when the
field's `classification` is `restricted` and its type is `string`, `text`, `date`, `decimal`, or
`structured`. An encrypted value has a size ceiling: an encrypted `string` or `text` field may declare
a `maximumLength` of at most 16,384 characters, and a `structured` field a `maximumBytes` of at
most 64 KiB. A [Record type](../../reference/glossary/#record-type) may hold up to 128 encrypted
fields. The facility template encrypts no field, so `bregctl check` on your own project is where
these rules first show up.

The database cannot compare ciphertext, so `bregctl check` refuses an encrypted field anywhere the
database would need the value: as a filterable, sortable, or row-boundary field in an access
profile, in a storage constraint or authored index, in a native `pattern`, in an event projection
or event condition, in derived SQL or a statistical dataset, in an Action's commit guard, or in a
Proposal's review context.

To find a record by an encrypted value, add a `lookup` block to the field. BReg stores a blind
index beside the ciphertext: an HMAC-SHA-256 of the value after the `normalization` steps you list
(`nfc`, `case-fold`, `trim`, `collapse-whitespace`, `remove-separators`, `uppercase`, `lowercase`,
in your order, at most eight). Set `unique: true` to make the index unique; a second record with the
same normalized value is refused as a conflict. Expose the lookup through a selector profile and the
access profile's `lookups` grant. A `structured` field cannot declare a lookup.

The index answers equality and nothing else, and it shows a database reader which rows hold a
value and which rows share one, without the value itself. Keep the normalization narrow: for an
identifier, `trim` and `remove-separators` usually say what "the same value" means.

For a name or any other text a person types, list `nfc` first. Without it, the composed and the
decomposed spelling of the same name (`é` as one character or as `e` plus a combining accent)
are different bytes, so they get different indexes: a lookup misses the record, and a `unique`
index accepts both spellings as two records. `nfc` rewrites the value to Unicode normalization
form C before the next step. `case-fold` applies NFC first, because the fold turns some combining
marks into letters (the Greek iota subscript folds to `ι`): two spellings that order their marks
differently would otherwise fold to different values. It then applies the full Unicode case fold,
so `ß`, `ẞ`, and `ss` are one value, which neither `lowercase` nor `uppercase` achieves. It ends
with NFC again, because folding some capitals leaves a decomposed sequence: `Ϊ́` (capital iota with
dialytika, then an acute accent) folds to `ι` plus two combining marks, and without the closing
NFC it would not match `ΐ`. A typical name lookup is
`normalization: [nfc, case-fold, trim, collapse-whitespace]`. Adding either step to the list of a
lookup that already holds data changes every stored digest; the section on changing the
normalization of an existing lookup below explains the reviewed migration that requires.

The [BReg configuration reference](../../reference/breg-configuration/) lists every member and
bound.

{/* Evidence: crates/registry-breg/src/compiler.rs, the `field.encrypted` checks
    (breg.field.encrypted-classification-invalid, encrypted-type-unsupported,
    encrypted-size-bound-exceeds-seal-limit, encrypted-pattern-refused,
    encrypted-lookup-type-unsupported, encrypted-lookup-normalization-too-long),
    breg.record-type.encrypted-fields-too-many, breg.access-profile.processing-encrypted,
    breg.access-profile.row-boundary-encrypted, breg.constraint.field-encrypted,
    breg.index.fields-encrypted, breg.event.projection-encrypted, breg.event.when-encrypted,
    breg.selector-profile.encrypted-lookup-required; crates/registry-breg/src/contract.rs,
    MAX_ENCRYPTED_FIELD_PLAINTEXT_BYTES, MAX_ENCRYPTED_FIELD_STRING_CHARACTERS,
    MAX_ENCRYPTED_FIELDS_PER_ENTITY, MAX_FIELD_LOOKUP_NORMALIZATION_STEPS, NormalizationStep,
    FieldLookupSource; crates/registry-breg/src/derived_sql.rs, refuse_encrypted_columns();
    crates/registry-breg/src/compiler/statistics.rs, breg.statistical-dataset.field-encrypted;
    crates/registry-breg/src/immediate_actions.rs,
    breg.action.commit_guards.targets-field-encrypted;
    crates/registry-breg/tests/field_encryption_contract.rs,
    native_proposal_context_refuses_encrypted_record_fields();
    crates/registry-platform-crypto/src/field_encryption.rs, blind_index_hmac();
    crates/registry-breg/src/field_encryption.rs, FieldEncryptionService::normalize(),
    case_fold_closes_with_nfc_so_folded_spellings_match(), and
    unicode_steps_give_equivalent_spellings_one_blind_index();
    crates/registry-breg/src/postgres/read.rs, lookup_sql();
    crates/registry-breg/tests/postgres_field_encryption.rs,
    blind_index_lookup_resolves_normalized_equality() and
    blind_index_unique_violation_maps_to_conflict(). */}

## Give the runtime its key

The deployment project's `runtime.yaml` names the key under `fieldEncryption.provider`. With
`type: transit`, BReg generates one data-encryption key per registry database and has HashiCorp
Vault or OpenBao Transit wrap it, reached through a local Unix socket (`unixSocketPath`, `mount`,
`keyName`); the database stores only the wrapped key. With `type: local-file`, `dekRef` names a
`secret:file/<name>` holding a base64 key, and startup refuses it unless the same file sets
`identity.databaseInitializationEnvironment` to `local`. [Deploy a registry](../../operate/breg/)
shows the block.

Each value is encrypted with AES-256-GCM under a key derived from that data key, bound to its
registry, Record type, field, and record, so ciphertext copied to another row does not open.
One key version is active, and BReg cannot rotate it. If you lose the wrapping key, you lose the
data: escrow it before the first encrypted field goes live, as the
[hardening checklist](../../security/hardening-checklist/) describes.

Startup refuses to serve when an active package declares an encrypted field and the key cannot be
unwrapped; there is no plaintext fallback. The [threat model](../../explanation/threat-model/#field-encryption-draws-a-boundary-inside-the-database)
covers the cryptography and its FIPS posture.

{/* Evidence: crates/registry-breg/src/field_encryption.rs, RawFieldEncryptionConfig,
    RawFieldEncryptionProvider, FieldEncryptionService::activate() and open_existing(), and the
    module comment on one key version; crates/registry-breg/src/startup.rs,
    FieldEncryptionCustody and the `databaseInitializationEnvironment` check;
    crates/registry-platform-crypto/src/field_encryption.rs, seal_field(), FieldAad, and
    FIELD_ENCRYPTION_ALGORITHM; crates/registry-breg/tests/postgres_field_encryption.rs,
    missing_field_encryption_provider_refuses_startup() and aad_binds_the_record_row(). */}

## What a reader sees

Encryption does not change who may read a field; the access profile does. A caller whose profile
lists the field in `readableFields` receives the plaintext value in record, list, history, and
revision reads. A caller whose profile does not list it gets a response without the field, as with
any unreadable field, and a request that selects it explicitly is refused. No API response
carries ciphertext. Someone reading the database directly sees ciphertext and the blind index.

If a stored value cannot be opened, the read answers HTTP `503` with
`runtime.field_encryption.unavailable`; Record types without encrypted fields keep serving.

{/* Evidence: crates/registry-breg/src/api/mod.rs, record reads select `readable_fields`,
    field_encryption_refusal(), field_encryption_unavailable(), and opened_held_body();
    crates/registry-breg/src/postgres/read.rs, the `selected_fields` subset check and
    open_row_members(); crates/registry-breg/src/postgres/{history_read,revision_read}.rs;
    crates/registry-breg/src/problem.rs, RuntimeFieldEncryptionUnavailable;
    crates/registry-breg/tests/postgres_field_encryption.rs,
    encrypted_fields_round_trip_and_store_only_envelopes(),
    revision_and_history_reads_decrypt_at_the_response_edge(), and
    tampered_envelope_fails_closed_and_sibling_entity_serves(). */}

## Proposals cannot change an encrypted field

A Proposal stores its before and after values for review, and those must not hold plaintext. A
Proposal may target a record that has encrypted fields, but its Plan may not set or clear one.
`bregctl check` does not catch this. The runtime refuses when it evaluates the Plan at Proposal
submission, with HTTP `400` `request.invalid`, before it locks or stores any target. If a
restricted value must go through review, keep that field unencrypted. Callers granted the field in
`writableFields` can still create and patch it directly.

{/* Evidence: crates/registry-breg/src/proposal_prepare.rs, resolve_targets() and prepare(),
    both returning MutationError::InvalidRequest when a mutation's field has `encryption`;
    crates/registry-breg/src/mutation/proposal.rs, execute_proposal(), ProposalOperation::Submit;
    crates/registry-breg/src/api/mod.rs, mutation_problem() and invalid_request();
    crates/registry-breg/src/proposal_prepare/tests.rs,
    encrypted_participation_is_refused_before_targets_resolve();
    crates/registry-breg/tests/postgres_field_encryption.rs,
    patch_replaces_the_envelope_and_test_op_is_refused(). */}

## Encrypt a field that already holds data

Adding `encrypted: true` to an existing field is a reviewed migration. Its descriptor declares a
step with `type: field-encryption-backfill`, which encrypts existing values chunk by chunk without
authored SQL, and one `history` choice:

- `erase-and-rebaseline` erases retained history so no plaintext copy remains in the database.
  Erasure removes whole record revisions, not only the field's past values.
- `retain-plaintext-history` encrypts from now on and leaves earlier revisions readable in
  plaintext to anyone with database access. Use it only as a recorded risk acceptance.

A descriptor without a choice is refused, and so is one that declares `history` for any other
change. Every field of one Record type encrypted in the same package shares one backfill step.
Backups taken before the migration still hold plaintext until you retire or re-encrypt them.

`bregctl field-encryption preflight` reports, without values, how many rows, revisions, and
retained copies each covered field affects, and which records would collide on a unique lookup.
It runs every step in one transaction on the serving registry, and lifts `FORCE ROW LEVEL
SECURITY` on each scanned Record type's table with an `ALTER TABLE`, which holds an
`ACCESS EXCLUSIVE` lock on that table until the preflight ends: that Record type's reads and
writes wait for the whole scan, so run it in a quiet window.
[Change an active registry](../../operate/breg-changes/) walks through preflight, apply, and
`erase-history`.

Two package changes are refused: removing encryption and adding a required encrypted field to an
existing Record type. For the second, add the field as optional, fill it through authorized writes,
then make it required in a later package.

### Change the normalization of an existing lookup

Editing the `normalization` list of an already encrypted field changes the digest of every stored
value, so the old digests would stop matching new queries and a `unique` index would not protect
the new spelling rules. BReg classifies that edit as a reviewed backfill, never as an automatic
change, and refuses a package that changes the list without one. The descriptor covers the field's
`FieldLookupChanged` change and declares one step with `type: field-encryption-backfill` whose
only object is the field's blind-index column (the member `<field>#lookup`). It carries no
`history` choice, because it rewrites no record, envelope, or revision. Only the engine derives
digests: a reviewed plan whose authored SQL writes a blind-index column, beside a re-index or an
encryption flip, is refused with `migration.review.blind-index-write-refused`.

At apply, the step opens each envelope under the active key, derives the digest under the new list,
and writes only the blind-index column, chunk by chunk, with the same resumable cursor as the
encryption backfill. Before the apply commits anything, ahead of the compiler DDL and of every
step in the plan, it checks every re-index and every encryption flip. It refuses the whole
package, naming up to 64 record ids and no values, when two records would share a digest under the
new list on a `unique` lookup, which is the case when two spellings of one name already coexist;
correct one of each pair first.

A record's new digest may equal the old digest a record later in the walk still holds, because
the two lists map different values onto one digest. That end state is valid, so the re-index does
not refuse it: on a `unique` lookup each chunk first clears any such stale digest on the rows it
has not reached, then writes the new digests. A cleared row gets its own new digest when the walk
reaches it. If the apply stops partway, a cleared record is not found by lookup until the resumed
step reaches it.

The registry stays in maintenance for the whole re-index and serves no Record reads or writes
until the step completes, including while an interrupted step waits to resume. Once the first
chunk commits, the target can no longer be reverted: `bregctl migration reconcile` reports it
`unresolvable`, because the reviewed migration committed steps that only the same target can
finish. Fix the cause and retry the same `apply`, which resumes the step after its last committed
chunk.

The same check counts the rows each re-index step walks, valued or null, and refuses a step whose
table holds more rows than its `maxTotalRows`, naming the step and both counts, because the step
would otherwise stop partway with old and new digests mixed. Raise `maxTotalRows` in a reviewed
package rehearsed against that count.

A refused apply leaves the active registry as it was: `bregctl migration reconcile` reports the
target `revertible`, reverting it returns the registry to `ready`, and it serves reads again.

Changing only the list is supported. Adding or removing the `lookup`, or changing its `unique`
flag, stays refused. `bregctl field-encryption preflight` scans a re-index step too: it opens the
envelopes with the configured field-encryption key, reports how many rows the step walks beside
its `maxTotalRows`, and names the records that would collide, so you can correct them before the
apply. Its re-index scan holds the same `ACCESS EXCLUSIVE` lock on the Record type's table until
the preflight ends, so run it in a quiet window.

{/* Evidence: crates/registry-breg/src/migration_plan.rs,
    ReviewedMigrationStepDescriptor::FieldEncryptionBackfill, ReviewedFieldEncryptionHistory,
    validate_descriptor_shape() (breg.migration.history),
    descriptor_covers_field_encryption_flip(), and validate_field_encryption_step_grouping();
    crates/registry-breg/src/package.rs, CompiledRegistryChangeCode::explanation() for
    FieldEncryptionChanged, FieldLookupChanged, and FieldAddedRequired,
    blind_index_needs_reindex_only(); crates/registry-breg/src/migration_plan.rs,
    ReviewedMigrationStepDescriptor::is_blind_index_reindex(),
    validate_blind_index_reindex_coverage(), and refuse_blind_index_writes();
    crates/registry-breg/tests/migration_plan.rs,
    reviewed_blind_index_reindex_refuses_an_authored_lookup_write_after_the_engine_step() and
    reviewed_encryption_flip_refuses_a_chunked_lookup_write();
    crates/registry-breg/src/postgres/interlock.rs,
    field_encryption_preflight(), scan_blind_index_reindex(),
    execute_blind_index_reindex_chunk(), clear_stale_unique_digests(), and
    set_force_row_security(); crates/registry-breg/src/field_encryption_backfill.rs,
    preflight_field_encryption_backfill(); crates/registry-breg/src/migration_reconcile.rs,
    classify() and UNRESOLVABLE_STEPS_COMMITTED;
    crates/registry-breg/tests/postgres_migration.rs,
    real_postgres_blind_index_reindex_resumes_and_makes_the_unique_index_unicode_aware(),
    real_postgres_blind_index_reindex_refuses_unicode_equivalent_duplicates_value_free(),
    real_postgres_refused_blind_index_reindex_stays_revertible_and_readable(),
    real_postgres_blind_index_reindex_checks_every_step_before_any_commits(),
    real_postgres_operator_preflight_scans_blind_index_reindex_steps(),
    real_postgres_blind_index_reindex_refuses_more_rows_than_its_ceiling(), and
    real_postgres_blind_index_reindex_completes_when_a_later_record_holds_a_new_digest();
    crates/registry-breg/src/field_encryption.rs, FieldEncryptionService::normalize();
    crates/registry-breg/src/field_encryption_backfill.rs,
    FieldEncryptionBackfillPreflightReport, MAX_NAMED_DUPLICATE_RECORDS, and
    erase_field_encryption_history(); crates/registry-breg/src/postgres/interlock.rs,
    field_encryption_duplicate_preflight(); crates/registry-bregctl/src/lib.rs,
    FieldEncryptionCommand. */}

## Next

- [Control access and reads](../breg-access/) to decide who reads the field.
- [Declare Actions and Proposals](../breg-actions/) for writes that go through review.
- [Threat model](../../explanation/threat-model/) for what encryption defends against.
- [Known limitations](../../explanation/known-limitations/) for the full list of BReg limits.