From 8d2ff615991063d8a1115c39ffc6d51e36b1929f Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:05:48 +0200 Subject: [PATCH] feat(bookkeeping): agent attribution into the immutable ledger layer (P0-1) (#678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bookkeeping): agent attribution into the immutable ledger layer Close the three attribution gaps left after 20260618120001 (which made commit_method record 'api_key' for MCP-relayed approvals): - journal_entries gains nullable committed_actor_type/committed_actor_label, stamped by commit_journal_entry in the same draft->posted UPDATE that writes commit_method. The RPC gains p_actor_type/p_actor_label (DEFAULT NULL; prior signature dropped first to avoid PostgREST overload ambiguity, same technique as 20260421140000). - write_audit_log now populates audit_log.actor_type/actor_label from transaction-local gnubok.actor_* GUCs set by the RPC (the established gnubok.allow_delete pattern). Unset GUCs COALESCE to 'user' — byte- identical to the column's previous effective DEFAULT for every pre-existing write path. - commitPendingOperation accepts opts.actor and runs the entire executor inside an AsyncLocalStorage runWithActor() scope read by commitEntry(), so EVERY journal commit an operation makes is attributed — closing the documented "commitMethod only reaches create_voucher" gap. MCP approve passes the api_key actor + key label; web single/bulk approve pass the user + email. Known limitation (documented): reverseEntry posts reversal vouchers via direct PostgREST writes, not the commit RPC — reversals keep NULL attribution until that path is RPC-ified (follow-up). pg-real coverage: lib/bookkeeping/__tests__/commit-actor.pg.test.ts (RPC param stamping, audit GUC read, transaction-locality, CHECK rejection, immutability of the new columns, single-signature guard). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(bookkeeping): split actor-context so client bundles never see node:async_hooks CI core-only build failed: engine.ts is reachable from client component bundles (invoices/[id] page), and the static node:async_hooks import in actor-context.ts cannot be chunked for the browser. Split the module: - actor-context.ts (isomorphic): CommitActor type + a storage registry + getActor(). In a client bundle the registry stays empty and getActor() returns undefined — identical to the server-side no-scope default. - actor-context-node.ts (server-only): owns the AsyncLocalStorage, binds it into the registry on import, exports runWithActor(). Imported only by the approval paths (commit.ts), which are never client-reachable. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../pending-operations/[id]/commit/route.ts | 6 +- .../bulk-commit/__tests__/route.test.ts | 9 +- .../pending-operations/bulk-commit/route.ts | 1 + .../pending-operations-tools.test.ts | 13 +- extensions/general/mcp-server/server.ts | 18 +- .../__tests__/actor-context.test.ts | 43 ++++ .../__tests__/commit-actor.pg.test.ts | 154 ++++++++++++++ .../__tests__/voucher-atomicity.test.ts | 44 ++++ lib/bookkeeping/actor-context-node.ts | 19 ++ lib/bookkeeping/actor-context.ts | 40 ++++ lib/bookkeeping/engine.ts | 10 + .../__tests__/voucher-executors.test.ts | 62 ++++++ lib/pending-operations/commit.ts | 29 +++ ...19120000_journal_entry_committed_actor.sql | 200 ++++++++++++++++++ 14 files changed, 638 insertions(+), 10 deletions(-) create mode 100644 lib/bookkeeping/__tests__/actor-context.test.ts create mode 100644 lib/bookkeeping/__tests__/commit-actor.pg.test.ts create mode 100644 lib/bookkeeping/actor-context-node.ts create mode 100644 lib/bookkeeping/actor-context.ts create mode 100644 supabase/migrations/20260619120000_journal_entry_committed_actor.sql diff --git a/app/api/pending-operations/[id]/commit/route.ts b/app/api/pending-operations/[id]/commit/route.ts index 20545289..aa3dd41b 100644 --- a/app/api/pending-operations/[id]/commit/route.ts +++ b/app/api/pending-operations/[id]/commit/route.ts @@ -43,7 +43,11 @@ export async function POST( user.id, companyId, op as PendingOperation, - { userEmail: user.email, commitMethod: 'user_accept' } + { + userEmail: user.email, + commitMethod: 'user_accept', + actor: { type: 'user', ...(user.email ? { label: user.email } : {}) }, + } ) if (result.status === 'committed') { diff --git a/app/api/pending-operations/bulk-commit/__tests__/route.test.ts b/app/api/pending-operations/bulk-commit/__tests__/route.test.ts index 812ab194..552714df 100644 --- a/app/api/pending-operations/bulk-commit/__tests__/route.test.ts +++ b/app/api/pending-operations/bulk-commit/__tests__/route.test.ts @@ -237,8 +237,13 @@ describe('POST /api/pending-operations/bulk-commit', () => { expect.objectContaining({ id: VALID_ID_1 }), // commit_method must be 'bulk_accept' so any journal_entries created // during bulk approval are tagged distinctly from single-approval ones - // (BFNAR 2013:2 behandlingshistorik). - { userEmail: 'test@test.se', commitMethod: 'bulk_accept' } + // (BFNAR 2013:2 behandlingshistorik). The actor option attributes the + // commits to the approving user (migration 20260619120000). + { + userEmail: 'test@test.se', + commitMethod: 'bulk_accept', + actor: { type: 'user', label: 'test@test.se' }, + } ) }) diff --git a/app/api/pending-operations/bulk-commit/route.ts b/app/api/pending-operations/bulk-commit/route.ts index 02df3e0a..8fec1b7f 100644 --- a/app/api/pending-operations/bulk-commit/route.ts +++ b/app/api/pending-operations/bulk-commit/route.ts @@ -68,6 +68,7 @@ export async function POST(request: Request) { const result = await commitPendingOperation(supabase, user.id, companyId, op, { userEmail: user.email, commitMethod: 'bulk_accept', + actor: { type: 'user', ...(user.email ? { label: user.email } : {}) }, }) if (result.status === 'committed') { results.push({ id, status: 'committed' }) diff --git a/extensions/general/mcp-server/__tests__/pending-operations-tools.test.ts b/extensions/general/mcp-server/__tests__/pending-operations-tools.test.ts index 55b54ec9..b019316e 100644 --- a/extensions/general/mcp-server/__tests__/pending-operations-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/pending-operations-tools.test.ts @@ -109,7 +109,13 @@ describe('gnubok_approve_pending_operation', () => { // the resolution silently fails and we fall back to just commitMethod). // An api_key actor records 'api_key' in the immutable layer — MCP-relayed // acknowledgment, not a first-party human session (vision §8 P0-1). - expect(commitSpy.mock.calls[0][4]).toMatchObject({ commitMethod: 'api_key' }) + // The actor option drives the runWithActor() scope inside + // commitPendingOperation so EVERY journal commit in the op is attributed + // (committed_actor_* + audit_log, migration 20260619120000). + expect(commitSpy.mock.calls[0][4]).toMatchObject({ + commitMethod: 'api_key', + actor: { type: 'api_key' }, + }) expect(result.status).toBe('committed') expect(result.operation_id).toBe('op-1') expect(result.data?.invoice_id).toBe('inv-1') @@ -137,7 +143,10 @@ describe('gnubok_approve_pending_operation', () => { { type: actorType } ) - expect(commitSpy.mock.calls[0][4]).toMatchObject({ commitMethod: expected }) + expect(commitSpy.mock.calls[0][4]).toMatchObject({ + commitMethod: expected, + actor: { type: actorType }, + }) } ) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index e5846824..2bb2dd99 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -8615,10 +8615,11 @@ export const tools: McpTool[] = [ // for first-party agent surfaces (e.g. in-app agent chat) once they // commit through this layer with a distinguishable actor type. // - // Note: commitPendingOperation currently threads commitMethod into the - // journal only for create_voucher ops (pre-existing); other operation - // types keep their per-handler defaults, with this approval's actor - // recorded in processing_history below either way. + // commitMethod reaches the journal only for create_voucher ops + // (pre-existing); the actor option below covers EVERY journal commit + // this operation makes via the runWithActor() scope inside + // commitPendingOperation, stamping journal_entries.committed_actor_* + // and the audit_log COMMIT row (migration 20260619120000). const commitMethod = actor?.type === 'api_key' ? ('api_key' as const) : ('user_accept' as const) @@ -8627,7 +8628,14 @@ export const tools: McpTool[] = [ userId, companyId, operation, - { commitMethod, ...(userEmail ? { userEmail } : {}) } + { + commitMethod, + actor: { + type: actor?.type === 'api_key' ? 'api_key' : 'user', + ...(actor?.label ? { label: actor.label } : {}), + }, + ...(userEmail ? { userEmail } : {}), + } ) // Audit the MCP-initiated approval. Failure must not break the user diff --git a/lib/bookkeeping/__tests__/actor-context.test.ts b/lib/bookkeeping/__tests__/actor-context.test.ts new file mode 100644 index 00000000..c92f5f9a --- /dev/null +++ b/lib/bookkeeping/__tests__/actor-context.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { runWithActor } from '../actor-context-node' +import { getActor } from '../actor-context' + +describe('actor-context (AsyncLocalStorage commit attribution)', () => { + it('returns undefined outside a runWithActor scope', () => { + expect(getActor()).toBeUndefined() + }) + + it('exposes the actor inside the scope, across awaits', async () => { + const seen: Array> = [] + await runWithActor({ type: 'api_key', label: 'Test Key' }, async () => { + seen.push(getActor()) + await new Promise((resolve) => setTimeout(resolve, 0)) + seen.push(getActor()) + }) + expect(seen).toEqual([ + { type: 'api_key', label: 'Test Key' }, + { type: 'api_key', label: 'Test Key' }, + ]) + expect(getActor()).toBeUndefined() + }) + + it('keeps concurrent scopes isolated', async () => { + const results = await Promise.all([ + runWithActor({ type: 'api_key', label: 'A' }, async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + return getActor()?.label + }), + runWithActor({ type: 'user', label: 'B' }, async () => { + return getActor()?.label + }), + ]) + expect(results).toEqual(['A', 'B']) + }) + + it('propagates into nested calls without parameter threading', async () => { + const deepRead = async () => getActor() + const middle = async () => deepRead() + const actor = await runWithActor({ type: 'agent_chat' }, middle) + expect(actor).toEqual({ type: 'agent_chat' }) + }) +}) diff --git a/lib/bookkeeping/__tests__/commit-actor.pg.test.ts b/lib/bookkeeping/__tests__/commit-actor.pg.test.ts new file mode 100644 index 00000000..b8ad9f3b --- /dev/null +++ b/lib/bookkeeping/__tests__/commit-actor.pg.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' +import { getPool } from '@/tests/pg/setup' +import { + insertBalancedLines, + insertDraftJournalEntry, + seedCompany, +} from '@/tests/pg/fixtures' + +/** + * Covers 20260619120000_journal_entry_committed_actor: + * - commit_journal_entry() gained p_actor_type/p_actor_label (DEFAULT NULL) + * and stamps journal_entries.committed_actor_* in the draft→posted UPDATE. + * - write_audit_log() reads the transaction-local gnubok.actor_* GUCs the + * RPC sets, so the COMMIT audit row carries actor attribution. + * - Every pre-existing call shape (2/4-arg) behaves byte-identically: + * NULL columns, audit actor_type 'user' (the column's previous effective + * DEFAULT), actor_label NULL. + * - The GUCs are transaction-local: attribution never leaks into later + * statements on the same connection. + * - The new columns are frozen by the posted-entry immutability trigger. + */ + +async function seedDraft() { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId }) + await insertBalancedLines(entryId) + return { companyId, entryId } +} + +async function fetchCommitAudit(entryId: string) { + const { rows } = await getPool().query( + `SELECT actor_type, actor_label + FROM public.audit_log + WHERE record_id = $1 AND action = 'COMMIT'`, + [entryId], + ) + return rows +} + +describe('commit_journal_entry — actor attribution (committed_actor_* + audit GUCs)', () => { + it('stamps committed_actor_* and the COMMIT audit row when actor params are passed', async () => { + const { companyId, entryId } = await seedDraft() + + await getPool().query( + `SELECT voucher_number FROM public.commit_journal_entry( + $1::uuid, $2::uuid, 'api_key', NULL, 'api_key', 'Claude Desktop')`, + [companyId, entryId], + ) + + const { rows } = await getPool().query( + `SELECT status, commit_method, committed_actor_type, committed_actor_label + FROM public.journal_entries WHERE id = $1`, + [entryId], + ) + expect(rows[0]).toEqual({ + status: 'posted', + commit_method: 'api_key', + committed_actor_type: 'api_key', + committed_actor_label: 'Claude Desktop', + }) + + const audit = await fetchCommitAudit(entryId) + expect(audit).toEqual([{ actor_type: 'api_key', actor_label: 'Claude Desktop' }]) + }) + + it('keeps the pre-attribution behaviour for callers that omit the new params', async () => { + const { companyId, entryId } = await seedDraft() + + // 2-arg call — the shape deployed code used before the 6-arg migration. + await getPool().query( + `SELECT voucher_number FROM public.commit_journal_entry($1::uuid, $2::uuid)`, + [companyId, entryId], + ) + + const { rows } = await getPool().query( + `SELECT committed_actor_type, committed_actor_label + FROM public.journal_entries WHERE id = $1`, + [entryId], + ) + expect(rows[0]).toEqual({ committed_actor_type: null, committed_actor_label: null }) + + // Audit row falls back to 'user' — the column's previous effective DEFAULT. + const audit = await fetchCommitAudit(entryId) + expect(audit).toEqual([{ actor_type: 'user', actor_label: null }]) + }) + + it('rejects committed_actor_type values outside the CHECK list', async () => { + const { companyId, entryId } = await seedDraft() + await expect( + getPool().query( + `SELECT voucher_number FROM public.commit_journal_entry( + $1::uuid, $2::uuid, NULL, NULL, 'robot', NULL)`, + [companyId, entryId], + ), + ).rejects.toMatchObject({ code: '23514' }) // check_violation + }) + + it('does not leak the actor GUCs into later transactions on the same connection', async () => { + const a = await seedDraft() + const b = await seedDraft() + + const client = await getPool().connect() + try { + await client.query( + `SELECT voucher_number FROM public.commit_journal_entry( + $1::uuid, $2::uuid, NULL, NULL, 'api_key', 'Leaky Key')`, + [a.companyId, a.entryId], + ) + // Same connection, next transaction: set_config(..., is_local=true) must + // have died with the previous transaction. + await client.query( + `SELECT voucher_number FROM public.commit_journal_entry($1::uuid, $2::uuid)`, + [b.companyId, b.entryId], + ) + } finally { + client.release() + } + + expect(await fetchCommitAudit(a.entryId)).toEqual([ + { actor_type: 'api_key', actor_label: 'Leaky Key' }, + ]) + expect(await fetchCommitAudit(b.entryId)).toEqual([ + { actor_type: 'user', actor_label: null }, + ]) + }) + + it('freezes committed_actor_* after posting (immutability trigger)', async () => { + const { companyId, entryId } = await seedDraft() + await getPool().query( + `SELECT voucher_number FROM public.commit_journal_entry( + $1::uuid, $2::uuid, NULL, NULL, 'api_key', 'Original')`, + [companyId, entryId], + ) + + await expect( + getPool().query( + `UPDATE public.journal_entries SET committed_actor_label = 'tampered' WHERE id = $1`, + [entryId], + ), + ).rejects.toThrow(/Cannot modify a posted journal entry/i) + }) + + it('exposes exactly one commit_journal_entry signature (no PostgREST overload ambiguity)', async () => { + const { rows } = await getPool().query( + `SELECT pg_get_function_identity_arguments(p.oid) AS args + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' AND p.proname = 'commit_journal_entry'`, + ) + expect(rows).toHaveLength(1) + expect(rows[0]!.args).toContain('p_actor_type') + expect(rows[0]!.args).toContain('p_actor_label') + }) +}) diff --git a/lib/bookkeeping/__tests__/voucher-atomicity.test.ts b/lib/bookkeeping/__tests__/voucher-atomicity.test.ts index 1960cf24..c502e988 100644 --- a/lib/bookkeeping/__tests__/voucher-atomicity.test.ts +++ b/lib/bookkeeping/__tests__/voucher-atomicity.test.ts @@ -16,6 +16,7 @@ vi.mock('@/lib/logger', () => ({ })) import { commitEntry, getNextVoucherNumber, createJournalEntry } from '../engine' +import { runWithActor } from '../actor-context-node' import { BookkeepingDatabaseError } from '../errors' describe('voucher number atomicity', () => { @@ -78,6 +79,8 @@ describe('voucher number atomicity', () => { p_entry_id: 'entry-1', p_commit_method: null, p_rubric_version: null, + p_actor_type: null, + p_actor_label: null, }) // from() was never called — the RPC handles everything atomically @@ -116,11 +119,52 @@ describe('voucher number atomicity', () => { p_entry_id: 'entry-1', p_commit_method: null, p_rubric_version: null, + p_actor_type: null, + p_actor_label: null, }) // from() called once to fetch the complete entry with lines expect(supabase.from).toHaveBeenCalledWith('journal_entries') }) + /** + * Actor attribution (migration 20260619120000): commitEntry forwards the + * surrounding runWithActor() scope to the RPC so the immutable layer can + * record WHO relayed the commit. Outside a scope the params stay null + * (asserted by the two tests above). + */ + it('commitEntry forwards the runWithActor scope to the RPC', async () => { + const postedEntry = { + id: 'entry-1', + company_id: 'co-1', + voucher_number: 1, + status: 'posted' as JournalEntryStatus, + lines: [], + } + const supabase = { + from: vi.fn().mockImplementation(() => ({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ data: postedEntry, error: null }), + }), + }), + })), + rpc: vi.fn().mockResolvedValue({ data: [{ voucher_number: 1 }], error: null }), + } + + await runWithActor({ type: 'api_key', label: 'Claude Desktop' }, () => + commitEntry(supabase as never, 'co-1', 'user-1', 'entry-1', 'api_key') + ) + + expect(supabase.rpc).toHaveBeenCalledWith('commit_journal_entry', { + p_company_id: 'co-1', + p_entry_id: 'entry-1', + p_commit_method: 'api_key', + p_rubric_version: null, + p_actor_type: 'api_key', + p_actor_label: 'Claude Desktop', + }) + }) + /** * getNextVoucherNumber is still used by reverseEntry and storno-service. * Those flows INSERT a new entry (not UPDATE a draft), so the atomic diff --git a/lib/bookkeeping/actor-context-node.ts b/lib/bookkeeping/actor-context-node.ts new file mode 100644 index 00000000..3384e6c0 --- /dev/null +++ b/lib/bookkeeping/actor-context-node.ts @@ -0,0 +1,19 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { bindActorStore, type CommitActor } from './actor-context' + +/** + * Server-only half of the commit actor context (see ./actor-context for why + * the split exists). Importing this module binds the AsyncLocalStorage into + * the isomorphic registry so getActor() works wherever engine.ts runs on the + * server. Only ever import this from server-side code (pending-operation + * commit paths, API routes) — never from anything reachable by a client + * component bundle. + */ +const actorStorage = new AsyncLocalStorage() + +bindActorStore(actorStorage) + +/** Run fn with the given actor visible to getActor() across awaits. */ +export function runWithActor(actor: CommitActor, fn: () => Promise): Promise { + return actorStorage.run(actor, fn) +} diff --git a/lib/bookkeeping/actor-context.ts b/lib/bookkeeping/actor-context.ts new file mode 100644 index 00000000..360bbbbe --- /dev/null +++ b/lib/bookkeeping/actor-context.ts @@ -0,0 +1,40 @@ +/** + * Transaction-scoped actor context for journal-entry commits — isomorphic half. + * + * Carries WHO is relaying a commit (api_key | user | agent_chat | …) from the + * approval entry points down to commitEntry() without threading a parameter + * through every pending-operation executor and entry-generator in between. + * commitEntry() reads it as a fallback and forwards it to the + * commit_journal_entry RPC, which stamps journal_entries.committed_actor_* and + * the audit_log COMMIT row (migration 20260619120000). + * + * engine.ts is reachable from client component bundles (e.g. the invoice + * detail page), and client chunks cannot load node:async_hooks — so this + * module holds only the type + a storage registry, and the AsyncLocalStorage + * implementation lives in ./actor-context-node (server-only, imported by the + * approval paths). In a client bundle the registry stays empty and getActor() + * returns undefined — the same no-attribution default as a server call + * outside a runWithActor() scope. + */ +export interface CommitActor { + /** Matches the journal_entries.committed_actor_type CHECK constraint. */ + type: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'system' | 'agent_chat' + /** Human-readable credential label, e.g. the API key name. */ + label?: string +} + +export interface ActorStore { + getStore(): CommitActor | undefined +} + +let store: ActorStore | null = null + +/** Bind the server-side AsyncLocalStorage. Called by ./actor-context-node. */ +export function bindActorStore(s: ActorStore): void { + store = s +} + +/** The actor for the current async execution scope, if any. */ +export function getActor(): CommitActor | undefined { + return store?.getStore() +} diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index f4f12c1b..df01f0df 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -13,6 +13,7 @@ import { } from '@/lib/bookkeeping/errors' import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver' import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync' +import { getActor } from '@/lib/bookkeeping/actor-context' import type { CreateJournalEntryInput, CreateJournalEntryLineInput, @@ -342,6 +343,12 @@ export async function createDraftEntry( * Uses the atomic commit_journal_entry RPC so the voucher number increment * and status update happen in one transaction. If the balance trigger rejects * the entry, the sequence increment rolls back — no burned numbers. + * + * Actor attribution: the surrounding runWithActor() scope (set by the + * approval entry points — commitPendingOperation, web approve routes) is + * forwarded to the RPC, which stamps journal_entries.committed_actor_* and + * the audit_log COMMIT row (migration 20260619120000). No scope → NULLs, + * identical to pre-attribution behaviour. */ export async function commitEntry( supabase: SupabaseClient, @@ -351,6 +358,7 @@ export async function commitEntry( commitMethod?: string, rubricVersion?: string ): Promise { + const actor = getActor() // Atomic: increment voucher sequence + update status in one transaction. // Rolls back the sequence if the balance trigger or any constraint fails. @@ -359,6 +367,8 @@ export async function commitEntry( p_entry_id: entryId, p_commit_method: commitMethod ?? null, p_rubric_version: rubricVersion ?? null, + p_actor_type: actor?.type ?? null, + p_actor_label: actor?.label ?? null, }) if (commitError) { diff --git a/lib/pending-operations/__tests__/voucher-executors.test.ts b/lib/pending-operations/__tests__/voucher-executors.test.ts index c35f5dd7..955b5254 100644 --- a/lib/pending-operations/__tests__/voucher-executors.test.ts +++ b/lib/pending-operations/__tests__/voucher-executors.test.ts @@ -41,6 +41,7 @@ vi.mock('@/lib/core/documents/document-service', async () => { }) import { commitPendingOperation } from '../commit' +import { getActor, type CommitActor } from '@/lib/bookkeeping/actor-context' import { createJournalEntry, findFiscalPeriod, reverseEntry } from '@/lib/bookkeeping/engine' import { correctEntry } from '@/lib/core/bookkeeping/storno-service' import { linkToJournalEntry } from '@/lib/core/documents/document-service' @@ -125,6 +126,67 @@ describe('commitPendingOperation: create_voucher', () => { expect(findFiscalPeriod).not.toHaveBeenCalled() }) + it('runs the executor inside the opts.actor attribution scope (migration 20260619120000)', async () => { + // The real commitEntry reads getActor() and forwards it to the commit RPC. + // Here we assert the dispatcher establishes the scope around the executor — + // the engine mock captures what attribution it would have seen. + let seenActor: CommitActor | undefined + vi.mocked(createJournalEntry).mockImplementationOnce(async () => { + seenActor = getActor() + return makeJournalEntry({ id: 'je-101', voucher_number: 43, voucher_series: 'A' }) + }) + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's commit update + + const op = makePendingOp({ + params: { + entry_date: '2026-05-12', + description: 'Attribution scope test', + fiscal_period_id: 'fp-1', + lines: [ + { account_number: '1010', debit_amount: 100, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 100 }, + ], + }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op, { + actor: { type: 'api_key', label: 'Claude Desktop' }, + }) + + expect(result.status).toBe('committed') + expect(seenActor).toEqual({ type: 'api_key', label: 'Claude Desktop' }) + }) + + it('leaves attribution unset when opts.actor is omitted (pre-attribution behaviour)', async () => { + let seenActor: CommitActor | undefined = { type: 'system' } // sentinel, must be overwritten + vi.mocked(createJournalEntry).mockImplementationOnce(async () => { + seenActor = getActor() + return makeJournalEntry({ id: 'je-102', voucher_number: 44, voucher_series: 'A' }) + }) + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) + enqueue({ data: null, error: null }) + + const op = makePendingOp({ + params: { + entry_date: '2026-05-12', + description: 'No actor scope', + fiscal_period_id: 'fp-1', + lines: [ + { account_number: '1010', debit_amount: 100, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 100 }, + ], + }, + }) + + await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + expect(seenActor).toBeUndefined() + }) + it('resolves fiscal_period from entry_date when omitted', async () => { vi.mocked(findFiscalPeriod).mockResolvedValueOnce('fp-resolved') vi.mocked(createJournalEntry).mockResolvedValueOnce( diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 7e7a1d9a..2b2076c0 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -28,6 +28,8 @@ import { createCreditNoteJournalEntry, } from '@/lib/bookkeeping/invoice-entries' import { createJournalEntry, findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine' +import { runWithActor } from '@/lib/bookkeeping/actor-context-node' +import type { CommitActor } from '@/lib/bookkeeping/actor-context' import { correctEntry } from '@/lib/core/bookkeeping/storno-service' import { closePeriod, lockPeriod, unlockPeriod, resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service' import { @@ -117,6 +119,16 @@ export interface CommitOptions { * 20260505190027_drop_agent_auto_commit. */ commitMethod?: 'user_accept' | 'bulk_accept' | 'agent' | 'api_key' + /** + * WHO is relaying this approval (api_key with the key's display name, plain + * user, agent_chat, …). Propagated to every journal-entry commit made by the + * operation via the runWithActor() AsyncLocalStorage scope — unlike + * commitMethod, which only the create_voucher executor threads explicitly — + * and stamped onto journal_entries.committed_actor_* plus the audit_log + * COMMIT row by the commit_journal_entry RPC (migration 20260619120000). + * Omitted → NULL attribution, identical to pre-attribution behaviour. + */ + actor?: CommitActor } // ── Helper: ensure fiscal period covers the date ────────────────── @@ -3019,6 +3031,12 @@ async function commitLinkTransactionJournalEntry( * * Used by both the human-approval route and the auto-commit path. Status row * transitions are applied here so the two callers stay consistent. + * + * When opts.actor is set, the entire executor runs inside a runWithActor() + * scope so EVERY journal-entry commit the operation makes — regardless of + * which entry generator produced it — carries actor attribution into + * journal_entries.committed_actor_* and the audit_log COMMIT row via + * commitEntry() → commit_journal_entry RPC (migration 20260619120000). */ export async function commitPendingOperation( supabase: SupabaseClient, @@ -3026,6 +3044,17 @@ export async function commitPendingOperation( companyId: string, pendingOp: PendingOperation, opts: CommitOptions = {} +): Promise { + const run = () => commitPendingOperationInner(supabase, userId, companyId, pendingOp, opts) + return opts.actor ? runWithActor(opts.actor, run) : run() +} + +async function commitPendingOperationInner( + supabase: SupabaseClient, + userId: string, + companyId: string, + pendingOp: PendingOperation, + opts: CommitOptions = {} ): Promise { // ── Atomic claim: flip status pending → committing in a single conditional // update. If 0 rows are affected, another caller (auto-commit ↔ human diff --git a/supabase/migrations/20260619120000_journal_entry_committed_actor.sql b/supabase/migrations/20260619120000_journal_entry_committed_actor.sql new file mode 100644 index 00000000..4601ed86 --- /dev/null +++ b/supabase/migrations/20260619120000_journal_entry_committed_actor.sql @@ -0,0 +1,200 @@ +-- Agent attribution into the immutable layer, part 2 (agent_first_vision.md §8 P0-1). +-- +-- 20260618120001 made commit_method record that an approval was agent-relayed +-- ('api_key'), but two gaps remained: +-- +-- 1. The trigger-written audit_log rows (the unconditional audit trail) never +-- populate actor_type/actor_label — the columns exist since 20260430120000 +-- but write_audit_log() leaves them at DEFAULT 'user'/NULL, so an auditor +-- reading audit_log cannot distinguish agent-relayed commits from +-- first-party human sessions. +-- 2. The actor LABEL (which credential, e.g. the API key name) never reaches +-- the immutable layer at all — it lives only in mutable, app-written +-- pending_operations/processing_history rows. +-- +-- Mechanism: commit_journal_entry() gains p_actor_type/p_actor_label params +-- (DEFAULT NULL — every existing caller keeps working unchanged). The RPC sets +-- transaction-local GUCs (the established gnubok.allow_delete pattern from +-- 20260415000000) that write_audit_log() reads, and stamps the values onto two +-- new nullable journal_entries columns in the same draft→posted UPDATE that +-- already writes commit_method. PostgREST callers cannot set GUCs themselves +-- (each request is its own connection/transaction), so the RPC is the only +-- entry point — which is exactly the choke point we want (BFNAR 2013:2 kap 8 +-- behandlingshistorik: automated processing must be identifiable). +-- +-- Backwards compatibility: +-- - New columns are nullable, no backfill — same rollout as commit_method. +-- - write_audit_log COALESCEs an unset GUC to 'user', which is byte-identical +-- to today's effective behaviour (column DEFAULT 'user') for every path +-- that does not pass the new params. +-- - The immutability triggers (migration 017) only inspect status +-- transitions; the new columns are written during the allowed draft→posted +-- branch and never touched afterwards. +-- +-- pg-test: covered-by lib/bookkeeping/__tests__/commit-actor.pg.test.ts + +-- ── 1. Provenance columns on the immutable entry itself ────────────────────── + +ALTER TABLE public.journal_entries + ADD COLUMN IF NOT EXISTS committed_actor_type TEXT + CHECK (committed_actor_type IS NULL OR committed_actor_type IN ( + 'user', 'api_key', 'mcp_oauth', 'cron', 'system', 'agent_chat' + )), + ADD COLUMN IF NOT EXISTS committed_actor_label TEXT; + +COMMENT ON COLUMN public.journal_entries.committed_actor_type IS + 'WHO relayed the commit (user | api_key | mcp_oauth | cron | system | agent_chat). Complements commit_method (HOW). NULL on rows committed before this column existed or via paths that do not pass actor context.'; +COMMENT ON COLUMN public.journal_entries.committed_actor_label IS + 'Human-readable credential label at commit time (e.g. API key name). Snapshot, not a foreign key.'; + +-- ── 2. commit_journal_entry: 4-arg → 6-arg with defaults ───────────────────── +-- DROP the exact prior signature first to avoid the PostgREST +-- "could not choose the best candidate function" overload ambiguity +-- (same consolidation technique as 20260421140000). Body copied verbatim from +-- the LATEST prior definition (20260421170500_commit_journal_entry_user_id_fallback, +-- which added the COALESCE(auth.uid(), v_entry_user_id) voucher-sequence +-- attribution for service-role callers) plus the actor additions. + +DROP FUNCTION IF EXISTS public.commit_journal_entry(uuid, uuid, text, text); + +CREATE OR REPLACE FUNCTION public.commit_journal_entry( + p_company_id uuid, + p_entry_id uuid, + p_commit_method text DEFAULT NULL, + p_rubric_version text DEFAULT NULL, + p_actor_type text DEFAULT NULL, + p_actor_label text DEFAULT NULL +) +RETURNS TABLE (voucher_number integer) +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_next integer; + v_fiscal_period_id uuid; + v_series text; + v_entry_user_id uuid; +BEGIN + -- Transaction-local actor context for write_audit_log (AFTER trigger on the + -- UPDATE below runs in this same transaction). Empty string = unset; the + -- trigger nullif()s it away. + PERFORM set_config('gnubok.actor_type', coalesce(p_actor_type, ''), true); + PERFORM set_config('gnubok.actor_label', coalesce(p_actor_label, ''), true); + + SELECT je.fiscal_period_id, COALESCE(je.voucher_series, 'A'), je.user_id + INTO v_fiscal_period_id, v_series, v_entry_user_id + FROM public.journal_entries je + WHERE je.id = p_entry_id + AND je.company_id = p_company_id + AND je.status = 'draft' + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Draft journal entry not found: %', p_entry_id; + END IF; + + INSERT INTO public.voucher_sequences (company_id, user_id, fiscal_period_id, voucher_series, last_number) + VALUES (p_company_id, COALESCE(auth.uid(), v_entry_user_id), v_fiscal_period_id, v_series, 1) + ON CONFLICT (company_id, fiscal_period_id, voucher_series) + DO UPDATE SET + last_number = public.voucher_sequences.last_number + 1, + updated_at = now() + RETURNING last_number INTO v_next; + + UPDATE public.journal_entries + SET voucher_number = v_next, + status = 'posted', + commit_method = p_commit_method, + rubric_version = p_rubric_version, + committed_actor_type = p_actor_type, + committed_actor_label = p_actor_label + WHERE id = p_entry_id + AND company_id = p_company_id; + + RETURN QUERY SELECT v_next; +END; +$$; + +-- ── 3. write_audit_log: read the actor GUCs ────────────────────────────────── +-- Verbatim copy of the latest definition (20260415000000_schema_sync.sql, 4i) +-- with ONE change: the INSERT also writes actor_type/actor_label, COALESCEing +-- an unset GUC to 'user' — today's effective DEFAULT — so every existing write +-- path produces byte-identical audit rows. + +CREATE OR REPLACE FUNCTION public.write_audit_log() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER +AS $function$ +DECLARE + v_user_id uuid; + v_company_id uuid; + v_action text; + v_old_state jsonb; + v_new_state jsonb; + v_record_id uuid; + v_desc text; +BEGIN + IF TG_OP = 'DELETE' THEN + v_old_state := to_jsonb(OLD); + v_new_state := NULL; + v_record_id := OLD.id; + v_user_id := (v_old_state->>'user_id')::uuid; + v_company_id := (v_old_state->>'company_id')::uuid; + v_action := 'DELETE'; + v_desc := 'Deleted ' || TG_TABLE_NAME || ' record'; + ELSIF TG_OP = 'INSERT' THEN + v_old_state := NULL; + v_new_state := to_jsonb(NEW); + v_record_id := NEW.id; + v_user_id := (v_new_state->>'user_id')::uuid; + v_company_id := (v_new_state->>'company_id')::uuid; + v_action := 'INSERT'; + v_desc := 'Created ' || TG_TABLE_NAME || ' record'; + ELSIF TG_OP = 'UPDATE' THEN + v_old_state := to_jsonb(OLD); + v_new_state := to_jsonb(NEW); + v_record_id := COALESCE(NEW.id, OLD.id); + v_user_id := COALESCE((v_new_state->>'user_id')::uuid, (v_old_state->>'user_id')::uuid); + v_company_id := COALESCE((v_new_state->>'company_id')::uuid, (v_old_state->>'company_id')::uuid); + v_action := 'UPDATE'; + v_desc := 'Updated ' || TG_TABLE_NAME || ' record'; + + IF TG_TABLE_NAME = 'journal_entries' THEN + IF OLD.status = 'draft' AND NEW.status = 'posted' THEN + v_action := 'COMMIT'; + v_desc := 'Committed journal entry ' || NEW.voucher_series || NEW.voucher_number; + ELSIF OLD.status = 'posted' AND NEW.status = 'reversed' THEN + v_action := 'REVERSE'; + v_desc := 'Reversed journal entry ' || OLD.voucher_series || OLD.voucher_number; + END IF; + END IF; + + IF TG_TABLE_NAME = 'fiscal_periods' THEN + IF (OLD.locked_at IS NULL AND NEW.locked_at IS NOT NULL) THEN + v_action := 'LOCK_PERIOD'; + v_desc := 'Locked fiscal period "' || NEW.name || '"'; + ELSIF (NOT OLD.is_closed AND NEW.is_closed) THEN + v_action := 'CLOSE_PERIOD'; + v_desc := 'Closed fiscal period "' || NEW.name || '"'; + END IF; + END IF; + END IF; + + v_user_id := COALESCE(v_user_id, auth.uid()); + + INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, actor_id, old_state, new_state, description, actor_type, actor_label) + VALUES ( + v_user_id, v_company_id, v_action, TG_TABLE_NAME, v_record_id, v_user_id, v_old_state, v_new_state, v_desc, + COALESCE(nullif(current_setting('gnubok.actor_type', true), ''), 'user'), + nullif(current_setting('gnubok.actor_label', true), '') + ); + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$function$; + +NOTIFY pgrst, 'reload schema';