* 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>
70 lines
2.2 KiB
TypeScript
70 lines
2.2 KiB
TypeScript
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 }) => {
|
|
const { id } = await params
|
|
// 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 },
|
|
)
|