classified
| Subpath | @noy-db/hub/classified |
| Factory | withClassified() |
| Layer | with-shape |
| Cluster | D — 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, auditablereveal()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
classifiedFieldsdeclared on the collection — this feature reads per-field declarations; the factory alone does nothing without them.- For
findByDigeston equatable digests: the collection must be opened withacknowledgeEquatableRisk(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.
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.
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.*)
| Preset | Storage | List projection | Riders | Write validation |
|---|---|---|---|---|
classified.creditCard({ pan, expiry?, cvc? }) | group | — | — | per member ↓ |
| — PAN member | recoverable | mask '•••• ${last4}' | last4, bin | Luhn check |
| — expiry member | recoverable | mask '••/••' | — | MM/YY |
| — CVC member | never | omit | — | none at put — any presence throws ClassifiedNeverStoredError |
classified.birthDate() | recoverable | mask '${yob}-••-••' | yob | valid ISO yyyy-mm-dd |
classified.email() | recoverable | mask '•••@${domain}' | domain | contains @ |
classified.phone() | recoverable | mask '•••••${last2}' | last2 | ≥ 5 digits |
classified.password(opts?) | digest-only | omit | — | NFC length ≥ minLength (default 10) |
classified.secretAnswer(opts?) | digest-only | omit | — | 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())
| Method | Returns | Purpose |
|---|---|---|
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
| Option | Type | Meaning |
|---|---|---|
classifiedFields | Record<string, ClassifiedEntry> | Field name → spec, or any key → group (groups claim their member field names). |
perRecordKeys | boolean | Required true when any field is digest-only — digest keys derive from the per-record key so forget() shreds them. |
acknowledgeEquatableRisk | boolean | Collection-level door for equatable fields. true with zero equatable members is a harmless no-op. |
Errors
| Error | Thrown when |
|---|---|
ClassifiedNotEnabledError | A read-egress op is called without withClassified(). |
ClassifiedConfigError | Invalid 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. |
ClassifiedValidationError | A write value fails its preset validator. |
ClassifiedNeverStoredError | A storage:'never' field is present on put(). |
ClassifiedRevealError | reveal() on a non-recoverable/unknown field, a missing record, or a never-written field. |
ClassifiedVerifyError | verify/verifyGroup/findByDigest caller bugs (unknown field, wrong storage form, no groupable members). |
ClassifiedRotationError | A 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:
| Export | Purpose |
|---|---|
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_CLASSIFIED | The 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, andfindByDigesttarget derivation runs a 600K-iteration PBKDF2 — that is the brute-force price tag, by design. Budget for it in hot login paths. findByDigestscans 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 (exactlylist + N getstore 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-onlyORrecoverableORnever, exactly one; adigest-onlyfield additionally cannot appear in the collection'ssensitive[]list (the overlap would silently downgrade it to recoverable sealing, so it is refused). recoverableanddigest-onlyfields cannot combine with CRDT modes or aconflictPolicyresolver — 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: falseis refused) withperRecordKeys: true. recoverableanddigest-onlydeclarations 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 whiledescribe()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 consumedescribe()'sclassifiedblock) to get the declared mask/omit/rider view. - Writing
nullto a digest-only field clears it (an explicit clear, not a validation error). - Pre-
forget()backups retain equatable_bidxtags — the partition of a forgotten record's secret can survive in old backups. - Export output changed in
0.3.0-pre.9for the default (non-redact-option) path of theas-*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-xmlemit a bare[sealed]cell /textcolumn where they previously emitted JSON-stringified sealed handles ("""[sealed]"""in CSV, ajsonbcolumn). 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
- via — the field-feature surface and the posture/taint machinery classified plugs into
- computed — how a derived field inherits a classified source's posture
- consent — the audit trail that
reveal/verify/findoperations feed - forget — per-record key shredding, the reason digest-only requires
perRecordKeys - Field metadata —
collection.describe()— where the serializedclassifiedprojection contract surfaces - history — pairs with classified for versioned, audited records
- Core 02 — Enclave — the envelope model classified fields build on
- Services overview · Orientation map
- Proven by showcase
128-with-classified.