# Bilko — Financial Audit Trail & Retention Architecture

# Bilko — Financial Audit Trail & Retention Architecture

**Parent:** MC #105872 · **Plan:** `~/.claude/plans/greedy-greeting-flame.md` (CEO-approved 2026-07-17) · **Design team:** Petter Graff (lead), Martin Kleppmann (event-log semantics), Bruce Momjian (Postgres)

CEO mandate (2026-07-17): Bilko is serious accounting software — every mutation of a financial record must leave a trace ("if someone deletes a file and claims we lost their data, we must be able to prove what happened"). Files themselves do not have to be kept once their legal retention window expires, but the **trace of what happened must survive forever**.

## Status at time of writing (2026-07-17)

Phase A (foundation) is **merged to `azdo/main`** via Azure DevOps PR #178 (commit `c44ee7c9`, squash-merge of the full A2–A8 + C2 chain, 24 files, 4707 insertions) — genuinely a two-parent merge commit (`git cat-file -p c44ee7c9` shows parents `3f144e82`/`ce7ee96f`), consistent with a real PR merge, though this specific instance doesn't carry azdo's usual auto-generated "Merge pull request N from branch into main" message text that its neighboring commits do (those PRs were completed via REST rather than the azdo web UI the same evening, per team lead). Three sibling PRs from the same wave: **#179** (Lexicon legal docs, commit `a71a0b15`), **#180** (D1/D2 demo-country fix, commit `3f144e82`), **#181** (B4 storage consolidation, commit `ce64b93b`). PR numbers/attribution here are as reported by the team lead; this session could not independently query the azdo PR REST endpoint (`az repos pr show` fails with a local keychain error, `-25308 Can't fetch password from system`, unrelated to the PAT itself — see `~/.claude/projects/-Users-makinja/memory/technical_azdo_git_auth_paths_sp_not_in_org_2026-07-17.md`, PAT was rotated and confirmed working earlier the same day). The merge-commit hashes and their file contents **are** independently tool-verified (`git log`/`git show --stat` against `azdo/main`). This page documents what is **live in `main` today** and flags explicitly what is still open.

