Skip to content

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

ActionRecorded againstTrigger
APPLICATION_SETTINGS_UPDATEDThe settings entry idAny write that changes the stored value
WORKFLOW_PUBLISHEDThe published workflowA draft becomes the live version for its name
WORKFLOW_DEPENDENT_REPINNEDThe parent workflowA publish re-points an already-published parent at the new subworkflow version
WORKFLOW_ARCHIVEDThe archived workflowEvery 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.

ColumnContents
user_id, user_emailWho, captured at write time
action, entity_type, entity_idWhat changed
changesThe diff (JSONB)
metadataContext that is not itself a change (JSONB)
created_atWhen, 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):

EndpointReturns
GET /audit-logA page of entries, newest first, without payloads.
GET /audit-log/:idOne 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.