hazo_audit
v2.1.2
Published
Field-level audit trail for hazo_connect-backed apps. Attach to any adapter via wrapWithAudit() to auto-capture writes; ships worker, Next.js routes, and React UI primitives.
Maintainers
Readme
hazo_audit
Field-level audit trail for hazo_connect-backed apps. Attach to any adapter
with wrapWithAudit() and every write to a captured table auto-produces an
outbox row, drained by an in-process worker into per-field events the UI can
render via <FieldAuditIcon />.
Zero changes to hazo_connect. Zero per-route boilerplate. Add a new
audited table with an INI block, not a code change.
npm i hazo_audit hazo_core hazo_connect hazo_config hazo_logs hazo_auth hazo_ui
npx hazo-audit-migrate --adapter=pg --connection-string=$DATABASE_URLhazo_core, hazo_connect, and hazo_config are required peers; hazo_logs,
hazo_auth, and hazo_ui are optional (only hazo_ui is needed for the React
components).
What it does
// instrumentation.ts
import { wrapWithAudit, startAuditWorker } from 'hazo_audit/server'
import { getHazoConnectSingleton } from 'hazo_connect/nextjs/setup'
const base = getHazoConnectSingleton()
const adapter = wrapWithAudit(base, { resolvers: { wp: wpResolver } })
startAuditWorker({ app_adapter: base })// middleware (or request handler)
import { runWithAuditContext } from 'hazo_audit/server'
await runWithAuditContext(
{ actor_kind: 'user', actor_user_id: session.user.id, scope_id: session.scope_id },
() => next(),
)// any field render site
import { FieldAuditIcon } from 'hazo_audit/client'
<FieldAuditIcon subjectKind="client_folder" subjectId={folder.id} fieldPath="client_name" />That's it. Insert/update/delete on any app_* or hazo_* table is captured,
diffed by microdiff for JSONB columns, and made queryable at /api/audit/events.
Architecture
caller writes
│
▼
wrapWithAudit(adapter) ─── reads INI ───► capture decision
│ ├─ pipeline tables → skip
│ ├─ exclude_* matches → skip
│ ├─ include_* matches → capture
│ └─ default → skip
│
▼ (captured)
hazo_audit_outbox ◄──────────── claimRows() ────► startAuditWorker
│
│ worker resolves before_row (caller hint, or chain prior outbox)
│ microdiff(before, after) for JSONB columns
│
▼
hazo_audit_field ◄──────────── /api/audit/events ────► <FieldAuditTrail>
(scope filter, mask sensitive)Entries
| Import | What |
|---|---|
| hazo_audit | Client-safe types and permission constants (AUDIT_VIEW_HISTORY, etc.). Also exports OpLabels interface and DEFAULT_OP_LABELS constant. |
| hazo_audit/server | wrapWithAudit, startAuditWorker, runWithAuditContext, createAuditedCrudService, emitIntentEvent. Server-only. |
| hazo_audit/nextjs | createAuditEventsRoutes — Next.js GET + reveal POST handlers. |
| hazo_audit/client | <FieldAuditIcon />, <FieldAuditTrail />. Safe in client components. |
Tailwind v4 setup (required for the client components)
The client components use Tailwind utility classes. With Tailwind v4's JIT,
classes inside node_modules/ are invisible unless you opt in. Add this to
your globals.css (or wherever you @import "tailwindcss";):
@import "tailwindcss";
@source "../node_modules/hazo_audit/dist";Without the @source line, the popover and trail will render without
styling.
Capture rules — config/hazo_audit_config.ini
INI is the primary source of truth. The only TypeScript escape hatch is
resolvers: {...} on wrapWithAudit (for project-specific JSONB path
canonicalisation).
[capture]
include_table_prefixes = app_, hazo_
exclude_table_prefixes = _
exclude_tables = hazo_chat_typing_indicators
[overrides.app_clients]
subject_kind = client
sensitive_columns = ssn, dob
[overrides.app_client_folders]
subject_kind = client_folder
jsonb_columns = working_paper
jsonb_path_exclude = working_paper.__prefill_data
resolver = wpCapture decision (deterministic, PRD §6.1): pipeline tables → exclude → include → default deny.
Public API (R1)
wrapWithAudit(adapter, opts?) → HazoConnectAdapter
Proxies query() and claimRows(). For writes to captured tables, enqueues
an outbox row after the underlying call succeeds. Best-effort: any audit
failure fires opts.onAuditFailure(err, ctx) but never throws into the user
write path.
wrapWithAudit(base, {
resolvers: { wp: wpResolver },
onAuditFailure: (err, ctx) => log.error('audit_failed', { ...ctx, err }),
})startAuditWorker({ app_adapter, ... }) → WorkerHandle
Drains the outbox via claimRows() (concurrency-safe), runs microdiff,
inserts field events, and dead-letters after max_attempts failures.
const worker = startAuditWorker({ app_adapter: base, poll_ms: 2000 })
// later
await worker.stop()
// or for tests
const { processed, failed } = await worker.drainOnce()runWithAuditContext(ctx, fn)
AsyncLocalStorage-backed actor identity. The wrap stamps these fields onto
each outbox row. Missing context → actor_kind: 'background_job'.
createAuditedCrudService(adapter, table) → AuditedCrudService<T>
Drop-in for hazo_connect's createCrudService plus an opts.audit hook
for supplying before_row (so the worker doesn't have to chain) and
piggybacking a Layer-1 intent_event onto the write.
await svc.updateById(folderId, patch, {
audit: {
before_row: previousRow,
intent_event: 'folder_field_edited',
intent_payload: { field: 'client_name' },
},
})emitIntentEvent({ event_name, payload?, subject_kind?, subject_id? })
Direct Layer-1 intent emission (no piggyback). withAudit(name, payload, fn)
ergonomic is Phase 2.
OpLabels / DEFAULT_OP_LABELS
Override the badge labels rendered on each event in <FieldAuditTrail />:
import { DEFAULT_OP_LABELS } from 'hazo_audit'
import { FieldAuditTrail } from 'hazo_audit/client'
// Keep defaults for insert/delete; rename update → "Edited"
<FieldAuditTrail
{...props}
opLabels={{ ...DEFAULT_OP_LABELS, update: 'Edited' }}
/>Default labels: insert → Added, update → Changed, delete → Removed.
Pass an empty string for an op to fall back to the raw DB op.
Intent labels (intentLabels)
When events are fired inside auditIntent(adapter, name, callback), the
trail can annotate each event with a human-readable source label:
<FieldAuditTrail
{...props}
intentLabels={{
survey_prefill_key_info: 'auto-fill',
wp_pdf_prefill: 'PDF import',
}}
/>
// Renders: "Changed · Pubs Abayasiri (via auto-fill)"The component fetches the correlated intent events automatically when
intentLabels is non-empty; no extra setup required.
createAuditEventsRoutes({ audit_adapter, getAuth }) → { GET, reveal: { POST } }
Next.js route factory.
GET /api/audit/events— requiresview_audit_history. Default-filters by caller'sscope_idunless they holdview_audit_across_scopes. Sensitive rows' values masked tonullunless caller holdsview_sensitive_audit.POST /api/audit/events/[id]/reveal— additionally requiresview_sensitive_audit. Side effect: writes anaudit_revealintent row.
createAuditEventsRoutes({
audit_adapter,
getAuth: async (req) => {
const a = await hazo_get_tenant_auth(req)
if (!a.authenticated) return null
return {
user_id: a.user.id,
scope_id: a.selected_scope_id,
permissions: a.permissions,
user_label: a.user.full_name,
}
},
})<FieldAuditIcon subjectKind subjectId fieldPath />
Renders a clock icon that opens a popover with the field's audit trail. Self-positioning, no Radix dep.
| Prop | Type | Default | Description |
|---|---|---|---|
| subjectKind | string | — | Required. Table-level subject kind (e.g. "client_folder"). |
| subjectId | string | — | Required. Record ID. |
| fieldPath | string | — | Field to scope the trail to. |
| api_base | string | "/api/audit" | Route prefix. |
| opLabels | OpLabels | DEFAULT_OP_LABELS | Override op badge text for individual ops. |
| intentLabels | Record<string, string> | — | Map of intent event names → display labels. Adds a "(via …)" annotation to events fired inside a named intent. |
<FieldAuditTrail subjectKind subjectId fieldPath? />
Renders the trail directly (use in a sidebar, modal, etc.).
| Prop | Type | Default | Description |
|---|---|---|---|
| subjectKind | string | — | Required. |
| subjectId | string | — | Required. |
| fieldPath | string | — | Filter to one field. Omit for the full record trail. |
| api_base | string | "/api/audit" | Route prefix. |
| opLabels | OpLabels | DEFAULT_OP_LABELS | Override op badge text. See OpLabels + DEFAULT_OP_LABELS. |
| intentLabels | Record<string, string> | — | Annotate changes with their originating intent. Key = bare event name (.completed/.failed stripped). |
| limit | number | 50 | Max events to fetch. |
Schema
Three tables (see migrations/001_init.sql for both Postgres + SQLite):
hazo_audit_outbox— pre-drain queue.hazo_audit_field— per-leaf field events (UI reads from this).hazo_audit_intent— Layer-1 named events.
CLI — hazo-audit-migrate
npx hazo-audit-migrate --adapter=pg --connection-string=$DATABASE_URL
npx hazo-audit-migrate --adapter=sqlite --path=./data/app.db
npx hazo-audit-migrate --adapter=pg --connection-string=$DATABASE_URL --dry-runPostgREST cannot run DDL; use --adapter=pg against the same Postgres
instance.
Limitations (R1)
| Shape | Behavior |
|---|---|
| Single-row insert/update/delete with eq-on-id WHERE | ✅ captured |
| Bulk insert | skipped + warning (opt-in fanout_bulk_writes) |
| Bulk update / delete (non-id WHERE) | skipped + warning |
| Composite primary key | skipped + warning (Phase 2) |
| Insert without Prefer: return=representation | skipped + warning |
| Raw writes (adapter.rawQuery) | pass-through, never audited |
See PRD §15 for the deferred-feature list.
Test-app
The test-app/ directory contains a Next.js demo:
npm run dev:test-app # http://localhost:3017Sidebar walks through:
- Capture & Drain — insert/update/delete a client, watch the outbox grow.
- Worker — manual
drainOnce(), see field events appear, retry counts. - History UI —
<FieldAuditIcon />and<FieldAuditTrail />in action. - Sensitive + Reveal — SSN masking and the reveal-is-itself-audited flow.
- Skipped Writes — bulk inserts that get skipped with a warning.
- Permissions — toggle cookies to exercise 401/403/masking.
Backed by SQLite (sql.js); the demo DB lives at test-app/data/demo.db.
What's new in v2.0.0
auditIntent(adapter, name, callback) — group multi-write transactions
import { auditIntent, runWithAuditContext } from 'hazo_audit/server';
await runWithAuditContext({ actor_kind: 'user', actor_user_id: 'u1' }, () =>
auditIntent(adapter, 'person.merge', async () => {
await crud.update(personA.id, { ...mergedFields });
await crud.delete(personB.id);
}, { metadata: { reason: 'manual merge by admin' } }),
);All captured writes inside the callback share a correlation_id. An opening
hazo_audit_intent row is written when the callback starts; a closing row
(person.merge.completed or person.merge.failed) lands on completion or
throw. Nested calls reuse the outer correlation_id.
snapshot(adapter, { table, recordId, asOf })
import { snapshot } from 'hazo_audit/server';
const snap = await snapshot(adapter, {
table: 'app_persons',
recordId: 'p1',
asOf: new Date('2026-05-01T10:00:00Z'),
});
// → { state: { name: 'Alice', email: '[email protected]' }, deleted: false, exists: true }Replays hazo_audit_field events for the subject up to asOf and returns the
computed field state.
revert(app_adapter, { table, recordId, toTimestamp, reason })
import { revert, RecordNotFoundError } from 'hazo_audit/server';
try {
const result = await revert(wrapped, {
audit_adapter: base, // optional; falls back to app_adapter when omitted
table: 'app_persons',
recordId: 'p1',
toTimestamp: new Date('2026-05-01T10:00:00Z'),
actorUserId: 'admin1',
reason: 'user-requested rollback',
});
// result.applied = 'insert' | 'update' | 'delete' | 'noop'
} catch (e) {
if (e instanceof RecordNotFoundError) { /* no audit history exists */ }
}Computes the target snapshot, diffs against the current row, and applies the
necessary PATCH/INSERT/DELETE — all wrapped in an audit.revert intent.
<AuditViewer /> (R3) — cross-record admin viewer
import { AuditViewer } from 'hazo_audit/client';
<AuditViewer
api_base="/api/audit"
initialFilters={{ subject_kind: 'person' }}
/>Filterable, grouped-by-correlation_id list of all audit events with CSV export.
<RevertConfirmDialog />
Modal helper for confirming a revert with a typed reason.
Capture default exclusions widened (R2)
The sample INI now ships with hazo_logs, hazo_notifications in exclude_tables
by default. The capture matrix is documented inline in config/hazo_audit_config.ini.sample.
Related
hazo_connect— provides the adapter interface andclaimRows()primitive.hazo_auth— typical source of session + permissions forgetAuth.[email protected]+— auto-wires<FieldAuditIcon />viafield_auditprop.
See design/PRD.md for the full design rationale and decision log.