| Item | Status |
|---|---|
| A1 — RS/BA/HR retention law verification | Done (MC #105873) |
| A2/A3 — `financial_audit_log` + `document_retention_manifest` schema | Merged main (V124, V125), PR #178 |
| A4/A5 — capture trigger, pilot + 6-table fan-out | Merged main (V126, V128), PR #178 |
| A6 — actor/request-id context plumbing | Merged main (3 adversarial rounds, see below), PR #178 |
| A7 — backfill of existing rows | Merged main (V129), PR #178 |
| A8 — activity feed + entity timeline read API | Merged main (V130), PR #178; **web UI panel not built**, flagged to Vizu |
| C1 — `deleted_by`/`deletion_reason` columns | Merged main (V127), PR #178 |
| C2 — `InvoiceService.deleteInvoice` USER_DELETE snapshot | Merged main, PR #178 |
| B4 — Storage: R2/GCS → Azure Blob SDK, `deleteBlob()` | Merged main (`ce64b93b`), PR #181 |
| B1 — retention citation fixes + per-type override table | **MC #105880, in progress** — not yet in code |
| B5 — upload path writes `document_retention_manifest` row | **MC #105884, in progress** |
| B2 — purge worker, dry-run mode | **MC #105881 created, blocked** — red-zone (Momjian+Graff), waiting on A3+B1 |
| B3 — purge worker, live mode | **MC #105882 created, blocked** — waiting on B2 + ≥1 clean dry-run sprint on stage + explicit CEO sign-off before first live activation |
| D1/D2 — demo-country display bug (parallel workstream, same wave, not part of audit trail scope) | Fixed and merged (`3f144e82`), PR #180; documented separately, referenced here only for context |

**Do not treat B1/B2/B3/B5 as done.** MC #105881/#105882 exist but are deliberately blocked, not started — the purge worker does not run anywhere today; nothing purges blobs or applies retention. This page's "purge runbook" section below describes the *design*, not a currently operable procedure.

## Why a third audit table

Two audit mechanisms already existed before this program:

- **V1 `logged_actions`** — append-only JSONB, but only ever used for `ENTRA_JIT_LINK`/`SECURITY_VIOLATION` events, and has no `org_id`/`country_code` (any RLS check against it needs an expensive subquery).
- **V51 `audit_log`** — append-only, but scoped to admin-portal actions only.

Neither covers ordinary financial-entity mutations (invoices, expenses, contacts, transactions). Before this program, `InvoiceService.deleteInvoice` was a **hard delete with no audit trail at all** (`InvoiceService.kt:1004-1018` pre-change) — the exact gap the CEO mandate calls out.

Kleppmann's arbitration: build a **third** table, not a consolidation of V1/V51 and not per-domain tables. It carries `org_id` + `country_code` from day one (unlike V1), so ADR-017 Phase 2B-style partitioning/tenant-query patterns work immediately.

## Schema

### `financial_audit_log` (V124, `apps/api/src/main/resources/db/migration/V124__financial_audit_log.sql`)

Partitioned, append-only audit trail for financial-entity mutations and read-sensitive actions.

```
event_id       BIGINT GENERATED ALWAYS AS IDENTITY
org_id         UUID NOT NULL REFERENCES organizations(id)
country_code   VARCHAR(10) NOT NULL      -- denormalized, indexed, NOT the partition key
entity_type    VARCHAR(32)
entity_id      UUID
action         VARCHAR(24) NOT NULL      -- INSERT | UPDATE | DELETE | EXPORT | DOWNLOAD |
                                          -- RESTORE | USER_DELETE | RETENTION_PURGE
actor_user_id  UUID REFERENCES users(id) ON DELETE SET NULL
actor_label    TEXT
before_data    JSONB
after_data     JSONB
changed_fields TEXT[]
reason         TEXT
request_id     TEXT
client_ip      INET
is_backfilled  BOOLEAN NOT NULL DEFAULT false
occurred_at    TIMESTAMPTZ(6) NOT NULL DEFAULT now()

PRIMARY KEY (event_id, occurred_at)   -- occurred_at required in PK: it's the partition key
```

Design decisions (Momjian + Kleppmann arbitration, all tool-verified against the live schema, not assumed):

- **`PARTITION BY RANGE (occurred_at)`, monthly.** Purge is a time predicate (`DROP PARTITION`, never a million-row `DELETE`). `country_code` is a plain indexed column, not the partition key — this is the explicit rebuttal of ADR-017 Phase 2B's original country-list partitioning idea (Momjian: YAGNI, purge doesn't need country as a physical boundary).
- **`retain_until` is deliberately NOT a column.** Retention differs by jurisdiction (HR/RS/BA_FED/BA_RS) and by entity type within a jurisdiction (see retention table below), so it's computed at purge time from `organizations.legal_retention_years` plus a per-type override table (B1, not yet built), not stored redundantly on every row.
- **Append-only enforcement is two layers, not one:** `REVOKE UPDATE/DELETE FROM bilko_app` (grants: INSERT+SELECT only) **and** `BEFORE UPDATE/DELETE RAISE EXCEPTION` triggers on the partitioned parent. The trigger layer exists specifically to also catch `bilko_admin`, which is `BYPASSRLS` but *not* superuser — `BYPASSRLS` skips RLS policies, it does not skip triggers. PG16 propagates parent triggers to all partitions automatically, including ones created later by the (not-yet-built) partition-maintenance job.
- **RLS is PERMISSIVE + FORCE**, consistent with the current Phase 2A org-isolation era — `org_isolation_select`/`org_isolation_insert` scoped to `app.current_org_id`, plus `platform_admin_full_read`. A RESTRICTIVE flip is an explicit, separate, later CEO-gated decision; this migration does not touch it.
- **`country_code` is `VARCHAR(10)`, not `VARCHAR(8)`.** This was a real bug caught by Momjian's design review of the first commit: the source column (`organizations.country`) was widened to `VARCHAR(10)` back in V16 specifically to hold `BA_FED`/`BA_RS` (6 chars), and V120 did the same for `compliance_deadlines.country` "to keep country-code columns consistent." The original `VARCHAR(8)` would have fit today's values with zero headroom — any future jurisdiction code longer than 8 characters would have thrown inside the audit trigger and aborted the *parent* invoice/expense mutation transaction, which is a worse failure mode than a normal app-level validation error on a table explicitly designed to be an invisible safety net. Fixed pre-merge; a dedicated `BA_FED`/`BA_RS` width test fixture was added (previously the 30/30 green suite only exercised `HR`/`RS`/`BA`, none of which are close to the old 8-char limit).
- Initial monthly partitions cover 2026-07 through 2026-10 only. **A partition-maintenance job to create future months does not exist yet** — this is an open operational gap, not a documentation oversight; inserts for November 2026 onward will fail with "no partition found for row" unless a job is built before then.

