ADR-0017: Append-only audit journal for financial writes
- Status: Accepted
- Date: 2026-06-14
Context
FR-14 lets an LLM agent write financial data through the MCP/API surface. Once a non-human actor can create, change and delete transactions, accounts and classifications, attribution and reversibility stop being a nicety and become a safety requirement (PRD FR-28, NFR-2): a hallucinated or erroneous edit must be detectable, attributable, and traceable after the fact — including deletions, which today leave no trace.
The codebase already derives every read model from the immutable transaction
history (ADR-0004, ADR-0011), but that history is itself mutable: a row can be
updated or deleted with nothing recorded about who did it, when, or what the
value was before. There are three independent write entry points (LiveView,
the JSON API, the MCP companion via the API per ADR-0002) and several write
contexts (Ledger, Portfolios, Classifications, Catalog, Fx,
Imports). Auditability is only trustworthy if no write path can bypass
the record — a convention (“remember to journal”) is not enough when an agent
and future contributors keep adding write paths.
The forces that shaped the decision:
- Atomicity. A journal entry that can commit without its business write (or vice versa) is worse than none — it lies. The two must be one transaction.
- Engine purity (ADR-0011, architecture D2). Computation engines are pure functions over injected data. Journaling is a side effect and must live in the imperative shell (the contexts), never in engines or read-model loaders.
- Mechanical completeness. Whether a write is journaled must be enforced by the database, not by reviewer vigilance, so that a newly added write path fails loudly instead of silently skipping the journal.
- Append-only. The journal is the one table that must not be rewritable, even by the application role that owns it.
Decision
Introduce an append-only audit_journal table and a single Portfolixir.Journal
context that is the only writer of it. Every committed financial write
records exactly one journal entry in the same database transaction as the
business write.
Table and schema
- Table
audit_journal, contextPortfolixir.Journal, schemaJournal.Entry. Columns:actor_type,actor_label,operation,resource_type,resource_id,beforeandafter(both JSONB),scenario_id(nullable), andinserted_at. There is noupdated_at— rows are never updated. operationis a closed enum:create | update | delete | upsert.upsertexists because anon_conflictwrite cannot deterministically report whether it created or updated.resource_typeis a closed list of stable string codes (e.g."transaction","cash_account") — never module names, so the codes survive refactors.
Append-only enforcement (database level)
Append-only is enforced by PostgreSQL triggers that raise on UPDATE, DELETE
and TRUNCATE of audit_journal. REVOKE alone is insufficient because
the application role owns the table. A standing test issues a real
UPDATE/DELETE against the table and asserts the raise, so a migration that
drops the trigger turns CI red.
Residual risk (a superuser, or a deliberate data-fix migration) is accepted and
documented. The named legitimate escape hatch for a data-fix or restore is
SET session_replication_role = replica, which disables triggers for the
session; the first such migration must use it explicitly rather than improvise.
Disaster recovery against the triggers is documented in docs/backup-restore.md
(restore runs with session_replication_role = replica).
Single entry point and atomicity
- All journal writes go through one fixed function,
Journal.record/3, which takes anEcto.Multiand returns anEcto.Multiwith the journal step appended. No journal insert exists anywhere else. - The business write and the journal insert run in one
Repo.transaction/1— both commit or neither. Completeness (“no committed financial write without a journal entry”) is a testable invariant and an acceptance criterion of the journal story. beforeis the changeset’sdataserialized;afteris built in aMulti.runstep placed after the named business step, from that step’s result. NoMultiintrospection magic.
Mechanical completeness via a session-variable guard
- Journaled tables carry a guard trigger that requires a transaction-local
session variable,
portfolixir.journal_actor, to be set. OnlyJournal.record/3(and the allowlisted non-journaled paths) set it, viaSET LOCAL. A rawRepo.update/Repo.delete/Repo.inserton a journaled table with no actor set fails loudly. - The guard reads
current_setting('portfolixir.journal_actor', true)withmissing_ok = true, so an absent variable raises the defined guard exception rather than PostgreSQL’sunrecognized configuration parameter. It rejects bothNULLand the empty string: resetting a custom GUC viaset_config(name, NULL, true)leaves an empty string (notNULL) on the connection, so on a pooled/recycled connection a later un-journaled write would otherwise pass anIS NULL-only check. Treating""as “no actor” closes that gap (verified against PostgreSQL 16). Journal.record/3prepends theSET LOCALstep and resets the variable in a final step, because under the Ecto SQL sandbox a test’s outer transaction would otherwise keep the variable alive past the business transaction and hide a missing-actor bug. Guard-trigger tests runasync: falseoutside the sandbox (real commit + cleanup).
Actor attribution
Portfolixir.Actoris an explicit struct (type+ optionallabel) passed as the first positional argument of every public context write function:Ledger.create_transaction(actor, attrs). Smuggling the actor through the process dictionary is forbidden.- The actor
typeis a closed taxonomy:owner_ui | api_token_rw | api_token_ro | import_session | system_job, extended only by amending this decision (the Phase-3 sync framework is expected to add per-provider types).
Scope
- Only committed writes are journaled. Changeset rejections, constraint violations and domain-rule rejections never reach the journal — nothing committed, nothing journaled.
- Deletions are journaled (the
beforesnapshot keeps a deleted record traceable; it is not silently gone). - Bulk imports journal per-record entries (a 5 000-row import is 5 000
entries) with actor
import_session. - Persisted what-if scenario writes (FR-27, future) are journaled with a
scenario marker (
scenario_id); journal queries default-filter to real (non-scenario) writes. - The journal is active from activation, with no backfill — pre-existing history is not retro-journaled (operator decision, 2026-06-12). Writes that happen before a given context is journaled are a documented audit-trail gap.
Allowlist of non-journaled writes
Market-data ingestion (quote sync, FX-rate sync) writes operational data, not
financial records, and is not journaled. The exempt write paths are a
closed list in one module, Portfolixir.Journal.Allowlist, guarded by a
meta-test so the exception set can only shrink, never grow silently. The
idempotency_keys table (future, FR/AR write idempotency) is likewise
operational state — not journaled, no guard trigger, never on the allowlist
(the allowlist governs only journaled-table writers).
Read surface
The journal is queryable through the JSON API and a matching MCP tool
(API/MCP parity, FR-16/AR-11), with a self-describing response (FR-13: as_of,
filters applied, ordering). A UI viewer is explicitly a follow-up; the API/MCP
surface comes first.
Rollout sequencing
The actor-first signature change and table arming land as sequenced
per-context PRs, not big-bang, in leaf-first order
(Catalog/Fx → Portfolios/Classifications → Ledger → Imports). Each
context’s journaled tables are armed by their own migration the moment that
context is fully actor-first. A meta-test couples the two mechanically: if a
context’s grandfather list is empty but its tables are not armed (or vice
versa), CI fails. This ADR and the journal infrastructure
(audit_journal table, append-only triggers, the reusable guard-trigger
function, Journal, Actor, Journal.Serializer, Journal.Allowlist) land
first; arming no table, they cannot break existing writes.
Rollout status
- Slice 0 — infrastructure (landed):
audit_journaltable, append-only and guard-trigger functions,Journal,Actor,Journal.Serializer,Journal.Allowlist; no business table armed. - Slice 1 — Catalog/Fx (landed):
Catalog’s security writes (create/update/delete/set_asset_classand the search-result writers) are actor-first and routed throughJournal.record/3; thesecuritiestable is armed by its own migration.Fx’s only write (upsert_many→ market-dataexchange_rates) stays allowlisted. The journal read surface ships asGET /api/v1/journaland the matchingportfolixir.journal.listMCP tool. Logo writes (operational, onsecurities) are journaled under a fixedsystem_jobactor; the migration-onlybackfill_inferred_asset_classes/0keeps its arity (immutable migrations) and is excluded from the actor gate. Meta-tests landed: append-only, allowlist, and the AST write-actor gate with a shrink-only grandfather list coupling arming to conversion. - Next slices: Portfolios/Classifications → Ledger → Imports, one context per iteration, each arming its tables as it becomes actor-first.
Consequences
- Every change to financial data becomes attributable and reversible by inspection, including deletions and agent-made edits — the safety net FR-14 requires before agents get write grants.
- Adding a new write path is not a place to forget journaling: the guard trigger rejects an un-journaled write to an armed table, so the failure is loud and immediate rather than a silent audit gap.
- The actor becomes a mandatory first argument of every public context
write function. This is a deliberate, wide signature change carried out as
sequenced per-context refactors; callers in LiveView, API controllers and the
import applier must thread an
Actorthrough. Tests construct writes through the real actor-first functions — there is no test-only journaling bypass. - Engines and read-model loaders stay pure: journaling lives only in the context shell (consistent with ADR-0011 and architecture D2). Engines compute; the shell writes and journals.
- The journal table grows unbounded by design. Retention and partitioning are a
named future concern (a follow-up ADR); the table carries indexes on
(resource_type, resource_id),actor_type,inserted_atandscenario_idto keep the read surface usable in the meantime. - A restore or data-fix must consciously use the
session_replication_role = replicaescape hatch; this is documented so the first disaster recovery is not the moment of discovery. before/afterare stored as JSONB through a singleJournal.Serializerthat encodes Decimals as strings and dates as ISO strings (Jason’s defaults would emit Decimals as floats and lose precision), with araisefallback on any unmapped type so a new field cannot be silently dropped from the audit record.