Audit: an append-only record of who did what
How a change and its audit event commit together, and how each viewer reads one trail through a capability scope
The Surulere pharmacy has closed for the evening when Folake opens Tomero one more time. Recent Activity on Home tells her the afternoon in two lines: "Paracetamol 500mg was submitted for approval" and "A stock movement was recorded for Amoxicillin syrup". Folake spent the afternoon at the bank. The app wrote the trail while the work happened, and the trail waits for her whenever she reads it.
The event record (audit_events) #
Every audited change writes one row to audit_events. The row records who acted and what changed: the acting user and the membership the action ran under, the entity the change touched, a stable event_type naming the change, a small structured metadata payload, and the moment of the write.
The event commits in the same transaction as the change it records. Every protected write runs the same pipeline: authorization, then the idempotency guard, then the repository writes, then the audit event, then commit. A change and its event land together or roll back together, and one shared service appends every event, so the pattern holds across features.
Only an effective change writes an event. A save that changes zero stored values commits nothing and emits nothing, and a duplicate replay of an idempotent write returns the stored outcome and appends zero second event. However many times a queued offline write retries, the trail gains one row.
Audit events only append. The API exposes zero update or delete routes for them, the code carries zero retention or pruning path, and a wrong or unclear event is corrected by appending a corrective event after it. The trail keeps every event, as written, for the life of the business.
The event row
| Field | What it records |
|---|---|
id | Event identity |
business_id | The owning business; empty for account-level events |
actor_user_id, membership_id | The acting user and the membership the action ran under; empty for system events |
entity_type, entity_id | The entity the change touched |
event_type | Stable dotted constant, such as product.created |
metadata | Small structured values, such as changed_fields |
request_id | The server request that carried the write |
client_operation_id | The write's idempotency key |
created_at | The moment of the write |
client_operation_id holds the same UUID an offline outbox row queues under, so a queued write and the single audit event it produced share one id; an index on (business_id, client_operation_id) serves that lookup, and the feed sort rides an index on (business_id, created_at).
The event vocabulary #
The vocabulary is 27 event types, dotted constants grouped into 10 families by prefix: product.created belongs to the product family. The families are user, business, location, member, employee, compensation profile, product, stock movement, approval request, and payroll run. The member family is the largest at 8 types, covering the invitation lifecycle from member.invited to member.joined alongside membership changes; the product family follows at 5.
user.profile_updated is the single account-level type. It carries an empty business_id and appears in zero business feeds; the settings doc carries its story. Every other type scopes to a business.
Write paths stamp a subject_label into event metadata at the moment of the write: the product name for product, stock-movement, and approval events, the employee full name for employee and compensation events, and the period label, "May 2026", for payroll-run events. The label feeds the entity-named copy on Home. Events written before labels existed keep generic phrasing: a backfill would rewrite append-only history and mislabel renamed entities, so old events stand unlabeled.
Metadata carries small domain-safe values alone: changed field names, counts, and ids. Invitation events mask the invited phone or email, and raw invite codes and tokens stay out of audit rows entirely; the team doc carries that rule. On read, a per-type allowlist shapes what leaves the server, and any key whose name suggests a secret is scrubbed recursively.
The 27 event types
| Family | Event types |
|---|---|
| user (1) | user.profile_updated |
| business (2) | business.created, business.updated |
| location (1) | location.updated |
| member (8) | member.invited, member.invitation_updated, member.invitation_resent, member.invitation_revoked, member.joined, member.updated, member.deactivated, member.reactivated |
| employee (2) | employee.created, employee.updated |
| compensation profile (1) | compensation_profile.created |
| product (5) | product.created, product.updated, product.deactivated, product.activated, product.deleted |
| stock movement (1) | stock_movement.created |
| approval request (3) | approval_request.submitted, approval_request.approved, approval_request.rejected |
| payroll run (3) | payroll_run.created, payroll_run.recalculated, payroll_run.finalized |
The scoping model #
One table serves every audit read, and the scoping model decides who reads which rows where. Two reads exist. The full feed returns everything in the business to holders of one grant. The Recent Activity preview on Home shows each member the families the member's view grants cover.
The full feed sits behind view_audit_history, one of the 6 Owner-only capabilities at launch. A member who calls it receives 403, whatever grants the member holds.
The preview scopes by event family. view_inventory reveals the five product types, view_stock_history reveals stock movements, view_employees reveals employee and compensation events, and view_payroll reveals payroll runs. approval_request.submitted appears with view_inventory on the requester's own preview alone, so a member follows the member's own submission and other submissions stay off that Home; the approvals doc carries the requester-side story. Everything else, the member, business, and location families, approval decisions, and unknown future types, stays behind view_audit_history.
Writing and reading separate cleanly. Recording a movement takes create_stock_movements; seeing movement activity takes view_stock_history. Ngozi holds the first alone, so the stock-out she records reaches the owner's Home and the full feed, and her own Recent Activity shows her product and submission events alone.
The scope applies inside the SQL query itself, for two reasons. The server is the single authorization source, and a capability map duplicated in the app would drift from it. And the preview reads a bounded window of newest rows, so a filter applied after the fetch would let a busy owner's events crowd a member's slice out of the window. A member whose grants cover zero families receives empty items with zero database read.
A requester learns the decision on a submission from the approval outcome item on Alerts. The decision's own events, approval_request.approved and approval_request.rejected, stay behind the owner grant.
The scoping map
| Capability | Events it reveals on Home |
|---|---|
view_inventory | product.created, product.updated, product.deactivated, product.activated, product.deleted |
view_inventory, requester's own rows | approval_request.submitted |
view_stock_history | stock_movement.created |
view_employees | employee.created, employee.updated, compensation_profile.created |
view_payroll | payroll_run.created, payroll_run.recalculated, payroll_run.finalized |
view_audit_history | every business-scoped type, the families above included |
The full feed (GET /audit-events) #
The feed returns individual events, newest first, one row per event with zero grouping. Pages run 1 to 100 events, 50 by default, behind an opaque cursor, and three filters narrow the read: event type, entity type, and actor.
Every event carries a server-composed summary that names the actor: "Ngozi submitted a product for approval". Composing on the server lets the app render future event types safely; an unknown type reads "Ngozi updated business records". The actor display name prefers the full name, then a masked email such as "n***@example.com", then a masked phone such as "***4021", then "Unknown user". A system event carries an empty actor and reads as "System".
Feed wire detail
- Filters are
event_type,entity_type, andactor_user_id, each optional and exact. - Sort is
created_atdescending, theniddescending. The cursor encodes that pair as opaque base64, and the service fetches one row beyond the page to computehas_next_page. - A malformed cursor answers
400 invalid_cursor; a limit outside 1 to 100 answers400 invalid_query_parameter; a member call answers403 forbidden; a caller outside the business answers404 business_context_not_found.
Recent Activity on Home #
The dashboard returns a recent_activity section to every active member, and the section always renders. The preview reads the newest rows the viewer's scope covers, up to 100, collapses each consecutive run by the same actor and the same event type into one item with a count, and returns at most 5 items. Grouping is presentation alone; the rows beneath stay untouched.
The copy is composed per viewer. The viewer's own actions read in the second person: "You submitted Paracetamol 500mg for approval". Everyone else's read passive and entity-first: "Paracetamol 500mg was submitted for approval". Preview copy names entities alone; a person's name appears in the full feed alone. The entity name is the write-time subject label, and an event that lacks one falls back to generic phrasing: "A product was added to inventory". Grouped items carry the count in both voices: "You recorded 3 stock movements" and "3 stock movements were recorded".
Actor identity follows the same line. A preview item includes its actor block for the owner and on the viewer's own rows; a member receives zero other members' identities on Home, in copy and in data alike.
A member whose scope covers zero families gets an empty list, and the screen shows "Recent activity from the areas you can access will appear here." The app renders the server's summary strings verbatim, so the copy ships from one place.
One row shows the whole model. Ngozi's Paracetamol 500mg submission, the same write the approvals and offline-sync docs follow, sits in the trail as one event: approval_request.submitted, actor Ngozi, subject label Paracetamol 500mg. Ngozi's Home reads it as her own action: "You submitted Paracetamol 500mg for approval". Folake's Home reads the same row passive and entity-first: "Paracetamol 500mg was submitted for approval". The full feed, read with the owner grant, names the actor: "Ngozi submitted a product for approval". The row was written once; every reading since is composition.
Preview copy by voice
| Case | The viewer's own action | Someone else's action |
|---|---|---|
| Labeled single event | "You added Paracetamol 500mg to inventory" | "Paracetamol 500mg was added to inventory" |
| Unlabeled single event | "You added a product to inventory" | "A product was added to inventory" |
| Grouped run | "You added 5 products to inventory" | "5 products were added to inventory" |
| Unknown event type | "You updated business records" | "Business records were updated" |
A preview item carries event_type, entity_type, event_count, summary, latest_created_at, and the actor block where the scoping rule allows it.
Launch boundaries #
The model is built so later reads arrive as additions. The boundaries today:
view_audit_historysits with the owner alone at launch, and the grant screen offers the area grants alone, so the full trail is the owner's read today.- The app ships one audit surface, the Home preview. The full feed is an API read today; zero app screens call it.
- Events written before subject labels ship generic preview copy; labels arrive with new events alone.
payslip.generatedis reserved for a future explicit payslip generation workflow and carries zero producer today; a finalized run emits one run-level event, payslip count included.