### `document_retention_manifest` (V125, generalizes the existing V78 `hr_einvoice_archive` WORM pattern to all uploaded files)

```
manifest_id       BIGSERIAL PRIMARY KEY
org_id            UUID NOT NULL REFERENCES organizations(id)
entity_type       VARCHAR(32)
entity_id         UUID
original_filename TEXT NOT NULL
content_type      TEXT
file_size         BIGINT
sha256_hex        CHAR(64) NOT NULL
storage_backend   TEXT
storage_path      TEXT
uploaded_by       UUID REFERENCES users(id) ON DELETE SET NULL
uploaded_at       TIMESTAMPTZ(6) NOT NULL DEFAULT now()
blob_purged_at    TIMESTAMPTZ(6)
blob_purge_reason TEXT
purged_by_job     TEXT
```

This table is **permanent by design** — it has no `retain_until` because it never expires. It is the direct, literal answer to the CEO scenario: a row here proves a file existed, who uploaded it, its exact SHA-256, and — once the blob itself is purged — exactly when, why (`RETENTION_PURGE` vs `USER_DELETE`, matching `financial_audit_log.action`), and by which job run. `bilko_app` has INSERT+SELECT only; there is no append-only trigger here (unlike `financial_audit_log`) because a legitimate, narrow future UPDATE path exists — the not-yet-built purge worker marking `blob_purged_at` — and that grant is deliberately deferred to the Phase B2/B3 migration that creates the `bilko_purge_worker` role, since granting to a role that doesn't exist yet is a Flyway failure.

### Capture mechanism (V126 pilot on `invoices`, V128 fan-out to 6 more tables)

Arbitration point 1 (Graff + Momjian, overruling a pure application-level design): **capture is a generic `AFTER` DB trigger** (`capture_financial_audit()`, `to_jsonb(OLD/NEW)`, keyed off `TG_TABLE_NAME`), synchronous and transactional — not `pg_notify`/async, which would create a durability gap. This was chosen specifically because this same repo already proved app-level capture gets forgotten: `deleteInvoice` never called `logged_actions` before this program. Covers `invoices`, `expenses`, `contacts`, `transactions`, `bank_accounts`, `bank_transactions`, `invoice_items`.

Read-sensitive actions the trigger structurally cannot see — `export`, `download`, `restore` — are Kleppmann's second arbitration point: those are **application-level** events written into the same table from the service layer, since a DB trigger only fires on `SELECT`... it never fires at all, it can't be the mechanism for read events.

### C1/C2 — soft-delete and hard-delete audit coverage

