guards
| Subpath | @noy-db/hub/guards |
| Factory | withGuard() |
| Layer | with-audit |
| Cluster | E — Time & Audit |
| LOC cost | ~700 (off-bundle when not opted in) |
What it does
guards lets you say "this record can't be written (or deleted) unless a
business rule allows it" — a per-record lock, a per-field freeze, and
a controlled amendment path for the rare case where a locked record must
legitimately change. It's the layer above a single permission bit: permissions
say who can write a collection, guards say whether this specific write is
allowed right now.
A guard's callbacks run on the decrypted record — after the per-collection encryption key is unwrapped, before the plaintext is re-encrypted for storage, and before any period close is checked — the storage backend never sees plaintext either way.
When you need it
- Accounting / financial books where issued invoices must not change
- Workflows that freeze specific fields after a status transition (e.g.
amountimmutable oncestatus === 'paid') - Audit-tracked corrections that must preserve an invariant (totals, balanced double-entry pairs)
- Any domain where a single permission bit ("can write") is too coarse
Prerequisites
- Guard declarations per collection (
guardStrategies) — the factory enables enforcement; each collection declares its own rules. - For amendment mode:
transactionsStrategy: withTransactions()is required, plusadmin,owner, orcustodianrole on every vault the amendment touches.
Opt in
import { createNoydb } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
import { withGuard, RecordLockedError, InvariantError } from '@noy-db/hub/guards'
import { withTransactions } from '@noy-db/hub/transactions'
const disbursementGuard = withGuard<Disbursement>({
collection: 'disbursements',
check: async (incoming, { vault }) => {
const inv = await vault.collection<Invoice>('invoices').get(incoming.invoiceId)
if (inv?.status === 'issued') {
throw new RecordLockedError('disbursements', incoming.id, 'invoice is issued')
}
},
amendment: {
roles: ['admin', 'owner'],
invariant: (changes) => {
const sum = (s: 'before' | 'after') =>
changes.reduce((t, c) => t + ((c[s])?.amount ?? 0), 0)
if (sum('before') !== sum('after')) {
throw new InvariantError('total must be preserved')
}
},
},
})
const db = await createNoydb({
store: toMemory(),
user: 'alice',
guardStrategies: [disbursementGuard],
transactionsStrategy: withTransactions(), // required for amendment mode
})
const vault = await db.openVault('books')
Usage
A locked write is rejected
The check callback above runs on every put(). If inv-1 is already
issued, the disbursement is locked and the write throws before it ever
reaches encryption or storage:
await vault.collection<Disbursement>('disbursements').put('d1', {
id: 'd1', invoiceId: 'inv-1', amount: 100,
})
// → throws RecordLockedError:
// "Cannot modify disbursements/d1 — locked by guard: invoice is issued.
// Use withTransactions({ amendment: true, reason }) with admin/owner role to override."
A frozen field is rejected
frozenFields locks specific fields once a condition on the existing
record becomes true — here, amount can no longer change once an invoice
is paid. Registered the same way as disbursementGuard above (add it to
guardStrategies):
import { withGuard, FieldFrozenError } from '@noy-db/hub/guards'
const invoiceGuard = withGuard<Invoice>({
collection: 'invoices',
frozenFields: {
when: (existing) => existing.status === 'paid',
fields: ['amount'],
},
})
await vault.collection<Invoice>('invoices').put('inv-2', { id: 'inv-2', status: 'paid', amount: 500 })
await vault.collection<Invoice>('invoices').put('inv-2', { id: 'inv-2', status: 'paid', amount: 600 })
// → throws FieldFrozenError:
// "Cannot change frozen field(s) on invoices/inv-2: amount.
// Use withTransactions({ amendment: true, reason }) with admin/owner role to override."
An authorized amendment overrides the lock
Both check and frozenFields are skipped inside an amendment
transaction — db.transaction({ amendment: true, reason }, fn), available
once withTransactions() is opted in. The caller's keyring
role must be admin, owner, or
custodian:
await db.transaction({ amendment: true, reason: 'correct posted amount' }, async (tx) => {
tx.vault('books').collection<Invoice>('invoices').put('inv-2', {
id: 'inv-2', status: 'paid', amount: 600,
})
})
await vault.collection<Invoice>('invoices').get('inv-2')
// → { id: 'inv-2', status: 'paid', amount: 600 }
If a guard declares an amendment.invariant, it still runs at commit over
the whole change-set and can reject the amendment — see Amendment
flow below.
API
Four primitives, one strategy:
| Primitive | When it fires | Throws |
|---|---|---|
check | Every put() (skipped in amendment mode) | Anything; conventionally RecordLockedError |
onDelete | Every delete() of an existing record (skipped in amendment mode); skipped for delete-of-absent | Anything; conventionally RecordLockedError |
frozenFields | Every put() after when(existing) becomes true | FieldFrozenError listing changed frozen fields |
amendment | Inside withTransactions({ amendment: true, reason }) | InvariantError if the rule fails |
check is put-only — onDelete is the dedicated delete-time hook. The
argument shapes mirror each other but the semantics are explicit:
check(incoming, ctx) validates a record being written;
onDelete(existing, ctx) validates a record being removed.
onDelete bypass paths
Two paths skip the onDelete callback. Both are by design:
- Amendment transactions (
db.transaction({ amendment: true })) for an authorized role — amendments are the generic unlock primitive, consistent with howfrozenFieldslets staged writes through. Theamendment.invariantblock (if declared) DOES still see the{ before, after: null }change pair and can reject the delete at commit time. - System-internal deletes — derivations tombstones
and materialized-view refresh deletes route
through an internal-only delete path that bypasses every user-facing
guard. Housekeeping writes are not user-initiated and would otherwise
trip user invariants registered against output collections. If
the internal delete fires while an amendment window is open
(e.g. an admin amendment edits a source row → triggers a
derivation cascade → cascades a tombstone), the change pair IS
pushed onto the amendment's change-set and surfaces to
amendment.invariant. This keeps the "truly unconditional" paired pattern below honest.
Truly unconditional delete-block — pair the two hooks
onDelete: () => { throw } alone is NOT unconditional. An admin
amendment can still bypass it. For legal-document immutability rules
(e.g. Thai Revenue Code §86: receipts are append-only forever) pair
onDelete with an amendment.invariant that re-throws on any
delete-shaped change:
withGuard<Receipt>({
collection: 'receipts',
onDelete: () => {
throw new RecordLockedError('receipts', '', 'receipts are append-only')
},
amendment: {
roles: ['admin', 'owner'],
invariant: (changes) => {
for (const c of changes) {
if (c.before !== null && c.after === null) {
throw new RecordLockedError('receipts', '', 'amendment cannot delete')
}
}
},
},
})
// Ordinary delete — blocked by `onDelete` before it ever reaches an amendment:
await vault.collection<Receipt>('receipts').delete('r1')
// → throws RecordLockedError: "Cannot modify receipts/ — locked by guard:
// receipts are append-only. Use withTransactions({ amendment: true, reason })
// with admin/owner role to override."
// Admin amendment attempting the same delete — blocked by the invariant instead:
await db.transaction({ amendment: true, reason: 'cleanup' }, async (tx) => {
tx.vault('books').collection<Receipt>('receipts').delete('r1')
})
// → throws InvariantError: "Cannot modify receipts/ — locked by guard:
// amendment cannot delete. Use withTransactions({ amendment: true, reason })
// with admin/owner role to override."
The thrown RecordLockedError inside invariant is wrapped in
InvariantError by GuardExecutor.runInvariant (the message survives);
the staged delete rolls back, the record stays.
immutableGuard and amendmentInvariant
immutableGuard packages the append-only pattern above into one helper.
Give it either after: (record) => boolean — the record becomes immutable
once the predicate turns true — or appendOnly: true, shorthand for
"immutable from creation" (they're mutually exclusive; omitting both throws
ValidationError). By default its amendment override is an empty-allow (so
an authorized role can amend freely) — backward compatible with a hand-rolled
withGuard. Pass the optional amendmentInvariant to keep a rule inviolable
even inside an amendment tx; it has the same signature as
GuardStrategy.amendment.invariant.
import { immutableGuard, RecordLockedError } from '@noy-db/hub/guards'
const receiptGuard = immutableGuard<Receipt>({
collection: 'receipts',
appendOnly: true, // immutable from creation — no `after` needed
amendmentInvariant: (changes) => {
for (const c of changes) {
if (c.before !== null && c.after === null) {
throw new RecordLockedError('receipts', '', 'amendment cannot delete')
}
}
},
})
await vault.collection<Receipt>('receipts').put('r1', { id: 'r1', total: 500 })
await vault.collection<Receipt>('receipts').put('r1', { id: 'r1', total: 600 })
// → throws RecordLockedError: "Cannot modify receipts/r1 — locked by guard:
// append-only collection. Use withTransactions({ amendment: true, reason })
// with admin/owner role to override."
Omitting amendmentInvariant leaves the empty-allow override in place.
transitionGuard(opts)
transitionGuard generalizes immutableGuard from "any state → locked"
to an arbitrary state graph. Declare the allowed arcs once; every write
that moves the lifecycle field along an undeclared edge throws
IllegalTransitionError. It's sugar over withGuard (no new write-path
code) and inherits the same ledgered amendment override.
transitionGuard<Sale>({
collection: 'sales', field: 'status',
transitions: { // absence of an arc = forbidden
draft: ['to_verify', 'cancelled'],
to_verify: ['proforma', 'draft', 'cancelled'],
proforma: ['invoiced', 'cancelled'],
invoiced: ['paid'], paid: [], cancelled: [],
},
initial: ['draft', 'to_verify'], // allowed status on insert (default: any)
allowIdempotent: true, // same→same write allowed (default true)
})
await vault.collection<Sale>('sales').put('s1', { id: 's1', status: 'invoiced' })
await vault.collection<Sale>('sales').put('s1', { id: 's1', status: 'draft' })
// → throws IllegalTransitionError: 'Cannot transition sales/s1 from "invoiced"
// to "draft" — not a declared arc. Use withTransactions({ amendment: true,
// reason }) with admin/owner role to override.'
- Insert (
existing === null):incoming[field]must be ininitial(any value wheninitialis omitted); a violation reportsfrom: '(none)'. - Update: the arc
(existing[field] → incoming[field])must be a declared edge, elseIllegalTransitionError(collection, id, from, to). A state absent from the map (or mapped to[]) is terminal. - Idempotent: a same-state write passes when
allowIdempotent(defaulttrue), so a put that changes other fields without moving state is fine. - Override: an
amendmenttx by an authorized role skips the check and is ledgered —immutableGuardis the WORM special case (every state →[]).
Amendment flow
db.transaction({ amendment: true, reason }, async tx => {
tx.vault('books').collection('lines').put(...)
tx.vault('books').collection('lines').put(...)
})
→ 1. Validate reason (ValidationError if missing/empty)
→ 2. First tx.vault(name): role check (admin | owner | custodian) — AmendmentForbiddenError fail-fast
→ 3. beginAmendment() on the touched vault's guard registry
→ 4. Writes buffered — check + frozenFields skipped; each write records
(before, after) plus (id, vBefore, vAfter)
→ 5. Invariant runs at commit, over the full change-set — a throw rolls
back every staged write as InvariantError
→ 6. Audit entry appended to the ledger (op: 'amendment') if a
history strategy is configured
Composition order
withGuard runs before encryption and before the period-close check,
as one stage in the Collection.put write pipeline:
Collection.put
1. Permission check — can this user write this collection at all?
2. GuardRegistry.check ← this service
3. Period-close check — is the record's business date in a closed period?
4. Schema validation
5. i18n auto-translate
6. Ref enforcement
7. Encrypt + store.put — plaintext never reaches this line's argument
8. Ledger append
Errors
Five error classes, all extending NoydbError and re-exported from
@noy-db/hub/guards:
RecordLockedError(collection, id, reason)—checkoronDeletethrewFieldFrozenError(collection, id, fields[])— a frozen field changedIllegalTransitionError(collection, id, from, to)—transitionGuardrejected an undeclared arcInvariantError(message)— an amendment invariant rejected the change-setAmendmentForbiddenError(userId, role)— caller's role can't open an amendment
Behavior when NOT opted in
createNoydb({ guardStrategies: [...] })is the only way to register guards; without it, no guard fires for any collection- Amendment mode also needs at least one guard registered: opening
db.transaction({ amendment: true, reason })on a vault with noguardStrategiesthrowsValidationErroras soon as the body callstx.vault(name)— fail-fast, before any write runs. If guards ARE registered but a touched collection has none of its own, writes to that collection still commit inside the amendment; they just don't contribute to any invariant RecordLockedError/FieldFrozenError/IllegalTransitionError/InvariantError/AmendmentForbiddenErrorare still importable from@noy-db/hub/guardsbut are never thrown by core
Pairs well with
- transactions — amendment mode is layered on
withTransactions - history — successful amendments append a multi-record summary entry to
the hash-chained ledger (per-record entries always fire from
Collection.put) - periods — guards run before the period seal, so a lock can preempt a
PeriodClosedErrorwith a more specific reason
Edge cases & limits
- Guards see the pre-schema-validation incoming record. If a schema
validator coerces values (trims strings, applies defaults), the guard
observes the raw user-supplied shape; the stored shape is post-coercion.
Guards should only consume fields declared in
frozenFields.fieldsor read viactx.vault— those paths are stable across schema transforms. checknever fires on delete.check(incoming, ctx)runs only onput(insert + update);onDelete(existing, ctx)is the dedicated delete-time hook, with the currently-persisted record as its argument.deleteof a non-existent record is a no-op. Guards are not consulted in that case; the call returns without error.- Multiple guards on the same collection run in registration order; first throw wins (short-circuits remaining guards).
- Amendment authorization is declarative-plus-enforced. The
amendment.rolesfield on a guard is documentation for readers of the config — the actual check is the caller's keyring role, which must beadmin,owner, orcustodian. - Amendment audit append is conditional on
historyStrategy. Vaults without history still enforce amendment rules (role check + invariant + rollback) but skip the multi-record summary entry. verifyBackupIntegrityandvault.at(timestamp)skip amendment entries — amendment ledger entries carry no(collection, id)of their own, so both the backup integrity check and point-in-time reads treat them as opaque audit summaries rather than per-record state.
Zero-knowledge guarantee
Guard functions run on plaintext, after the encryption key is
unwrapped, inside the client — never inside the
storage backend. The store sees only ciphertext envelopes. The
ReadOnlyVaultFacade passed as ctx.vault decrypts on access; no
plaintext ever reaches the store.
ReadOnlyVaultFacade surface
ctx.vault.collection<T>(name).get(id) // single record
ctx.vault.collection<T>(name).list() // every record (decrypts all)
ctx.vault.collection<T>(name).query() // chainable read-only builder
query() returns the same Query<T> builder used elsewhere in the
library. Its terminals (toArray, first, count, aggregate,
groupBy, live) are read-only — there is no .update() / .delete()
on a Query. Prefer .query().aggregate({ ... }) over .list() +
manual reduce when enforcing Σ-style invariants: only the records the
predicate touches get materialised, and the aggregate path is the same
one used by the rest of the library.
// Σ-over-siblings invariant — payment-allocation sum must not exceed payment
import { withGuard, InvariantError } from '@noy-db/hub/guards'
import { sum } from '@noy-db/hub/reduce'
const allocationGuard = withGuard<Allocation>({
collection: 'allocations',
check: async (incoming, { vault, existing }) => {
const payment = await vault.collection<Payment>('payments').get(incoming.paymentId)
const { total } = await vault
.collection<Allocation>('allocations')
.query()
.where('paymentId', '==', incoming.paymentId)
.aggregate({ total: sum('appliedAmount') })
.run()
const otherTotal = total - (existing?.appliedAmount ?? 0)
if (otherTotal + incoming.appliedAmount > payment.amount) {
throw new InvariantError('allocation total would exceed payment amount')
}
},
})
await vault.collection<Allocation>('allocations').put('a1', {
id: 'a1', paymentId: 'p1', appliedAmount: 1_000,
})
// → throws InvariantError: "allocation total would exceed payment amount"
Aggregates require the reduceStrategy: withReduce() opt-in (the
same opt-in everything else in the query DSL respects) — query()
itself is always present on the facade.
Audit-entry shape
{
op: 'amendment',
actor: 'alice',
ts: '2026-05-18T...',
collection: '', // amendment is multi-record; collection/id are empty
id: '',
version: 0,
payloadHash: '', // per-record entries carry real hashes
amendment: {
reason: 'correct split between travel and meals',
role: 'admin',
changes: [
{ collection: 'disbursements', id: 'd1', vBefore: 2, vAfter: 3 },
{ collection: 'disbursements', id: 'd2', vBefore: 1, vAfter: 2 },
],
invariantsPassed: ['disbursements'],
},
}
Visible via vault.ledger().entries() like every other ledger entry.
Not supported
- Per-field amendment. An amendment opens for a whole record write — there's no way to amend only specific frozen fields while leaving the rest of a locked record's rule enforced.
- Time-limited amendment windows. An amendment transaction has no
expiry of its own; it's an ordinary transaction with the
amendmentflag set, open for as long as the transaction body runs. - Cross-vault guards. A guard's
check/onDelete/invariantreads only its own vault throughctx.vault— it cannot read a different vault's collections. - Guards on
loadAll/saveAll. Bulk load/save bypasses the guard pipeline entirely. - Per-guard role lists.
amendment.roleson a guard is documentation for the reader — actual authorization is the single vault-wide keyring role check described above, not a per-guard list.
See also
- transactions — amendment mode's underlying
db.transaction() - history — the ledger that amendment entries append to
- periods — the period-close check guards run before
- Proven by showcases
79-with-guardand101-transition-guard.