stable

classified

Classified fields: declare sensitive fields once with a preset — sealed or digested storage, safe projections, audited reveal, and verify-without-reveal.
Subpath@noy-db/hub/classified
FactorywithClassified()
Layerwith-shape
ClusterD — Data Shape
LOC cost~1,150 (off-bundle when not opted in)

What it does

Some fields are more dangerous than the rest of the record: card numbers, passwords, birth dates, security answers. The classified service lets you declare those fields once, with a behavioral preset, and the hub enforces the whole lifecycle for you: validation on write, how (or whether) the value is persisted, what a list may show, and how the value can ever come back out.

Every classified field has exactly one storage form:

  • recoverable — the value is stored encrypted and can be read back, but only through a deliberate, auditable reveal() call. Normal reads return an opaque sealed handle (it logs and serializes as "[sealed]", never the value).
  • digest-only — the value itself is never stored; only a salted, key-bound digest is. You can verify a candidate against it (verify()), never read it back. This is how passwords and secret answers work.
  • never — the value must not be persisted at all (a card's CVC). Writing it throws.

Alongside the protected value, presets can materialize riders — safe derived companion fields (cardNumber_last4, email_domain) stored as plain fields — and declare a list projection (mask, omit, or rider) that tells list renderers and exporters what to show instead of the value.

Since 0.3.0-pre.9, a classified declaration is also a via field-feature binding carrying an enforced security posture — sealed-at-rest, deterministic-exact-only querying, non-exportable, forgettable. The posture is consulted generically by query, export, and forget (no classified-specific special cases), and — through the dependency graph — it is inherited by any computed field that declares a classified source in its deps: the derived value is sealed or redacted too, instead of silently leaking plaintext into an ordinary field.

When you need it

  • Storing payment-card data without hand-rolling PCI-style handling (PAN sealed, last4/BIN available for display, CVC refused at the write boundary)
  • Login secrets and account-recovery answers: verify a candidate without ever being able to read the stored value, including k-of-n "answer 2 of 3 questions" challenges
  • PII (emails, phone numbers, birth dates) that lists should show masked, with an audited path to the full value for support cases
  • Looking up which record holds a given secret (invite codes, API tokens) without storing the secret — the opt-in equatable blind index and findByDigest

Prerequisites

  • classifiedFields declared on the collection — this feature reads per-field declarations; the factory alone does nothing without them.
  • For findByDigest on equatable digests: the collection must be opened with acknowledgeEquatableRisk (the "Find by digest" section below explains the equality-search trade-off).
  • For erasure flows: pairs with forgetStrategy: withForget() — see forget.

Opt in

Declaring classifiedFields on a collection is always available — write enforcement, validation, and riders run with no factory. The four read-egress operations (reveal, verify, verifyGroup, findByDigest) are tree-shaken behind withClassified(); without it they throw ClassifiedNotEnabledError.

import { createNoydb } from '@noy-db/hub'
import { classified, withClassified } from '@noy-db/hub/classified'
import { toMemory } from '@noy-db/to-memory'

const db = await createNoydb({
  store: toMemory(),
  user: 'owner',
  secret: 'correct horse battery staple',
  classifiedStrategy: withClassified(),   // enables reveal / verify / verifyGroup / findByDigest
})

const vault = await db.openVault('crm')

const customers = vault.collection('customers', {
  classifiedFields: {
    // A group claims several record fields with one preset (differential policy per member):
    card: classified.creditCard({ pan: 'cardNumber', expiry: 'cardExpiry', cvc: 'cardCvc' }),
    // A single-field entry: the key is the record field name.
    email: classified.email(),
    dob: classified.birthDate(),
  },
})

Usage

Write-time enforcement, sealed reads, riders

On put(), the hub rejects storage:'never' fields, runs the preset validator, and materializes the riders — all before encryption, inside the trust boundary:

await customers.put('c1', {
  holder: 'Nok P.',
  cardNumber: '4242424242424242',
  cardExpiry: '12/28',
  email: 'nok@example.com',
  dob: '1990-04-12',
})

// storage:'never' refuses persistence — validate the CVC at capture, drop it before put():
await customers.put('c2', { cardNumber: '4242424242424242', cardCvc: '123' })
// → throws ClassifiedNeverStoredError

// preset validators run on every write:
await customers.put('c3', { cardNumber: '1234' })
// → throws ClassifiedValidationError: not a Luhn-valid card number

const record = await customers.get('c1')
{
  "holder": "Nok P.",
  "cardNumber": "[sealed]",
  "cardExpiry": "[sealed]",
  "email": "[sealed]",
  "dob": "[sealed]",
  "cardNumber_last4": "4242",
  "cardNumber_bin": "424242",
  "email_domain": "example.com",
  "dob_yob": "1990"
}

The recoverable values come back as sealed handles (shown here as their "[sealed]" JSON form); the riders are ordinary plain fields you can display, query, and index.

The declared list projection (mask / omit / rider) is a contract, not automatic redaction: kernel reads return sealed handles + riders as above, and collection.describe() serializes each field's projection so UI layers know what to render. applyListProjection (exported from @noy-db/hub) is the vetted helper that applies it — the as-* exporters use the same function:

import { applyListProjection } from '@noy-db/hub'

const row = applyListProjection(customers.describe(), record)
{
  "holder": "Nok P.",
  "cardNumber": "•••• 4242",
  "cardExpiry": "••/••",
  "email": "•••@example.com",
  "dob": "1990-••-••",
  "cardNumber_last4": "4242",
  "cardNumber_bin": "424242",
  "email_domain": "example.com",
  "dob_yob": "1990"
}

Audited reveal

reveal() is the single deliberate path from a sealed field back to plaintext. It decrypts one field of one record, and refuses everything that has nothing to reveal:

await customers.reveal('c1', 'cardNumber')   // → '4242424242424242'

await customers.reveal('c1', 'cardCvc')      // rejects: ClassifiedRevealError — storage:'never', nothing stored
await customers.reveal('c1', 'nope')         // rejects: ClassifiedRevealError — field is not classified

When the consent service is enabled, each reveal() records exactly one reveal entry in the consent audit trail (and no extra get entry), so "who looked at the full card number, when, and why" is answerable.

Verify without reveal

Digest-only fields are a verdict-only oracle: you can ask "is this candidate the stored value?" but never "what is the stored value?". Digest-only storage requires an encrypted collection with perRecordKeys: true — the digest keys derive from the per-record key, so forget shreds them.

const users = vault.collection('users', {
  perRecordKeys: true,                      // required for digest-only fields
  classifiedFields: {
    password: classified.password({ minLength: 12, rotateDays: 90, notLastN: 3 }),
    petName: classified.secretAnswer(),
    firstSchool: classified.secretAnswer(),
  },
})

await users.put('u1', {
  password: 'correct-horse-battery',
  petName: 'Rex',
  firstSchool: 'Wat Suan Dok',
})

await users.verify('u1', 'password', 'correct-horse-battery')   // → { ok: true }
await users.verify('u1', 'password', 'wrong-guess-1234')        // → { ok: false }

The verdict is bare — a failure carries no detail an attacker could use. When the preset declares rotateDays, an ok: true verdict past the deadline additionally carries mustRotate: true (never computed for a failed verdict). With notLastN, rewriting the field to one of its last N values throws ClassifiedRotationError.

Secret answers normalize before digesting (casefold, trim, whitespace-collapse), and verifyGroup runs a k-of-n challenge over all secretAnswer members in one call:

await users.verifyGroup('u1', { petName: 'rex', firstSchool: 'nope' }, { min: 1 })
// → { passed: true }   ('rex' matches 'Rex' after normalization)

await users.verifyGroup('u1', { petName: 'rex', firstSchool: 'nope' }, { min: 2 })
// → { passed: false }

verify() also works on recoverable fields — it routes through a normalized plaintext comparison inside the hub, so you can confirm "is this the email we have on file?" without revealing it.

With consent enabled, every verify() and every verifyGroup() call records exactly one verify audit entry — on success and failure, and one per group call, never one per member (attempt auditing without a member-count oracle).

Find by digest (equatable blind index)

By default, equal digest-only values are not correlatable — each write is salted. A field can opt into an equatable blind index: an extra store-visible tag (_bidx) that is equal exactly when the values are equal, which makes findByDigest(field, candidate) possible — "which records hold this secret?" without storing the secret. It is a double door: the field declares equatable: true and the collection must open acknowledgeEquatableRisk:

const invites = vault.collection('invites', {
  perRecordKeys: true,
  acknowledgeEquatableRisk: true,           // the collection-level door — see the warning below
  classifiedFields: {
    inviteCode: classified.password({ equatable: true }),
  },
})

await invites.put('r1', { inviteCode: 'invite-r1-secret-xyz' })
await invites.put('r2', { inviteCode: 'invite-r2-secret-abc' })

await invites.findByDigest('inviteCode', 'invite-r1-secret-xyz')  // → ['r1']
await invites.findByDigest('inviteCode', 'not-a-real-code-00')    // → []

Every hit is confirmed by a real digest verification before it is returned, so a forged or copied tag on another record never matches. After a value rotates, only the current value is findable. With consent enabled, one find sweep entry is recorded per call — never one per hit.

The equatable trade, stated plainly. Equal values produce equal store-visible tags: anyone with store access learns which records share the secret and how many share each value — never the value itself. A holder of the collection's data-encryption key can additionally test candidate values offline at PBKDF2-SHA256 speed (600K iterations, GPU-friendly): for low-entropy secrets like PINs or secret answers, recovering the equality partition is a matter of hours, not years. The iteration count raises the price; it does not make a low-entropy field safe — the real control is the door itself. Do not enable equatable on low-entropy fields unless a learnable partition is acceptable. Backups taken before a forget() retain the tags.

Forget — sealed slots and the scopedPurge knob

A classified declaration is forgettable, so forget erases a subject's classified data along with the rest of the record: every sealed slot keyed to the shredded record's per-record key is dropped (sealedFieldsShredded), and a legacy slot it cannot shred is reported (sealedResidue) rather than silently kept. These counts are unchanged from before classified became a via field-feature.

Separately, if a record's key was ever sealed to a host — a _sealed_cek host-delivery envelope that lets a granted at-* host recover the key — forget() purges those envelopes too. By default this purge is unconditional: it runs on every collection, including one that declares only sensitive: [...] and no classified field — because any collection can seal a key to a host, and narrowing the purge would silently leave some keys recoverable.

scopedPurge opts into the narrower, cheaper behavior. A deployment where most collections never seal to a host pays for that broad prefix scan for nothing; set scopedPurge: true on the forget cascade to purge _sealed_cek envelopes only for declaration-backed collections:

import { withForget } from '@noy-db/hub/forget'

const db = await createNoydb({
  store: toMemory(),
  user: 'owner',
  secret: 'correct horse battery staple',
  classifiedStrategy: withClassified(),
  forgetStrategy: withForget({
    subjects: { contacts: 'subjectId', audit: 'subjectId' },
    scopedPurge: true,
  }),
})

Under scopedPurge: true, a collection with a classifiedFields binding is declared and its envelopes are purged as before; every other collection is undeclared — its envelopes are left in place and reported, never silently dropped:

// contacts declares classifiedFields; audit has only `sensitive: [...]`.
// Both had a key sealed to a host for subject-1.
const result = await vault.forget('subject-1')
{
  "sealedCekEnvelopesPurged": 1,
  "scopedPurgeResidue": [
    { "reason": "skipped-undeclared-sealed-cek", "collection": "audit", "count": 1 }
  ]
}

count is the number of envelopes left in place for that collection, aggregated across the whole forget() call. You are trading the default's completeness for cost: purge fewer prefixes, in exchange for reviewing the residue report for everything left out of scope.

A bare sensitive: [...] collection is undeclared here. Sensitive fields without a classifiedFields binding do not bring a collection into scopedPurge scope — after forget() its _sealed_cek envelope remains, and a host granted that key can still recover it until the envelope is purged another way. Wrap the field in a classifiedFields binding to bring the collection into scope, or leave scopedPurge off for collections that use only bare sensitive. The declaration signal is also session-local: a declared collection never opened in the current session before forget() resolves as undeclared, so open your declared collections first.

scopedPurge is set once on the forget strategy at createNoydb() and applies identically to every vault from that instance — the same per-instance granularity as subjects.

API reference

withClassified()

function withClassified(): ClassifiedStrategy

Enables the four read-egress operations on collections that declare classifiedFields. Pass it to createNoydb({ classifiedStrategy }). Without it, the declaration/write side still runs, and reveal / verify / verifyGroup / findByDigest throw ClassifiedNotEnabledError.

The preset catalog (classified.*)

PresetStorageList projectionRidersWrite validation
classified.creditCard({ pan, expiry?, cvc? })group——per member ↓
— PAN memberrecoverablemask '•••• ${last4}'last4, binLuhn check
— expiry memberrecoverablemask '••/••'—MM/YY
— CVC memberneveromit—none at put — any presence throws ClassifiedNeverStoredError
classified.birthDate()recoverablemask '${yob}-••-••'yobvalid ISO yyyy-mm-dd
classified.email()recoverablemask '•••@${domain}'domaincontains @
classified.phone()recoverablemask '•••••${last2}'last2≥ 5 digits
classified.password(opts?)digest-onlyomit—NFC length ≥ minLength (default 10)
classified.secretAnswer(opts?)digest-onlyomit—non-empty after normalization

classified.password options: minLength? (default 10), rotateDays? (decorate ok: true verdicts with mustRotate after N days), notLastN? (0–8, refuse reuse of the last N values on rotate; each remembered value costs one 600K PBKDF2 on write), equatable?. classified.secretAnswer options: equatable?. secretAnswer members are groupable via verifyGroup and normalize casefold/trim/whitespace-collapse; password normalizes NFC-strict.

Riders become plain companion fields named <field>_<rider> (e.g. cardNumber_last4); a collision with a declared or computed field throws ClassifiedConfigError.

Collection operations (require withClassified())

MethodReturnsPurpose
reveal(id, field)Promise<unknown>Audited decrypt of one recoverable field. Refuses never, digest-only, unknown fields, missing records (ClassifiedRevealError).
verify(id, field, candidate)Promise<ClassifiedVerdict>Verdict-only check: { ok: boolean, mustRotate?: true }. Digest compare for digest-only; normalized in-hub plaintext compare for recoverable.
verifyGroup(id, answers, { min })Promise<{ passed: boolean }>k-of-n challenge over the collection's secretAnswer members.
findByDigest(field, candidate)Promise<readonly string[]>Ids whose equatable digest-only field equals candidate. Requires equatable: true + acknowledgeEquatableRisk: true. The three mis-declaration cases (not classified / not digest-only / not equatable) throw ClassifiedVerifyError with one identical message — no field-enumeration oracle.

Collection options

OptionTypeMeaning
classifiedFieldsRecord<string, ClassifiedEntry>Field name → spec, or any key → group (groups claim their member field names).
perRecordKeysbooleanRequired true when any field is digest-only — digest keys derive from the per-record key so forget() shreds them.
acknowledgeEquatableRiskbooleanCollection-level door for equatable fields. true with zero equatable members is a harmless no-op.

Errors

ErrorThrown when
ClassifiedNotEnabledErrorA read-egress op is called without withClassified().
ClassifiedConfigErrorInvalid declaration: duplicate field claim, rider collision, digest-only without encryption or perRecordKeys, equatable on a non-digest field or without the door, combining with CRDT/conflict resolvers, overlap with sensitive[], indexed/embedded digest fields.
ClassifiedValidationErrorA write value fails its preset validator.
ClassifiedNeverStoredErrorA storage:'never' field is present on put().
ClassifiedRevealErrorreveal() on a non-recoverable/unknown field, a missing record, or a never-written field.
ClassifiedVerifyErrorverify/verifyGroup/findByDigest caller bugs (unknown field, wrong storage form, no groupable members).
ClassifiedRotationErrorA digest-only rewrite reuses one of the last notLastN values.

All are exported from @noy-db/hub/classified, except ClassifiedNotEnabledError, which is exported from @noy-db/hub (it belongs to the kernel's strategy seam, not to the opt-in module).

Advanced exports

For building custom specs and tooling on top of the same machinery:

ExportPurpose
ClassifiedFieldSpec, ClassifiedGroup, ClassifiedEntry (types)The descriptor shapes behind the presets — a raw spec object works anywhere a preset does.
ClassifiedStorage, ClassifiedList, ClassifiedRider, ClassifiedVerdict (types)The storage-form, list-projection, rider, and verdict vocabularies.
ClassifiedStrategy, ClassifiedRevealCtx, ClassifiedVerifyCtx (types), NO_CLASSIFIEDThe strategy seam withClassified() implements; NO_CLASSIFIED is the throwing default.
resolveClassifiedFields(collection, config), ResolvedClassified (type)Flattens a classifiedFields config into a per-field map + rider computed entries.
enforceClassifiedWrite(record, byField, collection)The pure write-time gate (never-rejection + validators).
isClassifiedFieldSpec(x), isClassifiedGroup(x)Type guards for descriptor values.
luhnCheck(value)The card-number checksum used by the creditCard preset.

Limits & gotchas

  • Digest operations are deliberately slow. Every digest write, verify, and findByDigest target derivation runs a 600K-iteration PBKDF2 — that is the brute-force price tag, by design. Budget for it in hot login paths.
  • findByDigest scans the collection: one 600K digest derivation for the candidate (always exactly one, even on an empty collection — so timing cannot distinguish "no records" from "no match"), one envelope read per record (exactly list + N get store calls), and a confirming digest verification per hit — O(collection size), not an index lookup in the SQL sense.
  • No built-in rate limiter. For low-entropy secrets (PINs, secret answers) the enumeration math is on you — add application-side attempt limiting.
  • Storage forms are mutually exclusive per field — a field is digest-only OR recoverable OR never, exactly one; a digest-only field additionally cannot appear in the collection's sensitive[] list (the overlap would silently downgrade it to recoverable sealing, so it is refused).
  • recoverable and digest-only fields cannot combine with CRDT modes or a conflictPolicy resolver — merge paths bypass write enforcement, so the combination is refused at declaration (never-only declarations are exempt).
  • Digest-only fields cannot be indexed, text-indexed, embedded, deterministic, or a forget-subject key, and require an encrypted collection (encrypt: false is refused) with perRecordKeys: true.
  • recoverable and digest-only declarations are fixed at first open. A collection first opened without them cannot gain them later — the sealing and digest write paths are frozen at construction, so a late declaration would persist values unprotected while describe() advertises protection; it is refused instead. never-only declarations can reconcile onto an existing collection. A re-declaration that changes a field's storage form is always refused.
  • List masking is opt-in at the render layer. Kernel reads return sealed handles + riders; run applyListProjection (or consume describe()'s classified block) to get the declared mask/omit/rider view.
  • Writing null to a digest-only field clears it (an explicit clear, not a validation error).
  • Pre-forget() backups retain equatable _bidx tags — the partition of a forgotten record's secret can survive in old backups.
  • Export output changed in 0.3.0-pre.9 for the default (non-redact-option) path of the as-* exporters: vault.exportStream()/exportJSON() now redact a classified field to the plain string '[sealed]' on the record itself, so @noy-db/as-csv/as-sql/as-xml emit a bare [sealed] cell / text column where they previously emitted JSON-stringified sealed handles ("""[sealed]""" in CSV, a jsonb column). The new bytes are the intended ones — the old output was an accidental echo of a live, revealable handle reaching the export stream.

See also