Files
accounted/lib/core/bookkeeping/correction-chain.ts
T
Mattsson 05380ddf54 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>
2026-08-13 19:32:41 +02:00

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 }
}