An audit record explains which actor changed which resource and when. Arkoren’s event bus can connect that application fact to a recording function, but Arkoren does not provide an audit store or make notifications durable. Your application owns the record format, access policy, retention, and persistence boundary.
Choose the requirement before choosing how to publish. An optional diagnostic
trail can tolerate a lost record and use Emit. If every committed change must
have a record, write that record in the same database transaction as the change.
A direct insert is often the clearest dependency; Dispatch is an alternative
when your application deliberately expresses that dependency through listeners.
Defining Facts
For an invoice void, capture the identifiers and occurrence time where the
application authorizes and performs the action. In your invoice package, import
time and arkoren.dev/core/event:
type VoidedEvent struct {
InvoiceID string
ActorID string
At time.Time
}
var Voided = event.NewTopic[VoidedEvent]("invoice.voided")
An occurrence time in the payload remains meaningful if processing happens later.
Set ActorID from the authenticated, authorized application identity—not an
arbitrary request field. Background work needs an explicit trusted actor too;
it cannot assume the original request’s identity remains in a worker context.
Keep secrets and unnecessary personal data out of the payload. Stable identifiers can still identify people, so restrict audit access and retention. An event name or typed struct does not establish authorization or sanitize its fields.
Optional Observations
For an optional local trail, register a callback that selects only approved
fields. Here invoice is your package above, bus is the shared bus, context
is imported, and logger is your existing *slog.Logger:
_, err := bus.Listen(invoice.Voided, func(ctx context.Context, fact invoice.VoidedEvent) error {
logger.InfoContext(ctx, "invoice void observed",
"invoice_id", fact.InvoiceID,
"actor_id", fact.ActorID,
"at", fact.At)
return nil
})
After the application establishes that the invoice was voided, publish its fact:
bus.Emit(ctx, invoice.Voided, invoice.VoidedEvent{
InvoiceID: "invoice-2048",
ActorID: actor.ID,
At: time.Now(),
})
Here actor.ID is the already-authorized string identifier. The observer runs
before Emit returns. Its error or panic is logged rather than returned, and
later listeners are skipped. A process crash can lose the observation. A log
line also does not prove that an external collector durably stored it.
If publication occurs inside a transaction, the fact is still provisional: rollback remains possible. Use after-commit hooks for optional observations that must not describe rolled-back changes, or use the required-record pattern below. Do not infer commit merely from the event’s name.
Required Records
A required row must commit or roll back with the invoice change. Assume your
application has an invoice_audit table, a configured *database.Database named
db, and an application-owned audit.Insert(ctx, fact) function that inserts
the fact into that table using this database and the supplied context. It must
return any insert failure; it must not use a separate connection or detached context.
Register that insert as the required callback:
_, err := bus.Listen(invoice.Voided, func(ctx context.Context, fact invoice.VoidedEvent) error {
return audit.Insert(ctx, fact)
})
Check registration before accepting writes. Your application-owned
invoices.Void(ctx, id) below authorizes and changes the invoice using the same
database; it returns an error if the change cannot be performed. Keep the
failure branches that determine rollback:
err := db.WithTransaction(ctx, func(ctx context.Context) error {
if err := invoices.Void(ctx, "invoice-2048"); err != nil {
return err
}
return bus.Dispatch(ctx, invoice.Voided, invoice.VoidedEvent{
InvoiceID: "invoice-2048",
ActorID: actor.ID,
At: time.Now(),
})
})
The listener receives the transaction’s callback context, so its compatible
database insert joins the transaction. A failed insert returns through
Dispatch and the transaction body, preventing commit. A listener panic
propagates rather than being swallowed. Emit cannot enforce this requirement:
it hides callback errors from the publisher. Nor does Dispatch verify that a
listener exists—without the required registration it succeeds without a record.
Keep wiring and tests responsible for that invariant, or call the insert directly.
Do not perform remote calls inside the transaction listener: the publisher waits while holding transaction resources and locks. Do not use its callback context after the transaction ends. The database’s committed event runs outside the finished transaction and is too late to add an atomic audit row.
The enclosing transaction can return database.ErrAfterCommit after a confirmed
commit if cleanup or an after-commit hook fails. Do not retry the transaction on
that result: the change and audit row can already be committed. Transaction
ownership and recovery stay in Transactions.
Durable Forwarding
If an audit destination is remote, write a pending record in the same transaction and have an application-owned worker forward it. This durable pending record is often called an outbox. The worker needs an explicit identifier, occurrence time, and approved actor/resource fields; it cannot rely on the former request’s context. Make forwarding retry-safe and define deduplication at the destination.
A queue call from an ordinary event listener is only a best-effort handoff. An after-commit queue call avoids sending rolled-back changes, but a crash between commit and dispatch can still lose it. Neither gives exactly-once delivery. Queues provide worker transport; atomic record creation, forwarding policy, and retention remain your application’s responsibility.
Built-in Observations
Built-in Events can supplement an operational trail but use best-effort publication and cannot enforce your required audit row. Match the observation to the claim: payment call completion is not settlement, mail/push acceptance is not delivery, and secret notifications do not cover console or other-process changes. Session login changes request-local state before response-time persistence; it is not evidence that a cookie or stored session reached the client.
Some built-in payloads contain raw diagnostics or identifying data. A broad pattern listener should not forward whole payloads or contexts. Select fields under your privacy policy, and use the shared telemetry handle for correlation without treating a trace as a required business record.
Checking Guarantees
Use an isolated event.New() bus to test publication and listener behavior.
Delivery is synchronous, so a sequential test can assert collected payloads
immediately after the operation returns; no polling is needed. Concurrent tests
must synchronize shared collectors.
For required records, test the application boundary, not only callback execution: an insert failure must roll back the invoice change, successful commit must retain both rows, and missing registration must fail your wiring check. Test forwarding retries and destination deduplication separately. A bus test proves local callback behavior, not database durability or external delivery.
For dispatch behavior, see the event API reference.