feat(bookkeeping): correction-chain depth guard + Bedrock stream retry (#1581)

* feat(bookkeeping): bypassable chain-depth guard on corrections and stornos

Correcting or reversing an entry that already sits 3+ links deep in a
rattelse chain (correction_of_id/reverses_id walked in the DB, never
description matching) now throws CORRECTION_CHAIN_TOO_DEEP, steering the
caller to book ONE correction expressing the chain's net effect. Agents
looped storno+rattelse 10 deep on a live company (63/193 vouchers noise).

The guard is advisory, never a dead end: allow_deep_chain bypasses it on
every surface (correctEntry/reverseEntry option, REST body, MCP tool arg
staged through pending_operations, and confirm dialogs with Ratta anda /
Aterfor anda in the web UI). MCP staging pre-flight fires the guard at
stage time so the agent reconsiders in the same turn, and the executor
re-checks at commit. tools/list payload ceiling bumped 59K -> 59.5K for
the two bypass properties (trimmed to one sentence first).

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

* feat(agent): retry the Bedrock stream once on transient failures

A transient stream death (429/5xx, transport cut, or the two known
stream-corruption signatures: 'Unexpected event order' and 'request ended
without sending any chunks') killed the whole chat turn, stranding the
user mid-answer. The turn now retries once per turn after a short backoff:
safe because nothing is persisted until finalMessage() succeeds. A new
stream_restart event carries the pre-attempt text snapshot so the chat
client resets the partial bubble, drops uncompleted tool chips, and shows
'Forsoker igen...' until the retried stream produces text. Non-transient
errors (403, 400) keep the existing immediate-error path.

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

* fix(api): regenerate accounted-api skill and wire allow_deep_chain through v1

apiskill:check failed: CorrectJournalEntrySchema gained allow_deep_chain,
making references/journal-entries.md stale. Regenerated (hand-applied: the
generator output is deterministic from the registry). While wiring: the v1
correct route validated allow_deep_chain but dropped it, and the v1 reverse
route's strict body schema would have rejected it outright, leaving API
clients no bypass when the chain-depth guard fires. Both now forward the
flag to the engine and document CORRECTION_CHAIN_TOO_DEEP as a pitfall.

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

* chore: re-trigger CI after Vercel infra hang

The preview for e527e4044 compiled in 91s then hung 40 minutes in the
TypeScript phase and was killed with no error output; a CLI redeploy of
the identical code went Ready in 5m. Empty commit to refresh the git-
triggered deployment status.

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

* fix(bookkeeping): address CodeRabbit review on the chain-depth guard

- correction-chain: report rootVoucher only when the walk reached a
  genuine parentless root; a broken link, cycle, or hop-cap now yields
  null instead of presenting an intermediate voucher as the chain root.
- recordate: propagate allow_deep_chain end-to-end (recordateEntry
  option, route schema, and a Flytta anda bypass confirm in the dialog);
  a date move is another storno+rattelse layer and carried the guard
  with no override path.
- v1 correct/reverse: run the chain-depth guard before the dry-run
  return so a dry run gives the same verdict as the real execution.
- dashboard reverse route: 400 on malformed JSON or a non-boolean
  allow_deep_chain instead of silently reversing without the override;
  empty body stays the supported no-body case. Tests added.
- AgentChat stream_restart: discard the dead attempt's reasoning and
  re-arm the post-tool paragraph break so a retried turn doesn't render
  thinking twice or glue its continuation onto restored text.
- v1 reverse route doc comment updated for allow_deep_chain.

Not changed: the journal-list reverse flow (flagged as a dead end) can
never receive CORRECTION_CHAIN_TOO_DEEP: the list renders Aterfor only
for entries that are neither storno nor correction, and such entries
have no backward chain links, so their depth is always 0.

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

* test(bookkeeping): recordate route test expects the new options arg

recordateEntry now takes { allowDeepChain } as a sixth argument; the
route test's called-with assertion predates it.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-13 19:32:41 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e494662530
commit 05380ddf54
33 changed files with 1355 additions and 148 deletions
@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
correctionChainDepth,
CORRECTION_CHAIN_GUARD_DEPTH,
MAX_CHAIN_WALK,
} from '../correction-chain'
// Mock supabase: each .single() call resolves the next queued result and
// records the id filtered on, so tests can assert exactly which parents were
// fetched.
let results: Array<{ data?: unknown; error?: unknown }>
let fetchedIds: string[]
function makeClient() {
return {
from: vi.fn().mockImplementation(() => {
const b: Record<string, unknown> = {}
b.select = vi.fn().mockReturnValue(b)
b.eq = vi.fn().mockImplementation((col: string, value: string) => {
if (col === 'id') fetchedIds.push(value)
return b
})
b.single = vi.fn().mockImplementation(async () => results.shift() ?? { data: null, error: null })
return b
}),
}
}
function row(
id: string,
parent: { correction_of_id?: string | null; reverses_id?: string | null } = {},
voucher: { series?: string; number?: number } = {}
) {
return {
data: {
id,
correction_of_id: parent.correction_of_id ?? null,
reverses_id: parent.reverses_id ?? null,
voucher_series: voucher.series ?? 'A',
voucher_number: voucher.number ?? 1,
},
error: null,
}
}
beforeEach(() => {
vi.clearAllMocks()
results = []
fetchedIds = []
})
describe('correctionChainDepth', () => {
it('returns depth 0 with zero queries for an unchained entry', async () => {
const supabase = makeClient()
const info = await correctionChainDepth(supabase as never, 'company-1', {
id: 'orig-1',
correction_of_id: null,
reverses_id: null,
})
expect(info).toEqual({ depth: 0, rootVoucher: null })
expect(supabase.from).not.toHaveBeenCalled()
})
it('walks correction_of_id links to the root and reports its voucher', async () => {
// target(c3) → c2 → c1 → orig (root, voucher A7)
results = [
row('c2', { correction_of_id: 'c1' }, { number: 4 }),
row('c1', { correction_of_id: 'orig' }, { number: 3 }),
row('orig', {}, { series: 'A', number: 7 }),
]
const supabase = makeClient()
const info = await correctionChainDepth(supabase as never, 'company-1', {
id: 'c3',
correction_of_id: 'c2',
reverses_id: null,
})
expect(info).toEqual({ depth: 3, rootVoucher: 'A7' })
expect(fetchedIds).toEqual(['c2', 'c1', 'orig'])
})
it('follows reverses_id when correction_of_id is absent (storno links)', async () => {
results = [row('orig', {}, { series: 'B', number: 12 })]
const supabase = makeClient()
const info = await correctionChainDepth(supabase as never, 'company-1', {
id: 'storno-1',
correction_of_id: null,
reverses_id: 'orig',
})
expect(info).toEqual({ depth: 1, rootVoucher: 'B12' })
})
it('stops on a broken link (parent not found)', async () => {
results = [
row('c1', { correction_of_id: 'gone' }),
{ data: null, error: { message: 'not found' } },
]
const supabase = makeClient()
const info = await correctionChainDepth(supabase as never, 'company-1', {
id: 'c2',
correction_of_id: 'c1',
reverses_id: null,
})
expect(info.depth).toBe(1)
// The walk never reached a parentless node, so no voucher may be
// presented as the chain root.
expect(info.rootVoucher).toBeNull()
})
it('terminates on a cyclic chain instead of looping', async () => {
// a → b → a (cycle). The walk must stop when it re-encounters a.
results = [
row('b', { correction_of_id: 'a' }),
row('a', { correction_of_id: 'b' }),
]
const supabase = makeClient()
const info = await correctionChainDepth(supabase as never, 'company-1', {
id: 'a',
correction_of_id: 'b',
reverses_id: null,
})
expect(info.depth).toBeLessThanOrEqual(2)
})
it('caps the walk at MAX_CHAIN_WALK hops', async () => {
for (let i = 0; i < MAX_CHAIN_WALK + 5; i++) {
results.push(row(`e${i}`, { correction_of_id: `e${i + 1}` }))
}
const supabase = makeClient()
const info = await correctionChainDepth(supabase as never, 'company-1', {
id: 'start',
correction_of_id: 'e0',
reverses_id: null,
})
expect(info.depth).toBe(MAX_CHAIN_WALK)
expect(fetchedIds).toHaveLength(MAX_CHAIN_WALK)
// Capped before reaching the root: the last node still has a parent.
expect(info.rootVoucher).toBeNull()
})
it('guard threshold is 3: original → rättelse → rättelse-av-rättelse stays allowed', () => {
// Locked by the plan: depth 2 targets (fixing the fix) pass the guard,
// depth 3+ requires the explicit override.
expect(CORRECTION_CHAIN_GUARD_DEPTH).toBe(3)
})
})
@@ -1,7 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { makeJournalEntry, makeJournalEntryLine } from '@/tests/helpers'
import { BookkeepingDatabaseError, MeaninglessCorrectionError } from '@/lib/bookkeeping/errors'
import {
BookkeepingDatabaseError,
CorrectionChainTooDeepError,
MeaninglessCorrectionError,
} from '@/lib/bookkeeping/errors'
// ============================================================
// Mock: separate client (no .then) from query builder (thenable)
@@ -350,18 +354,20 @@ describe('correctEntry', () => {
results = [
{ data: correctionAsOriginal, error: null }, // 0: fetch original (the prior correction)
{ data: [{ id: 'acc-5430', account_number: '5430' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts (Step 0)
{ data: secondReversal, error: null }, // 2: insert reversal
{ data: null, error: null }, // 3: insert reversal lines
{ data: null, error: null }, // 4: post reversal
{ data: secondCorrection, error: null }, // 5: insert corrected
{ data: null, error: null }, // 6: insert corrected lines
{ data: null, error: null }, // 7: post corrected
{ data: [{ id: 'correction-1' }], error: null }, // 8: CAS update
{ data: null, error: null }, // 9: relink transactions
{ data: null, error: null }, // 10: relink documents
{ data: { ...secondReversal, lines: [] }, error: null }, // 11: fetch final reversal
{ data: { ...secondCorrection, lines: [] }, error: null }, // 12: fetch final corrected
// 1: chain-depth walker follows correction_of_id to the root (depth 1)
{ data: { id: 'orig-A', correction_of_id: null, reverses_id: null, voucher_series: 'A', voucher_number: 1 }, error: null },
{ data: [{ id: 'acc-5430', account_number: '5430' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 2: accounts (Step 0)
{ data: secondReversal, error: null }, // 3: insert reversal
{ data: null, error: null }, // 4: insert reversal lines
{ data: null, error: null }, // 5: post reversal
{ data: secondCorrection, error: null }, // 6: insert corrected
{ data: null, error: null }, // 7: insert corrected lines
{ data: null, error: null }, // 8: post corrected
{ data: [{ id: 'correction-1' }], error: null }, // 9: CAS update
{ data: null, error: null }, // 10: relink transactions
{ data: null, error: null }, // 11: relink documents
{ data: { ...secondReversal, lines: [] }, error: null }, // 12: fetch final reversal
{ data: { ...secondCorrection, lines: [] }, error: null }, // 13: fetch final corrected
]
const supabase = makeClient()
@@ -375,6 +381,83 @@ describe('correctEntry', () => {
expect(result.corrected.source_type).toBe('correction')
})
it('rejects a correction whose target already sits 3 links deep in the chain', async () => {
// Chain: orig-0 ← c1 ← c2 ← c3 (target). Depth of c3 is 3 → guard fires.
// Christoffer case 2026-08-11: agents stacked rättelser 10 deep.
const target = makeJournalEntry({
id: 'c3',
status: 'posted',
source_type: 'correction',
correction_of_id: 'c2',
fiscal_period_id: 'fp-1',
voucher_series: 'A',
lines: [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
],
})
results = [
{ data: target, error: null }, // 0: fetch original
{ data: { id: 'c2', correction_of_id: 'c1', reverses_id: null, voucher_series: 'A', voucher_number: 3 }, error: null }, // 1: walker hop 1
{ data: { id: 'c1', correction_of_id: 'orig-0', reverses_id: null, voucher_series: 'A', voucher_number: 2 }, error: null }, // 2: walker hop 2
{ data: { id: 'orig-0', correction_of_id: null, reverses_id: null, voucher_series: 'A', voucher_number: 1 }, error: null }, // 3: walker hop 3 (root)
]
const supabase = makeClient()
const err = await correctEntry(
supabase as never, 'company-1', 'user-1', 'c3', correctedLines
).catch((e) => e)
expect(err).toBeInstanceOf(CorrectionChainTooDeepError)
expect(err).toMatchObject({
code: 'CORRECTION_CHAIN_TOO_DEEP',
depth: 3,
chainRootVoucher: 'A1',
})
// Guard fires before any write or voucher-number draw.
expect(inserts).toHaveLength(0)
expect(getNextVoucherNumber).not.toHaveBeenCalled()
})
it('allowDeepChain: true bypasses the chain-depth guard without walking the chain', async () => {
const target = makeJournalEntry({
id: 'c3',
status: 'posted',
source_type: 'correction',
correction_of_id: 'c2',
description: 'Test purchase',
fiscal_period_id: 'fp-1',
voucher_series: 'A',
lines: [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
],
})
const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'c3' })
const correctedEntry = makeJournalEntry({ id: 'c4', correction_of_id: 'c3' })
results = [
{ data: target, error: null }, // 0: fetch original (no walker queries)
{ data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts
{ data: reversalEntry, error: null }, // 2: insert reversal
{ data: null, error: null }, // 3: reversal lines
{ data: null, error: null }, // 4: post reversal
{ data: correctedEntry, error: null }, // 5: insert corrected
{ data: null, error: null }, // 6: corrected lines
{ data: null, error: null }, // 7: post corrected
{ data: [{ id: 'c3' }], error: null }, // 8: CAS
{ data: null, error: null }, // 9: relink transactions
{ data: null, error: null }, // 10: relink documents
{ data: { ...reversalEntry, lines: [] }, error: null }, // 11: final reversal
{ data: { ...correctedEntry, lines: [] }, error: null }, // 12: final corrected
]
const supabase = makeClient()
const result = await correctEntry(
supabase as never, 'company-1', 'user-1', 'c3', correctedLines, { allowDeepChain: true }
)
expect(result.corrected.id).toBe('c4')
})
it('fails fast on unknown accounts: BEFORE the storno exists or a voucher number is consumed', async () => {
// Regression: the old flow created+posted the storno first, then hit
// AccountsNotInChartError on the corrected lines and had to cancel the
+90
View File
@@ -0,0 +1,90 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Correction-chain depth walker.
*
* A rättelse chain is linked in the DB: a correction carries
* `correction_of_id` and a storno carries `reverses_id`, both pointing at the
* entry they replace/cancel. Walking those links backwards from an entry
* gives the number of correction generations between it and the chain root
* (the original verifikat).
*
* Depth is derived from the links, never from description parsing:
* "Rättelse: Rättelse:" matching breaks the moment a caller supplies a custom
* verifikationstext (allowed since issue #1031).
*
* Used by the chain-depth guard (Christoffer case 2026-08-11: agents looped
* corrections of corrections 10 deep, drowning the journal in noise vouchers).
*/
/**
* Depth at which correctEntry/reverseEntry refuse without an explicit
* override. Original → rättelse → rättelse-av-rättelse is a legitimate flow
* (someone fixes the fix); a target already 3+ links deep is thrash in
* practice.
*/
export const CORRECTION_CHAIN_GUARD_DEPTH = 3
/** Hard cap on backward hops so a pathological or cyclic chain cannot loop. */
export const MAX_CHAIN_WALK = 10
interface ChainEntryRow {
id: string
correction_of_id?: string | null
reverses_id?: string | null
voucher_series?: string | null
voucher_number?: number | null
}
export interface CorrectionChainInfo {
/** Backward hops from the entry to the chain root (0 = not part of a chain). */
depth: number
/** Voucher ref of the chain root (e.g. "A113"), when it was reached. */
rootVoucher: string | null
}
/**
* Walk `correction_of_id`/`reverses_id` backwards from `entry` and return the
* chain depth plus the root voucher ref. One query per hop, capped at
* MAX_CHAIN_WALK; a broken link (parent not found) or a cycle ends the walk.
* An entry with no links costs zero queries.
*/
export async function correctionChainDepth(
supabase: SupabaseClient,
companyId: string,
entry: ChainEntryRow
): Promise<CorrectionChainInfo> {
const visited = new Set<string>([entry.id])
let depth = 0
let root: ChainEntryRow = entry
let parentId = entry.correction_of_id ?? entry.reverses_id ?? null
while (parentId && depth < MAX_CHAIN_WALK) {
if (visited.has(parentId)) break
visited.add(parentId)
const { data: parent, error } = await supabase
.from('journal_entries')
.select('id, correction_of_id, reverses_id, voucher_series, voucher_number')
.eq('id', parentId)
.eq('company_id', companyId)
.single()
if (error || !parent) break
depth++
root = parent as ChainEntryRow
parentId = root.correction_of_id ?? root.reverses_id ?? null
}
// Only a node with no backward link is the genuine chain root. When the
// walk stopped early (broken link, cycle, MAX_CHAIN_WALK), `root` is just
// the last node reached: presenting its voucher as the root would mislead.
const reachedRoot = (root.correction_of_id ?? root.reverses_id) == null
const rootVoucher =
depth > 0 && reachedRoot && root.voucher_series && root.voucher_number != null
? `${root.voucher_series}${root.voucher_number}`
: null
return { depth, rootVoucher }
}
+34 -1
View File
@@ -9,10 +9,15 @@ import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
import { normalizeLineDimensions } from '@/lib/bookkeeping/dimension-resolver'
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import {
correctionChainDepth,
CORRECTION_CHAIN_GUARD_DEPTH,
} from '@/lib/core/bookkeeping/correction-chain'
import {
AccountsNotInChartError,
BookkeepingDatabaseError,
CannotCorrectNonPostedError,
CorrectionChainTooDeepError,
EntryAlreadyReversedError,
EntryDateOutsideFiscalPeriodError,
FiscalPeriodNotFoundError,
@@ -137,6 +142,14 @@ export async function correctEntry(
* stale label echoing in the correction header (issue #1031).
*/
description?: string
/**
* Bypass the correction-chain depth guard. Correcting an entry that is
* already CORRECTION_CHAIN_GUARD_DEPTH+ links deep in a rättelse chain
* throws CorrectionChainTooDeepError unless this is set: the sanctioned
* fix is one correction expressing the chain's net effect, not another
* layer of storno+rättelse.
*/
allowDeepChain?: boolean
}
): Promise<{ reversal: JournalEntry; corrected: JournalEntry; documentRelinkError?: string }> {
// Validate the corrected lines are balanced
@@ -172,6 +185,17 @@ export async function correctEntry(
throw new CannotCorrectNonPostedError(original.status)
}
// Chain-depth guard: stop corrections stacking on corrections. Agents have
// looped storno+rättelse on their own rättelser 10 deep (Christoffer case
// 2026-08-11), turning a third of the journal into noise vouchers. Depth is
// walked over the DB links, so a custom verifikationstext cannot dodge it.
if (!options?.allowDeepChain) {
const chain = await correctionChainDepth(supabase, companyId, original)
if (chain.depth >= CORRECTION_CHAIN_GUARD_DEPTH) {
throw new CorrectionChainTooDeepError(chain.depth, chain.rootVoucher)
}
}
const originalLines = (original.lines as JournalEntryLine[]) || []
// Resolve where the corrected entry lands. Defaults to the original's own
@@ -497,7 +521,15 @@ export async function recordateEntry(
companyId: string,
userId: string,
originalEntryId: string,
newDate: string
newDate: string,
options?: {
/**
* Bypass the correction-chain depth guard (see correctEntry): a date
* move IS another storno+rättelse layer, so moving an entry already
* 3+ links deep needs the same explicit confirmation.
*/
allowDeepChain?: boolean
}
): Promise<{ reversal: JournalEntry; corrected: JournalEntry }> {
// Fetch original with lines
const { data: original, error: fetchError } = await supabase
@@ -565,6 +597,7 @@ export async function recordateEntry(
// Hand the entry we already fetched (with lines) to correctEntry so it
// doesn't re-read the same row.
preloadedOriginal: original as OriginalWithLines,
allowDeepChain: options?.allowDeepChain,
}
)