Command Bus
Sensitive writes are audited by a framework-level command path, not by
hand-placed audit calls. A sensitive
mutation — a create/update/delete of a domain record — runs as a named Command
through the CommandBus, which is the single, guaranteed writer of its audit entry.
Services in migrated modules never call the audit writer directly.
What running a Command guarantees
Running commandBus.run(command) performs, inside one scoped-fork transaction:
- resolves the actor from the ambient
TenantContext(fail-closed — no context ⇒ it throws before any write; the actor is never taken from a request body); - captures before-state, performs the write on the transactional
em, and records exactly one audit entry co-transactionally; - buffers the optional domain event and dispatches it once on commit.
So a committed command records one audit row and emits its event once; a rolled-back command records no audit row and emits no event. The write, the audit entry, and the event can never disagree.
Anatomy of a Command
interface Command<TResult> {
action: string; // dot-namespaced, e.g. 'product.update', 'credit_limit.adjust'
objectType: string; // e.g. 'product'
objectId: string;
capture?(ctx): Promise<AuditState>; // optional pre-state
run(ctx): Promise<{ result: TResult; before?; after?; skipAudit? }>;
event?(result): CommandEvent | undefined; // dispatched once on commit
}
skipAudit: true lets a command that decided not to mutate commit without an
audit row (a no-op business outcome), keeping "no write ⇒ no audit" honest.
Two audit paths
Two sanctioned mechanisms satisfy the coverage guarantee; both write one
co-transactional audit entry and derive the actor from the ambient TenantContext:
CommandBus.run(command)— owns its own scoped-fork transaction. Use it when the write can be expressed as a self-contained unit of work (the default, and the only path that supports reversibility/undo and buffered domain events).recordAuditFromContext(auditLog, em, input)— the lightweight companion (packages/platform/src/commands/audit-from-context.ts). It records the audit entry on anemthe caller already owns, committed by the caller's existingflush(). Use it when a write already runs inside its own transaction orpersistAndFlush(...)and cannot be wrapped in the bus's transaction without restructuring. The actor is best-effort here: with no ambient context (e.g. a pre-auth self-registration or a worker path) it records null actor ids rather than throwing, so background writes still audit. Callers that must have an actor use the bus.
Most module writes use a small private #audit(em, action, objectId, before, after)
helper that delegates to recordAuditFromContext, keeping the audit call one line at
each write site.
Reversibility & undo
A command may capture per-record before/after state so an operator can undo it.
The bulk product edit stores a RevertRecord[] on
catalog_bulk_operations; POST /admin/catalog/bulk-operations/:id/undo restores every
product whose current state still matches the operation, refuses any record changed
since with a conflict report (never a silent clobber), is idempotent-safe on
re-invocation, and audits the undo itself. Irreversible edits (e.g. category-bridge
changes) are marked non-reversible and offer no undo.
Auditable write vs. escape hatch
Not every mutation is an audited domain event. A write is classified as one of:
- Audited — an operator- or customer-initiated change to a durable domain record
(create/update/delete of catalog/orders/pricing/organizations/…), a security event
(password/role/MFA change, API key), or financial config (tax, promotion). These run
a Command or
recordAuditFromContext. - Escape-hatched — a write that is not an audited domain event, marked with a
command-coverage-ignore: <reason>comment inside the method. Recognized categories, each documented at the call site:- transient working state — carts, wishlists/shopping lists, cart coupon state (the resulting order/RFQ captures the audited durable record);
- telemetry — analytics ingestion, search-phrase recording, engagement tracking;
- auth/session infrastructure — session lifecycle,
lastLoginAt/lastUsedAtbookkeeping (session state is owned bySessionService); - delivery/execution & provider sync — email/newsletter dispatch, webhook replay/delivery, Stripe/payment/shipment provider-event ingestion and mirroring (the order/payment status transitions they drive are audited in the orders flow);
- idempotent boot reconcilers/seeds — settings/actions/CMS-hook/dictionary reconcilers and default seeders (system-invariant repairs, not operator writes).
The rule of thumb: audit the durable, operator-attributable state change; escape-hatch transient, telemetry, infrastructure, and derived/sync writes — always where the auditable event is captured elsewhere, and always with a one-line reason.
Coverage check (CI-enforced)
scripts/check-command-coverage.ts statically flags, per method and per route
handler, in every .ts file under src/modules/ and src/apps/, a sensitive mutation
(persist*, nativeUpdate, nativeDelete, remove*, flush) that is neither audited
nor escape-hatched, and the double-audit shape (a unit that both runs a Command and
audits by hand). A unit counts as covered when it runs a Command, defines a Command
literal, records audit (auditLog.record/recordWithin, recordAuditFromContext, or a
.audit recorder), delegates to such a unit (this.<runner>(), a module-level helper, or
a function-valued local such as a route file's const audit = …), is itself a helper
invoked by a covered unit (reverse delegation), or carries a command-coverage-ignore
comment.
What it opens
The walk used to match **/services/<file>.ts — one level, nothing else, which is 472
of the tree's 1152 module files. pim_ergonode/services/import/,
product_feeds/services/delivery/ and services/queues/ were a level too deep;
workers/, queues/, jobs/, commands/, every routes*.ts, every backend.ts boot
hook, every scripts/ entry point and every seeds/ reconciler were outside it
altogether — which is to say the check read clean over queue consumers and admin route
handlers, the two places writes actually live. Widening it found ten unaudited
operator-visible writes (nine admin route handlers across seven modules, one federated
sign-in account creation).
Four exclusions remain, each an argument rather than an omission: migrations/ (DDL with
no request and no actor), *.test.ts / *.d.ts (not shipped code) and audit_logs/ (the
audit writer itself — requiring an audit of the audit is circular). seeds/ and
scripts/ are in scope and carry written escape hatches instead.
Two narrowings paid for the widening. remove counts as an ORM mutation only off an
EntityManager — 30 of the first-pass findings were deps.<x>Service.remove(id) in a route
handler, a call into an audited service — while the staleness half keeps counting it
everywhere. And a route file is judged per handler: read as one unit, a single
commandBus.run anywhere in it clears every other handler, which is the masking the
per-method rule exists to prevent, one level up.
The platform-wide rollout is complete — all backend modules are migrated (207
registered command actions, ~120 documented escape hatches). CI runs the check with
--strict in the quality stage, so any finding in any module — including a
brand-new module — fails the build. Coverage cannot silently regress.
The escape hatch is swept for staleness
185 methods carry the ignore comment, and nothing used to re-read one: an
ignore written for a write that has since moved — into a Command, or into another
module's audited service — went on exempting a method that no longer needed exempting,
and the next write added there inherited the exemption in silence. A marker on a method
that no longer writes at all is now reported as stale-ignore and fails the build,
so the hatch is a two-way ratchet like every other ledger in the repository. Four were
found on the first run, all four in payment-gateway services whose local mirroring had
moved into ReceivePaymentHandler; their prose stayed as ordinary comments.
The staleness half deliberately looks for writes more widely than the flagging half —
it also counts a raw SQL write statement, a queue or Redis write (removeJobScheduler,
obliterate, del, …), an ambiguous remove off any receiver, and any write reached
through a call in the same file — so a marker guarding a real write the check cannot
itself see is left alone. Both errors then fall on the safe side: at worst a marker
outlives its write for one more refactor, never the reverse. Widening the scan had to
widen this half first: product_feeds/workers/taxonomy-refresh-worker.ts
documents its queue.removeJobScheduler(…) as "Redis-only", and a sweep that knew only
ORM and SQL would have demanded the deletion of a correct decision the moment workers/
came into scope.
A marker also has to be on the unit it exempts: inside the body, or in the doc comment directly above it. Four command files describe their module's policy in a file header that quotes the token, and reading a unit's full leading trivia let that header exempt whichever declaration happened to come first — then, once the sweep landed, report it as a dead marker nobody had written.
Converting a write
For a Command: extract the pure write onto the transactional em and run it through
commandBus.run(...); delete any prior manual audit call in the same change (avoid the
double-audit shape). For the lightweight path: add the #audit(...) helper delegating
to recordAuditFromContext and call it immediately before the method's flush(). For
a non-audited write: add a command-coverage-ignore: <reason> comment.
There is no action to register anywhere. Previously this paragraph ended with
"register every new Command action in backend/src/commands/command-registry.ts", and
that file's own header named two consumers for the list — this check, and operator-facing
undo affordances. Both were wrong from the day it was written: check-command-coverage.ts
has never imported it and decides coverage from a commandBus.run(...) call in the same
method, and the one undo affordance in the tree reads the reversible column on
catalog_bulk_operations, set per row when the operation captured revert state.
CommandBus.run never consulted it and Command.action is a plain string. So a 258-entry
hand-maintained allow-list read as a gate and gated nothing, which is
worse than no list at all: the next author asking "is this write covered?" got a confident
wrong answer from it. It is deleted. A Command's action is whatever string the Command
declares; what makes the write audited is that it runs through CommandBus.run, and the
only thing that checks it is the check described above.