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
@@ -15,6 +15,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
if (!validation.success) return validation.response
const result = await correctEntry(supabase, companyId, user.id, id, validation.data.lines, {
description: validation.data.description,
allowDeepChain: validation.data.allow_deep_chain,
})
return NextResponse.json({ data: result })
},
@@ -109,7 +109,8 @@ describe('POST /api/bookkeeping/journal-entries/[id]/recordate', () => {
'company-1',
'user-1',
'entry-1',
'2025-07-03'
'2025-07-03',
{ allowDeepChain: undefined }
)
})
@@ -13,7 +13,9 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
const { id } = await params
const validation = await validateBody(request, RecordateJournalEntrySchema)
if (!validation.success) return validation.response
const result = await recordateEntry(supabase, companyId, user.id, id, validation.data.new_entry_date)
const result = await recordateEntry(supabase, companyId, user.id, id, validation.data.new_entry_date, {
allowDeepChain: validation.data.allow_deep_chain,
})
return NextResponse.json({ data: result })
},
{ requireWrite: true },
@@ -74,7 +74,69 @@ describe('POST /api/bookkeeping/journal-entries/[id]/reverse', () => {
expect(status).toBe(200)
expect(body.data).toEqual(reversalEntry)
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-1')
expect(mockReverseEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'entry-1',
undefined,
{ allowDeepChain: false },
)
})
it('returns 400 for malformed JSON instead of silently reversing without the override', async () => {
// Raw Request: createMockRequest would JSON.stringify the body and turn
// the malformed payload into a valid JSON string literal.
const request = new Request('http://localhost:3000/api/bookkeeping/journal-entries/entry-1/reverse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{ allow_deep_chain: tru',
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(mockReverseEntry).not.toHaveBeenCalled()
})
it('returns 400 for a non-boolean allow_deep_chain', async () => {
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/reverse', {
method: 'POST',
body: { allow_deep_chain: 'yes' },
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(mockReverseEntry).not.toHaveBeenCalled()
})
it('forwards allow_deep_chain=true from the body (guard bypass)', async () => {
const reversalEntry = makeJournalEntry({
id: 'reversal-1',
reverses_id: 'entry-1',
source_type: 'storno',
})
mockReverseEntry.mockResolvedValue(reversalEntry)
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/reverse', {
method: 'POST',
body: { allow_deep_chain: true },
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockReverseEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'entry-1',
undefined,
{ allowDeepChain: true },
)
})
it('maps a typed concurrent-reversal error to the canonical envelope (409)', async () => {
@@ -1,15 +1,68 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
const ReverseJournalEntrySchema = z
.object({
allow_deep_chain: z.boolean().optional(),
})
.strict()
ensureInitialized()
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal-entry.reverse',
async (_request, { supabase, companyId, user }, { params }) => {
async (request, { supabase, companyId, user }, { params }) => {
const { id } = await params
const reversalEntry = await reverseEntry(supabase, companyId, user.id, id)
// Body is optional (existing callers POST with none): the only accepted
// field is the chain-depth guard override from the "Återför ändå" confirm.
// An empty body is the supported no-body case; malformed JSON or a
// non-boolean field is a caller bug and gets a 400 instead of silently
// reversing without the override the caller thought they sent.
let allowDeepChain = false
const rawText = await request.text()
if (rawText.trim()) {
let parsedJson: unknown
try {
parsedJson = JSON.parse(rawText)
} catch {
return NextResponse.json(
{
error: {
code: 'VALIDATION_ERROR',
message: 'Ogiltig JSON i förfrågan.',
message_en: 'Body is not valid JSON.',
},
},
{ status: 400 },
)
}
const parsed = ReverseJournalEntrySchema.safeParse(parsedJson)
if (!parsed.success) {
return NextResponse.json(
{
error: {
code: 'VALIDATION_ERROR',
message: 'Ogiltigt fält i förfrågan.',
message_en: 'Invalid request body.',
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
},
},
{ status: 400 },
)
}
allowDeepChain = parsed.data.allow_deep_chain === true
}
const reversalEntry = await reverseEntry(supabase, companyId, user.id, id, undefined, {
allowDeepChain,
})
return NextResponse.json({ data: reversalEntry })
},
{ requireWrite: true },
@@ -6,11 +6,12 @@
* posted with the new lines. All three remain in the verifikationsserie,
* linked via reverses_id, reversed_by_id, and correction_of_id.
*
* Body: `{ lines: [...], description? }`: the new balanced lines. The
* corrected entry inherits entry_date, fiscal_period_id, and voucher_series
* from the original. Its description defaults to "Rättelse: <original>";
* pass `description` to override it (e.g. when the original label named the
* wrong account).
* Body: `{ lines: [...], description?, allow_deep_chain? }`: the new balanced
* lines. The corrected entry inherits entry_date, fiscal_period_id, and
* voucher_series from the original. Its description defaults to
* "Rättelse: <original>"; pass `description` to override it (e.g. when the
* original label named the wrong account). `allow_deep_chain` bypasses the
* correction-chain depth guard (CORRECTION_CHAIN_TOO_DEEP at 3+ levels).
*
* Idempotent (mandatory Idempotency-Key). Dry-runnable.
*/
@@ -25,7 +26,8 @@ import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { CorrectJournalEntrySchema } from '@/lib/api/schemas'
import { validateBalance } from '@/lib/bookkeeping/engine'
import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { correctionChainDepth, CORRECTION_CHAIN_GUARD_DEPTH } from '@/lib/core/bookkeeping/correction-chain'
import { CorrectionChainTooDeepError, isBookkeepingError } from '@/lib/bookkeeping/errors'
const JournalEntryCorrected = z.object({
reversal_id: z.string().uuid(),
@@ -52,6 +54,7 @@ registerEndpoint({
'The new lines must balance. JOURNAL_ENTRY_NOT_BALANCED if not.',
'The original\'s entry_date and fiscal_period_id are inherited. If the original\'s period has been locked since posting, the call returns PERIOD_LOCKED.',
'Three voucher numbers are advanced in this call: the original (already burned), the reversal, and the corrected. The series stays unbroken.',
'A chain 3+ corrections deep returns CORRECTION_CHAIN_TOO_DEEP (409). Compute the net effect of the whole chain and book ONE correction, or pass allow_deep_chain=true to override.',
],
example: {
request: {
@@ -110,7 +113,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
const { lines, description } = parsed.data
const { lines, description, allow_deep_chain } = parsed.data
const balance = validateBalance(lines)
if (!balance.valid) {
@@ -124,7 +127,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// CANNOT_CORRECT_NON_POSTED otherwise but we want the structured envelope).
const { data: original, error: fetchErr } = await ctx.supabase
.from('journal_entries')
.select('id, status, entry_date, voucher_series')
.select('id, status, entry_date, voucher_series, correction_of_id, reverses_id')
.eq('company_id', ctx.companyId!)
.eq('id', entryId)
.maybeSingle()
@@ -133,7 +136,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
if (!original) {
return v1ErrorResponseFromCode('JOURNAL_ENTRY_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
const typed = original as { id: string; status: string; entry_date: string; voucher_series: string }
const typed = original as {
id: string
status: string
entry_date: string
voucher_series: string
correction_of_id: string | null
reverses_id: string | null
}
if (typed.status !== 'posted') {
return v1ErrorResponseFromCode('CANNOT_CORRECT_NON_POSTED', ctx.log, {
requestId: ctx.requestId,
@@ -141,6 +151,21 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Chain-depth guard BEFORE the dry-run return: a dry run must give the
// same verdict the real execution would, so a depth-3 chain without the
// override reports CORRECTION_CHAIN_TOO_DEEP here too. correctEntry
// re-checks on the real path.
if (!allow_deep_chain) {
const chain = await correctionChainDepth(ctx.supabase, ctx.companyId!, typed)
if (chain.depth >= CORRECTION_CHAIN_GUARD_DEPTH) {
return v1ErrorResponse(
new CorrectionChainTooDeepError(chain.depth, chain.rootVoucher),
ctx.log,
{ requestId: ctx.requestId },
)
}
}
// Period-lock pre-check on the INHERITED entry_date. /reverse already has
// this guard against its `reversal_date`; /correct must match because
// both the storno and the corrected entry land on typed.entry_date and
@@ -180,7 +205,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
ctx.userId,
entryId,
lines,
{ description },
{ description, allowDeepChain: allow_deep_chain },
)
return ok(
{
@@ -6,7 +6,9 @@
* the reversal carries `reverses_id` back to it and the original is annotated
* with `reversed_by_id`. Both entries remain visible in the verifikationsserie.
*
* Optional body: `{ reversal_date?: ISO date }`. Defaults to today.
* Optional body: `{ reversal_date?: ISO date, allow_deep_chain?: boolean }`.
* reversal_date defaults to today; allow_deep_chain overrides the
* correction-chain depth guard (CORRECTION_CHAIN_TOO_DEEP at 3+ levels).
*
* Idempotent (mandatory Idempotency-Key).
*/
@@ -19,11 +21,16 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { correctionChainDepth, CORRECTION_CHAIN_GUARD_DEPTH } from '@/lib/core/bookkeeping/correction-chain'
import { CorrectionChainTooDeepError, isBookkeepingError } from '@/lib/bookkeeping/errors'
const ReverseRequest = z
.object({
reversal_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'reversal_date must be ISO YYYY-MM-DD').optional(),
// Explicit override of the correction-chain depth guard: reversing an
// entry 3+ links deep in a rättelse chain returns
// CORRECTION_CHAIN_TOO_DEEP without it.
allow_deep_chain: z.boolean().optional(),
})
.strict()
@@ -51,6 +58,7 @@ registerEndpoint({
'Idempotency-Key is mandatory.',
'reversal_date defaults to today; the reversal is posted in the fiscal period covering that date. If today\'s period is locked the call returns PERIOD_LOCKED.',
'You cannot reverse a draft (status must be posted). Use /correct after commit if the original needs replacing.',
'Reversing an entry 3+ links deep in a correction chain returns CORRECTION_CHAIN_TOO_DEEP (409). Book ONE net-effect correction instead, or pass allow_deep_chain=true to override.',
],
example: {
request: { reversal_date: '2026-05-13' },
@@ -85,6 +93,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const entryId = idParse.data
let bodyReversalDate: string | undefined
let bodyAllowDeepChain = false
let rawBody: unknown = null
try {
const text = await request.text()
@@ -104,6 +113,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
bodyReversalDate = parsed.data.reversal_date
bodyAllowDeepChain = parsed.data.allow_deep_chain === true
}
const today = new Date().toISOString().split('T')[0]
@@ -122,7 +132,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// Pre-flight: confirm the original exists, is posted, and not already reversed.
const { data: original, error: fetchErr } = await ctx.supabase
.from('journal_entries')
.select('id, status, reversed_by_id, voucher_series, voucher_number')
.select('id, status, reversed_by_id, voucher_series, voucher_number, correction_of_id, reverses_id')
.eq('company_id', ctx.companyId!)
.eq('id', entryId)
.maybeSingle()
@@ -131,7 +141,13 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
if (!original) {
return v1ErrorResponseFromCode('JOURNAL_ENTRY_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
const typed = original as { id: string; status: string; reversed_by_id: string | null }
const typed = original as {
id: string
status: string
reversed_by_id: string | null
correction_of_id: string | null
reverses_id: string | null
}
if (typed.status !== 'posted') {
return v1ErrorResponseFromCode('CANNOT_REVERSE_NON_POSTED', ctx.log, {
requestId: ctx.requestId,
@@ -145,6 +161,20 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Chain-depth guard BEFORE the dry-run return: a dry run must give the
// same verdict the real execution would. reverseEntry re-checks on the
// real path.
if (!bodyAllowDeepChain) {
const chain = await correctionChainDepth(ctx.supabase, ctx.companyId!, typed)
if (chain.depth >= CORRECTION_CHAIN_GUARD_DEPTH) {
return v1ErrorResponse(
new CorrectionChainTooDeepError(chain.depth, chain.rootVoucher),
ctx.log,
{ requestId: ctx.requestId },
)
}
}
if (ctx.dryRun) {
return dryRunPreview(
{
@@ -157,7 +187,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
try {
const reversal = await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, entryId, reversalDate)
const reversal = await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, entryId, reversalDate, {
allowDeepChain: bodyAllowDeepChain,
})
return ok(
{
reversal_id: reversal.id,