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:
co-authored by
Claude Fable 5
parent
e494662530
commit
05380ddf54
@@ -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')
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user