feat(mcp): bulk-link documents to vouchers in one staged approval (#1411)

gnubok_link_documents_to_vouchers stages up to 300 document-to-verifikat
links as a single pending operation, addressed by voucher_series /
voucher_number / fiscal_year instead of journal_entry_id UUIDs, for bulk
receipt-migration jobs where N separate tools mean N separate approvals.

Staging resolves every row server-side and returns a per-row hit or miss,
so a systematic offset such as a wrong fiscal_year is visible before
anything is approved rather than after N approvals. Only resolved rows
enter the staged operation.

The WORM precondition and the document lookup are shared with the
single-document executor through precheckDocumentLink: a bulk call must
enforce exactly the invariants N single calls would, and a second copy of
a BFL 5 kap 6 § guard is a copy that keeps the old behaviour when the
first is hardened.

A batch that links nothing returns 409 instead of a committed no-op.
Partial skips stay committed, but an approval-gated operation on
räkenskapsinformation must not leave an audit record asserting a run that
changed nothing.

The tool is search-only: a one-off migration tool does not belong in the
default catalog every session pays for in context, and keeping it there
pushed the tools/list projection past the 58.5K token ceiling that
payload-size.bench.test.ts guards.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bjornbergenheim
2026-08-17 14:52:58 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 4300cb711b
commit 3841ab9f54
12 changed files with 682 additions and 32 deletions
+1
View File
@@ -796,6 +796,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-03] whatsapp-inbox M11 ('stopp'): copy changed to say PAUSED rather than disconnected; the keyword only sets muted_at. Muting also stops persisting content (body_text/raw_payload null on muted senders, matching the unknown-sender discipline) so the promise is true in the data too. Actually revoking on 'stopp' was rejected: 'start' must be able to re-open the same binding, and the settings panel already owns real disconnection ("Koppla från").
[2026-08-03] whatsapp-inbox raw_payload: the sender's plaintext E.164 number is stripped before persisting (redactRawPayload) and replies resolve the recipient by decrypting the link's phone_enc. Storing the number verbatim on every message row defeated the point of the AES-256-GCM column and contradicted the RoPA claim that it is never in the clear. Legacy rows still holding `from` keep working via a fallback read.
[2026-08-05] Representation clarifying question has NO amount floor (removed the 150 kr gate after the Swedish compliance review on PR #1340): documenting deltagare + syfte is what makes a representation expense deductible at all (BFL 5 kap 6-7 §) and that duty is not conditioned on any sum; the 300 kr/person figure is the VAT-deduction base cap, an unrelated rule. Noise is bounded by the triggers instead (receipt-shaped + restaurant/cafe/hotel merchant, <=1 question per receipt, <=2 per burst, <=6 per sender per day, one "nej" dismisses).
[2026-08-05] gnubok_link_documents_to_vouchers is search-only, shares the single tool's WORM precheck, and rejects an all-skipped batch. Search-only because a one-off bulk migration tool does not belong in the catalog every session pays for in context, and keeping it default pushed the tools/list projection past the 58.5K ceiling, whose own guidance prefers opt-in search over raising the budget. The WORM guard and the document lookup moved into a shared precheckDocumentLink rather than being copied into the bulk executor: two copies of a BFL 5 kap 6 § guard drift, and a bulk call must be exactly N single calls. Staging now also previews already_linked and duplicate_in_batch instead of resolving them as matched and skipping them silently after approval. A batch that links nothing returns 409 rather than a committed no-op: partial skips stay committed, but an approval-gated operation on räkenskapsinformation must not leave an audit record asserting a run that changed nothing.
[2026-08-05] Trap-closing batch (G2/G3/G4/G10/G12/G14 from the aktivering/K3/projekt analysis) shipped as five separate PRs, so each entry below names the PR that carries it; nothing here describes code that lives only in this diff. Three non-obvious calls: (a) legal wording about WHEN switching frameworks is allowed was omitted from the K3-to-K2 dialog (this PR) because BFNAR 2012:4 is not covered by any repo skill, so the dialog states system consequences only; an interim K3_REQUIRED_FOR_UPPSKJUTEN_SKATT registry error was written and then deleted within this same PR when the founder decision removed the feature it gated, so it never shipped. (b) PR "assets K2 guard": account 1081 flagged k2_excluded in the TS BAS reference + data migration 20260805112000 for existing chart rows: BFNAR 2016:10 punkt 10.4 substance (skill k2-vs-k3.md line 67) outranks the incomplete 20260225103139 backfill list; 1260/1269 stay unflagged in this pass because no asset-register category range reaches 1260-1279, a full k2_excluded-list reconciliation against official BAS marks is a separate follow-up. (c) PR "year-end preflight blocker links": validateYearEndReadiness gained a REQUIRED blockers: {code,message}[] field with errors: string[] kept as a derived mirror, so the public v1 compliance route stays byte-identical while PreflightStep gets stable codes; the voucher-gap and trial-balance link branches in BlockerRow were deleted as dead code rather than given a new page.
[2026-08-05] K3 uppskjuten skatt on obeskattade reserver ESCALATED, not changed: the dispositions builder books Dr 8940 / Cr 2240 = 20.6 % of gross 21xx in juridisk person, but swedish-asset-accounting (depreciation.md:88) states K3 29.37 keeps obeskattade reserver at gross in juridisk person with the 79.4/20.6 split reserved for koncernredovisning, so the entry double-counts the tax portion (result charged twice, 2240 overstated). Prod blast radius verified read-only 2026-08-05: 0 companies on K3, 2 companies with any posted 2240/8940 lines (3 entries, SIE-import shaped), and INK2 field 7651 adds the whole 8900-8989 range back so the error is tax-neutral. Per CLAUDE.md "stop and ask" on Swedish tax law this goes to the founder before any engine change; the K3 principle note was meanwhile reworded to describe what the engine actually books.
[2026-08-05] K3 29.37 escalation RESOLVED same day, founder chose removal: the uppskjuten_skatt disposition (Dr 8940 / Cr 2240 = 20.6 % of gross 21xx) is deleted end to end in juridisk person: proposal step, POST kind, DispositionsStep case, K2-to-K3 account seeding, and the K3_REQUIRED_FOR_UPPSKJUTEN_SKATT error registered earlier the same day (the gate died with the feature it gated). latent-tax-calculator.ts keeps only LATENT_TAX_DEFAULT_RATE for analytical presentation (soliditet/justerat EK in build-data.ts); the 2240/8940 chart rows of already-switched companies are deliberately left in place (audit history precedent from the original seeding comment). Old clients sending the kind now get schema 400, chosen over a bespoke 410-style error because the kind no longer exists in the contract and the only known clients are our own UI and MCP, neither of which sends it. Refinement after the review round: 29.37 makes gross vs split an ELECTION for the legal entity rather than a prohibition, so what shipped is a product default (gross), not a claim that the split is unlawful; the deleted code implemented NEITHER election, since it credited 2240 while the BR still carried 21xx gross, and no company can be on the split method today (0 K3 companies on prod). Offering the split as a disclosed policy option is a possible future feature, and would have to ship together with the matching redovisningsprincip note and a BR that presents the reserves net.