outbox
Transactional audit entries and webhook events — core's own `logAuditTx`/`emitTx` and the `core.outbox` that app services get from @tereo-io/sdk. Use when writing an audit entry or emitting a webhook
SKILL.md.claude/skills/outbox/SKILL.md
GitHubTransactional Outbox — Audit Entries + Webhook Events
There are two outboxes, one on each side of core's API. Both give the same guarantee: the audit entry or event exists exactly when the business write commits, and not at all when it rolls back.
| Inside core | In an app service | |
|---|---|---|
| Code | packages/tereo/src/utils/outbox.ts |
@tereo-io/sdk — src/clients/outbox.ts |
| Write an audit entry | logAuditTx(tx, …) — inserts into audit.audit_log_entries directly |
core.outbox.audit(tx, …) — outbox row, destination = "audit" |
| Emit a webhook event | emitTx(tx, …) — row in core.outbox, destination = "webhook" |
core.outbox.emit(tx, …) — outbox row, destination = "webhook" |
| Table | core.outbox (Prisma model Outbox) |
the app's own, default core_outbox, created by the app's migration |
| Reaper | createOutboxReaper in worker.ts (ROLE=worker or all) |
OutboxClient, started by core.start() |
| Dispatch | in-process: emitWithIdempotency (utils/processEvent.ts) |
HTTP to core: audit.logAsync / webhooks.emitAsync |
| Event types | definitions/eventTypes.ts |
the service's provisioning document (service.eventTypes) |
There is no separate outbox package here, no outboxClient.ts, and no
initializeOutbox.
Mental model
- Inside a Prisma transaction, make the business write and record the entry or event on the same
tx. - Post-commit, a reaper claims due rows with
FOR UPDATE SKIP LOCKED, dispatches each, and deletes the row on success. Failures back off; aftermaxAttemptsthe row is dead-lettered and kept for replay. - The outbox row id is the idempotency key, injected by the reaper at dispatch. A retry after a crash between dispatch and delete is deduplicated by core.
Core writes audit entries directly because audit is a schema in the same database — no outbox
needed. Webhook fan-out enqueues BullMQ jobs in Redis, which cannot join a Postgres transaction, so
events go through core.outbox. An app service's database is not core's, so both go through its
outbox.
Part 1 — Inside core
Call-site pattern (controllers)
// packages/tereo/src/routes/v1/admin/things/thing/controllers.ts
import { getDefaultOrganizationId } from "../../../../utils/defaultOrganization.js";
import { validateBody, validateParams } from "../../../../utils/validateReq.js";
import { validateUser } from "../../../../utils/auth.js";
import { ThingIdParamSchema } from "../schemas.js";
import type { Request, Response } from "express";
import { UpdateThingSchema } from "./schemas.js";
import db from "../../../../utils/db/index.js";
import {
computeChanges,
emitTx,
logAuditTx,
} from "../../../../utils/outbox.js";
export const updateThing = async (req: Request, res: Response) => {
const auth = validateUser(req);
const params = validateParams(ThingIdParamSchema, req);
const body = validateBody(UpdateThingSchema, req);
const existing = await db.things.get(params.id);
const updated = await db.$transaction(async (tx) => {
await db.things.update(params.id, body, tx);
const next = await db.things.get(params.id, tx);
await logAuditTx(tx, {
auth,
action: "UPDATE",
entityType: "Thing",
entityId: next.id,
entityName: next.name,
changes: computeChanges(existing, next),
});
await emitTx(tx, {
organizationId: getDefaultOrganizationId(),
eventType: "auth.thing.updated",
entityType: "Thing",
entityId: next.id,
data: { id: next.id, name: next.name },
});
return next;
});
res.json(updated);
};
routes/v1/admin/roles/role/controllers.ts (updateRole) is the real version of this.
Boundary rule: controllers open transactions with db.$transaction from utils/db/index.ts —
every controller in src/routes/ does; none calls prisma.$transaction. Mutation helpers in
utils/db/{model}.ts take an optional trailing tx?: Prisma.TransactionClient.
Which organization: org-scoped resources use their own org. A bucket's events spread
bucketEventScope(bucket) (utils/outbox.ts): its organization, or for a bucket outside any
(an app service's) organizationId: null plus the bucket's serviceId, which fans out only to
endpoints of all organizations and that service's endpoints. Never skip an emit because a bucket
has no organization. System-wide ones — organizations, roles, clients, services, registrations — use
getDefaultOrganizationId() from utils/defaultOrganization.ts, cached at startup so it is
synchronous.
Background jobs and unauthenticated paths act as the system user:
auth: { userId: SYSTEM_USER_ID, email: null } (definitions/systemUser.ts) — see
routes/v1/files/signed/objects/bucket/by-id/object/controllers.ts, jobs/webhookDelivery.ts, utils/processEvent.ts.
logAuditTx input
AuditLogInput:
| Field | Required | Notes |
|---|---|---|
auth |
yes | { userId, email } — usually validateUser(req); userId is a FK into auth.users |
action |
yes | "CREATE" | "UPDATE" | "DELETE" | "MERGE" | "ARCHIVE" | "RESTORE" |
entityType |
yes | The model name, e.g. "Role" |
entityId |
yes | Id of the affected row |
organizationId |
no | Defaults to null |
entityName |
no | Human-readable label |
changes |
no | computeChanges(existing, next) for UPDATE |
metadata |
no | Free-form JSON |
correlationId |
no | Defaults to the current request id (getRequestId()) |
Set by logAuditTx, not the caller: source = "tereo" (SOURCE), ipAddress from the
request context (null outside a request), and writerUserId = SYSTEM_USER_ID — the entry was written
in-process, not through the audit API.
emitTx input
EmitTxInput:
| Field | Required | Notes |
|---|---|---|
organizationId |
yes | Tenant scope — see Which organization above; null for a resource outside any |
serviceId |
no | With a null organization: the service whose resource it is (its endpoints get the event) |
eventType |
yes | Must be listed in definitions/eventTypes.ts |
entityType |
yes | The model name |
entityId |
yes | Id of the affected row |
data |
yes | The payload subscribers receive |
eventType is checked at publish time. emitTx throws
Unknown webhook event type "…" — add it to definitions/eventTypes.ts before writing the row, so a
typo fails the request instead of dead-lettering later.
There is no idempotencyKey field on either input: the reaper uses the row id.
emitTx also captures the active W3C trace context (traceparent/tracestate) on the row; the
reaper dispatches under it, so the delivery chains off the request that caused it.
Two key invariants
- Inside the transaction:
logAuditTxandemitTxwrite to the sametxas the business write, so they commit or roll back together. Call them last, just before returning from the callback. - Outside the transaction: side effects with no database write — WebSocket notifications
(
websocket/emitters/) — run afterdb.$transactionresolves.updateRoleis the example.
Adding a new event type
- Add
{ eventType, description }to the right group indefinitions/eventTypes.ts(authEvents,fileEvents,webhookEvents,serviceEvents). Existing prefixes:auth.,file-service.,webhook.,core.service.. - Use it in
emitTx({ eventType: "…" }). - That is all:
EVENT_TYPESis part ofCORE_DECLARATION, registered byregisterCore()on every start, which is what makes it subscribable and letsprocessEventaccept it. Removing one there retires it a day later — don't remove a type subscribers rely on without saying so.
If the API docs should list it, add a webhook definition to the OpenAPI spec under webhooks: (see the
openapi skill).
Reaper behavior (core)
createOutboxReaper(options) in utils/outbox.ts, started by startWorker() in worker.ts — so
only in processes with ROLE=worker or ROLE=all. It registers one dispatcher, webhook (dispatchOutboxEvent), which
parses the payload with emitEventSchema and calls emitWithIdempotency({ ...event, idempotencyKey: row.id }).
| Option | Default |
|---|---|
pollIntervalMs |
1000 |
batchSize |
100 |
maxAttempts |
100 (≈ 12 hours of retry runway) |
retryBaseMs |
2000 |
maxBackoffMs |
30 min |
claimTtlMs |
60 s — a claim older than this may be re-claimed |
- Claims with
UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) RETURNING, so any number of workers can run one. - Dispatches the claimed batch in parallel (
Promise.all). - Success →
DELETE. Failure → exponential backoff with full jitter. - A dispatcher throws
PermanentDispatchErrorfor a row that can never succeed; it is dead-lettered at once. Core'swebhookdispatcher (dispatchOutboxEventinutils/processEvent.ts) does so for a malformed payload and for a 4xxAppErrorother than 409/429 (an unregistered or retired event type). - A row with no dispatcher, or past
maxAttempts, is dead-lettered: a structuredoutbox.dead_lettererror log, and the row stays withdead_lettered_atset. - Replay: clear
dead_lettered_at, setattempts = 0andnext_attempt_at = NOW().
Delivery semantics (core)
- At-least-once, deduplicated.
emitWithIdempotencykeys on(caller, organizationId, eventType, idempotencyKey)in Redis — the caller is the system user for the reaper — for 24 hours, so a re-dispatched row creates no second set of deliveries. The claim on a key in progress lasts 60 s, so a worker that dies mid-dispatch doesn't strand the row. - Unordered. A batch dispatches in parallel, so a
createdand anupdatedfor the same entity can fan out in either order. Subscribers should tolerate reordering. correlationIdasymmetry.logAuditTxfillscorrelationIdfrom the request id; events carry none. Put it indataif a subscriber needs it.
Part 2 — In an app service (@tereo-io/sdk)
App services never reach core's database. core.outbox gives them the same pattern against their
own database. The authority is packages/tereo-sdk/README.md (section Outbox).
Setup
Create the table in the app's own migration. Default name
core_outbox;schema.tableworks (billing.core_outbox). Names must be lowercase letters, digits and_— they are interpolated into SQL. Either:- add the
CoreOutboxPrisma model from the SDK README (@@map("core_outbox")), or - use
outboxTableSql(table)— theCREATE TABLEplus a partial index on pending rows (WHERE "dead_lettered_at" IS NULL).
The reaper's SQL is hard-coded to these column names — do not rename them.
- add the
Pass
outboxtocreateCoreClientand start it at boot:
import { createCoreClient } from "@tereo-io/sdk";
import { prisma } from "./db/prisma.js";
import { Redis } from "ioredis";
export const core = createCoreClient({
service: "product-service",
redis: new Redis(process.env.REDIS_URL!, { maxRetriesPerRequest: null }),
outbox: { db: prisma },
});
await core.start();
The event types and the roles (webhook_publisher, audit_writer) come from the service's
provisioning document, not from a call at boot:
{ "version": "1.0.0", "service": { "slug": "product-service", "name": "Product",
"secretFrom": "PRODUCT_CLIENT_SECRET",
"access": { "roles": ["webhook_publisher", "audit_writer"] },
"eventTypes": [{ "eventType": "product.item.created", "description": "Item created" }] } }
OutboxOptions: db (required — anything with $executeRawUnsafe/$queryRawUnsafe: a Prisma
client or transaction; Postgres only), table, dispatch (default true; false on write-only
replicas), and the same tuning knobs and defaults as core's reaper (pollIntervalMs, batchSize,
maxAttempts, retryBaseMs, maxBackoffMs, claimTtlMs).
Without the outbox option, reading core.outbox throws. core.start() starts the reaper;
core.stop() stops it.
The service needs the audit_writer and webhook_publisher roles (approved by an admin) for the
reaper's sends to succeed.
Call-site pattern
await prisma.$transaction(async (tx) => {
const item = await tx.item.create({ data });
await core.outbox.audit(tx, {
userId,
action: "CREATE",
entityType: "Item",
entityId: item.id,
});
await core.outbox.emit(tx, {
organizationId,
eventType: "product.item.created",
entityType: "Item",
entityId: item.id,
data: item,
});
});
audit(tx, entry)takesAuditLogEntryInput:userId,action,entityType,entityId, and optionallyorganizationId,userEmail,entityName,changes,metadata,ipAddress,correlationId. Nothing is filled from a request context — passipAddressandcorrelationIdyourself if you want them.sourceis theservicegiven tocreateCoreClient.emit(tx, event)takesEmitEventParams:organizationId,eventType,entityType,entityId,data. It callswebhooks.assertEventTypefirst, so an event type not in the declaration (or ineventTypes) throws now, not at dispatch.AuditLogServiceClient.computeChanges(old, next)(static) gives the same field diff as core's.- Leave
idempotencyKeyout: the reaper overwrites it with the row id.
Reaper behavior (SDK)
Same claim query, backoff, dead-lettering and replay as core's, with these differences:
- Sequential within a batch, oldest
next_attempt_atfirst. A full batch schedules the next pass immediately. - Two dispatchers, fixed:
audit→audit.logAsync,webhook→webhooks.emitAsync, each withidempotencyKey: row.id. There is no extension point for other destinations. processBatch()is public — tests call it to drain the outbox deterministically.
Delivery semantics (SDK)
- At-least-once, deduplicated by core.
POST /v1/audit/entrieskeys on (writer user, key) andPOST /v1/webhooks/eventson (writer user, organization, event type, key), both in Redis for 24 hours. - A permanent rejection dead-letters at once. Core answers 400 for an audit entry whose
userIdororganizationIdis not inauth, and for an unregistered or retired event type; any 4xx except 401, 408, 409 (the same key still in flight) and 429 dead-letters the row on that attempt. Network errors and 5xx back off as usual. Watch foroutbox.dead_letterlogs.
Don't
- Don't run
logAuditTx,emitTxorcore.outbox.*outside a transaction. If there is no paired write (rare), open a transaction just for the record. - Don't open
prisma.$transactionin a core controller — usedb.$transaction. - Don't emit an event type that isn't declared — core:
definitions/eventTypes.ts; app: the declaration. - Don't use
audit.log/webhooks.emit(or theAsyncvariants) for state-changing writes in an app service. They are best-effort and bypass the outbox; the reaper is their real caller. - Don't write audit entries through the HTTP API from inside core.
logAuditTxinserts directly. - Don't add audit or webhook calls to read endpoints.
Common gotchas
utils/audit.tsis not the audit log. ItsauthLogwrites security events (auth_failure,token_issued, …) to the Pino logger. Audit entries come fromlogAuditTx.webhook.testis registered but never emitted through the outbox — onlyPOST /v1/webhooks/webhooks/:id/testuses it.processEventaudits only HTTP emits (WebhookEvent, one per event, under the caller). Core's own events write no such entry; one that reaches no endpoint is only logged at debug level.computeChangesskipscreatedAt,updatedAt,deletedAt,archivedAtand returnsundefined, not{}, when nothing else changed. Pass it straight through.The reaper runs under test too —
NODE_ENV=testskips only the webhook-delivery and partition workers. Fan-out is asynchronous, so a test polls for its effect:src/tests/flows/webhooks/(e.g.endpointEvents.test.ts) waits (waitFor) for theWebhookDeliveryrow anemitTxleads to.