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:
Jakob Wennberg
2026-09-06 18:56:36 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 39d409d257
commit cce0de5704
17 changed files with 1970 additions and 40 deletions
+10
View File
@@ -1610,6 +1610,16 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-05] Utlägg phase 2, repayment from the bank line: create_expense_payout_batch takes an optional bank transaction and, under the same row locks, requires an unbooked SEK outflow equal to the claims' total and stamps it (journal_entry_id, is_business, reconciliation_method) in the transaction that posts the verifikat and flips the claims to paid. The pairing bank line ↔ person is computed at read time from the open claims (exact öre match on one person's total, ambiguous totals skipped), surfaced as a suggested match on Hem and as the inbox row's primary button, instead of a new hint column on transactions: the candidate pool is the handful of open claims, so a column written at ingest would add schema and a stale-pointer class for no saving. Old 6-parameter RPC signature dropped in the same migration so a 6-argument call cannot become ambiguous. "Betala ut" on /expenses stays for companies paying from an account without a feed. "Via lön" and the supplier-form control are split into their own issues rather than stretched into this PR.
[2026-09-06] Utlägg gaps closed on the phase 2 PR: (1) a manual "Matcha mot utlägg" picker in the inbox row menu for outflows the exact-amount pairing missed, offered only while the company has open claims; the sum of picked receipts must equal the row to the öre and the same RPC books it, so a partial receipt can never be marked paid (partial payout inside the RPC was considered and left out: it changes money logic for a rare case, rounding a transfer up is not solved). (2) A foreign receipt (currency other than SEK) defaults VAT to 0 in the Underlag dialog with a note: foreign VAT is not deductible on 2641, and the wizard's seller-country step is gone from this path. (3) Enskild firma: a claim on 2018 is egen insättning, not a debt; it is excluded from Att göra, the attention resource, suggestions and the picker, and a payout for it debits 2013 (eget uttag) instead of 2018.
[2026-09-05] SIE precheck refuses a closed or locked containing year up front (conflict verdict with the remedy: Öppna igen / Lås upp) instead of letting the voucher RPC fail with the trigger text; the årsredovisning warns when the comparison year has no entries instead of deriving BR comparatives from the IB voucher: derivation would hide that the RR comparatives are still unknown, and manual/IB comparatives after a migration are a product decision (follow-up issue).
[2026-09-06] Fiscal-year edit dialog (#2287, PR #2337) lets the name follow typed dates only while the existing name has fiscalYearName()'s shape (isDerivedFiscalYearName), not only when it equals the name for the current dates: the customer row's "Räkenskapsår 2027" never matched its dates and would otherwise have stayed wrong after the dates were fixed; a hand-written name is never overwritten. FiscalPeriodEditor (Settings > Företag, first year only, i18n-keyed name that writes "Fiscal year N" in the EN locale) is now a strict subset of Ändra and was left in place as a founder call because removing it is user-visible.
[2026-09-06] Article company-scope for invoice lines (#2059 part 2, PR #2339) enforced once in buildInvoiceWriteData (covers cookie, v1, webshop, sales-order and MCP-update writers) rather than as a DB constraint: invoice_items has no company_id and articles no UNIQUE (id, company_id), so a composite FK needs a denormalized column + backfill + sync trigger and a constraint trigger cannot be validated against prod rows the session could not query; DB enforcement proposed as option B for after Part 1 rebases commitCreateInvoice onto the builder.
[2026-09-06] Migrated invoice rows (#2313, PR #2340) are written only through complete_invoice_rows (FOR UPDATE on the invoice, insert iff no rows, header in the same transaction), shared by the completion pass and the wizard; the unique index on invoice_items (invoice_id, sort_order) is deferred: it needs a renumbering of legacy rows under posted invoices first and is the weaker invariant anyway (two writers with different sort_order sets both pass it while still doubling the invoice).
[2026-09-06] Completion cron (#2309, PR #2341) sizes registers with a PostgREST embed-null HEAD count (invoice_items=is.null) and orders smallest first, no stored state: the pass's own candidate load is a full register read, not a count; the new grammar is proven by a tool-pg test because a count that silently returned 0 would switch the cron off; a persisted fairness counter for the reverse-starvation case was rejected as state for a regime prod is not in (57 consents in 60 days), the deferred list in every run summary is the signal instead. The phantom-column schema guard now recognises the embed-null anti-join (is on a name the same chain embedded) instead of an allowlist entry.
[2026-09-06] Guided/manual SIE mapping step (#2212, PR #2342) renders "created on import" as the target for any in-range source account the chart lacks (incl. nameless ones), instead of adding source accounts to the target list or blocking: the import engine already created them correctly; the defect was the step hiding it, and the shared-component fix covers both wizards.
[2026-09-06] Assistant surfaces (#2204, PR #2343) gate on one client flag (CompanyContext.assistantAvailable from getAiStatus) and hide, the FAB falls back to general.help, no global disable switch: the capability flag already says "cannot run", a second switch is a state nobody asked for, and a disabled control the user cannot enable is noise on a self-host.
[2026-09-06] Migrated-paid invoices (#2213, PR #2344) are detected from data (settled status, zero invoice_payments rows, no posted invoice_paid/invoice_cash_payment voucher keyed on the invoice), not a provenance column: every Accounted settlement path writes the sub-ledger row fail-closed, so the state itself is the marker; external_invoice_number is the self-billing field, not a migration marker.
[2026-09-06] Duplicate-payment needle (#2299, PR #2345) = first non-legal-form token of >= 2 chars (not >= 3 as the issue proposed), one .or() probe on merchant_name+description, one detector shared by supplier and customer mark-paid: two-letter first tokens are initialisms banks keep verbatim (SJ, 3M, DB), and the alnum-only needle is what makes the .or() DSL interpolation safe.
[2026-09-06] MCP already-explained guard (#2294, PR #2346) refuses to stage instead of staging an op with a blocker in its preview: a staged-but-blocked op could still be approved, would auto-reject at commit and would pollute /pending; a coded refusal carrying the voucher set, the link call and the exact force binding gives the agent the same steer with no new op state, and commit re-runs the same shared guard as the last gate.
[2026-09-06] Invoice-to-verifikat links (#2298, PR #2347) are resolved by readers from the invoice side (invoices.journal_entry_id / invoice_payments.journal_entry_id) instead of being written onto the posted entry at link time: writing source_type/source_id to a posted verifikat is outside the rättelse paths (Hard Rule 1) and would erase the SIE-import provenance, so the missing-underlag RPCs, their TS mirrors and the periodisk sammanställning all follow the link; the PS report also stopped filtering on source_type, which had silently dropped kontantmetod (invoice_cash_payment) sales.
[2026-09-06] Recurring invoice month phase (yearly in February, quarterly Feb/May/Aug/Nov) is exposed as a first/next invoice date (start_date on create, next_run_date on update; web dialog + MCP), not the reporter's "months offset" dropdown: an offset is a derived value relative to now that changes meaning when the interval changes, while the date maps one-to-one onto the next_run_date column that already anchors the phase, so no migration and no per-interval range rules. A date off the day_of_month grid is refused (400) instead of normalized, because the cron advances from the due date and an off-grid first run would drift back to day_of_month on the second run.
[2026-09-06] Voucher series names live on the existing Verifikationsserier list in settings, not a separate group: the list already enumerates the letters in use, and a name belongs next to the letter it names. Rows are the union of used, configured and named letters so a freshly assigned series can be named before its first verifikat.
[2026-09-06] Declined the request to import only SIE accounts with IB, UB or saldo <> 0: an inactive account is a harmless row in the chart, and dropping accounts breaks re-imports of later years that reference them. The chart imports whole.