05380ddf54
* 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>
91 lines
3.2 KiB
TypeScript
91 lines
3.2 KiB
TypeScript
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 }
|
|
}
|