Skip to content

6. Audit Module

A “Recent activity” panel that lists every write to the system, with a timestamp. We will not change a single existing component. The audit module observes events the rest of the app already emits.

import {
createProviderToken,
defineKitModule,
type KitEvent,
type KitRuntime,
} from '@atheory-ai/kitsune-core'
export type AuditEntry = {
id: string
type: string
timestamp: number
surface?: string
entity?: { type: string; id: string }
detail?: Record<string, unknown>
}
export type AuditService = {
list(): AuditEntry[]
subscribe(listener: () => void): () => void
}
export const AuditToken = createProviderToken<AuditService>('audit')
const TRACKED = new Set([
'note.created',
'note.updated',
'note.deleted',
])
export function auditModule(maxEntries = 50) {
const entries: AuditEntry[] = []
const listeners = new Set<() => void>()
const notify = () => listeners.forEach((listener) => listener())
const record = (event: KitEvent) => {
if (!TRACKED.has(event.type)) return
entries.unshift({
id: event.id,
type: event.type,
timestamp: event.timestamp,
surface: event.context?.surface as string | undefined,
entity: event.context?.entity ?? (event.payload?.note as { id: string } | undefined),
detail: event.payload as Record<string, unknown> | undefined,
})
if (entries.length > maxEntries) entries.length = maxEntries
notify()
}
const service: AuditService = {
list: () => entries.slice(),
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
return defineKitModule({
name: 'audit',
setup(runtime: KitRuntime) {
runtime.provide(AuditToken, service)
},
events: {
'note.created': record,
'note.updated': record,
'note.deleted': record,
},
})
}

The module:

  • Subscribes to three events.
  • Maintains a fixed-size ring of entries.
  • Exposes its log via a provider token so a panel can render it.

src/render.ts:

import type { AuditEntry } from './audit-module.js'
export function renderAuditPanel(entries: AuditEntry[]): string {
if (entries.length === 0) {
return `<p class="muted">No activity yet.</p>`
}
return `
<ol class="audit">
${entries.map((entry) => `
<li>
<strong>${entry.type}</strong>
<small>${new Date(entry.timestamp).toLocaleString()}</small>
${entry.entity ? `<small>${escape(entry.entity.type)}/${escape(entry.entity.id)}</small>` : ''}
</li>
`).join('')}
</ol>
`
}

CSS:

.audit { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.5rem; }
.audit li { font-size: 0.875rem; display: grid; gap: 0.125rem; }
.audit small { color: light-dark(oklch(45% 0 0), oklch(70% 0 0)); }

Add a third column to the layout in renderApp:

export function renderApp(notes: Note[], audit: AuditEntry[]): string {
return `
<div class="app-grid app-grid--three">
<section class="sidebar">${renderNoteList(notes)}</section>
<section id="editor" class="editor">
<kit-boundary surface="empty-editor" feature="notes">
<p class="muted">Select a note from the left.</p>
</kit-boundary>
</section>
<aside class="aside">
<kit-boundary surface="audit-panel">
<h2>Recent activity</h2>
${renderAuditPanel(audit)}
</kit-boundary>
</aside>
</div>
`
}

CSS:

.app-grid--three { grid-template-columns: minmax(14rem, 18rem) 1fr minmax(14rem, 18rem); }

In main.ts:

import { auditModule, AuditToken } from './audit-module.js'
shell.modules = [
notesModule(seedNotes),
auditModule(),
notificationModule(),
dialogModule(),
debugModule(),
]
queueMicrotask(() => {
const notes = shell.runtime.inject(NotesToken)
const audit = shell.runtime.inject(AuditToken)
if (!notes || !audit) return
const draw = () => {
document.getElementById('main')!.innerHTML = renderApp(notes.list(), audit.list())
}
draw()
notes.subscribe(draw)
audit.subscribe(draw)
})

Create a note, update one, delete one. The right panel updates with each action. No other code changed. Not the form. Not the cards. Not the editor. The audit module observed events and the panel re-rendered.

This is the architectural payoff in a single chapter. Adding capability did not require touching the UI. The button that creates a note doesn’t know audit exists.

A note on observation vs imperative writes

Section titled “A note on observation vs imperative writes”

You’ll notice the audit module subscribes to note.created, not note.create. The form dispatches the command (note.create), the persistence module handles it and emits the event (note.created). The audit module observes the event.

This separation matters: if you replaced notesModule with a Supabase-backed version, audit would still work. The audit module observes the fact, not the implementation.

A delete-confirmation dialog using native <dialog>. We’ll wire it through the metadata protocol so deletion is a two-step interaction: open the dialog, confirm, dispatch.

Next: Chapter 7 — Confirm with kit-dialog →