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). Phase— B4genuinely (storagea consolidation)two-parent ismerge separately mergedcommit (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) |
C1 — deleted_by/deletion_reason columns |
Merged main (V127), PR #178 |
C2 — InvoiceService.deleteInvoice USER_DELETE snapshot |
Merged |
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 | |
| B3 — purge worker, live mode | |
| D1/D2 — demo-country display bug (parallel workstream, same wave, not part of audit trail scope) | Fixed and merged (3f144e82) |
Do not treat B1/B2/B3/B5 as done. TheMC #105881/#105882 exist but are deliberately blocked, not started — the purge worker does not existrun yetanywhere —today; nothing purges blobs or applies retention today.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 forENTRA_JIT_LINK/SECURITY_VIOLATIONevents, and has noorg_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-rowDELETE).country_codeis 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_untilis 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 fromorganizations.legal_retention_yearsplus 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) andBEFORE UPDATE/DELETE RAISE EXCEPTIONtriggers on the partitioned parent. The trigger layer exists specifically to also catchbilko_admin, which isBYPASSRLSbut not superuser —BYPASSRLSskips 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_insertscoped toapp.current_org_id, plusplatform_admin_full_read. A RESTRICTIVE flip is an explicit, separate, later CEO-gated decision; this migration does not touch it. country_codeisVARCHAR(10), notVARCHAR(8). This was a real bug caught by Momjian's design review of the first commit: the source column (organizations.country) was widened toVARCHAR(10)back in V16 specifically to holdBA_FED/BA_RS(6 chars), and V120 did the same forcompliance_deadlines.country"to keep country-code columns consistent." The originalVARCHAR(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 dedicatedBA_FED/BA_RSwidth test fixture was added (previously the 30/30 green suite only exercisedHR/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_reasonto every table withdeleted_at— the actual list was tool-verified via a liveinformation_schema.columnsquery 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.deleteInvoicestill does a hard delete (DRAFT-only semantics unchanged), but now writes afinancial_audit_logrow withaction='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-levelDELETErow for the same statement:USER_DELETEis the business-level "someone claims we lost their invoice, prove what happened" answer; the trigger'sDELETErow 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:325cites "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:
V50currently setsorganizations.legal_retention_years = 10for BA orgs generally. FBiH law requires at least 11. AnyBA_FEDorganization 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 afinancial_audit_logmonthly 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): batchedDELETEwithLIMIT 5000+ commit per batch +pg_sleepthrottle. - 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, matchingReceiptService.deleteBlob()'s existing idempotent contract from B4) before the manifest row is markedblob_purged_at. - Dedicated
bilko_purge_workerrole,BYPASSRLS, least-privilege, narrower thanbilko_admin. ASECURITY DEFINER purge_expired_partition()function owned bybilko_adminis 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_logtable — 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_cronavailability on Azure Flexible Server has not been verified (flagged to FlowForge); the fallback is the existing app-level cron pattern already used byInvoiceCronRoutes.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(fromorganizations.legal_retention_years+ the not-yet-built per-type override table from B1), and the action that would be taken (DROP PARTITIONvs. batchedDELETE). 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_manifestrow 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 —
notMCstarted.#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_logbeyond 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
- Context: CEO mandate, gaps in V1/V51 coverage,
deleteInvoicehard-delete-with-no-trail example. - Decision: third table (
financial_audit_log), RANGE-partitioned byoccurred_atmonthly,country_codedenormalized not partition key; dual append-only enforcement (REVOKE + trigger); hybrid capture (DB trigger for mutations, application-level for read-sensitive actions); permanentdocument_retention_manifestfor file-level trace, decoupled from blob lifecycle. - Alternatives considered and rejected: consolidating into V1 or V51 (Kleppmann — wrong semantics, V1 has no tenant context); async/
pg_notifycapture (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). - 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.
- Status: Phase A implemented and merged; Phase B (retention enforcement) open.