feat(dimensions): PR6 retro-tagging — audited retag carve-out, BulkTagWorkbench, staged MCP tool (#867)

* feat(dimensions): PR6 retro-tagging — audited retag carve-out, workbench, staged MCP tool

Tier-2 retro-tagging (founder decision №1, approved 2026-07-02): posted
entries in OPEN periods can have their dimension tags changed through ONE
audited path — everything about the verifikat itself stays immutable.

Carve-out (migration 20260702170000): the line-immutability trigger gains a
single narrow branch — while the transaction-local GUC set by the RPC is
active, an UPDATE of a posted line is admitted iff every non-dimension
column is unchanged, enforced by a whole-row to_jsonb diff (any future
column is protected by construction; mirrors cost_center/project are in the
changeable set because they are derived views of dimensions['1']/['6']).
Precedent: mark_entry_as_opening_balance (20260613120000).

retag_line_dimensions RPC: tenant guard (20260619130100 pattern), writer
gate (viewers rejected), posted-only, open period + company lock date
enforced, every code validated against the ACTIVE registry, immutable
dimension_retag_log row (before/after/actor/reason, INSERT-only via its own
trigger, no FKs so the trail survives hard-deletes) written BEFORE the
carve-out UPDATE. Idempotent no-op without a log row. Untag ({}) supported.
Legal position per the plan: dimensions are internredovisning metadata, not
BFL 5 kap 7§ verifikat content — this is strictly more conservative than
Fortnox/Visma (dimension-only diffs, open periods only, immutable log,
storno past locks — Tier 3 has no exceptions).

Mandatory pg suite (11 tests): GUC-less updates still blocked; amounts/
description can never change even under the GUC (transaction-local);
closed/locked/lock-date, role, registry, draft and cross-tenant rejections;
log immutability; gnubok.allow_delete bulk path unaffected.

UX (all writes through the ONE RPC): pencil on posted-voucher lines in
bookkeeping/[id] ("Påverkar endast internredovisningen, inte verifikatet")
+ retag-history card; BulkTagWorkbench at /dimensions/tagging (filters,
shift-select, merge vs "Ersätt tagg" replace mode, reversal-pair warning
with "Inkludera motverifikat" auto-selection, per-line failure display).

MCP: gnubok_tag_journal_lines (bookkeeping:write) — filter block resolved
via resolve-don't-select, ≤500 lines, staged via pending_operations (new
op type migration 20260702171000, medium risk tier, shared Zod validation
boundary between staging and commit; executor loops the RPC per line with
partial-success aggregation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dimensions): address #867 review — SQLSTATE classification, blocking storno confirm, documented divergence

- Retag route classifies RPC errors by SQLSTATE instead of message-regex:
  P0001 (every rule violation in the RPC) → 409 verbatim, 42501 (tenant
  guard) → 403, anything else → logged 500 with a generic message. No more
  substring sniffing.
- The workbench's storno-pair warning escalates to a BLOCKING confirmation
  naming the unselected counter-vouchers before apply (Srf U 14 gross
  reporting — one-legged retags silently skew project P&L; the banner alone
  was advisory).
- The empty-bag divergence is now documented on both schemas as intentional:
  the direct dialog/workbench path allows {} (human untags phantom codes,
  logged with reason), the MCP staged path rejects it (agents never
  bulk-clear history).

