Audit narrator + ticker tape
The audit chain has always been tamper-evident. What v0.7.182 → v0.7.200 added is that every entry is now human-readable — not a code + a JSON blob, a sentence. And that sentence surfaces where the operator can see it: a ticker tape resting 25 px below every tab. The point is not to interrogate — the point is that an owner glancing at the ticker knows what just happened in the store, in real time, without opening anything.
| Shipped | v0.7.182 (substrate) → v0.7.200 (final polish) |
| Substrate | src/core/audit.ts + src/core/audit-narrate.ts + migrations/040_audit_events.sql |
| Reader | GET /api/audit/recent — the last N narrated events, newest first |
What changed
The audit chain used to write mutation rows through recordMutation() — event_type + before / after JSON + a hash chain. That's still there (nothing about the chain integrity changed). What migration 040 adds is a narrated tier:
audit_events.narration— a plain-English sentence generated deterministically at write timeaudit_events.event_type— the same discriminator the writer already had, now feeding the narrator's verb/lexiconaudit_events.actor_nameand related snapshot columns — so a narration reads as "Marcus voided …" not "staff #7 voided …" even if the staff row is later renamed- FULL-coverage tamper hash — the narration is inside the hash, not adjacent to it, so the sentence can't drift from the fact
The reader (recentAuditEvents + /api/audit/recent) returns those rows in newest-first order, capped at the ticker's window; every module's client polls it on tab-return.
The narrator library
core/audit-narrate.ts is a deterministic idiom library — same event, same sentence, every time. The function signature is roughly:
narrate(event: AuditEvent): string
It's a switch over event_type, with a small vocabulary of verbs and a helper that resolves substrate references — a variant_id becomes the readable product name, a customer_id becomes the display_name, a transaction_id becomes the TRX identifier. The narration is stored on the row at write time, not re-rendered per read; a variant renamed later doesn't rewrite history.
Sales-flagship examples (v0.7.194 introduced the family):
sales.create→ "Marcus rung up Joe Customer's $2,411.48 sale — 3 items, cash + card split, GST + PST."sales.void→ "Marcus voided Joe Customer's $2,411.48 sale — customer changed his mind."sales.refund→ "Marcus refunded $412.00 of Joe Customer's TRX-2026-004417 — 1 line returned New."deposit.create→ "Marcus opened a $500 layaway for Joe Customer against a $2,200 build."deposit.add_funds→ "Marcus took another $250 on Joe Customer's layaway ($750 / $2,200 paid)."deposit.cancel→ "Marcus cancelled Joe Customer's layaway; $500 refunded to store credit."
The lexicon extends to the whole audit surface as more modules route through recordNarrated — Service ticket status transitions, Inventory adjustments, Purchases receiving, Settings permission grants. Each event owns its sentence in the narrator; adding a new event type is one switch arm and one test.
The ticker footer
The shell renders a 25-px ticker tape below every tab. Rows scroll left; the last few events are always visible. Verbose peek on hover — the full sentence with actor, subject, and quantities. Click-to-open was tried and pulled in v0.7.200: an operator glancing at the ticker isn't asking to interrogate a specific transaction, they're asking "what's happening in the store right now?" — a clickable ticker read as an inbox and pushed the operator into a deep-dive they didn't want. The ticker is glanceable, not interactive.
Toasts were retired in v0.7.175 — the ticker replaces them. A toast fades and is gone; a ticker entry is durable and part of the audit chain that produced it.
The recordNarrated write path
Any mutation that should surface on the ticker goes through recordNarrated(db, event):
recordAuditEvent(db, event)writes the row with the FULL-coverage tamper hash — this is the existing chainnarrate(event)runs at the same commit boundary — sentence is stamped into the same row, inside the same hash- The row becomes visible to
/api/audit/recentat the next poll
Terse writeAudit() entries (existing, pre-narrator) were made owner-readable in v0.7.197 — the narrator now resolves variant #N, transaction #N, and staff #N references into readable names + actor + verb lexicon so even the historical rows read as sentences on the ticker.
What the ticker is NOT
- Not a notification centre. A cashier isn't paged by a ticker entry; the ticker isn't a queue.
- Not push. No WebSocket. A polled reader is enough for a footer that shows "the last few things"; the tamper chain is authoritative for the audit story.
- Not an inbox. No unread state. No "mark as read". No click-to-open — the ticker is glanceable, not actionable.
- Not the whole audit chain. The chain has always logged everything mutating; the ticker surfaces a curated subset (mutations worth narrating) — administration reads still go through the audit chain UI, not the ticker.
See also
- Three strata — Kernel · Meta · Modules —
core/audit.ts+core/audit-narrate.tsin the Kernel - Audit everything — the doctrine the tamper chain implements
- Sales module — the flagship consumer of the narrator