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
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
correctionChainDepth,
|
||||
CORRECTION_CHAIN_GUARD_DEPTH,
|
||||
MAX_CHAIN_WALK,
|
||||
} from '../correction-chain'
|
||||
|
||||
// Mock supabase: each .single() call resolves the next queued result and
|
||||
// records the id filtered on, so tests can assert exactly which parents were
|
||||
// fetched.
|
||||
let results: Array<{ data?: unknown; error?: unknown }>
|
||||
let fetchedIds: string[]
|
||||
|
||||
function makeClient() {
|
||||
return {
|
||||
from: vi.fn().mockImplementation(() => {
|
||||
const b: Record<string, unknown> = {}
|
||||
b.select = vi.fn().mockReturnValue(b)
|
||||
b.eq = vi.fn().mockImplementation((col: string, value: string) => {
|
||||
if (col === 'id') fetchedIds.push(value)
|
||||
return b
|
||||
})
|
||||
b.single = vi.fn().mockImplementation(async () => results.shift() ?? { data: null, error: null })
|
||||
return b
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function row(
|
||||
id: string,
|
||||
parent: { correction_of_id?: string | null; reverses_id?: string | null } = {},
|
||||
voucher: { series?: string; number?: number } = {}
|
||||
) {
|
||||
return {
|
||||
data: {
|
||||
id,
|
||||
correction_of_id: parent.correction_of_id ?? null,
|
||||
reverses_id: parent.reverses_id ?? null,
|
||||
voucher_series: voucher.series ?? 'A',
|
||||
voucher_number: voucher.number ?? 1,
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
results = []
|
||||
fetchedIds = []
|
||||
})
|
||||
|
||||
describe('correctionChainDepth', () => {
|
||||
it('returns depth 0 with zero queries for an unchained entry', async () => {
|
||||
const supabase = makeClient()
|
||||
const info = await correctionChainDepth(supabase as never, 'company-1', {
|
||||
id: 'orig-1',
|
||||
correction_of_id: null,
|
||||
reverses_id: null,
|
||||
})
|
||||
expect(info).toEqual({ depth: 0, rootVoucher: null })
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('walks correction_of_id links to the root and reports its voucher', async () => {
|
||||
// target(c3) → c2 → c1 → orig (root, voucher A7)
|
||||
results = [
|
||||
row('c2', { correction_of_id: 'c1' }, { number: 4 }),
|
||||
row('c1', { correction_of_id: 'orig' }, { number: 3 }),
|
||||
row('orig', {}, { series: 'A', number: 7 }),
|
||||
]
|
||||
const supabase = makeClient()
|
||||
const info = await correctionChainDepth(supabase as never, 'company-1', {
|
||||
id: 'c3',
|
||||
correction_of_id: 'c2',
|
||||
reverses_id: null,
|
||||
})
|
||||
expect(info).toEqual({ depth: 3, rootVoucher: 'A7' })
|
||||
expect(fetchedIds).toEqual(['c2', 'c1', 'orig'])
|
||||
})
|
||||
|
||||
it('follows reverses_id when correction_of_id is absent (storno links)', async () => {
|
||||
results = [row('orig', {}, { series: 'B', number: 12 })]
|
||||
const supabase = makeClient()
|
||||
const info = await correctionChainDepth(supabase as never, 'company-1', {
|
||||
id: 'storno-1',
|
||||
correction_of_id: null,
|
||||
reverses_id: 'orig',
|
||||
})
|
||||
expect(info).toEqual({ depth: 1, rootVoucher: 'B12' })
|
||||
})
|
||||
|
||||
it('stops on a broken link (parent not found)', async () => {
|
||||
results = [
|
||||
row('c1', { correction_of_id: 'gone' }),
|
||||
{ data: null, error: { message: 'not found' } },
|
||||
]
|
||||
const supabase = makeClient()
|
||||
const info = await correctionChainDepth(supabase as never, 'company-1', {
|
||||
id: 'c2',
|
||||
correction_of_id: 'c1',
|
||||
reverses_id: null,
|
||||
})
|
||||
expect(info.depth).toBe(1)
|
||||
// The walk never reached a parentless node, so no voucher may be
|
||||
// presented as the chain root.
|
||||
expect(info.rootVoucher).toBeNull()
|
||||
})
|
||||
|
||||
it('terminates on a cyclic chain instead of looping', async () => {
|
||||
// a → b → a (cycle). The walk must stop when it re-encounters a.
|
||||
results = [
|
||||
row('b', { correction_of_id: 'a' }),
|
||||
row('a', { correction_of_id: 'b' }),
|
||||
]
|
||||
const supabase = makeClient()
|
||||
const info = await correctionChainDepth(supabase as never, 'company-1', {
|
||||
id: 'a',
|
||||
correction_of_id: 'b',
|
||||
reverses_id: null,
|
||||
})
|
||||
expect(info.depth).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('caps the walk at MAX_CHAIN_WALK hops', async () => {
|
||||
for (let i = 0; i < MAX_CHAIN_WALK + 5; i++) {
|
||||
results.push(row(`e${i}`, { correction_of_id: `e${i + 1}` }))
|
||||
}
|
||||
const supabase = makeClient()
|
||||
const info = await correctionChainDepth(supabase as never, 'company-1', {
|
||||
id: 'start',
|
||||
correction_of_id: 'e0',
|
||||
reverses_id: null,
|
||||
})
|
||||
expect(info.depth).toBe(MAX_CHAIN_WALK)
|
||||
expect(fetchedIds).toHaveLength(MAX_CHAIN_WALK)
|
||||
// Capped before reaching the root: the last node still has a parent.
|
||||
expect(info.rootVoucher).toBeNull()
|
||||
})
|
||||
|
||||
it('guard threshold is 3: original → rättelse → rättelse-av-rättelse stays allowed', () => {
|
||||
// Locked by the plan: depth 2 targets (fixing the fix) pass the guard,
|
||||
// depth 3+ requires the explicit override.
|
||||
expect(CORRECTION_CHAIN_GUARD_DEPTH).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { makeJournalEntry, makeJournalEntryLine } from '@/tests/helpers'
|
||||
import { BookkeepingDatabaseError, MeaninglessCorrectionError } from '@/lib/bookkeeping/errors'
|
||||
import {
|
||||
BookkeepingDatabaseError,
|
||||
CorrectionChainTooDeepError,
|
||||
MeaninglessCorrectionError,
|
||||
} from '@/lib/bookkeeping/errors'
|
||||
|
||||
// ============================================================
|
||||
// Mock: separate client (no .then) from query builder (thenable)
|
||||
@@ -350,18 +354,20 @@ describe('correctEntry', () => {
|
||||
|
||||
results = [
|
||||
{ data: correctionAsOriginal, error: null }, // 0: fetch original (the prior correction)
|
||||
{ data: [{ id: 'acc-5430', account_number: '5430' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts (Step 0)
|
||||
{ data: secondReversal, error: null }, // 2: insert reversal
|
||||
{ data: null, error: null }, // 3: insert reversal lines
|
||||
{ data: null, error: null }, // 4: post reversal
|
||||
{ data: secondCorrection, error: null }, // 5: insert corrected
|
||||
{ data: null, error: null }, // 6: insert corrected lines
|
||||
{ data: null, error: null }, // 7: post corrected
|
||||
{ data: [{ id: 'correction-1' }], error: null }, // 8: CAS update
|
||||
{ data: null, error: null }, // 9: relink transactions
|
||||
{ data: null, error: null }, // 10: relink documents
|
||||
{ data: { ...secondReversal, lines: [] }, error: null }, // 11: fetch final reversal
|
||||
{ data: { ...secondCorrection, lines: [] }, error: null }, // 12: fetch final corrected
|
||||
// 1: chain-depth walker follows correction_of_id to the root (depth 1)
|
||||
{ data: { id: 'orig-A', correction_of_id: null, reverses_id: null, voucher_series: 'A', voucher_number: 1 }, error: null },
|
||||
{ data: [{ id: 'acc-5430', account_number: '5430' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 2: accounts (Step 0)
|
||||
{ data: secondReversal, error: null }, // 3: insert reversal
|
||||
{ data: null, error: null }, // 4: insert reversal lines
|
||||
{ data: null, error: null }, // 5: post reversal
|
||||
{ data: secondCorrection, error: null }, // 6: insert corrected
|
||||
{ data: null, error: null }, // 7: insert corrected lines
|
||||
{ data: null, error: null }, // 8: post corrected
|
||||
{ data: [{ id: 'correction-1' }], error: null }, // 9: CAS update
|
||||
{ data: null, error: null }, // 10: relink transactions
|
||||
{ data: null, error: null }, // 11: relink documents
|
||||
{ data: { ...secondReversal, lines: [] }, error: null }, // 12: fetch final reversal
|
||||
{ data: { ...secondCorrection, lines: [] }, error: null }, // 13: fetch final corrected
|
||||
]
|
||||
|
||||
const supabase = makeClient()
|
||||
@@ -375,6 +381,83 @@ describe('correctEntry', () => {
|
||||
expect(result.corrected.source_type).toBe('correction')
|
||||
})
|
||||
|
||||
it('rejects a correction whose target already sits 3 links deep in the chain', async () => {
|
||||
// Chain: orig-0 ← c1 ← c2 ← c3 (target). Depth of c3 is 3 → guard fires.
|
||||
// Christoffer case 2026-08-11: agents stacked rättelser 10 deep.
|
||||
const target = makeJournalEntry({
|
||||
id: 'c3',
|
||||
status: 'posted',
|
||||
source_type: 'correction',
|
||||
correction_of_id: 'c2',
|
||||
fiscal_period_id: 'fp-1',
|
||||
voucher_series: 'A',
|
||||
lines: [
|
||||
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
|
||||
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
|
||||
],
|
||||
})
|
||||
results = [
|
||||
{ data: target, error: null }, // 0: fetch original
|
||||
{ data: { id: 'c2', correction_of_id: 'c1', reverses_id: null, voucher_series: 'A', voucher_number: 3 }, error: null }, // 1: walker hop 1
|
||||
{ data: { id: 'c1', correction_of_id: 'orig-0', reverses_id: null, voucher_series: 'A', voucher_number: 2 }, error: null }, // 2: walker hop 2
|
||||
{ data: { id: 'orig-0', correction_of_id: null, reverses_id: null, voucher_series: 'A', voucher_number: 1 }, error: null }, // 3: walker hop 3 (root)
|
||||
]
|
||||
|
||||
const supabase = makeClient()
|
||||
const err = await correctEntry(
|
||||
supabase as never, 'company-1', 'user-1', 'c3', correctedLines
|
||||
).catch((e) => e)
|
||||
expect(err).toBeInstanceOf(CorrectionChainTooDeepError)
|
||||
expect(err).toMatchObject({
|
||||
code: 'CORRECTION_CHAIN_TOO_DEEP',
|
||||
depth: 3,
|
||||
chainRootVoucher: 'A1',
|
||||
})
|
||||
|
||||
// Guard fires before any write or voucher-number draw.
|
||||
expect(inserts).toHaveLength(0)
|
||||
expect(getNextVoucherNumber).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allowDeepChain: true bypasses the chain-depth guard without walking the chain', async () => {
|
||||
const target = makeJournalEntry({
|
||||
id: 'c3',
|
||||
status: 'posted',
|
||||
source_type: 'correction',
|
||||
correction_of_id: 'c2',
|
||||
description: 'Test purchase',
|
||||
fiscal_period_id: 'fp-1',
|
||||
voucher_series: 'A',
|
||||
lines: [
|
||||
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
|
||||
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
|
||||
],
|
||||
})
|
||||
const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'c3' })
|
||||
const correctedEntry = makeJournalEntry({ id: 'c4', correction_of_id: 'c3' })
|
||||
results = [
|
||||
{ data: target, error: null }, // 0: fetch original (no walker queries)
|
||||
{ data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts
|
||||
{ data: reversalEntry, error: null }, // 2: insert reversal
|
||||
{ data: null, error: null }, // 3: reversal lines
|
||||
{ data: null, error: null }, // 4: post reversal
|
||||
{ data: correctedEntry, error: null }, // 5: insert corrected
|
||||
{ data: null, error: null }, // 6: corrected lines
|
||||
{ data: null, error: null }, // 7: post corrected
|
||||
{ data: [{ id: 'c3' }], error: null }, // 8: CAS
|
||||
{ data: null, error: null }, // 9: relink transactions
|
||||
{ data: null, error: null }, // 10: relink documents
|
||||
{ data: { ...reversalEntry, lines: [] }, error: null }, // 11: final reversal
|
||||
{ data: { ...correctedEntry, lines: [] }, error: null }, // 12: final corrected
|
||||
]
|
||||
|
||||
const supabase = makeClient()
|
||||
const result = await correctEntry(
|
||||
supabase as never, 'company-1', 'user-1', 'c3', correctedLines, { allowDeepChain: true }
|
||||
)
|
||||
expect(result.corrected.id).toBe('c4')
|
||||
})
|
||||
|
||||
it('fails fast on unknown accounts: BEFORE the storno exists or a voucher number is consumed', async () => {
|
||||
// Regression: the old flow created+posted the storno first, then hit
|
||||
// AccountsNotInChartError on the corrected lines and had to cancel the
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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 }
|
||||
}
|
||||
@@ -9,10 +9,15 @@ import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
|
||||
import { normalizeLineDimensions } from '@/lib/bookkeeping/dimension-resolver'
|
||||
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
|
||||
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
|
||||
import {
|
||||
correctionChainDepth,
|
||||
CORRECTION_CHAIN_GUARD_DEPTH,
|
||||
} from '@/lib/core/bookkeeping/correction-chain'
|
||||
import {
|
||||
AccountsNotInChartError,
|
||||
BookkeepingDatabaseError,
|
||||
CannotCorrectNonPostedError,
|
||||
CorrectionChainTooDeepError,
|
||||
EntryAlreadyReversedError,
|
||||
EntryDateOutsideFiscalPeriodError,
|
||||
FiscalPeriodNotFoundError,
|
||||
@@ -137,6 +142,14 @@ export async function correctEntry(
|
||||
* stale label echoing in the correction header (issue #1031).
|
||||
*/
|
||||
description?: string
|
||||
/**
|
||||
* Bypass the correction-chain depth guard. Correcting an entry that is
|
||||
* already CORRECTION_CHAIN_GUARD_DEPTH+ links deep in a rättelse chain
|
||||
* throws CorrectionChainTooDeepError unless this is set: the sanctioned
|
||||
* fix is one correction expressing the chain's net effect, not another
|
||||
* layer of storno+rättelse.
|
||||
*/
|
||||
allowDeepChain?: boolean
|
||||
}
|
||||
): Promise<{ reversal: JournalEntry; corrected: JournalEntry; documentRelinkError?: string }> {
|
||||
// Validate the corrected lines are balanced
|
||||
@@ -172,6 +185,17 @@ export async function correctEntry(
|
||||
throw new CannotCorrectNonPostedError(original.status)
|
||||
}
|
||||
|
||||
// Chain-depth guard: stop corrections stacking on corrections. Agents have
|
||||
// looped storno+rättelse on their own rättelser 10 deep (Christoffer case
|
||||
// 2026-08-11), turning a third of the journal into noise vouchers. Depth is
|
||||
// walked over the DB links, so a custom verifikationstext cannot dodge it.
|
||||
if (!options?.allowDeepChain) {
|
||||
const chain = await correctionChainDepth(supabase, companyId, original)
|
||||
if (chain.depth >= CORRECTION_CHAIN_GUARD_DEPTH) {
|
||||
throw new CorrectionChainTooDeepError(chain.depth, chain.rootVoucher)
|
||||
}
|
||||
}
|
||||
|
||||
const originalLines = (original.lines as JournalEntryLine[]) || []
|
||||
|
||||
// Resolve where the corrected entry lands. Defaults to the original's own
|
||||
@@ -497,7 +521,15 @@ export async function recordateEntry(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
originalEntryId: string,
|
||||
newDate: string
|
||||
newDate: string,
|
||||
options?: {
|
||||
/**
|
||||
* Bypass the correction-chain depth guard (see correctEntry): a date
|
||||
* move IS another storno+rättelse layer, so moving an entry already
|
||||
* 3+ links deep needs the same explicit confirmation.
|
||||
*/
|
||||
allowDeepChain?: boolean
|
||||
}
|
||||
): Promise<{ reversal: JournalEntry; corrected: JournalEntry }> {
|
||||
// Fetch original with lines
|
||||
const { data: original, error: fetchError } = await supabase
|
||||
@@ -565,6 +597,7 @@ export async function recordateEntry(
|
||||
// Hand the entry we already fetched (with lines) to correctEntry so it
|
||||
// doesn't re-read the same row.
|
||||
preloadedOriginal: original as OriginalWithLines,
|
||||
allowDeepChain: options?.allowDeepChain,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user