stable

guards

Per-record locks, per-field freezes, and an audited amendment override for business rules stricter than a plain write permission.
Subpath@noy-db/hub/guards
FactorywithGuard()
Layerwith-audit
ClusterE — 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. amount immutable once status === '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, plus admin, owner, or custodian role 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:

PrimitiveWhen it firesThrows
checkEvery put() (skipped in amendment mode)Anything; conventionally RecordLockedError
onDeleteEvery delete() of an existing record (skipped in amendment mode); skipped for delete-of-absentAnything; conventionally RecordLockedError
frozenFieldsEvery put() after when(existing) becomes trueFieldFrozenError listing changed frozen fields
amendmentInside 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:

  1. Amendment transactions (db.transaction({ amendment: true })) for an authorized role — amendments are the generic unlock primitive, consistent with how frozenFields lets staged writes through. The amendment.invariant block (if declared) DOES still see the { before, after: null } change pair and can reject the delete at commit time.
  2. 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 in initial (any value when initial is omitted); a violation reports from: '(none)'.
  • Update: the arc (existing[field] → incoming[field]) must be a declared edge, else IllegalTransitionError(collection, id, from, to). A state absent from the map (or mapped to []) is terminal.
  • Idempotent: a same-state write passes when allowIdempotent (default true), so a put that changes other fields without moving state is fine.
  • Override: an amendment tx by an authorized role skips the check and is ledgered — immutableGuard is 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) — check or onDelete threw
  • FieldFrozenError(collection, id, fields[]) — a frozen field changed
  • IllegalTransitionError(collection, id, from, to) — transitionGuard rejected an undeclared arc
  • InvariantError(message) — an amendment invariant rejected the change-set
  • AmendmentForbiddenError(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 no guardStrategies throws ValidationError as soon as the body calls tx.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 / AmendmentForbiddenError are still importable from @noy-db/hub/guards but 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 PeriodClosedError with 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.fields or read via ctx.vault — those paths are stable across schema transforms.
  • check never fires on delete. check(incoming, ctx) runs only on put (insert + update); onDelete(existing, ctx) is the dedicated delete-time hook, with the currently-persisted record as its argument.
  • delete of 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.roles field on a guard is documentation for readers of the config — the actual check is the caller's keyring role, which must be admin, owner, or custodian.
  • 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.
  • verifyBackupIntegrity and vault.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 amendment flag set, open for as long as the transaction body runs.
  • Cross-vault guards. A guard's check / onDelete / invariant reads only its own vault through ctx.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.roles on 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