Skip to content

[UX] playout — Audit log ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: playout-studio/playout · Branch: develop @ 998e706d · Files reviewed: 13 Patterns: data-display/timeline

Summary ​

The Audit view is 56 lines and renders correctly, and its action vocabulary is genuinely well built — a closed AuditActionSchema enum, a documented legacy-prose fallback in useAuditActionLabel, and all 20 action labels fully translated in en/fr/no (a real exception to playout's project-level # TODO: translate problem). The defects are all in what the page claims: it is a chronological record of what went to air, presented with total confidence, built from writes that were never confirmed, over a read whose failure is indistinguishable from "nothing happened". The single most important thing is that a reader has no way — none — to separate a recorded action from one that actually reached air, and the page's own empty state actively asserts the wrong thing when the read fails.

Project-level defects that also apply here, not re-filed (see PROJECT-LEVEL.md): NAV-03 (no per-route document-title mechanism exists), and @playout/ui VTitle hardcoding <h1>.

packages/ui is in-repo, so VTitle.vue is asserted from source, not Unverified.

Findings ​

1. The log presents unconfirmed writes as fact — a recorded action is indistinguishable from one that reached air — High · MSG-06 (proposed, see Baseline additions: LOG-01) ​

Where: src/stores/audit.store.ts:22-31; consumer at src/views/Events/Audit.vue:22-43; representative writers at src/views/Events/Overlay/Queue.vue:398, :417, :555

What: This is the consumer side of the write-ordering defect already filed against the lower-third and queue overlays. Every call site fires the log before the Firestore write that changes what is on air, and never awaits either:

js
audit.log("QUEUE_ELEMENT_SHOW", elementName);   // Queue.vue:417
queue.push(el);                                  // the write that actually goes to air
js
audit.log("QUEUE_CLEAR", "");                    // Queue.vue:555
await queue.clear();                             // awaited — but only after the log

The store's log() returns the addDoc promise, but no call site consumes it, and when there is no collection ref or no signed-in user doc it returns Promise.resolve() — a silent no-op that is indistinguishable from success. Meanwhile addDoc writes to Firestore's local cache immediately, so the entry appears in list (audit.store.ts:15) at once and looks committed. If the write is later rejected by rules or fails offline, it silently vanishes; if the subsequent on-air write fails, the audit entry survives and the log now asserts something that never happened.

The view compounds this by rendering every entry identically (Audit.vue:22-43): no metadata.hasPendingWrites check, no pending/confirmed /failed distinction, no error state on the row. The data field is a bare human string, and the structured meta payload the schema captures (packages/schemas/src/audit.schema.ts:88-106) is never surfaced, so there is not even a secondary signal to cross-check against.

Why it matters: The audit log's only job is to reconstruct what happened during a live broadcast — typically after a complaint or an on-air mistake. A reader sees "Went live with queue element X" at a precise second and has no basis to doubt it. An operator can be recorded as having put something to air that the failed Firestore write never actually showed, and there is nothing on the page to suggest uncertainty. The corpus's own timeline edge-case guidance is exactly this: "Verify that optimistic or asynchronous states reconcile correctly after a failure."

Fix: Two parts. (a) In the writers, await the on-air write and log after it resolves, or log optimistically and delete/mark the entry in the .catch(). (b) In this view, render the state honestly: read hasPendingWrites from the snapshot metadata and show unconfirmed rows in a visually and textually distinct state ("not confirmed"), never as settled history. Do not convey it by colour alone.

Severity call: High rather than Blocker. The page renders and the feature is operable; the harm is that its output can be wrong in a way it never discloses. The Blocker-rated instances belong to the writers, where they are already filed.


2. A failed or still-loading read renders as "No audit entries yet" — High · MSG-06 ​

Where: src/views/Events/Audit.vue:9-16; src/stores/audit.store.ts:15

What: The empty state is gated on audit.list.length === 0 and nothing else. useCollection from vuefire exposes error and pending, but the store returns only the collection value and the view consults neither. So three completely different situations render as one screen:

  • the subscription is still loading (list starts [] — the empty state flashes on every visit);
  • the read was rejected (an operator downgraded mid-event, rules denial at firestore.rules:150);
  • the client is offline with a cold cache;
  • and, genuinely, nothing happened yet.

All four produce: "No audit entries yet. Actions performed during this event will appear here."

Why it matters: On any other screen this is the ordinary MSG-06 empty-vs- error confusion. On an audit log it is materially worse, because the empty state is a positive claim. "No audit entries yet" tells someone investigating an incident that no operator did anything — which is exactly the conclusion an unreported read failure would fabricate. The difference between "there were no actions" and "we could not load the actions" is the difference between exonerating someone and knowing nothing.

Fix: Branch on the load state, not the array length. Render a skeleton or "Loading audit entries…" while pending (CONTENT-04), a distinct error state with a retry when error is set, and reserve the empty copy for a confirmed successful read that returned zero documents. Expose vuefire's error/pending from the store so the view can reach them.

Severity call: High rather than Medium because of what the copy asserts. A neutral empty state would be Medium; a confident factual claim generated by a failure is user harm.


3. Audit entries can be deleted by a tenant administrator, and the log discloses nothing — Medium · SEC (proposed, see Baseline additions: LOG-02) ​

Where: firestore.rules:150-153; src/stores/audit.store.ts:17

What: The rules get the important half right — there is no update rule, so an entry cannot be altered after the fact. But allow delete: if isTenantAdministrator() permits outright removal, and the store still ships a remove(audit) helper that calls deleteDoc. That helper is currently unused by any view (no delete control exists in Audit.vue), but the client capability and the rule are both live, so deletion is reachable from the console or any client build.

The view shows no entry count, no sequence numbers, and no tombstones, so a deleted entry leaves no gap a reader could notice.

Why it matters: In playout's role model a tenant administrator is also a tenant operator (isTenantOperator() returns true for admin), so the person whose actions are recorded can be the same person permitted to delete the record — and the page gives a reader no way to tell it has happened. The log is presented as a complete history when it is actually a history minus whatever an administrator chose to remove.

Fix: Either drop allow delete entirely (an audit trail should be append-only, which the missing update rule already half-establishes) and remove the unused remove() from the store; or, if operational deletion is genuinely required, soft-delete with a visible tombstone row so the log's own surface shows that an entry was removed and by whom.

Severity call: Medium, and it is a close one. It needs a privileged insider rather than an outsider, and per the severity normalisation that keeps it below High — but it is filed rather than dropped because it is the second way this page claims more certainty than it has.


4. No filter, search, grouping or pagination over an unbounded list — Medium · FORM-12 (proposed, see Baseline additions: LOG-03) ​

Where: src/stores/audit.store.ts:14; src/views/Events/Audit.vue:17-44

What: The query is query(actionsRef, orderBy("at", "desc")) with no limit(), and the view renders every returned document into a single flat <ul> — no virtualisation, no pagination, no "load more". There is no filter by operator, by action type or by time window, and no search, despite the queue row anticipating data-display/filter-panel. Rows are visually near-identical: avatar, name, timestamp, one line of text. Several actions log an empty data (Queue.vue:398, :460, :463, :555), so consecutive banner-toggle or timer entries are literally indistinguishable apart from the timestamp.

Why it matters: A multi-hour service generates an entry per show, hide, blackout, timer toggle and queue push — hundreds of rows. The realistic task ("who cleared the playlist around 19:40, and what was live before that?") becomes scrolling a homogeneous list looking for one timestamp. The timeline pattern names "Grouping or filters" as a first-class part of the anatomy and its Performance section calls for pagination or windowing "when the layout would otherwise render too many items at once".

Fix: Add limit() with incremental loading, and a filter row over operator and action type (both are closed sets — admins and AuditActionSchema) plus a time-range filter. Group rows by hour or by minute to give the eye an anchor.


5. Per-row <h3> under an <h1> — heading level skipped, and the outline is a list of names — Medium · A11Y-04 ​

Where: src/views/Events/Audit.vue:33; packages/ui/src/components/VTitle.vue:2

What: VTitle renders a literal <h1> (read from source — packages/ui is in-repo). The page therefore has h1 → h3 with no h2, a skipped level. Worse than the skip: the <h3> content is the operator's display name, repeated on every row. The list is also <ul> (:17) when the content is strictly chronological; the corpus's reference implementation for a timeline uses <ol>.

Why it matters: A screen-reader user navigating by heading — the normal way to skim a long page — gets a heading list containing the same handful of names two hundred times, and no structural heading for the log itself. The name is a data field, not a section title, so the outline conveys nothing about the page's structure.

Fix: Render the name as a <p>/<span> and drop the heading entirely, or promote the action to the row heading if a heading is wanted. Add a real <h2> for the list section if one is needed. Change <ul role="list"> to <ol>; the explicit role="list" then becomes unnecessary.


6. Timestamps are ambiguous, timezone-less, and machine-unreadable — Medium · CONTENT-01 ​

Where: src/views/Events/Audit.vue:36-38; src/utils/format.ts (datetime)

What: datetime() hand-builds DD/MM/YYYY, HH:mm:ss from a Date in the browser's local zone, with no locale awareness and no timezone indicator. src/plugins/i18n.ts:4-9 configures no datetimeFormats, so nothing in the app localises dates. Separately, the <time> element at :36-38 carries no datetime attribute — the machine-readable value the element exists to provide is simply absent.

Why it matters: An audit trail is read after the fact, often by someone who was not in the gallery and may not be in the same timezone as the operator's browser. "30/07/2026, 19:04:12" does not say which zone it is in, and an English reader may parse 07/30 before 30/07. On a page whose entire purpose is establishing when something happened, an ambiguous timestamp undermines the record. The bare <time> also gives assistive tech and any copy-paste or export path nothing parseable.

Fix: Use Intl.DateTimeFormat with the active i18n locale and timeZoneName: "short" (or render UTC explicitly and label it), and populate :datetime="new Date(at.seconds * 1000).toISOString()" on the <time> element.


7. Empty-state copy is hardcoded English — Medium · CONTENT-01 ​

Where: src/views/Events/Audit.vue:13-15

What: "No audit entries yet. Actions performed during this event will appear here." is a bare string literal in the template. Notably this is the only i18n gap in the feature: all 20 action labels plus unknown are properly translated in en.yml:948-969, fr.yml:954-974 and no.yml:949-969, with real translations rather than the # TODO: translate English passthrough documented project-wide in PROJECT-LEVEL.md. Someone did this feature's vocabulary properly and missed the one string outside the loop.

Why it matters: CI's check:locales compares keys, so a string with no key at all is invisible to it. A French or Norwegian operator opening an event's audit log before anything has happened — the common first visit — sees English, and per finding 2 this is also the string shown when the read fails.

Fix: Add audit.empty to en/fr/no and render $t("audit.empty"). While there, split it into the four states from finding 2.


8. Truncated rows hide the evidence, with no way to see it — Low · A11Y-04 / LOG-03 ​

Where: src/views/Events/Audit.vue:33, :40

What: Both the name (truncate) and the entire action line (truncate on the <p> wrapping the label and the data span) clip with an ellipsis. There is no title attribute, no wrap, no expand affordance, and no detail view.

Why it matters: data is the actual content that went to air — a Bible reference, a song title, and for INFO_LOWERTHIRD_SHOW (QueueInfo.vue:68) the full text of the info lowerthird. On a narrow viewport the one piece of evidence in each row is exactly the part that gets cut, and the user has no recourse to read it.

Fix: Let the line wrap (break-words instead of truncate), or add :title="auditElement.data" plus an expand-on-click detail row that also shows the structured meta payload.


9. New entries arrive silently; the listener outlives the route — Low · MSG-01, NAV-02 ​

Where: src/views/Events/Audit.vue:17-21; src/stores/audit.store.ts:8-15

What: Two small things in one place. (a) The list is a live Firestore subscription, so rows appear while the page is open, but the <ul> carries no aria-live — a screen-reader user watching the log during a broadcast is never told anything changed (WCAG 4.1.3). (b) useCollection is called at Pinia store setup, so its scope is the app's, not the component's: the subscription is opened the first time any view calls useAudit() — including every overlay view that only wants log() — and is never released on unmount. Every operator therefore holds an open listener on the entire unbounded audit collection for the whole session, whether or not they ever open this page.

Why it matters: (a) is a genuine but low-impact announcement gap — and worth tuning rather than blanket-adding, since announcing every entry during a live show would be noisy; aria-live="polite" on the list, or an announced count, is the proportionate fix. (b) is bandwidth and memory spent on data the current view does not display, which compounds finding 4's missing limit().

Fix: Add aria-live="polite" (or a politely-announced "N new entries" control). Split the store so log() does not force the subscription — expose the collection through a composable scoped to this view, or lazily create it.


10. datetime() will throw on a document with an unresolved at — Low · MSG-06 ​

Where: src/views/Events/Audit.vue:38; src/utils/format.ts (datetime)

What: The template dereferences auditElement.at.seconds with no guard, and datetime() accepts unixSeconds: number with no null handling. log() writes at: serverTimestamp() (audit.store.ts:29), which is null in the writing client's local snapshot until the server acknowledges. A null at throws a TypeError mid-render and takes down the whole list, not just the row.

Why it matters — with an honest reachability caveat: I could not construct a path to this through today's UI. Pending writes are local to the writing tab, and this route has no logging control; the Mousetrap bindings that survive route changes (LowerThird.vue:94, Bible.vue:201) only focus a search field and do not log. So this is a latent robustness defect, reachable today only by a document written without at (console, script, a future writer), not a live bug. Rated Low on that basis rather than dropped, because the crash mode is total: one bad document blanks the entire audit log.

Fix: Guard the render (v-if="auditElement.at", or a pendingLabel fallback) and make datetime() null-safe the way its sibling date() already is. This overlaps neatly with finding 1's pending-state rendering.

Unverified ​

  • A11Y-01 (contrast). text-faint, text-muted and text-text on bg-panel cannot be settled from class names; needs computed colour values in both themes. The empty-state copy at :13 uses text-faint at text-sm, which is the most likely candidate to fail — worth measuring first.
  • A11Y-06 (responsive / short viewport). The row is a flex line of avatar + name + timestamp with truncate; how much of data survives at 375px needs a rendered viewport. Related to finding 8 but not assertable from source.
  • A11Y-02 (target size). Not applicable as written — the view contains no interactive elements at all, so there is nothing to measure. That absence is itself finding 4.
  • Whether MdiClipboardTextSearchOutline (:12) emits an aria-hidden or role="img" attribute depends on unplugin-icons' compiled output, which I did not read. If it renders a bare <svg> it is correctly ignored by most AT; I am not asserting a finding either way.

Baseline additions ​

The baseline has no rule covering a surface whose purpose is to be an accurate record. Three proposed, with the orchestrator to renumber:

  • LOG-01 — An audit or history surface must distinguish a recorded action from a confirmed one. Where entries are written optimistically or before the action they describe has resolved, the unconfirmed state must be visible in the record itself, and never conveyed by colour alone. (This is the same root as the MSG-06 family — "non-throwing SDK result discarded, failure shown as success" — but the consumer-side obligation is distinct enough to state separately, and it is what finding 1 actually turns on. Merge at the orchestrator's discretion.)
  • LOG-02 — An audit trail is append-only, and any permitted removal is disclosed on the surface that presents it. No silent deletes; a gap in the record must be visible as a gap.
  • LOG-03 — A chronological record longer than one screen offers filtering by actor and by action type, and bounds its query. An unbounded, unfilterable list is not a usable record.

Also worth noting for the orchestrator's MSG-06 reconciliation: findings 2 and 10 here are variants (b) "failed fetch must not render as empty state" and (a) "failure never rendered as empty/permanent-loading", which PROJECT-LEVEL.md already identifies as one rule. This draft cites them as MSG-06 on that assumption.

Cross-project note ​

  • Findings 1–3 (LOG-01/02/03) are playout-specific in their live-broadcast form, but the shape — an activity/history surface presenting unconfirmed or incomplete data as settled fact — should be checked in customer-portal (bid history) and tt-time-tracker (time-entry history and invoices). Both have records that a user or an accountant reads after the fact. members has no comparable surface that I am aware of.
  • Finding 2 (empty-state-as-error) is already confirmed as a four-project theme in PROJECT-LEVEL.md's alignment table. This is playout's instance on its highest-stakes read.
  • Finding 4 (unbounded list, no filter) is worth checking wherever data-display/filter-panel appears in a queue row — notably playout's own Platform admin (#21) and Admin management (#18), and the admin tables in customer-portal and tt-time-tracker.
  • Finding 6 (timezone-less, non-localised timestamps) is a strong candidate for a cross-project sweep: none of the four is likely to be labelling timezones, and three of them display times a user is expected to act on.
  • Money check requested by PROJECT-LEVEL.md: this feature displays no currency, but src/utils/format.ts was read in full while auditing it, and playout's currency() helper uses minimumFractionDigits: 2 with maximumFractionDigits of 2 (or 4 for sub-cent amounts) — it does not truncate to 0 fraction digits. That is one of the two outstanding cells in the currency-precision table: playout passes.