fix(audit): alias learning is not a rule change, stop logging it (BFNAR noise) (#2134)
Production falsified 20260901103000's exclusion list within 30 minutes of deploy: 15 of the first 16 UPDATE audit rows on categorization_templates changed only the learning columns plus counterparty_aliases, because the learning path (lib/bookkeeping/counterparty-templates.ts) merges new aliases in the same write that bumps occurrence_count. Projected ~800 noise rows/day against ~50/day of real rule changes, each one rendered into the legally-facing behandlingshistorik as "Konteringsmall aendrad: Alias". counterparty_aliases joins the trigger's strip list. The trade-off is explicit: a human editing ONLY aliases is no longer logged. Accepted because alias growth is overwhelmingly automatic, and a change that also touches accounts, VAT, pattern or the active flag still logs: the first real such row (2026-09-01 19:02:17Z, debit/credit/vat accounts changed by the learning loop, BFN's automatkontering case exactly) was captured correctly and stays captured under the new WHEN clause. The pre-fix noise rows stay in audit_log (append-only). The read model stops labelling the column, so alias-only diffs, historical ones included, render as no-ops rather than rule changes; a diff that also carries a real change shows only the real change. pg-test extended: alias+learning update writes no audit row, alias+account update still does. Read-model test pins the pre-fix noise row shape to null. Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Opus 5
parent
53c9c0c4f9
commit
191fb2cfce
@@ -4,7 +4,7 @@ import { insertAuthUser, insertCompany, insertCompanyMember } from './fixtures'
|
||||
import { getPool, withUserContext } from './setup'
|
||||
|
||||
/**
|
||||
* Behandlingshistorik, part 3 (migration 20260901103000): the behandlingsregler
|
||||
* Behandlingshistorik, part 3 (migrations 20260901103000 + 20260901200000): the behandlingsregler
|
||||
* tables and the import logs write to the immutable audit_log, learning-only
|
||||
* updates on categorization_templates are filtered, the global payroll
|
||||
* constants are logged without a company, and app_releases is an append-only,
|
||||
@@ -65,21 +65,36 @@ describe('behandlingshistorik audit triggers (BFNAR 2013:2 p. 9.16)', () => {
|
||||
VALUES ($1, $2, $3, 'Spotify AB', '6540', '1930')`,
|
||||
[templateId, userId, companyId],
|
||||
)
|
||||
// Learning on every booking: occurrence_count / confidence / last_seen_date.
|
||||
// Learning on every booking: occurrence_count / confidence / last_seen_date,
|
||||
// and counterparty_aliases, which the learning path merges in the same
|
||||
// write (20260901200000; prod showed 15 of the first 16 audit rows were
|
||||
// alias+learning noise).
|
||||
await getPool().query(
|
||||
`UPDATE public.categorization_templates
|
||||
SET occurrence_count = occurrence_count + 1, confidence = 0.9, last_seen_date = CURRENT_DATE
|
||||
SET occurrence_count = occurrence_count + 1, confidence = 0.9, last_seen_date = CURRENT_DATE,
|
||||
counterparty_aliases = ARRAY['SPOTIFY STOCKHOLM 4711']
|
||||
WHERE id = $1`,
|
||||
[templateId],
|
||||
)
|
||||
expect(await auditActions('categorization_templates', templateId)).toEqual(['INSERT'])
|
||||
|
||||
// A rule change (the account) is logged.
|
||||
await getPool().query(`UPDATE public.categorization_templates SET debit_account = '6212' WHERE id = $1`, [templateId])
|
||||
// But an account change arriving in the same write as learning columns is
|
||||
// a behandlingsregel change and must still log (the automatkontering case).
|
||||
await getPool().query(
|
||||
`UPDATE public.categorization_templates
|
||||
SET occurrence_count = occurrence_count + 1, credit_account = '1931',
|
||||
counterparty_aliases = ARRAY['SPOTIFY STOCKHOLM 4711', 'SPOTIFY AB']
|
||||
WHERE id = $1`,
|
||||
[templateId],
|
||||
)
|
||||
expect(await auditActions('categorization_templates', templateId)).toEqual(['INSERT', 'UPDATE'])
|
||||
|
||||
// A plain rule change (the account) is logged.
|
||||
await getPool().query(`UPDATE public.categorization_templates SET debit_account = '6212' WHERE id = $1`, [templateId])
|
||||
expect(await auditActions('categorization_templates', templateId)).toEqual(['INSERT', 'UPDATE', 'UPDATE'])
|
||||
|
||||
await getPool().query(`DELETE FROM public.categorization_templates WHERE id = $1`, [templateId])
|
||||
expect(await auditActions('categorization_templates', templateId)).toEqual(['INSERT', 'UPDATE', 'DELETE'])
|
||||
expect(await auditActions('categorization_templates', templateId)).toEqual(['INSERT', 'UPDATE', 'UPDATE', 'DELETE'])
|
||||
})
|
||||
|
||||
it('logs booking_template_library changes (no user_id column: actor falls back to auth.uid())', async () => {
|
||||
|
||||
Reference in New Issue
Block a user