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
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { AgentIntent } from '@/lib/agent/intents/types'
|
||||
import type { StreamEvent } from '../run-turn'
|
||||
|
||||
// Anthropic client mock: `finalMessage()` delegates to a queued mock so tests
|
||||
// can make the first stream attempt fail and the retry succeed. Mirrors the
|
||||
// adapter in run-turn-memory.test.ts.
|
||||
const messagesCreate = vi.fn()
|
||||
vi.mock('@/lib/agent/composer/client', () => ({
|
||||
getAnthropic: () => ({
|
||||
messages: {
|
||||
create: messagesCreate,
|
||||
stream: (args: unknown) => {
|
||||
const stream = {
|
||||
on: () => stream,
|
||||
finalMessage: () => messagesCreate(args),
|
||||
}
|
||||
return stream
|
||||
},
|
||||
},
|
||||
}),
|
||||
SONNET_MODEL: 'claude-sonnet-5',
|
||||
MAX_TOKENS_NO_THINKING: 5400,
|
||||
MAX_TOKENS_STANDARD: 16000,
|
||||
MAX_TOKENS_DEEP: 24000,
|
||||
}))
|
||||
|
||||
vi.mock('../system-prompt', () => ({
|
||||
buildSystemPrompt: vi.fn().mockResolvedValue({
|
||||
blocks: [],
|
||||
promptHash: 'sha256:test',
|
||||
atomsLoaded: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
const getMock = vi.fn()
|
||||
const getManyMock = vi.fn()
|
||||
vi.mock('@/lib/agent/tools/registry', () => ({
|
||||
agentToolRegistry: {
|
||||
get: (...args: unknown[]) => getMock(...args),
|
||||
getMany: (...args: unknown[]) => getManyMock(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
import { runChatTurn, isTransientStreamError } from '../run-turn'
|
||||
|
||||
function fakeSupabase() {
|
||||
const passthrough: Record<string, unknown> = {}
|
||||
const proxy: unknown = new Proxy(passthrough, {
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
|
||||
}
|
||||
return () => proxy
|
||||
},
|
||||
})
|
||||
return proxy as unknown as Parameters<typeof runChatTurn>[0]['supabase']
|
||||
}
|
||||
|
||||
function makeIntent(): AgentIntent {
|
||||
return {
|
||||
id: 'general.help',
|
||||
buttonLabel: 'x',
|
||||
sheetTitle: 'x',
|
||||
atoms: { mode: 'progressive', horizontal: [], includeCompanyVertical: false, includeCompanyModifiers: false },
|
||||
tools: [],
|
||||
model: 'claude-sonnet-5',
|
||||
capture: async () => ({}),
|
||||
promptTemplate: () => '',
|
||||
}
|
||||
}
|
||||
|
||||
async function runTurn(events: StreamEvent[]): Promise<void> {
|
||||
await runChatTurn({
|
||||
supabase: fakeSupabase(),
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
companyName: 'Acme AB',
|
||||
firstName: 'Anna',
|
||||
intent: makeIntent(),
|
||||
conversationId: 'conv-1',
|
||||
userMessage: 'hej',
|
||||
persist: false,
|
||||
emit: (e) => {
|
||||
events.push(e)
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function transientError(message: string, status?: number): Error & { status?: number } {
|
||||
const err = new Error(message) as Error & { status?: number }
|
||||
if (status !== undefined) err.status = status
|
||||
return err
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getManyMock.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('isTransientStreamError', () => {
|
||||
it('classifies the known stream-corruption signatures as transient', () => {
|
||||
expect(isTransientStreamError(new Error('Unexpected event order, got message_stop'))).toBe(true)
|
||||
expect(isTransientStreamError(new Error('request ended without sending any chunks'))).toBe(true)
|
||||
})
|
||||
|
||||
it('classifies throttling, 5xx, and transport cuts as transient', () => {
|
||||
expect(isTransientStreamError(transientError('Too many requests', 429))).toBe(true)
|
||||
expect(isTransientStreamError(transientError('Internal failure', 503))).toBe(true)
|
||||
expect(isTransientStreamError(new Error('read ECONNRESET'))).toBe(true)
|
||||
expect(isTransientStreamError(new Error('Request timed out'))).toBe(true)
|
||||
})
|
||||
|
||||
it('classifies auth and validation failures as permanent', () => {
|
||||
expect(isTransientStreamError(transientError('Forbidden', 403))).toBe(false)
|
||||
expect(isTransientStreamError(transientError('Invalid model', 400))).toBe(false)
|
||||
// A 4xx stays permanent even when the message contains a transient token.
|
||||
expect(isTransientStreamError(transientError('socket auth rejected', 403))).toBe(false)
|
||||
expect(isTransientStreamError(new Error('validation failed: max_tokens'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runChatTurn: transient stream retry', () => {
|
||||
it('retries once on a transient failure and completes the turn', async () => {
|
||||
messagesCreate
|
||||
.mockRejectedValueOnce(new Error('Unexpected event order, got message_stop'))
|
||||
.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'Hej igen.' }],
|
||||
stop_reason: 'end_turn',
|
||||
})
|
||||
|
||||
const events: StreamEvent[] = []
|
||||
await runTurn(events)
|
||||
|
||||
const restarts = events.filter((e) => e.kind === 'stream_restart')
|
||||
expect(restarts).toHaveLength(1)
|
||||
expect(restarts[0]).toMatchObject({ kind: 'stream_restart', assistant_text: '' })
|
||||
expect(events.find((e) => e.kind === 'error')).toBeUndefined()
|
||||
expect(events.find((e) => e.kind === 'turn_complete')).toBeDefined()
|
||||
expect(messagesCreate).toHaveBeenCalledTimes(2)
|
||||
}, 15_000)
|
||||
|
||||
it('does not retry a non-transient failure (403): error emitted, turn thrown', async () => {
|
||||
messagesCreate.mockRejectedValueOnce(transientError('Forbidden', 403))
|
||||
|
||||
const events: StreamEvent[] = []
|
||||
await expect(runTurn(events)).rejects.toThrow('Forbidden')
|
||||
|
||||
expect(events.filter((e) => e.kind === 'stream_restart')).toHaveLength(0)
|
||||
expect(events.find((e) => e.kind === 'error')).toBeDefined()
|
||||
expect(messagesCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries at most once per turn: a second transient failure surfaces as an error', async () => {
|
||||
messagesCreate
|
||||
.mockRejectedValueOnce(transientError('Service unavailable', 503))
|
||||
.mockRejectedValueOnce(transientError('Service unavailable', 503))
|
||||
|
||||
const events: StreamEvent[] = []
|
||||
await expect(runTurn(events)).rejects.toThrow('Service unavailable')
|
||||
|
||||
expect(events.filter((e) => e.kind === 'stream_restart')).toHaveLength(1)
|
||||
expect(events.find((e) => e.kind === 'error')).toBeDefined()
|
||||
expect(messagesCreate).toHaveBeenCalledTimes(2)
|
||||
}, 15_000)
|
||||
})
|
||||
+185
-94
@@ -51,6 +51,52 @@ export function friendlyModelError(err: unknown): string {
|
||||
return 'Något gick fel hos assistenten. Försök igen om en stund.'
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a Bedrock stream failure is transient: the identical request can
|
||||
* succeed on an immediate retry without any input change. Covers throttling
|
||||
* (429), server errors (5xx), transport cuts (timeout/reset/socket), and the
|
||||
* two known SDK stream-corruption signatures observed in prod on the pinned
|
||||
* 0.29.x SDK ("Unexpected event order", "request ended without sending any
|
||||
* chunks"). Auth/validation failures (4xx other than 429) are NOT transient:
|
||||
* retrying them is wasted work and they must keep surfacing immediately.
|
||||
*/
|
||||
export function isTransientStreamError(err: unknown): boolean {
|
||||
const status = (err as { status?: number } | null)?.status
|
||||
if (status === 429) return true
|
||||
if (typeof status === 'number' && status >= 500) return true
|
||||
// A non-429 4xx is permanent regardless of message text.
|
||||
if (typeof status === 'number' && status >= 400) return false
|
||||
|
||||
const name = (err as { name?: string } | null)?.name ?? ''
|
||||
const raw = err instanceof Error ? err.message : ''
|
||||
let cause = ''
|
||||
try {
|
||||
const c = (err as { cause?: unknown } | null)?.cause
|
||||
cause = c instanceof Error ? `${c.name} ${c.message}` : c != null ? String(c) : ''
|
||||
} catch {
|
||||
cause = ''
|
||||
}
|
||||
const text = `${name} ${raw} ${cause}`.toLowerCase()
|
||||
return (
|
||||
text.includes('unexpected event order') ||
|
||||
text.includes('request ended without sending any chunks') ||
|
||||
text.includes('throttl') ||
|
||||
text.includes('rate limit') ||
|
||||
text.includes('rate exceeded') ||
|
||||
text.includes('too many') ||
|
||||
text.includes('timeout') ||
|
||||
text.includes('timed out') ||
|
||||
text.includes('etimedout') ||
|
||||
text.includes('econnreset') ||
|
||||
text.includes('network') ||
|
||||
text.includes('socket')
|
||||
)
|
||||
}
|
||||
|
||||
// One automatic retry per turn on a transient stream failure, after a short
|
||||
// backoff. Per-turn, not per-iteration: a turn that dies twice is not a blip.
|
||||
const STREAM_RETRY_BACKOFF_MS = 750
|
||||
|
||||
// One turn of the chat loop:
|
||||
//
|
||||
// 1. Resolve context (company, profile, ranked memory).
|
||||
@@ -97,6 +143,15 @@ export type StreamEvent =
|
||||
memory_kind?: 'fact' | 'preference' | 'pattern' | 'correction'
|
||||
content?: string
|
||||
}
|
||||
| {
|
||||
// The Bedrock stream died on a transient error and the turn is being
|
||||
// retried once. Text and eager tool chips from the dead stream were
|
||||
// never persisted; the chat surface must reset the in-progress
|
||||
// assistant bubble to `assistant_text` (what had accumulated BEFORE the
|
||||
// failed attempt) and drop un-completed tool chips.
|
||||
kind: 'stream_restart'
|
||||
assistant_text: string
|
||||
}
|
||||
| { kind: 'turn_complete'; assistant_text: string }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
@@ -255,6 +310,12 @@ export async function runChatTurn(args: RunTurnArgs): Promise<void> {
|
||||
|
||||
let assistantText = ''
|
||||
let iterations = 0
|
||||
// One automatic retry per TURN when the Bedrock stream dies on a transient
|
||||
// error (throttling, 5xx, transport cut, stream corruption). The failed
|
||||
// attempt persisted nothing (persist happens after finalMessage() succeeds),
|
||||
// so a clean retry is safe; the client is told to discard the partial
|
||||
// bubble via stream_restart.
|
||||
let streamRetryUsed = false
|
||||
|
||||
// Extended thinking ("tänka längre"): when the intent opts in, every model
|
||||
// call in the loop gets a reasoning channel so the agent reasons BEFORE it
|
||||
@@ -291,103 +352,133 @@ export async function runChatTurn(args: RunTurnArgs): Promise<void> {
|
||||
// sees Anna's reply appear word-by-word instead of waiting 1-5 s for
|
||||
// the full block to land. We still collect the final assembled message
|
||||
// for tool detection, persistence and stop-reason control flow.
|
||||
const stream = anthropic.messages.stream({
|
||||
model,
|
||||
max_tokens: maxTokens,
|
||||
system: systemPrompt.blocks,
|
||||
messages,
|
||||
tools: tools.length > 0 ? tools.map(toAnthropicTool) : undefined,
|
||||
...(thinking ? { thinking } : {}),
|
||||
...(outputConfig ? { output_config: outputConfig } : {}),
|
||||
})
|
||||
|
||||
stream.on('text', (delta) => {
|
||||
assistantText += delta
|
||||
emit({ kind: 'text_delta', delta })
|
||||
})
|
||||
|
||||
// Track which tool_use ids have already been announced to the client so
|
||||
// the dispatch loop below doesn't re-emit them. Eager-emitting on
|
||||
// `content_block_start` shaves the perceived lag for tool chips: the
|
||||
// chip appears the moment the LLM commits to a tool call, instead of
|
||||
// after the entire response is buffered.
|
||||
const eagerToolIds = new Set<string>()
|
||||
stream.on('streamEvent', (ev) => {
|
||||
// The raw stream event shape depends on the SDK; we care about
|
||||
// content_block_start with a tool_use block, and content_block_delta
|
||||
// carrying extended-thinking text.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const e = ev as any
|
||||
if (
|
||||
e?.type === 'content_block_delta' &&
|
||||
e?.delta?.type === 'thinking_delta' &&
|
||||
typeof e.delta.thinking === 'string'
|
||||
) {
|
||||
emit({ kind: 'reasoning_delta', delta: e.delta.thinking })
|
||||
return
|
||||
}
|
||||
if (e?.type === 'content_block_start' && e?.content_block?.type === 'tool_use') {
|
||||
const block = e.content_block
|
||||
if (typeof block.id === 'string' && typeof block.name === 'string') {
|
||||
eagerToolIds.add(block.id)
|
||||
emit({
|
||||
kind: 'tool_use',
|
||||
tool_use_id: block.id,
|
||||
name: block.name,
|
||||
// Input is still being streamed at this point; the chip only
|
||||
// displays the tool name so empty input is fine.
|
||||
input: {},
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
//
|
||||
// The attempt loop wraps stream creation + finalMessage() so a transient
|
||||
// failure (throttling, 5xx, transport cut, stream corruption) gets ONE
|
||||
// automatic retry per turn. Everything the dead stream emitted is
|
||||
// reverted: assistantText rolls back to its pre-attempt snapshot and the
|
||||
// client discards the partial bubble on stream_restart.
|
||||
let response
|
||||
try {
|
||||
response = await stream.finalMessage()
|
||||
} catch (err) {
|
||||
// Surface as a chat error so the UI clears its streaming state. Re-throw
|
||||
// to let the route's outer try/catch persist the failure if needed.
|
||||
// Normalize Bedrock throttling/timeout/5xx into a friendly Swedish line.
|
||||
//
|
||||
// Extract status/code/cause/stack explicitly: the logger keeps only
|
||||
// name/message/code from an Error and drops the stack in production, so
|
||||
// the real failure was invisible (every prod log just said "request ended
|
||||
// without sending any chunks"). These fields tell us whether the empty
|
||||
// stream is auth (403), bad model/region (400), throttling (429), or a
|
||||
// genuine transport cut. No secrets: AWS/SDK errors carry none, and the
|
||||
// logger still redacts personnummer/UUIDs from any string.
|
||||
const bedrockErr = err as {
|
||||
status?: number
|
||||
code?: string
|
||||
cause?: unknown
|
||||
stack?: string
|
||||
}
|
||||
let errCause: string | undefined
|
||||
try {
|
||||
errCause =
|
||||
bedrockErr?.cause != null
|
||||
? String(
|
||||
bedrockErr.cause instanceof Error
|
||||
? `${bedrockErr.cause.name}: ${bedrockErr.cause.message}`
|
||||
: bedrockErr.cause,
|
||||
).slice(0, 300)
|
||||
: undefined
|
||||
} catch {
|
||||
errCause = '[uninspectable cause]'
|
||||
}
|
||||
log.error('Bedrock stream failed', err, {
|
||||
conversationId,
|
||||
companyId,
|
||||
let eagerToolIds = new Set<string>()
|
||||
for (;;) {
|
||||
const assistantTextBefore = assistantText
|
||||
const stream = anthropic.messages.stream({
|
||||
model,
|
||||
iterations,
|
||||
errStatus: typeof bedrockErr?.status === 'number' ? bedrockErr.status : undefined,
|
||||
errCode: typeof bedrockErr?.code === 'string' ? bedrockErr.code : undefined,
|
||||
errCause,
|
||||
errStack: typeof bedrockErr?.stack === 'string' ? bedrockErr.stack.slice(0, 1200) : undefined,
|
||||
max_tokens: maxTokens,
|
||||
system: systemPrompt.blocks,
|
||||
messages,
|
||||
tools: tools.length > 0 ? tools.map(toAnthropicTool) : undefined,
|
||||
...(thinking ? { thinking } : {}),
|
||||
...(outputConfig ? { output_config: outputConfig } : {}),
|
||||
})
|
||||
emit({ kind: 'error', message: friendlyModelError(err) })
|
||||
throw err
|
||||
|
||||
stream.on('text', (delta) => {
|
||||
assistantText += delta
|
||||
emit({ kind: 'text_delta', delta })
|
||||
})
|
||||
|
||||
// Track which tool_use ids have already been announced to the client so
|
||||
// the dispatch loop below doesn't re-emit them. Eager-emitting on
|
||||
// `content_block_start` shaves the perceived lag for tool chips: the
|
||||
// chip appears the moment the LLM commits to a tool call, instead of
|
||||
// after the entire response is buffered.
|
||||
eagerToolIds = new Set<string>()
|
||||
stream.on('streamEvent', (ev) => {
|
||||
// The raw stream event shape depends on the SDK; we care about
|
||||
// content_block_start with a tool_use block, and content_block_delta
|
||||
// carrying extended-thinking text.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const e = ev as any
|
||||
if (
|
||||
e?.type === 'content_block_delta' &&
|
||||
e?.delta?.type === 'thinking_delta' &&
|
||||
typeof e.delta.thinking === 'string'
|
||||
) {
|
||||
emit({ kind: 'reasoning_delta', delta: e.delta.thinking })
|
||||
return
|
||||
}
|
||||
if (e?.type === 'content_block_start' && e?.content_block?.type === 'tool_use') {
|
||||
const block = e.content_block
|
||||
if (typeof block.id === 'string' && typeof block.name === 'string') {
|
||||
eagerToolIds.add(block.id)
|
||||
emit({
|
||||
kind: 'tool_use',
|
||||
tool_use_id: block.id,
|
||||
name: block.name,
|
||||
// Input is still being streamed at this point; the chip only
|
||||
// displays the tool name so empty input is fine.
|
||||
input: {},
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
response = await stream.finalMessage()
|
||||
break
|
||||
} catch (err) {
|
||||
if (!streamRetryUsed && isTransientStreamError(err)) {
|
||||
streamRetryUsed = true
|
||||
log.warn('Bedrock stream failed transiently, retrying once', {
|
||||
conversationId,
|
||||
companyId,
|
||||
model,
|
||||
iterations,
|
||||
errMessage: err instanceof Error ? err.message.slice(0, 300) : String(err).slice(0, 300),
|
||||
errStatus: (err as { status?: number } | null)?.status,
|
||||
})
|
||||
// Roll back what the dead stream produced. Nothing was persisted
|
||||
// (persist happens after finalMessage() succeeds), so state-wise
|
||||
// this attempt never happened; the client resets its bubble.
|
||||
assistantText = assistantTextBefore
|
||||
emit({ kind: 'stream_restart', assistant_text: assistantText })
|
||||
await new Promise((resolve) => setTimeout(resolve, STREAM_RETRY_BACKOFF_MS))
|
||||
continue
|
||||
}
|
||||
// Surface as a chat error so the UI clears its streaming state. Re-throw
|
||||
// to let the route's outer try/catch persist the failure if needed.
|
||||
// Normalize Bedrock throttling/timeout/5xx into a friendly Swedish line.
|
||||
//
|
||||
// Extract status/code/cause/stack explicitly: the logger keeps only
|
||||
// name/message/code from an Error and drops the stack in production, so
|
||||
// the real failure was invisible (every prod log just said "request ended
|
||||
// without sending any chunks"). These fields tell us whether the empty
|
||||
// stream is auth (403), bad model/region (400), throttling (429), or a
|
||||
// genuine transport cut. No secrets: AWS/SDK errors carry none, and the
|
||||
// logger still redacts personnummer/UUIDs from any string.
|
||||
const bedrockErr = err as {
|
||||
status?: number
|
||||
code?: string
|
||||
cause?: unknown
|
||||
stack?: string
|
||||
}
|
||||
let errCause: string | undefined
|
||||
try {
|
||||
errCause =
|
||||
bedrockErr?.cause != null
|
||||
? String(
|
||||
bedrockErr.cause instanceof Error
|
||||
? `${bedrockErr.cause.name}: ${bedrockErr.cause.message}`
|
||||
: bedrockErr.cause,
|
||||
).slice(0, 300)
|
||||
: undefined
|
||||
} catch {
|
||||
errCause = '[uninspectable cause]'
|
||||
}
|
||||
log.error('Bedrock stream failed', err, {
|
||||
conversationId,
|
||||
companyId,
|
||||
model,
|
||||
iterations,
|
||||
retried: streamRetryUsed,
|
||||
errStatus: typeof bedrockErr?.status === 'number' ? bedrockErr.status : undefined,
|
||||
errCode: typeof bedrockErr?.code === 'string' ? bedrockErr.code : undefined,
|
||||
errCause,
|
||||
errStack: typeof bedrockErr?.stack === 'string' ? bedrockErr.stack.slice(0, 1200) : undefined,
|
||||
})
|
||||
emit({ kind: 'error', message: friendlyModelError(err) })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const assistantContent: ContentBlock[] = response.content
|
||||
|
||||
@@ -1173,6 +1173,10 @@ export const CorrectJournalEntrySchema = z.object({
|
||||
// the user replace a header that echoed the wrong account's label (#1031).
|
||||
description: z.string().trim().min(1, 'Description cannot be empty').optional(),
|
||||
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
|
||||
// Explicit override of the correction-chain depth guard (the "Rätta ändå"
|
||||
// confirm in the UI). Without it, correcting an entry 3+ links deep in a
|
||||
// rättelse chain returns CORRECTION_CHAIN_TOO_DEEP.
|
||||
allow_deep_chain: z.boolean().optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
@@ -1356,6 +1360,9 @@ export const UpdateDimensionValueSchema = z
|
||||
*/
|
||||
export const RecordateJournalEntrySchema = z.object({
|
||||
new_entry_date: isoDate,
|
||||
// Explicit override of the correction-chain depth guard ("Flytta ändå"):
|
||||
// a date move is another storno+rättelse layer, so it carries the guard too.
|
||||
allow_deep_chain: z.boolean().optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -686,6 +686,9 @@ describe('reverseEntry: storno guard', () => {
|
||||
let jeCall = 0
|
||||
const jeResults = [
|
||||
{ data: original, error: null },
|
||||
// Chain-depth walker: follows correction_of_id to the chain root
|
||||
// (depth 1, well under the guard threshold).
|
||||
{ data: { id: 'entry-0', correction_of_id: null, reverses_id: null, voucher_series: 'A', voucher_number: 1 }, error: null },
|
||||
{ data: reversal, error: null },
|
||||
{ data: null, error: null },
|
||||
{ data: [{ id: 'entry-1' }], error: null },
|
||||
|
||||
@@ -7,12 +7,17 @@ import {
|
||||
CannotEditNonDraftError,
|
||||
CannotReverseNonPostedError,
|
||||
CannotReverseStornoError,
|
||||
CorrectionChainTooDeepError,
|
||||
EntryAlreadyReversedError,
|
||||
EntryDateOutsideFiscalPeriodError,
|
||||
FiscalPeriodNotFoundError,
|
||||
JournalEntryNotBalancedError,
|
||||
JournalEntryNotFoundError,
|
||||
} from '@/lib/bookkeeping/errors'
|
||||
import {
|
||||
correctionChainDepth,
|
||||
CORRECTION_CHAIN_GUARD_DEPTH,
|
||||
} from '@/lib/core/bookkeeping/correction-chain'
|
||||
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import {
|
||||
normalizeLineDimensions,
|
||||
@@ -1039,7 +1044,15 @@ export async function reverseEntry(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
entryId: string,
|
||||
reversalDate?: string
|
||||
reversalDate?: string,
|
||||
options?: {
|
||||
/**
|
||||
* Bypass the correction-chain depth guard: reversing an entry that is
|
||||
* already CORRECTION_CHAIN_GUARD_DEPTH+ links deep throws
|
||||
* CorrectionChainTooDeepError unless set (see storno-service correctEntry).
|
||||
*/
|
||||
allowDeepChain?: boolean
|
||||
}
|
||||
): Promise<JournalEntry> {
|
||||
|
||||
// Fetch original entry with lines
|
||||
@@ -1071,6 +1084,16 @@ export async function reverseEntry(
|
||||
throw new CannotReverseStornoError(original.source_type)
|
||||
}
|
||||
|
||||
// Chain-depth guard: a storno on an entry already deep in a rättelse chain
|
||||
// is almost always an agent reflexively cancelling its own correction
|
||||
// (Christoffer case 2026-08-11). Same guard and bypass as correctEntry.
|
||||
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 lines = (original.lines as JournalEntryLine[]) || []
|
||||
|
||||
// Create reversed lines (swap debit and credit, preserve dimensions)
|
||||
|
||||
@@ -43,6 +43,7 @@ export const CURRENCY_REVALUATION_ALREADY_EXISTS = 'CURRENCY_REVALUATION_ALREADY
|
||||
export const INVALID_MAPPING_RESULT = 'INVALID_MAPPING_RESULT' as const
|
||||
export const BOOKKEEPING_DATABASE_ERROR = 'BOOKKEEPING_DATABASE_ERROR' as const
|
||||
export const MEANINGLESS_CORRECTION = 'MEANINGLESS_CORRECTION' as const
|
||||
export const CORRECTION_CHAIN_TOO_DEEP = 'CORRECTION_CHAIN_TOO_DEEP' as const
|
||||
export const NO_OPEN_PERIOD_FOR_DATE = 'NO_OPEN_PERIOD_FOR_DATE' as const
|
||||
export const TARGET_PERIOD_CLOSED = 'TARGET_PERIOD_CLOSED' as const
|
||||
export const TARGET_PERIOD_LOCKED = 'TARGET_PERIOD_LOCKED' as const
|
||||
@@ -220,6 +221,32 @@ export class MeaninglessCorrectionError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raised when a correction/reversal targets an entry that already sits
|
||||
* CORRECTION_CHAIN_GUARD_DEPTH or more links deep in a rättelse chain
|
||||
* (correction_of_id/reverses_id walked backwards). Stacking yet another
|
||||
* storno+rättelse on top buries the journal in noise vouchers; the sanctioned
|
||||
* fix is ONE correction expressing the net effect of the whole chain. The
|
||||
* guard is advisory: callers bypass it with an explicit allowDeepChain flag
|
||||
* (allow_deep_chain on the API/MCP surfaces), so it never dead-ends a
|
||||
* legitimate deep fix.
|
||||
*/
|
||||
export class CorrectionChainTooDeepError extends Error {
|
||||
readonly code = CORRECTION_CHAIN_TOO_DEEP
|
||||
constructor(
|
||||
public readonly depth: number,
|
||||
public readonly chainRootVoucher: string | null
|
||||
) {
|
||||
super(
|
||||
`Correction chain is already ${depth} levels deep` +
|
||||
(chainRootVoucher ? ` (chain root: ${chainRootVoucher})` : '') +
|
||||
'. Compute the net effect of the whole chain and book ONE correction instead, ' +
|
||||
'or pass allow_deep_chain=true to override.'
|
||||
)
|
||||
this.name = 'CorrectionChainTooDeepError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raised when a verifikation is moved (recordate) to a date that no fiscal
|
||||
* period covers. We do not auto-create periods on a correction.
|
||||
@@ -339,6 +366,7 @@ export function isBookkeepingError(err: unknown): boolean {
|
||||
err instanceof InvalidMappingResultError ||
|
||||
err instanceof BookkeepingDatabaseError ||
|
||||
err instanceof MeaninglessCorrectionError ||
|
||||
err instanceof CorrectionChainTooDeepError ||
|
||||
err instanceof NoOpenPeriodForDateError ||
|
||||
err instanceof TargetPeriodClosedError ||
|
||||
err instanceof TargetPeriodLockedError ||
|
||||
@@ -532,6 +560,19 @@ export function bookkeepingErrorResponse(err: unknown): NextResponse | null {
|
||||
)
|
||||
}
|
||||
|
||||
if (err instanceof CorrectionChainTooDeepError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
details: { depth: err.depth, chainRootVoucher: err.chainRootVoucher },
|
||||
},
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
if (err instanceof NoOpenPeriodForDateError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -465,6 +465,20 @@ export function getErrorMessage(
|
||||
return 'Rättelsen saknar ekonomisk innebörd: varje konto netto till noll. En rättelse måste beskriva en faktisk affärshändelse (BFL 5 kap. 5 §).'
|
||||
}
|
||||
|
||||
if (structured.code === 'CORRECTION_CHAIN_TOO_DEEP') {
|
||||
const details = structured.details as
|
||||
| { depth?: number; chainRootVoucher?: string | null }
|
||||
| undefined
|
||||
const depthPart =
|
||||
typeof details?.depth === 'number'
|
||||
? `Kedjan är redan ${details.depth} nivåer djup`
|
||||
: 'Rättelsekedjan är redan flera nivåer djup'
|
||||
const rootPart = details?.chainRootVoucher
|
||||
? ` (ursprungsverifikat ${details.chainRootVoucher})`
|
||||
: ''
|
||||
return `${depthPart}${rootPart}. Räkna ut nettoeffekten av hela kedjan och gör EN rättelse istället, eller skicka allow_deep_chain=true för att rätta ändå.`
|
||||
}
|
||||
|
||||
if (structured.code === 'BOOKKEEPING_DATABASE_ERROR') {
|
||||
// A DB-layer error may carry a user-relevant cause (e.g. period lock
|
||||
// trigger). Try the known-pattern map before falling back to the
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
CannotCorrectNonPostedError,
|
||||
CannotReverseNonPostedError,
|
||||
CannotReverseStornoError,
|
||||
CorrectionChainTooDeepError,
|
||||
DimensionValidationError,
|
||||
EntryAlreadyReversedError,
|
||||
EntryDateOutsideFiscalPeriodError,
|
||||
@@ -442,6 +443,12 @@ function extractBookkeepingDetails(err: unknown): { code: string; details?: unkn
|
||||
if (err instanceof MeaninglessCorrectionError) {
|
||||
return { code: err.code, details: { reason: err.reason } }
|
||||
}
|
||||
if (err instanceof CorrectionChainTooDeepError) {
|
||||
return {
|
||||
code: err.code,
|
||||
details: { depth: err.depth, chainRootVoucher: err.chainRootVoucher },
|
||||
}
|
||||
}
|
||||
if (err instanceof DimensionValidationError) {
|
||||
return { code: err.code, details: { issues: err.issues } }
|
||||
}
|
||||
|
||||
@@ -283,6 +283,18 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Rättelsen motsvarar ingen ekonomisk händelse: det finns inget att rätta.',
|
||||
message_en: 'The correction represents no economic event: nothing to correct.',
|
||||
},
|
||||
CORRECTION_CHAIN_TOO_DEEP: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Rättelsekedjan är redan flera nivåer djup. Räkna ut nettoeffekten av hela kedjan och gör EN rättelse istället, eller skicka allow_deep_chain=true för att rätta ändå.',
|
||||
message_en:
|
||||
'The correction chain is already several levels deep. Compute the net effect of the whole chain and book ONE correction instead, or pass allow_deep_chain=true to override.',
|
||||
remediation: {
|
||||
description:
|
||||
'Read the full chain with gnubok_query_journal (follow correction_of_id/reverses_id to the chain root), compute the net effect across all entries, and stage ONE correction on the live entry that expresses it. Only pass allow_deep_chain=true if stacking another correction is genuinely intended.',
|
||||
tool: 'gnubok_query_journal',
|
||||
},
|
||||
},
|
||||
NO_OPEN_PERIOD_FOR_DATE: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
|
||||
@@ -774,7 +774,8 @@ describe('commitPendingOperation: correct_entry', () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ account_number: '2645' }),
|
||||
expect.objectContaining({ account_number: '2614' }),
|
||||
])
|
||||
]),
|
||||
{ allowDeepChain: false }
|
||||
)
|
||||
})
|
||||
|
||||
@@ -930,7 +931,8 @@ describe('commitPendingOperation: reverse_entry', () => {
|
||||
'company-1',
|
||||
'user-1',
|
||||
'je-original',
|
||||
undefined
|
||||
undefined,
|
||||
{ allowDeepChain: false }
|
||||
)
|
||||
})
|
||||
|
||||
@@ -964,7 +966,8 @@ describe('commitPendingOperation: reverse_entry', () => {
|
||||
'company-1',
|
||||
'user-1',
|
||||
'je-original',
|
||||
'2026-05-20'
|
||||
'2026-05-20',
|
||||
{ allowDeepChain: false }
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -4687,7 +4687,12 @@ async function commitCorrectEntry(
|
||||
// made in May 2026 for a December 2025 voucher correctly lands in 2025,
|
||||
// keeping that period's balances consistent. The is_closed pre-flight
|
||||
// above is what blocks corrections to already-locked periods.
|
||||
const result = await correctEntry(supabase, companyId, userId, entryId, lines)
|
||||
const result = await correctEntry(supabase, companyId, userId, entryId, lines, {
|
||||
// Staged by the MCP tool when the agent explicitly overrode the
|
||||
// chain-depth guard; the guard re-checks here so a stale approval of a
|
||||
// chain that grew deeper in the meantime still stops.
|
||||
allowDeepChain: params.allow_deep_chain === true,
|
||||
})
|
||||
return {
|
||||
data: {
|
||||
original_entry_id: entryId,
|
||||
@@ -4760,7 +4765,9 @@ async function commitReverseEntry(
|
||||
}
|
||||
|
||||
try {
|
||||
const reversal = await reverseEntry(supabase, companyId, userId, entryId, reversalDate)
|
||||
const reversal = await reverseEntry(supabase, companyId, userId, entryId, reversalDate, {
|
||||
allowDeepChain: params.allow_deep_chain === true,
|
||||
})
|
||||
// Invariant per BFL 5 kap 5§: the storno must land in the same fiscal period
|
||||
// as the original entry. reverseEntry() at lib/bookkeeping/engine.ts:492 uses
|
||||
// original.fiscal_period_id, but assert it here so a future engine change that
|
||||
|
||||
Reference in New Issue
Block a user