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
@@ -162,9 +162,15 @@ describe('tools/list payload size guard', () => {
// upload_id/upload_url/expires_at (method, size cap and echo fields
// moved to description prose) and mime_type made optional on complete;
// the ~360-token remainder is the two tools' wire contract.
// * 59K → 59.5K with the correction-chain depth guard: allow_deep_chain on
// gnubok_correct_entry + gnubok_reverse_journal_entry (the explicit
// bypass agents must discover to override CORRECTION_CHAIN_TOO_DEEP).
// Both property descriptions trimmed to one sentence first; headroom
// before the change was under 20 tokens, so even the trimmed wire
// contract crossed.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(59_000)
expect(approxTokens).toBeLessThan(59_500)
})
})
@@ -587,6 +587,94 @@ describe('gnubok_correct_entry: registration', () => {
})
})
describe('gnubok_correct_entry / gnubok_reverse_journal_entry: chain-depth guard', () => {
// Base row for a posted correction that already sits 3 links deep:
// c3 → c2 → c1 → orig-0 (root, voucher A7).
const deepTarget = {
id: '22222222-2222-4222-8222-222222222222',
status: 'posted',
entry_date: '2026-05-12',
description: 'Rättelse: Rättelse: Rättelse: Köp',
voucher_number: 15,
voucher_series: 'A',
fiscal_period_id: 'fp-1',
correction_of_id: 'c2',
reverses_id: null,
fiscal_periods: { name: '2026', is_closed: false, locked_at: null },
lines: [
{ account_number: '5420', debit_amount: 1000, credit_amount: 0, line_description: null, currency: null, amount_in_currency: null, exchange_rate: null, tax_code: null, dimensions: null, cost_center: null, project: null },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000, line_description: null, currency: null, amount_in_currency: null, exchange_rate: null, tax_code: null, dimensions: null, cost_center: null, project: null },
],
}
const walkerRows = [
{ data: { id: 'c2', correction_of_id: 'c1', reverses_id: null, voucher_series: 'A', voucher_number: 12 }, error: null },
{ data: { id: 'c1', correction_of_id: 'orig-0', reverses_id: null, voucher_series: 'A', voucher_number: 9 }, error: null },
{ data: { id: 'orig-0', correction_of_id: null, reverses_id: null, voucher_series: 'A', voucher_number: 7 }, error: null },
]
const replacementLines = [
{ account_number: '5410', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
]
it('refuses to stage a correction on a chain 3 levels deep with an agent-actionable error', async () => {
const { supabase, enqueue, enqueueMany } = createQueuedMockSupabase()
// Lines carry no dimensions, so no dimension-registry query precedes the
// entry fetch.
enqueue({ data: deepTarget, error: null })
enqueueMany(walkerRows)
await expect(
correctEntry.execute(
{ entry_id: deepTarget.id, lines: replacementLines },
'company-1',
'user-1',
supabase as never,
),
).rejects.toMatchObject({
code: 'CORRECTION_CHAIN_TOO_DEEP',
depth: 3,
chainRootVoucher: 'A7',
})
})
it('stages when allow_deep_chain=true and forwards the flag to the executor params', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: deepTarget, error: null })
// No walker queries: the guard is skipped entirely on explicit override.
enqueue({ data: { bookkeeping_locked_through: null }, error: null })
enqueue({ data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null })
enqueue({ data: { id: 'op-deep-1' }, error: null })
await correctEntry.execute(
{ entry_id: deepTarget.id, lines: replacementLines, allow_deep_chain: true },
'company-1',
'user-1',
supabase as never,
)
const insertArgs = findCall('pending_operations', 'insert')
expect(insertArgs).toBeDefined()
const payload = (insertArgs as unknown[])[0] as { params?: { allow_deep_chain?: boolean } }
expect(payload.params?.allow_deep_chain).toBe(true)
})
it('refuses to stage a storno on a chain 3 levels deep', async () => {
const { supabase, enqueue, enqueueMany } = createQueuedMockSupabase()
enqueue({ data: deepTarget, error: null })
enqueueMany(walkerRows)
await expect(
reverseEntry.execute(
{ entry_id: deepTarget.id },
'company-1',
'user-1',
supabase as never,
),
).rejects.toMatchObject({ code: 'CORRECTION_CHAIN_TOO_DEEP', depth: 3 })
})
})
describe('gnubok_reverse_journal_entry: staging gates', () => {
it('is registered with bookkeeping:write scope and is not read-only', async () => {
const { TOOL_SCOPE_MAP } = await import('@/lib/auth/api-keys')
+39 -3
View File
@@ -168,7 +168,8 @@ import {
} from '@/lib/core/bookkeeping/kontantmetod-cutoff'
import { generateSIEExport } from '@/lib/reports/sie-export'
import { generateFullArchive, estimateArchiveSize } from '@/lib/reports/full-archive-export'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { bookkeepingErrorResponse, CorrectionChainTooDeepError } from '@/lib/bookkeeping/errors'
import { correctionChainDepth, CORRECTION_CHAIN_GUARD_DEPTH } from '@/lib/core/bookkeeping/correction-chain'
import { getSuggestedCategories, buildMerchantHistory, merchantHistoryFor } from '@/lib/transactions/category-suggestions'
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
import { buildDuplicateBookingClaim } from '@/lib/transactions/categorize-core'
@@ -14705,6 +14706,10 @@ export const tools: McpTool[] = [
required: ['account_number'],
},
},
allow_deep_chain: {
type: 'boolean',
description: 'Override the chain-depth guard (refuses at 3+ rättelse levels: book ONE net-effect correction instead). True only when another layer is intended.',
},
},
required: ['entry_id', 'lines'],
},
@@ -14713,6 +14718,7 @@ export const tools: McpTool[] = [
async execute(args, companyId, userId, supabase, actor) {
const entryRef = args.entry_id as string
const rawLines = args.lines as Array<Record<string, unknown>> | undefined
const allowDeepChain = args.allow_deep_chain === true
if (!entryRef || !Array.isArray(rawLines) || rawLines.length < 2) {
throw new Error('entry_id and at least two lines are required')
@@ -14767,6 +14773,8 @@ export const tools: McpTool[] = [
voucher_number: number
voucher_series: string
fiscal_period_id: string
correction_of_id: string | null
reverses_id: string | null
fiscal_periods: { name?: string; is_closed?: boolean; locked_at?: string | null } | { name?: string; is_closed?: boolean; locked_at?: string | null }[] | null
lines: Array<{
account_number: string
@@ -14785,7 +14793,7 @@ export const tools: McpTool[] = [
const { data, error: origErr } = await supabase
.from('journal_entries')
.select(
'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, ' +
'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, correction_of_id, reverses_id, ' +
'fiscal_periods!journal_entries_fiscal_period_id_fkey!inner(name, is_closed, locked_at), ' +
'lines:journal_entry_lines(account_number, debit_amount, credit_amount, line_description, currency, amount_in_currency, exchange_rate, tax_code, dimensions, cost_center, project)'
)
@@ -14816,6 +14824,15 @@ export const tools: McpTool[] = [
)
}
// Chain-depth guard at staging time so the agent reconsiders NOW, not at
// approval. The executor (commitCorrectEntry → correctEntry) re-checks.
if (!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 || []
return stagePendingOperation(supabase, companyId, userId, 'correct_entry',
@@ -14823,6 +14840,7 @@ export const tools: McpTool[] = [
{
entry_id: entryId,
lines,
...(allowDeepChain ? { allow_deep_chain: true } : {}),
},
{
original: {
@@ -14887,6 +14905,10 @@ export const tools: McpTool[] = [
entry_id: { type: 'string', description: 'Journal entry UUID OR voucher ref like "A-113". Prefer voucher refs: UUIDs reused from earlier tool output are frequently hallucinated by LLM callers.' },
reversal_date: { type: 'string', pattern: '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', description: 'Optional ISO yyyy-MM-dd date for the storno verifikation. Defaults to today (Swedish timezone). Period attribution always follows the original entry, regardless of this date.' },
reason: { type: 'string', maxLength: 500, description: 'Optional human-readable reason: shown in pending_operations review. Not stored on the storno itself. Max 500 chars.' },
allow_deep_chain: {
type: 'boolean',
description: 'Override the chain-depth guard (refuses at 3+ rättelse levels: book ONE net-effect correction instead). True only when another storno layer is intended.',
},
},
required: ['entry_id'],
},
@@ -14896,6 +14918,7 @@ export const tools: McpTool[] = [
const entryRef = args.entry_id as string
const reversalDate = typeof args.reversal_date === 'string' ? args.reversal_date : undefined
const reason = typeof args.reason === 'string' ? args.reason : undefined
const allowDeepChain = args.allow_deep_chain === true
if (!entryRef) {
throw new Error('entry_id is required')
@@ -14926,6 +14949,8 @@ export const tools: McpTool[] = [
voucher_number: number
voucher_series: string
fiscal_period_id: string
correction_of_id: string | null
reverses_id: string | null
fiscal_periods: { name?: string; is_closed?: boolean; locked_at?: string | null } | { name?: string; is_closed?: boolean; locked_at?: string | null }[] | null
lines: Array<{
account_number: string
@@ -14937,7 +14962,7 @@ export const tools: McpTool[] = [
const { data, error: origErr } = await supabase
.from('journal_entries')
.select(
'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, ' +
'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, correction_of_id, reverses_id, ' +
'fiscal_periods!journal_entries_fiscal_period_id_fkey!inner(name, is_closed, locked_at), lines:journal_entry_lines(account_number, debit_amount, credit_amount, line_description)'
)
.eq('id', entryId)
@@ -14967,6 +14992,16 @@ export const tools: McpTool[] = [
)
}
// Chain-depth guard at staging time (mirrors gnubok_correct_entry): a
// storno on an entry already deep in a rättelse chain is almost always
// an agent reflexively cancelling its own correction.
if (!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 || []
const reversedPreviewLines = originalLines.map((l) => ({
account_number: l.account_number,
@@ -14993,6 +15028,7 @@ export const tools: McpTool[] = [
{
entry_id: entryId,
reversal_date: reversalDate,
...(allowDeepChain ? { allow_deep_chain: true } : {}),
},
{
original: {