Audit trail
Every mutating contract in this kit — a
workflow transition,
an inline edit, a
bulk action —
answers a question the moment it commits. The audit trail is the
record that the question was ever asked. It is not a component
(hc-timeline ships)
and not one endpoint (no recipe could pretend it is): it is a
doctrine, so it lives here.
The row
Section titled “The row”One appended row per committed change:
| Column | Rule |
|---|---|
actor | from the session, never the form — the browser’s claim of who acted is not evidence |
verb | the domain verb (approve, price-change, import) — not the HTTP method |
entity, entity_id | what changed |
at | the server clock — client clocks are presentation (localized timestamps), never record |
request_id | correlates the entry with your logs |
summary | one server-rendered sentence — the trail is read by humans |
before → after | the changed fields only; big payloads as structured data, rendered as a diff on demand |
When to write — mapped to the shipped contracts
Section titled “When to write — mapped to the shipped contracts”The write points are exactly the commit points the recipes already define, which yields the rule that matters: the trail is exactly-once because commits are.
- A workflow transition
writes one entry; the
409loser writes nothing — it changed nothing. - An inline-edit or
datagrid edit
commit writes one entry; a
422writes nothing. - A bulk action writes one entry per changed row plus one for the batch (the batch entry carries the counts, like the CSV import report).
- Undo delete writes both the delete and the restore — undo is a second change, not an erasure of the first.
- An idempotency-key replay writes no second entry — the replayed response came from storage; nothing committed twice.
Append-only
Section titled “Append-only”No UPDATE, no DELETE. A correction is a new entry whose summary
says it corrects; the wrong entry stays, because “we once believed X”
is part of the record. Retention is declared policy (the
idempotency-key TTL stance, at archive timescales) — expiry is a
policy sweep, never an edit.
The read side
Section titled “The read side”hc-timeline is the
display contract — server-rendered, newest first, data-variant on
the items that represent warnings (return, reject) or failures:
<ol class="hc-timeline"> <li class="hc-timeline__item" data-variant="success"> <span class="hc-timeline__marker" aria-hidden="true"></span> <div class="hc-timeline__content"> <time class="hc-timeline__time" datetime="2026-08-29T09:14:00Z" data-hc-time>2026-08-29 09:14</time> <p class="hc-timeline__title">Tanaka approved purchase request REQ-58214</p> <p class="hc-timeline__description">Amount ¥48,000 · request j_8f3k2</p> </div> </li> …</ol><time datetime>+data-hc-timegets localized relative dates (installTime()) while the wire stays ISO.- Load the trail with a lazy panel on the record page — most visits never open it; don’t pay for it.
- Page it with
data-hx-swap="beforeend"load-more (the timeline component’s htmx section). - In a grid, the trail is row-detail material — the record’s page owns its history.
- Large
before→afterpayloads render as anhc-codeunified diff on demand — the timeline entry links it, never inlines it.
What it deliberately does not do
Section titled “What it deliberately does not do”- No client-side event capture. The server writes the trail from what it committed, not from what the browser claims happened — a trail assembled from client events is a wish, not a record.
- No undo. Undo delete is a contract; the trail is a record. Reading the trail to rebuild state is event sourcing — a different architecture, deliberately out of scope.
- No PII advice beyond the obvious: the trail is data. Retention policy applies to it like any other table; “append-only” is not an exemption from deletion law, it is a reason to design the row so the personal data lives in the entity, and the trail references it.