feat(mcp): already-explained voucher guard at stage and commit for match_batch_allocate (#2294) (#2346)
* feat(mcp): already-explained voucher guard at stage and commit for match_batch_allocate The dashboard match-batch route refused BATCH_TX_POSSIBLE_DUPLICATE when posted, unlinked vouchers already summed to the bank row (PR #2300), but the MCP door (gnubok_match_batch_allocate staging + commitMatchBatchAllocate) called the RPC with no guard, so an agent could book a Bankgirot aggregate a second time. The detector existed once; the guard lived in one door. One shared decision helper, lib/invoices/already-explained-guard.ts, now sits on top of the existing detectors (no fork) and is called by the dashboard route, the MCP staging tools and the commit executors: - gnubok_match_batch_allocate refuses to stage, coded BATCH_TX_POSSIBLE_DUPLICATE, naming the vouchers, the reconcile_match / link_transaction_to_journal_entry call that resolves the row, and the exact force + expected_journal_entry_ids binding. - commitMatchBatchAllocate runs the same guard before the RPC and re-validates a staged force binding against the set detected at commit, so a stale approval cannot book a duplicate; 409 auto-rejects with the vouchers in result_data. - force + expected_journal_entry_ids on the tool mirror MatchBatchSchema; an honoured override stages with a compliance_warning and, after the booking succeeds, writes BankTransactionDuplicateDismissed to behandlingshistorik (dashboard route included; it only logged before). - gnubok_match_transaction_to_invoice and commitMatchTransactionInvoice get the dashboard's 1:1 soft-duplicate guard (MATCH_INVOICE_POSSIBLE_DUPLICATE / MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH) with force + expected_journal_entry_id; at commit it runs before the storno. - Registry: both duplicate codes gain retryable: false and a remediation. Catalog payload held under the 60K ceiling by trimming the two tools' own descriptions (59 988 measured, ledger entry in payload-size.bench.test.ts). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * docs(decisions): record the 2026-09-06 ten-issue batch's first-principles choices Carries the DECISIONS.md lines for PRs #2337 #2339 #2340 #2341 #2342 #2343 #2344 #2345 #2346 #2347 in one place so the ten branches do not conflict on this file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(mcp): refuse an unverifiable forced override, surface a failed duplicate check, validate the binding (#2294 review) Review round on PR #2346 (CodeRabbit + compliance): - guardAlreadyExplained returned 'clear' when the detector threw even with force=true, so a forced 1:N override could book without re-validating expected_journal_entry_ids and left no behandlingshistorik record. It now returns a distinct 'unverifiable' outcome under force (mirrors guardDuplicatePaymentVoucher); the dashboard route, the MCP staging tool and the commit executor all refuse it with the new registry code BATCH_TX_EXPLAINED_CHECK_FAILED (409, retryable, remediation). Regression tests on every caller. - A detector failure without force still fails open at stage time, but no longer silently: the tools track onDetectError and stage a complianceNote, so preview_data.compliance_warning is set on both match_batch_allocate (GenericPreview renders it) and match_transaction_invoice (MatchTransactionInvoicePreview now renders data.compliance_warning through AttnLine). - expected_journal_entry_ids / expected_journal_entry_id are validated at the MCP boundary (array of 1 to 10 non-empty strings / non-empty string) and refused with VALIDATION_ERROR instead of being silently filtered. No schema description text added: catalog payload unchanged. - RoPA: .compliance/ropa.yaml gains bookkeeping.duplicate_dismissal_history for the BankTransactionDuplicateDismissed record (Art. 6(1)(c), BFNAR 2013:2 p. 9.16, retention per BFL 7 kap, stored in processing_history). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
39d409d257
commit
cce0de5704
@@ -698,6 +698,12 @@ const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
'Det finns redan en bokförd verifikation på samma belopp och datum. Har du redan bokfört denna betalning? Koppla bankhändelsen till befintlig verifikation, eller skapa ny verifikation ändå om de inte hör ihop.',
|
||||
message_en:
|
||||
'A posted journal entry already books the same amount on a nearby date. The user may have already booked this payment manually: link to the existing voucher or pass force=true to create a new one anyway.',
|
||||
retryable: false,
|
||||
remediation: {
|
||||
description:
|
||||
'Link the bank row to the existing voucher instead of booking a second one: gnubok_link_transaction_to_journal_entry (pass invoice_id to settle the kundfaktura at the same time) or gnubok_reconcile_match. Only if the row is a genuinely separate payment, call again with force=true and expected_journal_entry_id set to the id the refusal named.',
|
||||
tool: 'gnubok_link_transaction_to_journal_entry',
|
||||
},
|
||||
},
|
||||
MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH: {
|
||||
httpStatus: 409,
|
||||
@@ -3696,6 +3702,29 @@ const MATCH_BATCH: Record<string, StructuredErrorEntry> = {
|
||||
'Transaktionen ser redan ut att vara bokförd: en eller flera verifikationer utan bankkoppling summerar exakt till beloppet. Koppla transaktionen till dem i stället, eller bokför ändå om de inte hör ihop.',
|
||||
message_en:
|
||||
'The transaction already looks booked: one or more posted vouchers with no bank link add up exactly to its amount. Link the transaction to them instead, or pass force=true with expected_journal_entry_ids to book anyway.',
|
||||
retryable: false,
|
||||
remediation: {
|
||||
description:
|
||||
'Link the bank row to the vouchers the message names instead of booking it again: gnubok_reconcile_match with account_key "bank:<cash_account_id>" and one pair { external_ids: [transaction_id], journal_entry_ids: [...], allocations }. Only if the row is a genuinely separate affärshändelse, call again with force=true and expected_journal_entry_ids set to exactly the ids the refusal listed.',
|
||||
tool: 'gnubok_reconcile_match',
|
||||
},
|
||||
},
|
||||
// force=true reached a door whose already-explained check could not run:
|
||||
// an override that cannot be re-verified against the current voucher set
|
||||
// is refused, never waved through. Transient by nature (a ledger scan that
|
||||
// timed out), hence retryable; a staged operation refused this way is
|
||||
// auto-rejected and has to be staged again.
|
||||
BATCH_TX_EXPLAINED_CHECK_FAILED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Dubblettkontrollen kunde inte köras, så "bokför ändå" avvisades: ett åsidosättande som inte kan verifieras igen bokförs aldrig. Försök igen.',
|
||||
message_en:
|
||||
'The already-explained check could not run, so force=true was refused: an override that cannot be re-verified against the current vouchers is never honoured. Retry the request.',
|
||||
retryable: true,
|
||||
remediation: {
|
||||
description:
|
||||
'Retry after a short backoff. A staged operation refused this way is auto-rejected: stage it again with the same force + expected_journal_entry_ids, or link the row to the vouchers with gnubok_reconcile_match instead.',
|
||||
},
|
||||
},
|
||||
BATCH_TX_ZERO_AMOUNT: {
|
||||
httpStatus: 400,
|
||||
|
||||
Reference in New Issue
Block a user