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
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user