Triage notes: the log's missing FKs are the point (behandlingshistorik must
survive undo_sie_import hard-deletes — a cascade would erase the trail);
SIE exports are generated fresh on demand, never cached, so post-retag
exports carry the new object lists automatically; date-scoped registry
values are deliberately not enforced at retag because entry creation does
not enforce them either — enforcing in one path only would be incoherent
(both belong to the PR10 rules engine).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 17:02:34 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent fb3fe82a56
commit 816b1769c8
25 changed files with 3493 additions and 4 deletions
+55
View File
@@ -763,6 +763,23 @@ export const CreateDimensionValueSchema = z
{ message: 'Slutdatum får inte vara före startdatum', path: ['end_date'] },
)
/**
* POST /api/bookkeeping/journal-entry-lines/[lineId]/retag — Tier-2 retro-
* tagging (dimensions plan PR6). The RPC enforces every rule (posted only,
* open period, lock date, active registry values); this schema only shapes
* the request. An empty bag {} untags the line.
*/
export const RetagLineDimensionsSchema = z.object({
// {} passes (no entries to validate) = UNTAG. Intentional divergence from
// the MCP staged path (RetagLineDimensionsParamsSchema), which rejects an
// empty bag: a human clearing phantom tags via the dialog/workbench is a
// deliberate act with a logged reason; an agent bulk-clearing history is
// not something we allow to be staged. The retag log records {} as the
// new value either way (#867 review).
dimensions: DimensionsBagSchema,
reason: z.string().min(3).max(500),
})
/** PATCH /api/dimensions/[id]/values/[valueId] — no `code` field by design. */
export const UpdateDimensionValueSchema = z
.object({
@@ -2138,3 +2155,41 @@ export const SalaryEmployeeOverrideSchema = z
},
)
// ============================================================
// Dimensions PR6 — bulk retro-tagging workbench (appended at end
// of file by PR6 to avoid conflicts; keep new schemas below).
// ============================================================
/**
* Query filters for GET /api/dimensions/tagging/lines (the BulkTagWorkbench
* line browser). All filters optional; `limit` is a hard cap (default 200,
* max 500) — the route fetches limit+1 and reports `total_capped` instead of
* paginating (dimensions plan §3, v1 scope).
*/
export const DimensionTaggingLinesQuerySchema = z.object({
period_id: uuid.optional(),
date_from: saneIsoDate.optional(),
date_to: saneIsoDate.optional(),
account_from: accountNumber.optional(),
account_to: accountNumber.optional(),
/** Free-text ilike filter on journal_entries.description. */
text: z.string().trim().max(200).optional(),
/** '1' → only lines whose dimensions map is empty ({}). */
only_untagged: z.enum(['0', '1']).optional(),
limit: z.coerce.number().int().min(1).max(500).default(200),
})
/**
* Body for POST /api/dimensions/tagging/apply. One dimensions object applied
* to every listed line via the retag_line_dimensions RPC (the UI groups
* selected lines by their computed resulting map and issues one POST per
* distinct map). `dimensions` reuses THE bag schema so validation cannot
* drift from the engine/API layers; an empty bag is allowed — replace mode
* uses it to clear phantom tags. `reason` mirrors the RPC's >= 3 chars CHECK.
*/
export const DimensionTaggingApplySchema = z.object({
line_ids: z.array(uuid).min(1).max(500),
dimensions: DimensionsBagSchema,
reason: z.string().trim().min(3).max(500),
})
+2
View File
@@ -210,6 +210,8 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_list_dimension_values: 'reports:read',
gnubok_create_dimension_value: 'bookkeeping:write',
gnubok_get_dimension_pnl: 'reports:read',
// Staged bulk retag of posted-line dimensions (dimensions PR6).
gnubok_tag_journal_lines: 'bookkeeping:write',
// Document inbox
gnubok_upload_document: 'transactions:write',
gnubok_list_inbox_items: 'transactions:read',
@@ -0,0 +1,233 @@
/**
* commitRetagLineDimensions — executor tests (dimensions PR6).
*
* The executor is private to lib/pending-operations/commit.ts and reached
* through commitPendingOperation, same pattern as
* dimension-value-executor.test.ts. Staging-side coverage (the MCP tool's
* filter matching + cap gates) lives in
* extensions/general/mcp-server/__tests__/tag-journal-lines.test.ts. The RPC
* itself (period/lock/registry/role enforcement) is covered by
* tests/pg/dimension-retag.pg.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import type { PendingOperation } from '@/types'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
import { commitPendingOperation } from '../commit'
const uuidAt = (i: number) => `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'retag_line_dimensions',
status: 'pending',
title: 'test',
params: {},
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'medium',
created_at: '2026-07-02T00:00:00Z',
resolved_at: null,
updated_at: '2026-07-02T00:00:00Z',
...overrides,
} as PendingOperation
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: retag_line_dimensions — schema validation', () => {
it('rejects a non-UUID line id at the commit boundary (tampered params)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher reject update
const op = makePendingOp({
params: { line_ids: ['not-a-uuid'], dimensions: { '6': 'P01' }, reason: 'Rätt projekt' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/Invalid line_ids/)
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('rejects more than 500 line_ids', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher reject update
const op = makePendingOp({
params: {
line_ids: Array.from({ length: 501 }, (_, i) => uuidAt(i)),
dimensions: { '6': 'P01' },
reason: 'Rätt projekt',
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/Invalid line_ids.*capped at 500/)
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('rejects a missing reason', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher reject update
const op = makePendingOp({
params: { line_ids: [uuidAt(1)], dimensions: { '6': 'P01' } },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/Invalid reason/)
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('rejects an empty dimensions bag (retag never bulk-clears)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher reject update
const op = makePendingOp({
params: { line_ids: [uuidAt(1)], dimensions: {}, reason: 'Rensa allt' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/Invalid dimensions/)
expect(supabase.rpc).not.toHaveBeenCalled()
})
})
describe('commitPendingOperation: retag_line_dimensions — execution', () => {
it('happy path: one RPC call per line, aggregates changed/unchanged', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { changed: true, log_id: 'log-1' }, error: null }) // line 1 rpc
enqueue({ data: { changed: false, log_id: null }, error: null }) // line 2 rpc (already tagged)
enqueue({ data: null, error: null }) // finalize update
const op = makePendingOp({
params: {
line_ids: [uuidAt(1), uuidAt(2)],
dimensions: { '1': 'KS01', '6': 'P01' },
reason: 'Retro-taggning av projektet',
filter_summary: 'konto 4010, datum 2024-01-01–2024-12-31',
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
retagged: 1,
unchanged: 1,
failed_count: 0,
failed: [],
dimensions: { '1': 'KS01', '6': 'P01' },
filter_summary: 'konto 4010, datum 2024-01-01–2024-12-31',
})
const rpc = supabase.rpc as ReturnType<typeof vi.fn>
expect(rpc).toHaveBeenCalledTimes(2)
expect(rpc.mock.calls[0][0]).toBe('retag_line_dimensions')
expect(rpc.mock.calls[0][1]).toEqual({
p_company_id: 'company-1',
p_line_id: uuidAt(1),
p_dimensions: { '1': 'KS01', '6': 'P01' },
p_reason: 'Retro-taggning av projektet',
p_user_id: 'user-1',
})
expect(rpc.mock.calls[1][1]).toMatchObject({ p_line_id: uuidAt(2) })
})
it('partial failure: continues past a failing line and reports it', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { changed: true, log_id: 'log-1' }, error: null }) // line 1 ok
enqueue({ data: null, error: { message: 'Perioden är låst — använd rättelseverifikat (storno).' } }) // line 2 fails
enqueue({ data: { changed: true, log_id: 'log-3' }, error: null }) // line 3 ok
enqueue({ data: null, error: null }) // finalize update
const op = makePendingOp({
params: {
line_ids: [uuidAt(1), uuidAt(2), uuidAt(3)],
dimensions: { '6': 'P01' },
reason: 'Retro-taggning',
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ retagged: 2, unchanged: 0, failed_count: 1 })
expect(result.data?.failed).toEqual([
{ line_id: uuidAt(2), error: 'Perioden är låst — använd rättelseverifikat (storno).' },
])
expect(supabase.rpc).toHaveBeenCalledTimes(3)
})
it('caps the echoed failures at 20 but counts them all', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { changed: true, log_id: 'log-1' }, error: null }) // line 1 ok
for (let i = 0; i < 22; i++) {
enqueue({ data: null, error: { message: `fel ${i}` } })
}
enqueue({ data: null, error: null }) // finalize update
const op = makePendingOp({
params: {
line_ids: Array.from({ length: 23 }, (_, i) => uuidAt(i)),
dimensions: { '6': 'P01' },
reason: 'Retro-taggning',
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ retagged: 1, failed_count: 22 })
expect((result.data?.failed as unknown[]).length).toBe(20)
})
it('fails the operation when EVERY line fails', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: { message: 'Verifikationsraden hittades inte.' } })
enqueue({ data: null, error: { message: 'Verifikationsraden hittades inte.' } })
enqueue({ data: null, error: null }) // dispatcher reject update
const op = makePendingOp({
params: {
line_ids: [uuidAt(1), uuidAt(2)],
dimensions: { '6': 'P01' },
reason: 'Retro-taggning',
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/Ingen rad kunde taggas om \(2 rader misslyckades\)/)
expect(result.error).toMatch(/Verifikationsraden hittades inte/)
})
})
+81
View File
@@ -75,6 +75,7 @@ import { appendProcessingHistory } from '@/lib/processing-history/append'
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
import { CreateArticleParamsSchema, UpdateArticleParamsSchema } from '@/lib/pending-operations/schemas/article'
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
import { RetagLineDimensionsParamsSchema } from '@/lib/pending-operations/schemas/retag-line-dimensions'
import { BulkBookInboxSchema } from '@/lib/api/schemas'
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
@@ -557,6 +558,83 @@ async function commitCreateDimensionValue(
}
}
/**
* Executor for the staged retag_line_dimensions operation
* (gnubok_tag_journal_lines — dimensions PR6). Loops the staged line_ids
* through the retag_line_dimensions RPC — the ONE audited write path for
* changing dimension tags on posted lines. The RPC enforces everything per
* line at commit time (open period, company lock date, active registry
* values, writer role, posted status) and writes an immutable
* dimension_retag_log row before touching the line.
*
* Partial-success semantics: one line failing (e.g. its period was locked
* between staging and approval) must not roll back the lines already
* retagged — each RPC call is its own transaction. Failures are collected
* and echoed (capped at 20) so the caller can re-stage just the failed set.
* Only when EVERY line fails does the operation as a whole fail.
*/
async function commitRetagLineDimensions(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
// Defense in depth: re-validate the staged params at the commit boundary so
// a tampered pending_operations row cannot inject arbitrary ids or a
// malformed bag (ASVS V4.5) — mirrors commitCreateDimensionValue.
let validated
try {
validated = RetagLineDimensionsParamsSchema.parse(params)
} catch (err) {
if (err instanceof z.ZodError) {
const issue = err.issues[0]
const path = issue?.path?.join('.') ?? 'params'
return { error: `Invalid ${path}: ${issue?.message ?? 'validation failed'}`, status: 400 }
}
throw err
}
let retagged = 0
let unchanged = 0
const failed: Array<{ line_id: string; error: string }> = []
for (const lineId of validated.line_ids) {
const { data, error } = await supabase.rpc('retag_line_dimensions', {
p_company_id: companyId,
p_line_id: lineId,
p_dimensions: validated.dimensions,
p_reason: validated.reason,
p_user_id: userId,
})
if (error) {
failed.push({ line_id: lineId, error: error.message })
continue
}
if ((data as { changed?: boolean } | null)?.changed) retagged++
else unchanged++
}
if (failed.length > 0 && retagged === 0 && unchanged === 0) {
return {
error: `Ingen rad kunde taggas om (${failed.length} rader misslyckades). Första felet: ${failed[0].error}`,
status: 400,
}
}
return {
data: {
retagged,
unchanged,
failed_count: failed.length,
// Echo at most 20 failures — enough to act on without bloating
// result_data on a pathological 500-line all-but-one failure.
failed: failed.slice(0, 20),
dimensions: validated.dimensions,
...(validated.filter_summary ? { filter_summary: validated.filter_summary } : {}),
},
}
}
async function commitCreateTransaction(
supabase: SupabaseClient,
userId: string,
@@ -3654,6 +3732,9 @@ async function commitPendingOperationInner(
case 'create_dimension_value':
result = await commitCreateDimensionValue(supabase, userId, companyId, pendingOp.params)
break
case 'retag_line_dimensions':
result = await commitRetagLineDimensions(supabase, userId, companyId, pendingOp.params)
break
case 'create_invoice':
result = await commitCreateInvoice(supabase, userId, companyId, pendingOp.params)
break
+4
View File
@@ -63,6 +63,10 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// (BFL 5 kap 6 §) and becomes immutable once the JE is posted. Medium so a
// human confirms the doc-to-verifikat pairing before it locks.
link_document_to_voucher: 'medium',
// Dimension-only diff on posted lines (verifikat stays immutable), fully
// audited via dimension_retag_log — but it rewrites reporting history, so
// it crosses a human at medium.
retag_line_dimensions: 'medium',
// ── High: irreversible, compliance-critical, or external side-effects
send_invoice: 'high', // emails the customer
@@ -0,0 +1,60 @@
/**
* Authoritative server-side validation for the retag_line_dimensions staged
* operation (dimensions PR6 — retro-tagging). Used by:
* - The MCP tool gnubok_tag_journal_lines execute() before staging
* (extensions/general/mcp-server/server.ts)
* - commitRetagLineDimensions() before looping the retag_line_dimensions
* RPC (lib/pending-operations/commit.ts)
*
* Defense in depth: validating at the commit boundary protects the DB even if
* a caller writes directly to pending_operations.params bypassing the MCP
* tool (ASVS V4.5 / ISO A.8.28), mirroring CreateDimensionValueParamsSchema.
* The RPC itself re-enforces everything per line (open period, lock date,
* active registry values, writer role) — this schema is the shape gate.
*
* The dimensions bag delegates to DimensionsBagSchema — THE bag schema shared
* with the API layer and the voucher staging path — so the retag write path
* cannot drift from how dimensions are validated everywhere else. An empty
* bag is rejected: this operation tags lines, it never bulk-clears them.
*/
import { z } from 'zod'
import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
/**
* 500-line cap per staged retag (dev_docs plan §3): keeps the approval
* preview reviewable by a human and bounds the per-line RPC loop at commit.
*/
export const RETAG_MAX_LINES = 500
export const RetagLineDimensionsParamsSchema = z
.object({
line_ids: z
.array(z.string().uuid('line_ids must contain journal_entry_lines UUIDs'))
.min(1, 'line_ids must contain at least one line')
.max(RETAG_MAX_LINES, `line_ids is capped at ${RETAG_MAX_LINES} lines per operation`),
// Non-empty by design — and deliberately STRICTER than the direct API
// path (RetagLineDimensionsSchema in lib/api/schemas.ts), which allows
// {} so a human can untag phantom codes via the dialog/workbench. An
// agent bulk-clearing dimension history is not a stageable operation
// (#867 review documented the divergence).
dimensions: DimensionsBagSchema.refine(
(bag) => Object.keys(bag).length > 0,
'dimensions must contain at least one {sie_dim_no: code} pair',
),
reason: z.preprocess(
(v) => (typeof v === 'string' ? v.trim() : v),
z
.string()
.min(3, 'Ange en anledning till ändringen (minst 3 tecken)')
.max(500, 'reason is capped at 500 characters'),
),
/**
* Human description of how the lines were selected (the tool's filter
* block), carried only for the approval preview / audit context — the
* executor never re-runs the filter, it acts on line_ids verbatim.
*/
filter_summary: z.string().max(500).optional(),
})
.strict()
export type RetagLineDimensionsParams = z.infer<typeof RetagLineDimensionsParamsSchema>