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
+37
View File
@@ -256,6 +256,10 @@ export default function AgentChat({
}
}, [streaming, onStatus])
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// True between a stream_restart event and the retried stream's first sign of
// life: renders a discreet "Försöker igen…" line so the reset bubble doesn't
// read as the assistant silently going blank.
const [retryNotice, setRetryNotice] = useState(false)
const scrollerRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Active turn's controller, kept in a ref (not state) so the stop button
@@ -377,6 +381,7 @@ export default function AgentChat({
setStreaming(true)
setErrorMessage(null)
setRetryNotice(false)
let response: Response
try {
@@ -598,7 +603,31 @@ export default function AgentChat({
})),
)
break
case 'stream_restart': {
// The server's model stream died on a transient error and is being
// retried. Nothing from the dead attempt was persisted: reset the
// in-progress bubble to the server's pre-attempt snapshot, drop tool
// chips that never completed (eager chips from the dead stream), and
// discard the dead attempt's reasoning so the retried attempt's
// thinking doesn't render twice. The post-tool paragraph break is
// re-armed only when restored text exists: the snapshot always ends
// at an iteration boundary (after tool results), so the retried
// continuation should open its own paragraph there.
setRetryNotice(true)
const restored = typeof ev.assistant_text === 'string' ? ev.assistant_text : ''
breakBeforeNextTextRef.current = restored.length > 0
setMessages((prev) =>
updateLastAssistant(prev, (m) => ({
...m,
text: restored,
reasoning: undefined,
toolCalls: m.toolCalls?.filter((tc) => tc.completed),
})),
)
break
}
case 'text_delta':
setRetryNotice(false)
// Insert a paragraph break ONCE when text resumes after a tool
// call, so post-tool narration starts on its own line instead of
// gluing onto the previous sentence ("kategoriseras.Inget historik").
@@ -706,9 +735,11 @@ export default function AgentChat({
break
}
case 'error':
setRetryNotice(false)
setErrorMessage(ev.message as string)
break
case 'turn_complete': {
setRetryNotice(false)
if (!firstTurnFiredRef.current && conversationIdRef.current) {
firstTurnFiredRef.current = true
onFirstTurnComplete?.(conversationIdRef.current)
@@ -811,6 +842,12 @@ export default function AgentChat({
</div>
))}
{retryNotice && !errorMessage && (
<div className="text-xs text-muted-foreground px-1">
Anslutningen bröts, försöker igen…
</div>
)}
{errorMessage && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{errorMessage}
@@ -58,6 +58,9 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
const [lines, setLines] = useState<CorrectionLine[]>([])
const [description, setDescription] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// Non-null when the server refused with CORRECTION_CHAIN_TOO_DEEP: holds the
// reported chain depth and opens the bypass confirm ("Rätta ändå").
const [deepChainDepth, setDeepChainDepth] = useState<number | null>(null)
// Index of the line whose combobox opened the create dialog, and the search
// string it was showing. Null index = the dialog is closed.
const [creatingAccountForLine, setCreatingAccountForLine] = useState<number | null>(null)
@@ -191,7 +194,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
const hasValidLines = lines.length >= 2 && lines.every((l) => l.account_number.length === 4)
async function handleSubmit() {
async function handleSubmit(allowDeepChain = false) {
if (!isBalanced || !hasValidLines) return
setIsSubmitting(true)
@@ -211,17 +214,26 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
// Only sent when the user changed the auto prefill: the server
// fallback ("Rättelse: <original>") stays the source of truth.
description: correctionDescriptionForSubmit(description, entry.description),
...(allowDeepChain ? { allow_deep_chain: true } : {}),
}),
})
const result = await res.json()
if (!res.ok) {
// Chain-depth guard: open the bypass confirm instead of a dead-end
// toast. "Rätta ändå" resubmits with allow_deep_chain=true.
const structured = (result as { error?: { code?: string; details?: { depth?: number } } })?.error
if (structured?.code === 'CORRECTION_CHAIN_TOO_DEEP') {
setDeepChainDepth(structured.details?.depth ?? 3)
return
}
const error = new Error('Failed to create correction') as Error & { body?: unknown; status?: number }
error.body = result
error.status = res.status
throw error
}
setDeepChainDepth(null)
const correctedId = result.data?.corrected?.id
@@ -411,12 +423,37 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
Avbryt
</Button>
<Button
onClick={handleSubmit}
onClick={() => handleSubmit()}
disabled={!isBalanced || !hasValidLines || isSubmitting}
>
{isSubmitting ? 'Skapar...' : 'Skapa ändringsverifikation'}
</Button>
</DialogFooter>
{/* Chain-depth guard confirm: the server refused because this entry
already sits deep in a rättelse chain. Advisory, never a dead end:
"Rätta ändå" resubmits with allow_deep_chain=true. */}
<Dialog open={deepChainDepth != null} onOpenChange={(next) => { if (!next) setDeepChainDepth(null) }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('deep_chain_title')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t('deep_chain_body', { depth: deepChainDepth ?? 3 })}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeepChainDepth(null)} disabled={isSubmitting}>
{t('deep_chain_cancel')}
</Button>
<Button
onClick={() => { setDeepChainDepth(null); void handleSubmit(true) }}
disabled={isSubmitting}
>
{t('deep_chain_correct_anyway')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
{/* Nested on purpose: closing this one (Esc, click-outside, Avbryt) must
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
@@ -38,11 +39,15 @@ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
export default function RecordateEntryDialog({ entry, open, onOpenChange, onMoved }: Props) {
const { toast } = useToast()
const router = useRouter()
const t = useTranslations('journal_detail')
const [newDate, setNewDate] = useState(entry.entry_date)
const [preview, setPreview] = useState<PeriodStatus | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
// Non-null when the server refused with CORRECTION_CHAIN_TOO_DEEP: holds the
// reported chain depth and opens the bypass confirm ("Flytta ändå").
const [deepChainDepth, setDeepChainDepth] = useState<number | null>(null)
// Reset to the original date each time the dialog opens.
useEffect(() => {
@@ -100,22 +105,33 @@ export default function RecordateEntryDialog({ entry, open, onOpenChange, onMove
const canSubmit = dateChanged && targetOpen && !isSubmitting
async function handleSubmit() {
async function handleSubmit(allowDeepChain = false) {
if (!canSubmit) return
setIsSubmitting(true)
try {
const res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/recordate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_entry_date: newDate }),
body: JSON.stringify({
new_entry_date: newDate,
...(allowDeepChain ? { allow_deep_chain: true } : {}),
}),
})
const result = await res.json()
if (!res.ok) {
// Chain-depth guard: open the bypass confirm instead of a dead-end
// toast. "Flytta ändå" resubmits with allow_deep_chain=true.
const structured = (result as { error?: { code?: string; details?: { depth?: number } } })?.error
if (structured?.code === 'CORRECTION_CHAIN_TOO_DEEP') {
setDeepChainDepth(structured.details?.depth ?? 3)
return
}
const error = new Error('Failed to move entry') as Error & { body?: unknown; status?: number }
error.body = result
error.status = res.status
throw error
}
setDeepChainDepth(null)
const correctedId = result.data?.corrected?.id
toast({
title: 'Verifikationen flyttad',
@@ -238,10 +254,35 @@ export default function RecordateEntryDialog({ entry, open, onOpenChange, onMove
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
<Button onClick={() => handleSubmit()} disabled={!canSubmit}>
{isSubmitting ? 'Flyttar…' : 'Flytta verifikationen'}
</Button>
</DialogFooter>
{/* Chain-depth guard confirm: the server refused because this entry
already sits deep in a rättelse chain. Advisory, never a dead end:
"Flytta ändå" resubmits with allow_deep_chain=true. */}
<Dialog open={deepChainDepth != null} onOpenChange={(next) => { if (!next) setDeepChainDepth(null) }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('deep_chain_title')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t('deep_chain_body', { depth: deepChainDepth ?? 3 })}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeepChainDepth(null)} disabled={isSubmitting}>
{t('deep_chain_cancel')}
</Button>
<Button
onClick={() => { setDeepChainDepth(null); void handleSubmit(true) }}
disabled={isSubmitting}
>
{t('deep_chain_move_anyway')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
)