- **V127** adds `deleted_by` + `deletion_reason` to every table with `deleted_at` — the actual list was tool-verified via a live `information_schema.columns` query against a full V1..V126 Testcontainers replay, not assumed from the task spec, and correctly caught tables the spec missed (`bank_accounts`, `chat_conversations`, `exchange_rates`, `expense_documents`, `offer_items`, `offers`, `travel_orders`).
- **C2**: `InvoiceService.deleteInvoice` still does a hard delete (DRAFT-only semantics unchanged), but now writes a `financial_audit_log` row with `action='USER_DELETE'` — a full invoice+line-items JSON snapshot — in the same transaction, before the delete. This is deliberately **not deduplicated** against the V128 trigger's own low-level `DELETE` row for the same statement: `USER_DELETE` is the business-level "someone claims we lost their invoice, prove what happened" answer; the trigger's `DELETE` row is the safety net that fires even for deletions that bypass the service entirely (e.g. direct SQL).

### A8 — read API

`GET /orgs/{id}/activity` (org-wide feed) and `GET /{entity}/{id}/timeline` (single-entity history), both paginated, both using the composite indexes created in V124. Gated by a new `activity:read` permission (V130), seeded **only** for `owner`/`admin` — not `viewer`, not `accountant` — because `before_data`/`after_data` carry full row snapshots including PII (e.g. a contact's OIB/JMBG-equivalent tax ID). Both endpoints filter directly on `financial_audit_log.org_id = principal.organizationId` rather than the existing `ResourceAccessFilter.requireOrgOwnership` helper, because that helper's table coverage predates this program and is missing 3 of the 7 audited tables. Cross-org access returns a 404 for the org feed and an empty list (not a 403/404) for the entity timeline, specifically to avoid leaking entity-existence across tenants via response-code side channel. **The web UI panel for this API was explicitly scoped out** — flagged to Vizu as a separate task rather than built as a rushed stub, per the implementing agent's own assessment that building it to the existing app's quality bar is realistically over an hour of frontend work.

## A6 saga — three adversarial rounds, two real bugs caught

A6 (`SET LOCAL app.current_user_id` / `app.current_request_id`, MC #105877) is the most important lesson from this program for anyone touching Ktor plugin install-order in this codebase. It went through three rounds of Momjian build → Parisa Tabriz (Securion) adversarial verify before landing.

**Round 1 finding (ThreadLocal vs. coroutine dispatcher hop):** the original implementation used a plain `ThreadLocal` to carry actor context. `dbQuery{}` dispatches onto `Dispatchers.IO`, which is a thread pool — a `ThreadLocal` set on the request-handling thread is simply not visible on whatever IO-pool thread the query actually executes on. Result: `actor_user_id` silently NULL for essentially all real requests. Fixed by switching to a kotlinx.coroutines `ThreadContextElement`, which is designed to survive exactly this kind of dispatcher hop.

**Round 2 finding (install-site / phase-ordering — the more interesting one):** even with the coroutine-context mechanism now correct in isolation, it was still not effective end-to-end, because `installOrgScopePlugin()` was installed via `intercept(ApplicationPhase.Plugins)` at the `Application.module()` level — which runs **before** Ktor's routing tree even dispatches into the `authenticate("bilko-jwt") { }` block, i.e. before `BilkoPrincipal` is resolved. So `call.principal<BilkoPrincipal>()` inside the interceptor was **always** null in production, authenticated request or not.

This is the same class of bug as **MC #104962**, which had already found and fixed the identical mistake for the sibling `TrialGatePlugin`: install at `Application` level and the plugin sees a permanently-null principal, no matter how correct its internal logic is. `TrialGatePlugin`'s own doc comment says this explicitly — but `installOrgScopePlugin()` had not been given the same fix.

The org_id side of the same mechanism was *not* affected, and understanding why is the actual transferable lesson: `orgTransaction(organizationId: String, ...)` takes `organizationId` as an explicit function parameter, sourced by every real call site via `effectiveOrgId(principal)` called **inside** the route handler (which does run after auth). So org_id never depended on the broken plugin's principal read at all — `currentOrgIdThreadLocal` turned out to be dead code in production (zero call sites). `actor_id` had no equivalent parameter — its only source was the broken plugin — so only the actor side silently failed.

**Fix:** `installOrgScopePlugin()` was changed from an `Application` extension to a `Route` extension, intercepting `ApplicationPhase.Call` (not `Plugins`), called as the literal first statement inside `authenticate("bilko-jwt") { }` in `Routing.kt` — before `install(TrialGatePlugin)` and all route registrations. `TrialGatePlugin` couldn't be copied verbatim as a pattern because it uses the `on(AuthenticationChecked)` hook, which is a plain synchronous callback with no `proceed()` — fine for a check-and-throw, but unable to keep a coroutine context element alive forward into a later route handler's `dbQuery` call, which is exactly what A6 needs.

Round 3's regression test (`OrgScopeActorContextHttpIntegrationTest.kt`) is itself a lesson worth keeping: it deliberately goes through the *real* HTTP client, the *real* JWT verifier, and the *real* routing tree — not the `withActorContextForTest` seam that round 2's test used, which structurally could not have caught this bug because it never calls `installOrgScopePlugin()`'s actual install path at all.

**Lesson for future Ktor plugin work in this codebase:** if a plugin needs `call.principal<T>()` to be non-null, verify empirically (not by inspection) which phase it actually observes the principal at — `ApplicationPhase.Plugins` at the `Application` level is provably too early for anything gated by `authenticate(...)`, regardless of where in the source file the `intercept()` call is textually nested. This has now bitten two different plugins (`TrialGatePlugin` in #104962, `OrgScopeSessionVariable` here) with the same root cause.

## Retention law — verified findings (MC #105873, primary sources)

| Jurisdiction | Books (journal/ledger) | Auxiliary books | Financial statements | e-invoice (fiscalized) | Payroll records |
|---|---|---|---|---|---|
| Croatia (HR) | at least 11 years (ZOR NN 78/2015 čl. 10(2)) | at least 11 years | permanent* (pin at B1) | 6 years (Zakon o fiskalizaciji NN 89/2025 čl. 35) | ≥6 years / analytics permanent |
| Serbia (RS) | 10 years (Zakon o računovodstvu, "Sl. glasnik RS" 73/2019 čl. 28) | 5 years | 20 years | n/a (SEF rules separate — pin at B1) | permanent |
| BiH — Federation (BA_FED) | at least 11 years (Zakon o računovodstvu i reviziji FBiH, "Sl. novine FBiH" 15/2021 čl. 49) | pin at B1 | pin at B1 | n/a | pin at B1 |
| BiH — Republika Srpska entity (BA_RS) | at least 10 years ("Sl. glasnik RS" 115/2025, article number TBD — not yet pinned from primary text) | at least 5 years | permanent | n/a | pin at B1 |

Two compliance findings from this verification are **not yet fixed in code** — they are B1's job, not done:

- **Code cites a dead law:** `PluginRS.kt:325` cites "Sl. glasnik RS br. 62/2013, čl. 24" — that law was replaced by 73/2019 in its entirety. The 10-year figure for books happens to still be correct, but the legal basis citation is wrong and must be updated to 73/2019 čl. 28.
- **🔴 Compliance gap, not just a citation issue:** `V50` currently sets `organizations.legal_retention_years = 10` for BA orgs generally. FBiH law requires **at least 11**. Any `BA_FED` organization in Bilko today is configured with a retention period one year short of its legal minimum. This is a live data-value fix, not a comment fix, and is the highest-priority item inside B1.

The BA_RS article number in the new 115/2025 law could not be pinned from available sources (target site had a TLS failure); the 10/5/permanent figures are confirmed via secondary sources, but B1's implementer must pin the exact article from the official Sl. glasnik RS 115/25 text before it is cited in any docs or code comment — this is a repeat of the same "don't propagate an unverified article number" discipline that caught the RS 62/2013→73/2019 gap in the first place.

## GDPR — erasure vs. retention

Position (plan arbitration point 7, not yet formalized in Privacy Policy/DPIA — that's Lexicon's MC #105277, linked, separate from this page): under **GDPR Art. 17(3)(b)**, the right to erasure does not apply where processing is necessary for compliance with a legal obligation — here, the accounting-law retention requirements in the table above. A financial audit snapshot (`financial_audit_log.before_data`/`after_data`, or a `document_retention_manifest` row) is **not** deleted on a user erasure request during the applicable retention window. The only erasure-adjacent action available is pseudonymizing `actor_label`/user-identifying fields after the retention window closes, and that is explicitly scoped as: every erasure request goes through Securion/legal review, never a self-serve deletion path, and never automatic.

## Purge worker — design (not yet built; B2/B3 are open tasks)

This section is a design skeleton for the not-yet-implemented `RetentionPurgeWorker`, so it is documented in the same place as the schema it operates on. **Nothing described below exists in the running system today.**

- **Primary path:** `DROP TABLE <partition>` for a `financial_audit_log` monthly partition once every row in it is past its jurisdiction's retention window. Fallback for mixed-retention partitions (a partition spanning rows from different orgs/countries with different retention lengths): batched `DELETE` with `LIMIT 5000` + commit per batch + `pg_sleep` throttle.
- **Order of operations is fixed and load-bearing: blob first, then row.** Deleting the `document_retention_manifest`/audit-row pointer before the blob would create an orphaned blob with no trace pointing to it — the opposite of this program's purpose. The blob delete must succeed (or be confirmed already-gone, 404-as-success, matching `ReceiptService.deleteBlob()`'s existing idempotent contract from B4) before the manifest row is marked `blob_purged_at`.
- **Dedicated `bilko_purge_worker` role**, `BYPASSRLS`, least-privilege, narrower than `bilko_admin`. A `SECURITY DEFINER purge_expired_partition()` function owned by `bilko_admin` is the intended privilege-elevation boundary rather than granting the worker role broad table access directly.
- **The purge worker's own actions are themselves audited** ("audit the auditor") in a `purge_worker_log` table — any trigger-disable needed for a legitimate purge happens inside the same transaction as the log write.
- **Dry-run is the default mode, and is required before any live run.** A dry-run must print the candidate rows/partitions it *would* purge and delete nothing; the acceptance test for B2 is specifically that a row still exists after a dry-run pass.
- **Live mode (B3) requires explicit CEO sign-off before its first activation**, and only after at least one full sprint of clean dry-run output on stage. This is a 🔴 red-zone item in the parent plan — senior-only, never a locally-dispatched builder task.
- **Cron mechanism is not yet confirmed.** `pg_cron` availability on Azure Flexible Server has not been verified (flagged to FlowForge); the fallback is the existing app-level cron pattern already used by `InvoiceCronRoutes.kt` (`COMPLIANCE_CRON_SECRET`).
- **How to read a dry-run log once B2 exists:** each dry-run entry will list, per candidate partition/row set: org, country, entity type, computed `retain_until` (from `organizations.legal_retention_years` + the not-yet-built per-type override table from B1), and the action that would be taken (`DROP PARTITION` vs. batched `DELETE`). Approval for a live run means a human has read that candidate list and confirmed it matches expectation — not just that the dry-run exited zero.

## Relationship to ADR-017 Phase 2B

ADR-017 Phase 2B originally proposed country-code-list partitioning. It was never implemented — Flyway's actual `HEAD` was `V116` at the start of this program (V36/V37 slots referenced in the old ADR discussion were long since consumed by unrelated migrations), so this program's `financial_audit_log` design is effectively a green-field implementation of "partition a big multi-tenant table," not a migration of an existing partitioned table. Momjian's rebuttal, adopted here: partition by `occurred_at` (RANGE, monthly) with `country_code` as a plain indexed column, not a partition key — purge is fundamentally a time predicate, and a country-list partition scheme would need to be extended by hand every time a new market is added, for no purge-performance benefit.

## Testing discipline (applies to every migration in this program)

Every migration listed above has a companion `V1xxMigrationTest.kt` (Testcontainers `postgres:16`) that does a **full `V1..V1xx` replay**, not a fresh-schema shortcut — this is the direct lesson from the V121 incident (Exposed's `SchemaUtils.create()`-based test schemas do not see `CHECK` constraints introduced by a migration; the only way to prove a constraint is real is to replay the actual migration chain and show that removing it makes the negative test fail). Each migration test includes negative tests (`UPDATE`/`DELETE` on the audit tables must throw, for both `bilko_app` and `bilko_admin`), an RLS cross-org test, and — where relevant — a partition-existence/partition-drop test.

## Evidence index

| Task | Evidence |
|---|---|
| A1 retention law verification | `~/system/evidence/105873/a1-retention-verification.md` |
| A6 build (3 rounds) | `~/system/evidence/105875/momjian-review.md` |
| A6 adversarial verify (Securion, 3 rounds) | `~/system/evidence/105877/parisa-verify.md`, `~/system/evidence/105877/verdict.md` |
| A7 backfill | `~/system/evidence/105878/verdict.md` |
| A8 activity/timeline API | `~/system/evidence/105879/verdict.md` |
| B4 storage consolidation | `~/system/evidence/105883/verdict.md` |
| C1 soft-delete columns | `~/system/evidence/105885/verdict.md` |
| C2 delete snapshot | `~/system/evidence/105886/verdict.md` |
| D1 demo-country bug root cause | `~/system/evidence/105874/d1-rootcause.md` |
| D2 demo-country bug fix + E2E | `~/system/evidence/105887/d2-verdict.md` |

## Open follow-ups

- MC #105880 (B1) — fix the dead-law citation and the BA_FED 10→11 year compliance gap; build the per-type retention override table.
- MC #105884 (B5) — upload path writes a `document_retention_manifest` row at upload time (blocked on both V125 and the B4 Azure Blob refactor being merged, which they now are — this can proceed).
- B2/B3 purge worker — MC #105881/#105882 created, both blocked (B2 on A3+B1; B3 on B2 + a clean dry-run sprint + CEO sign-off). Dry-run first, live mode requires CEO sign-off.
- Partition-maintenance job for `financial_audit_log` beyond 2026-10 — not started; will start failing inserts in November 2026 if not built.
- Web UI panel for the A8 activity/timeline API — flagged to Vizu, not scheduled.
- MC #105277 (Lexicon) — Privacy Policy/DPIA update for the Art. 17(3)(b) erasure-vs-retention position described above.
- ADR for this design has not been written into the repo yet — outline below, for whoever picks up that task.

## ADR outline (for a future repo-side ADR, not written to the repo by this task)

Proposed title: **ADR-0XX — Financial Audit Trail: append-only partitioned log, not a consolidation of V1/V51**

1. Context: CEO mandate, gaps in V1/V51 coverage, `deleteInvoice` hard-delete-with-no-trail example.
2. Decision: third table (`financial_audit_log`), RANGE-partitioned by `occurred_at` monthly, `country_code` denormalized not partition key; dual append-only enforcement (REVOKE + trigger); hybrid capture (DB trigger for mutations, application-level for read-sensitive actions); permanent `document_retention_manifest` for file-level trace, decoupled from blob lifecycle.
3. Alternatives considered and rejected: consolidating into V1 or V51 (Kleppmann — wrong semantics, V1 has no tenant context); async/`pg_notify` capture (durability gap); country-code partitioning per original ADR-017 Phase 2B sketch (Momjian — no purge-performance benefit, extra operational burden per new market); hash-chained tamper-evidence (deferred, not rejected — revisit only on explicit regulatory/enterprise requirement).
4. Consequences: purge becomes a partition-drop operation once B2/B3 land; every future table needing financial audit coverage follows the V126/V128 trigger-fan-out pattern; A6's install-site lesson should be called out as a standing Ktor-plugin gotcha for this codebase.
5. Status: Phase A implemented and merged; Phase B (retention enforcement) open.