Compliance Audit Trail
Compliance Audit Trail
Brain keeps an append-only record of the changes that can weaken a safety threshold or bind a new compute graph to production. Compliance review of the 18+ cutover depends on this trail: application request logs do not qualify, because they are neither durable nor attributable.
This page covers the Brain trail only. Strip’s separate admin audit surface is unrelated and still undocumented — see Security Model.
What Is Audited
| Action | Recorded against | Trigger |
|---|---|---|
APPLICATION_SETTINGS_UPDATED | The settings entry id | Any write that changes the stored value |
WORKFLOW_PUBLISHED | The published workflow | A draft becomes the live version for its name |
WORKFLOW_DEPENDENT_REPINNED | The parent workflow | A publish re-points an already-published parent at the new subworkflow version |
WORKFLOW_ARCHIVED | The archived workflow | Every version of a name is withdrawn from service |
A re-pin is recorded against the parent rather than the subworkflow that triggered it, because the parent is what starts executing a different graph without anyone publishing it.
What Is Deliberately Not Audited
Draft authoring — create, update, delete, and new-version — produces no entry. Flank autosaves a draft every few seconds, so a per-write trail would bury the binding events under canvas noise.
The resulting attribution gap is closed by workflow.updated_by, which names
the last person to edit the draft. Publish entries carry that id as
metadata.authoredBy, so the trail distinguishes who wrote a graph from who
put it into production.
Guarantees
Append-only. AuditLogRepository exposes insertion and nothing else: no
update, no delete, no raw table handle. There is no database trigger; the
guarantee is the repository’s narrow surface plus database backup policy, which
matches how Sirloin guards audits.strip_audit_logs.
Fail-closed. Every entry is spliced into the same transaction as the change
it describes, via AuditLogService.createOperation. A change that cannot be
recorded is rolled back rather than applied unaudited. This applies to settings
writes, publish, re-pin, and archive alike.
Attributable. auditActorFromUser throws when a request carries no
authenticated user, so an unattributable change fails instead of degrading to an
anonymous entry. Unattended writes such as boot-time fixture seeding use the
explicit SYSTEM_ACTOR. All four audited routes are ADMIN-only.
No-op writes are not changes. A settings save whose value is deep-equal to
the stored one still updates updatedAt, but records no entry.
Storage
Table audit_log in the Brain database. Column names and types mirror
Sirloin’s audits.strip_audit_logs, so one reader can serve both trails without
reshaping either. The two live in separate databases that never cross-query, so
combining them is an application-level join rather than a UNION.
| Column | Contents |
|---|---|
user_id, user_email | Who, captured at write time |
action, entity_type, entity_id | What changed |
changes | The diff (JSONB) |
metadata | Context that is not itself a change (JSONB) |
created_at | When, as timestamptz |
created_at is the one place this table departs from Brain’s own convention,
which is naive timestamps everywhere else. An audit timestamp without an offset
cannot say when something happened if the database server is not on UTC, and
Sirloin’s audit table made the same call.
The domain layer calls the writer an actor, not a user, because boot-time
seeding writes as SYSTEM. That name stops at the storage boundary: the columns
stay user_id and user_email to match Sirloin.
There is deliberately no updated_at. Indexes cover lookup by entity, by user,
and by recency, each newest-first.
Settings entries store changes as { before, after } with full values.
Workflow publishes store a change summary instead: added, removed, and
reconfigured nodes, rewired edges, graph digests on both sides, changed scalar
fields spelled out, and the names of reshaped contracts without their bodies.
Node positions are excluded from the summary so that dragging a node on the
canvas does not read as a behaviour change.
Every node reference carries its label as well as its id, so a reviewer can
tell which node is meant without opening the editor.
A changed node also carries the fields that differ inside it, covering label,
name, type, category, and its configuration, with both sides spelled out.
This is what lets a reviewer see that a threshold moved from 0.9 to 0.4
rather than only that some node changed. Node configs hold prompts, model
settings, and thresholds, not credentials, so recording values does not widen
the secret surface; re-check that assumption before adding a node type that
takes one.
The configuration diff descends into nested objects and compares leaves, up to
four levels below config. Without that, node types which keep everything under
one key — call_model puts its whole request under payload — would report
that single key as changed and say nothing useful. Arrays are compared whole
rather than per index, because element order carries meaning: reordering
image_urls changes which image goes where, and a list reads better than a set
of index entries.
Two bounds keep one entry from growing without limit. A leaf longer than 512
serialized characters is recorded as elided rather than copied, which keeps a
prompt body out of every publish. Beyond 40 field changes on a single node, the
remainder is counted in truncated instead of listed — a node that changed that
much is a rewrite, and the two workflow versions are the better source.
Elided values stay readable from the two workflow rows. Do not rely on that as
the general answer, though: WorkflowRepository.delete can remove a version
outright, so an entry that cannot explain itself is an entry that may one day
explain nothing. Prefer recording enough in the summary.
Reading The Trail
Brain exposes two admin-only endpoints, both behind @Roles(UserRole.ADMIN):
| Endpoint | Returns |
|---|---|
GET /audit-log | A page of entries, newest first, without payloads. |
GET /audit-log/:id | One entry including changes and metadata. |
The list takes page and limit alongside the entity_type, entity_id,
action, user_email, from, and to filters. Email matching is
case-insensitive. A bare date in from or to covers the whole day: from
starts at 00:00:00Z and to ends at 23:59:59.999Z, so a single day is
selected by passing the same date to both.
List rows omit changes and metadata on purpose. A publish summary on a
large graph is far bigger than the row that describes it, and a compliance
reviewer scanning a page needs who, when, and what kind of change — not the
payload of every entry on screen.
Both committed Brain clients cover these endpoints:
apps/sirloin/pkg/brain-client/api_audit_log.go and Fennec’s generated
client.ts. Fennec’s page calls brainApi directly rather than the generated
client, matching the other Brain-backed pages.
Fennec serves the admin view at /audit-log under the Compliance menu, with
filters held in the URL so a view can be pasted into an incident thread. Each
row opens a detail panel that renders the payload per action: a key-level
before/after for settings, a node and edge summary for a publish, a sentence
for a dependent re-pin, and the withdrawn versions for an archive. An action
without a matching renderer falls back to raw JSON, so an action added later
still displays rather than blanking the panel.
Known Gaps
- No retention or archival policy is defined for
audit_log. TODO(@pawel): agree retention with compliance. - No kill switch. Because writes are fail-closed, a broken audit writer also stops settings writes and workflow publishes. FOXY-806 asks for a flag that disables the writer without deleting history. Deferred deliberately: a bypass outlives the incident it was added for. Two constraints bind whoever adds it. The flag must not live in ApplicationSettings, which is itself audited, or it could be flipped through the editor it is meant to police, and that write would be the last one recorded. Disabling the writer also means keeping the 18+ cutover dark, since serving adult traffic unaudited is the state this trail exists to prevent. TODO(@pawel): decide before cutover.
- Protected safety floors are not enforced; the editor can still lower a threshold, it is merely recorded. TODO(@pawel): floor values pending from compliance.
Verification
Run pnpm lint && pnpm tsc && pnpm test from apps/brain/. The behaviour above
is covered by unit tests beside each source file, including the fail-closed
rollback, the actor requirement, and the publish change summary.