Files
accounted/DECISIONS.md
T
Jakob Wennberg fea5dfd1f9 fix(payments): correct pain.001 dialect per Swedbank Validex run (#1507)
* fix(payments): correct pain.001 dialect per Swedbank Validex run

Real MIG validation (eken.validex.net) rejected the first generated file
on four rules: character set (e-acute in names), missing InitgPty OrgId,
BGNR creditors demanding a BGNR debtor, and Strd lacking RfrdDocAmt.
Names and messages now transliterate to the MIG set, the org number is
required at batch creation (settings first, companies fallback), bankgiro
payees debit the company bankgiro in their own PmtInf group when one
exists (IBAN otherwise, with Cdtr PstlAdr/Ctry SE always present), and
structured OCR remittance repeats the amount as RfrdDocAmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(payments): review quick wins on the MIG pass

NFC-normalize before transliteration (decomposed marks from PDF-pasted
names fold to the precomposed forms the map knows), a dedicated settings
link label for the missing-org state, and coverage for an invalid
company bankgiro being dropped from the debtor snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 20:53:43 +02:00

544 KiB
Raw Blame History

Decision Log

One line per decision: [YYYY-MM-DD] <decision>: <why>. Appended by agents and humans when a non-obvious choice is made (approach picked over an alternative, dependency declined, action stopped by a CLAUDE.md rule). Read before re-litigating a past decision.

[2026-08-03] Issue #317 derives löneväxling pension and SLP at the shared salary-entry boundary from the frozen salary_runs.calculation_params rate snapshot: this fixes dashboard, MCP, and v1 booking without a schema migration, and prevents a live config change from altering an already reviewed run between calculation and posting.

[2026-08-01] Invoice payment dates stored in timestamptz use a shared UTC-noon representation: paid_at has date-only business semantics, and noon preserves the selected or bank transaction date when formatted in UTC, Europe/Stockholm, and all negative UTC offsets through UTC-12; UTC midnight displays as the prior day in American time zones. [2026-08-01] Privately paid supplier-invoice creation stays outside the payment-date correction: that flow deliberately journals on the invoice date while its optional out-of-pocket payment date can differ, so changing only paid_at would require a separate Swedish accounting semantics decision.

[2026-07-02] Adopted this decision log: CLAUDE.md rewritten per config-over-prompt principles; decisions persist here instead of being re-derived each session. [2026-07-03] Prod constraint clobber (self-inflicted, repaired in ~10 min): applied pending_operations link_document_to_voucher migration from a checkout predating 20260702171000 (retag_line_dimensions): hand-copied CHECK lists clobber concurrent adds. Zero impact (no retag ops in window). Rule: before applying any expand-types migration to prod, diff the list against the LIVE prod constraint, not the local file history. Long-term fix queued in mcp_optimization_plan P0-1 follow-up (audit test now guards CI). [2026-07-03] Archived 4 completed/superseded plans to dev_docs/archive/ (dimensions_implementation_plan, specialized-agent-plan, api_ai_architecture/PLAN, mcp-apps-architecture-reference): moved, not deleted, because dev_docs is gitignored (no git history to recover from). Live remnants relocated first: PR10 backlog → dimensions_architecture.md; eval-harness spec → claude_surface_plan.md §2.1. agent_first_vision.md §8 marked superseded by claude_surface_plan.md (Skatteverket filing is BUILT, contra its P0 item 6). [2026-07-03] Moved this log from dev_docs/DECISIONS.md to repo root: dev_docs/ is gitignored, so the log was invisible to other developers; root matches the existing convention (CONTRIBUTING.md, SECURITY.md). [2026-07-03] Converted the last three full-page create flows (salary run, employee, recurring schedule) to ?new=1 URL-driven modals matching the verifikat/invoice pattern (#861); old /new routes survive as redirects for bookmarks/agent intents. Moved forms keep their existing hardcoded-Swedish strings: translating them is out of scope for the modal conversion. [2026-07-03] Momsdeklaration hard-gates on vat_registered === false (EmptyState + settings CTA), not a soft banner: onboarding Step 4 asks the question explicitly, so false is a deliberate answer rather than "unconfigured" (DB default only matters for pre-onboarding companies, which the gate copy points to settings anyway). [2026-07-03] VAT view auto-fetches on period change and drops the "Hämta" button; fetch state is derived from a key-tagged result object instead of setLoading/setError in the effect: keeps react-hooks/set-state-in-effect ratchet at baseline (repo gate is per-rule count). [2026-07-03] Added ReportDescriptor.standalone (only vat-declaration) to hide the report-shell back link + fiscal-year selector, instead of changing behavior for all params:'calendar' reports: periodisk-sammanstallning keeps its current shell; scoped diff. [2026-07-03] New user-facing strings on skattekonto follow that file's existing hardcoded-Swedish convention; the deadlines callout uses next-intl (page already translated). Year-end stays Swedish per .claude/rules/i18n.md. [2026-07-05] Salary run "Ångra godkännande" transitions approved → review (not straight to draft) and hard-deletes generated-but-unfiled AGI declarations — symmetric with the approve step for a clean audit trail, and stale AGI XML must not stay exportable. Blocked with 409 once the AGI is pending_signature/submitted/accepted: the lawful path is then a correction AGI with the same specifikationsnummer. Payment-file tracking is cleared; whether the file reached the bank is outside app knowledge, so the UI confirm makes the user own that check. [2026-07-05] PR #894 bot triage: accepted the delete-after-update reorder (destructive op last) and the manual-filing warning in confirm_unapprove_agi; declined soft-cancel status for unfiled AGI drafts and preserving approved_by on recall — a never-filed generated AGI is regenerable working data derived entirely from retained run data (not räkenskapsinformation; unapprove 409s once anything is filed), and the approval with legal weight is the one in force at booking, which unapprove can never touch (paid/booked runs are locked out). [2026-07-05] Fixed supplier-invoice VAT silently dropped via MCP inbox conversion: gnubok_create_supplier_invoice_from_inbox now derives vat_amount from summed lineItems instead of the unreconciled OCR totals.vat field, and createSupplierInvoiceRegistrationEntry/CashEntry/PrivatelyPaidEntry gate the 2641 posting on itemsHaveVat(items) instead of invoice.vat_amount > 0. Chose to fix both the immediate source (server.ts) and the downstream gate (supplier-invoice-entries.ts) rather than just one: the header field is inherently a redundant, independently-sourced aggregate that can drift again from a different call site in the future, so the engine itself should never trust it as a gate. [2026-07-06] Migration 20260706100000 adds profiles.deleted_at/anonymized_at (ADD COLUMN IF NOT EXISTS) alongside committing anonymize_user_account verbatim: the prod function writes those columns but no repo migration ever created them, so without the columns the drift capture would ship a function that fails on every from-scratch database (CI replay, self-hosted). No-op on prod. [2026-07-06] v1 reconciliation run: confidence_threshold has NO server-side default when omitted (existing API consumers keep current behavior; only the unattended enable-banking sync callers pass DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD=0.9); registry pitfalls recommend 0.9 to integrators. Revisit if telemetry shows API callers auto-applying fuzzy matches. [2026-07-06] Salary surfaces (payslip PDF x2, payslip email, AGI, KU10, BG/LB + SEPA payment files) now resolve the employer name via getCompanyDisplayName (company_settings.company_name, fallback companies.name), matching invoices. Chose read-side coalesce (Option A) over mirroring the name onto companies.name on write (Option B): companies.name is write-once at onboarding and never authoritative for these surfaces, so A needs no migration/backfill. Included the Skatteverket forms (AGI/KU10) despite the sarskilt-foretagsnamn caveat because the frozen onboarding name (e.g. a lagerbolag's Grundstenen name) is unambiguously wrong and there is no dedicated registered-name field yet; a Bolagsverket-synced legal-name field is the real long-term fix. [2026-07-06] Sidebar company switcher: extended the same company_settings.company_name coalesce to the NON-active companies in the switcher list (the active one was already overridden with displayName in app/(dashboard)/layout.tsx). Fetched all the user's current names via a bare company_settings.select('company_id, company_name') added to the existing dashboard-layout Promise.all, relying on the company_settings SELECT RLS (company_id IN user_company_ids()) to scope it, rather than an explicit .in(companyIds) that would depend on allMemberships and add a serial round-trip on the every-dashboard-render critical path. Do not "harden" it with an explicit company_id filter: there is no single id here (it is deliberately the set of the caller's companies), and adding one reintroduces the round-trip. [2026-07-06] Employees API 500 (ERR_CRYPTO_INVALID_AUTH_TAG "length: 6"): root cause was the v1 REST create route storing personnummer plaintext (skipped encryptPersonnummer), which then threw in every decrypt-on-read path. Fix = encrypt-on-write in v1 create + decrypt-on-read in v1 list/detail/patch (the whole v1 employee module assumed plaintext storage) + a tolerant guard in decryptPersonnummer that passes a raw 12-digit value through with a warn (chosen over per-call-site guards: one change un-breaks roster/runs/payslips/KU/AGI/MCP at once, and stays a safety net + observability against any future non-encrypting writer). Prod backfill re-encrypts the 5 existing plaintext rows (2 companies) via scripts/backfill-encrypt-personnummer.ts. Deferred: duplicate-personnummer detection is already broken for encrypted rows (unique index sits on the random-IV column so it never collides); proper fix is a deterministic HMAC hash column + migration, tracked as a follow-up, not folded into the outage fix. [2026-07-06] Employee "Anstallda" clearing/kontonummer validation: added a shared structural validator (lib/salary/payment/bank-account.ts) wired into the create form, edit form, CreateEmployeeSchema, and the PATCH route, so a typo is caught at entry instead of at Bankgirot LB generation. Scoped to structure (4-digit clearing or 5-digit Swedbank 8xxxx; 5-11 digit account; both-or-neither) to mirror encodeReceiverAccount and avoid false rejections; per-bank mod10/mod11 checksum deliberately deferred to a vetted soft-warning follow-up (needs the official clearing-range table; getting it wrong rejects valid accounts). Update validated only when a bank field actually changes so legacy free-text bank data stays editable. Bank-name lookup is a conservative major-ranges-only table returning null (never a guessed name) for unknown clearings. [2026-07-06] Kontoplan (chart-of-accounts) load optimization: fixed a double-fetch (the load effect depended on hideK2Excluded, which the effect itself set, re-running every fetch on each visit), deferred the BAS catalog + K2 setting to first "BAS-katalog" tab open, and moved usage counts off the first-paint critical path. Slimmed /api/bookkeeping/accounts/reference to return only the company's activation rows and merge against the client-bundled BAS_REFERENCE, instead of re-sending the full ~1,300-account catalog (~400KB) every load. For the slow get_account_usage_counts aggregate (prod worst case ~440ms, ~58k heap-buffer hits) added a covering index on journal_entry_lines (journal_entry_id, account_number) so the inner join becomes an index-only scan (verified on staging: node flips to Index Only Scan, 111 heap fetches). Chose the covering index over denormalizing company_id onto journal_entry_lines + (company_id, account_number) index: the latter would need the commit_journal_entry RPC write path changed (Hard Rule #2) plus a 599k-row backfill, disproportionate for a usage-count column. Migration 20260706120000 applied to staging only; prod deploy pending Emil's go. [2026-07-06] Momsdeklaration "file without Skatteverket connection": the VAT report was never gated on the connection (it renders from bookkeeping via /api/reports/vat-declaration); users just read the not-connected "Anslut med BankID" card as a wall. Fix is communication only: added an always-visible VatManualFilingCard under the report (copy-the-rutor + skatteverket.se link) and reframed SkatteverketPanel's not-connected state to "Skicka direkt till Skatteverket (valfritt)". Put the manual card in VatDeclarationView (always rendered) rather than inside SkatteverketPanel, which returns null when the skatteverket extension is disabled, so core/self-hosted users also get manual-filing guidance. New strings kept hardcoded Swedish to match the surrounding momsdeklaration surface (VatDeclarationView + SkatteverketPanel use zero next-intl; VAT ruta labels are a "stays Swedish" surface per i18n rules) rather than adding lone en.json keys to an otherwise all-Swedish card. Rutor copy logic extracted to lib/reports/vat-manual-filing.ts (pure, unit-tested) since components aren't tested. SKATTEVERKET_MOMS_URL is a named constant flagged for Emil to confirm the exact stable e-service URL before shipping. [2026-07-06] Recurring invoices resend: the daily cron was accidentally dropped from vercel.json in #559 (2026-05-22), so no recurring schedule has sent since. Re-registering it as an hourly cron plus a per-schedule send_hour (0-23, Europe/Stockholm, DST-aware via Intl, no dep). Cron never sends for a past date: a schedule with next_run_date < today is rolled forward without generating (protects outages + the reactivation path). Crucially, the enabling migration (20260706140000) does a ONE-TIME pause of every schedule that exists at deploy so nothing resumes emailing customers behind their back after weeks of silence: users must consciously reactivate (Resume now confirms for auto_send schedules) or click "Skapa faktura nu" (new POST /[id]/run, leaves next_run_date untouched). Chose pause-all-existing over roll-forward-and-resume on Emil's explicit call (prior incident: customers got invoices they shouldn't). No backfill of the ~6 dark weeks. Also fixed the /invoices/recurring/[id] row-click 404 by removing the dead navigation (no detail page exists; edit page deferred). Reminders cron (also dropped in #559) deliberately left for a separate task. Migration not applied to prod by me. [2026-07-06] Recurring schedule reactivation (PATCH status -> active) rolls a stale next_run_date forward immediately and STRICTLY into the future (never today, even when today is the schedule's day_of_month), and clears last_run_warning: relying on the cron's stale-roll-forward left a past "Naesta korning" visible for up to an hour, and rolling to today would let the cron send within the hour of reactivation, colliding with the no-surprise-sends rule. Today's invoice is the explicit "Skapa faktura nu" action instead. Cron stale-roll-forward kept as the outage safety net. Verified prod has 2 active schedules (both auto_send, next_run 2026-06-01 and 2026-07-05, both stale), so the deploy race between the Vercel cron and the pause migration is harmless with current data: stale rows roll forward without sending in any ordering. [2026-07-06] Momsdeklaration manual-filing affordance, format decision: replaced the copy-the-rutor clipboard button with a downloadable momsdeklaration PDF (new route app/api/reports/vat-declaration/pdf via withRouteContext + lib/reports/vat-declaration-pdf-template.tsx), and added PDF alongside xlsx in the report's Exportera menu. Rationale: for manual moms filing you submit NO file (you type the rutor into skatteverket.se); moms has no SRU (that's income tax) and its only machine channel is the Skatteverket API. So the export is a read/record document, PDF reads like the actual SKV 4700, and the PDF disclaimer says explicitly it is not an inlamnad deklaration. Amounts are rendered in hela kronor (Skatteverket files whole kronor, no ore): buildManualFilingRows rounds each ruta and recomputes ruta 49 from the rounded output/input rutor per the Section G formula so the document ties out; this whole-krona rounding is deliberate and NOT the ore-precision money rule (nothing here is posted). On-screen report keeps ore (regulated rendering, out of scope). New route uses withRouteContext (not the older createClient+getUser pattern of the sibling report routes) so check:guards' antipattern ratchet stays green. [2026-07-06] "Spara som mall" on the manual bookkeeping form (JournalEntryForm): wired a save-as-template action next to the existing "Anvand mall" picker so users can capture a booking pattern at the moment they figure it out (user request). Reused the exact building blocks the invoice-inbox BookDirectlyDialog already uses (deriveTemplateLinesFromBooking + shared TemplateForm mode=create + POST /api/settings/booking-templates), so no new lib/API/DB. Two deviations from the extension dialog, both deliberate: (1) built the TemplateForm entityLabels from the settings_booking_templates i18n keys (entity_all/entity_enskild_firma/entity_aktiebolag) instead of reusing BookDirectlyDialog's hardcoded Swedish TEMPLATE_ENTITY_LABELS const, because JournalEntryForm is bilingual (journal_form namespace) whereas the inbox dialog is a Swedish-only extension surface; the four new button/dialog strings were added to both sv.json and en.json. (2) Button placed in BOTH the mobile and desktop layout rows AND in both create + edit (editEntryId) modes, mirroring where "Anvand mall" already renders, rather than gating it to fresh entries. accountNameMap derived from the form's existing catalog state (CatalogAccount) so template line labels get BAS names. No component test (repo has none); relied on the already-tested deriveTemplateLinesFromBooking. Not extracting TEMPLATE_ENTITY_LABELS to a shared const to keep the diff off BookDirectlyDialog. [2026-07-06] Recurring schedule editing: reused NewRecurringScheduleDialog for both create and edit (POST vs PATCH, driven by ?edit=, prefilled from the already-loaded list row which carries items + send_hour, no extra fetch) rather than building a separate detail page. This is the "edit surface" deferred earlier ("Create dialog only"); clicking a row now opens the prefilled editor instead of doing nothing. PATCH additionally recomputes next_run_date to the next STRICTLY-future occurrence when day_of_month actually changes (compared against the stored value, not merely present in the payload, so editing name/items/time never moves an imminent send), mirroring the reactivation roll-forward. Existing email-gate + reactivation logic in PATCH cover the edit path unchanged. No schema change. [2026-07-06] v1 invoice POST (#895) refactored onto buildInvoiceWriteData instead of extending the hand-rolled compute: the v1 route was silently dropping ROT/RUT, article_id, revenue_account, accrual, and line_type fields that CreateInvoiceSchema already accepted; one shared builder eliminates that drift class permanently. Wire-shape kept: VAT_RULE_VIOLATION details stay snake_case via a mapping shim. [2026-07-06] v1 dimension value DELETE mirrors internal semantics (hard-delete unreferenced, 409 DIMENSION_VALUE_REFERENCED with archive hint otherwise) rather than DELETE=archive: identical behavior across dashboard and API beats a simpler mental model that would surprise users comparing the two surfaces. Value dates (end_date for projects) ride the existing PATCH; whole-dimension DELETE stays unsupported. [2026-07-06] Fastigheter-on-customers (item 3 of #895) deferred to a follow-up issue instead of shipping a quick column: single-default-property vs multi-property registry changes the data model and the ROT prefill UX; needs its own design pass. [2026-07-06] v1 articles endpoint is read-only list (GET) under invoices:read: the #895 ask is "pick articles when composing invoices via API", not article CRUD; linking article_id does not auto-fill line fields (caller copies price/VAT), matching how invoice_items freeze article data at write time. [2026-07-06] Kept two-step potential-match fetch on /transactions instead of single PostgREST embed: prod schema cache has no FK relationship for transactions.potential_supplier_invoice_id (PGRST200; migration 20260225100248 ADD COLUMN IF NOT EXISTS likely skipped the REFERENCES clause because the column pre-existed). Revisit after adding the FK via a new migration. [2026-07-06] Bolagsverket testbank E2E as skipped-by-default vitest (BOLAGSVERKET_TESTBANK_E2E=1): needs the IP-bound firewall opening, so it can never run in CI; GUIDE's documented test pnr 190001010106 fails Luhn, 190001010107 is the accepted one. [2026-07-06] Paywall leak sweep gating choices: SKV unlock (DELETE /declaration/lock) left ungated so a lapsed company can recover a draft it locked while entitled; agi/kontrollera HU/IU gated (direct SKV API interaction = paid, file download stays free); recurring auto-send blocks only the email, invoice creation stays free (freeze-and-retain). [2026-07-07] Sjalvfaktura via the public invoice API (support request: "kan inte hitta det i docs"): exposed the RECEIVED self-billing invoice (mottagen sjalvfaktura, ML 17 kap 15: a SALE, Debit 1510 / Credit 30xx+26xx) on the public API as an OPTIONAL is_self_billed flag on the existing POST /api/v1/.../invoices endpoint (+ external_invoice_number, self_billing_agreement_ref, received_date), on Emil's explicit call ("configure sjalvfaktura when creating an invoice ... optional field") over a dedicated /invoices/self-billed v1 endpoint. First built the WRONG interpretation (issue a self-invoice on a SUPPLIER's behalf = a purchase, on the supplier-invoice stack, new SJ- series + PDF + MCP tool + migration 20260706130000); reverted all of it after Emil clarified the user meant the existing seller/received feature, which already existed internally (/api/invoices/self-billed, cookie-session) but was absent from the public API. No migration for B (is_self_billed/external_invoice_number/self_billing_agreement_ref columns already exist from 20260613100000). Extracted lib/invoices/self-billed-sale.ts (resolveSelfBilledSaleDraft + createSelfBilledSaleInvoice) as the single implementation and refactored the internal route to a thin wrapper over it, so the dashboard "Sjalvfaktura" tab and the API can't drift (internal route test still green, 8/8). Fields added as PLAIN optionals (no z.superRefine) so UpdateInvoiceSchema = CreateInvoiceSchema.omit() keeps working (superRefine turns it into ZodEffects, which has no .omit); "external_invoice_number + received_date required when is_self_billed" enforced in the route instead. Documented the flag in the invoices.create registerEndpoint (description + pitfall) since the whole ask was "can't find it in docs". No git touched; nothing deployed. [2026-07-07] Compliance-review sweep on add/api-and-invoice. FIXED: (1) recurring cron double-send window: replaced the read-only "already ran today" check with an atomic compare-and-set claim on last_run_at (release-on-failure) so two overlapping hourly invocations can't both spawn from the same stale batch row; (2) recurring schedule edit dialog could PATCH auto_send=true for a customer with no email (disabled-but-checked box, async customer load after defaultValues): added a useEffect that forces auto_send=false whenever the effective customer lacks an email, mirroring the manual-select guard; (3) momsdeklaration manual-filing rows: switched Math.round -> Math.trunc (öretal faller bort per SFL 22 kap 1 §; in-repo swedish-sru-filing skill confirms öre are DROPPED not rounded, and this now matches the SRU income-tax path). This narrows the earlier [2026-07-06] "whole-krona rounding is deliberate" decision: whole-krona stays deliberate, but the öre handling is truncation, not round-to-nearest. DECLINED (with rationale): self-billed "Självfakturering" notation + own-voucher-number findings are misdirected: createSelfBilledSaleInvoice books a RECEIVED självfaktura (the counterparty issues the document, we render no PDF), and numberOverride only sets the human-readable verifikat description/tag, not the sequential verifikationsnummer (still assigned atomically by commit_journal_entry). Bank-account mod11 padStart padding is correct: leading zeros contribute 0 and preserve the right-aligned check-digit weighting (already tested vs a real Forex account). AGI/KU10 employer name kept as [2026-07-06] (särskilt-företagsnamn caveat already accepted). Livsmedel 12%->6% (April 2026) reactivation guard declined as a description-keyword food-detector: violates the determinism/never-guess principle, and the one-time pause-all-on-deploy already forces conscious reactivation as the natural checkpoint. [2026-07-07] bank_file_imports dedup key widened (user_id,file_hash) -> (company_id,file_hash), migration 20260707130000 applied to prod: the old key made a same-user re-import of the same file into a second company resolve the upsert onto the first company's row, which RLS rightly blocked (42501). Mirrors what 20260330130000 did for sie_imports; v1 route's BANK_IMPORT_DUPLICATE_OTHER_COMPANY pre-check removed as obsolete (structured-errors entry kept for API compat). [2026-07-07] A1 route-auth campaign migrated 118/119 routes off hand-rolled supabase.auth.getUser() onto withRouteContext (enforces MFA/AAL2); ratchet baseline lowered 119->1. [2026-07-07] mcp-oauth/authorize left on the raw-auth baseline (count 1, not 0): it renders an HTML consent page and issues 303 redirects, which withRouteContext (JSON envelopes + company-context gate) cannot express; MFA is enforced instead via a route-local requireAal2() step-up (AAL1 sessions redirect to /mfa/verify) since consent mints a long-lived API key that bypasses MFA thereafter. [2026-07-07] Added { requireWrite: true } to POST /api/reports/vat-declaration/rc-basis-gaps/fix: it calls correctEntry() (storno of a posted entry) and was reachable by viewer-role members. [2026-07-07] Two GET routes kept requireWrite (salary/runs payment bg-lb/pain001, skatteverket payment-file): they persist a file_generated_at stamp and previously gated viewers, so dropping the gate would regress write-protection. [2026-07-07] Ledger-context as MCP resource, compute-on-read, SECURITY INVOKER RPC: rejected new tool (description budget), cron regen (wasteful), LLM narrative v1 (calculators principle); cache only when measured slow. See dev_docs/ledger_context_resource.md [2026-07-07] Ledger-context research (openwiki-grounded, verified): digest-in-tool is load-bearing (claude.ai connector supports ONLY tool calls, resources unsupported); confidence must be count-grounded not model-authored (arXiv 2410.09724); prereqs before quality work: merchant-name normalization (splinter bug, #1 unlock), supplier-invoice CTE, storno filter, pending_operations feedback FK, eval harness. Full: dev_docs/ledger_context_resource.md Findings section. [2026-07-07] Reconciled ledger-context prereqs INTO dev_docs/bank_transaction_ai_normalization.md (§14): plan is the strategic superset; ledger-context RPC gets interim normalizeCounterpartyName() now, re-keys to entity_id at Phase 2/Layer F. Closed 4 gaps: RPC in Layer F substrate list, supplier-side digest patterns, storno/correction exclusion (§13+§14), pending_operations audit+FK for agent-suggestion attribution. [2026-07-08] Ledger-context prereq trifecta folded into the P1 branch pre-merge (normalize_counterparty_key SQL mirror of normalizeCounterpartyName + supplier_patterns CTE + storno filter + evidence{seen,agree,share,last_booked} format) instead of follow-up PRs: shipping first then fixing would break the payload shape consumers had just learned. Storno filter deliberately asymmetric: account_usage excludes source_type='storno' only; counterparty CTE has NO source_type filter because correctEntry() relinks transactions.journal_entry_id to the correction (the join self-heals) and excluding 'correction' would drop exactly the human-corrected booking. Faithful-mirror discipline: bare "KORT " prefix is NOT stripped (TS doesn't either); hardening the prefix list must change the TS+SQL pair together (pg test pins this). Payload caps trimmed 20/20 -> 15/15 + supplier 10 to hold the 12 KB budget with evidence objects. [2026-07-08] Ledger-context dominant-contra VAT bug, found by the switch-on check (calling gnubok_get_agent_briefing on real prod data, not synthetic tests): counterparty patterns for foreign SaaS (Google/ngrok/Supabase) showed dominant_account 2614 (reverse-charge output VAT) instead of 5420 (software expense). Cause: the dominant_account CTE excluded only 19xx, so on a reverse-charge booking (expense + 2645 + 2614 + 1930) the three non-bank accounts tie and the account_number ASC tiebreak picks the low VAT number 2614. Fix (migration 20260708110000): also exclude 26xx (always moms in BAS, never characterizes a counterparty); 23xx/24xx/25xx/27xx stay eligible so loan/tax counterparties (e.g. ALMI) still surface their real account. supplier_patterns unaffected (aggregates supplier_invoice_items.account_number = expense only). Regression pg test asserts 5420 over 2614; verified it fails on the old function. [2026-07-08] Built the P2 "Vad din agent vet" page (/agent-knowledge, Analys nav group, icon Brain): the human render of the same ledger-context payload the AI agent reads. RSC calling buildLedgerContext(supabase, companyId) directly (one payload, two renderers, no new API/data path), sections mirror the payload 1:1 (coverage strip, counterparty patterns with monochrome confidence bars + seen/agree evidence, supplier patterns, explicit rules distinguished as instructions, account usage, VAT profile, conventions). Chose a STANDALONE page over a 3rd tab in /settings/assistant: faithful to the plan's page spec + its sales-demo intent (Fortnox shows your data, this shows your patterns). UNGATED (shows the user their own derived booking stats, no live AI compute, doubles as an upsell) rather than CAPABILITY.ai-gated: set requiredCapability on the nav item to paywall. VAT treatment labels + BAS account names stay Swedish in both locales (regulatory domain terms, per .claude/rules/i18n.md). Verified: build compiles the route (client/server boundary resolved), lint+tsc clean on new files, i18n sv/en parity (54 keys). Data correctness already proven live on prod via the briefing + RPC. [2026-07-08] Deep entity-resolved ledger analysis + "Vad din agent vet" graph. (1) Fixed median_booking_lag_days: was median(entry_date - transaction date) = ~0 tautology (bank flow dates the voucher to the transaction; verified 151/152 entries entry_date==txn date on prod); switched to committed_at (real posting lag, median 90 for Arcim). migration 20260708120000, applied to prod. (2) New get_ledger_deep_context RPC (migration 20260708130000): full-history, deterministic, read-side deep analysis merging counterparties by normalize_counterparty_key (delivers "Claude = 14 bookings across 12 name variants, weekly, 9710 kr, always 5420"), mining booked verifikat for spend (sum abs bank amount), recurrence cadence (median gap of distinct dates), dominant account (19xx+26xx excluded) + share, supplier entities from supplier_invoices. Chose read-side compute-on-read (no new tables/backfill job/embeddings) as the pragmatic v1 over the full persistent counterparty_entity substrate (bank_transaction_ai_normalization.md) which stays deferred: name-key merge alone delivers the wow; cross-name merges needing the bank-account hard key or fuzzy/embeddings are the deferred layer. (3) Page reworked from tables to a radial-hub SVG graph (company center, accounts inner ring, payees outer ring, hover reveals variants/spend/cadence/account+share). Both RPCs applied to prod + version-matched; light get_ledger_usage_stats stays the agent digest, deep RPC feeds the page. Then restructured the page (hero graph + Kompetens/Minne/Regler tabs) and added a read-only Kompetens (atoms) + Fakta (memory) overview mirroring /settings/assistant. [2026-07-08] Bedrock prod outage + Docker build failure both root-caused to dependabot #884 (a1fad319, 2026-07-06) bumping @anthropic-ai/bedrock-sdk 0.29.1->0.32.0. Runtime: 0.32.0 streaming returns an empty event stream ("request ended without sending any chunks", no HTTP status) - proven NOT a creds/region issue (prod diagnostic logged AKIA key + eu-west-1). Two prior sessions mis-diagnosed it as an AWS env collision and shipped/reverted #937 (BEDROCK_AWS_* rename) with no effect. "Works locally, fails on prod/CI" because local node_modules was stale at 0.29.1 while prod/Docker build fresh from the lockfile (0.32.0). Fix: pin back to ^0.29.1 + regenerate lockfile. FOLLOW-UP: add a dependabot ignore/exact-pin so it does not re-bump to 0.32.x and re-break both. [2026-07-08] One reconciliation PR adopts 3 prod-orphaned migrations (20260707113729 enrichment + 20260708120000/130000 ledger-stats RPCs) plus their pg-tests/fixtures onto main, instead of waiting on #927+#935 to merge: prod ledger was 3 versions ahead of the repo, leaving the default Supabase branch MIGRATIONS_FAILED and blocking every preview branch from being created. SQL committed byte-identical under the exact apply-time versions -> no-op on prod (idempotent), clean on fresh replays, and a no-op on #927/#935's next rebase. Carries #935's DB layer only (migrations + pg-tests + fixtures), not its UI/lib/i18n. Root anti-pattern: all three applied to prod via MCP apply_migration without committing the file (CLAUDE.md "never leave the remote DB ahead of the repo"). [2026-07-08] Pinned @anthropic-ai/bedrock-sdk to exact 0.29.1 (dependabot #884 auto-bumped it to 0.32.0, which broke Bedrock streaming in prod: empty stream / "request ended without sending any chunks"). Guarded three ways against accidental re-bump: exact pin in package.json, dependabot ignore, and a pinned-dep check in scripts/checks/no-new-antipatterns.mjs (check:guards). Unpin only once 0.32.x streaming is verified against Bedrock. [2026-07-09] "Vad din agent vet" hero reworked from the flat radial-hub map into "Reconciliation Aurora" (LedgerGraph.tsx), a cinematic dark SVG panel, on the founder's explicit "maximum wow" directive (a design judge-panel of 4 divergent concepts picked it over circle-packing / flow-pulses / living-constellation). Encodings: node area = √spend, colour = cadence (weekly/monthly/irregular, the one semantic axis), shape = supplier vs counterparty, confidence = optical depth-of-field (crisp = sure, blurred = unsure). Signature on-mount act: the top ~5 name-merged payees' raw bank descriptors scatter in and collapse into one node with a counting-up ×N badge. Two deliberate calls: (a) a self-contained ALWAYS-DARK panel that bends design.md's flat/achromatic-surface rule (justified: it is a demo hero, self-contained, premium-not-neon, and DOF/glow only read on dark); (b) ZERO new deps: rejected d3-hierarchy (circle-pack) and d3-force (constellation) because a fixed 3-level tree is ~40 lines of deterministic polar trig and force physics would reshuffle the layout every load, breaking the repeatable live demo; framer-motion@12 (already installed) drives the entrance/merge, CSS keyframes drive idle pulse/breathe (single clock, no 40 JS loops), and confidence buckets into 3-4 shared SVG blur filters attached only after each node's entrance spring settles (avoids per-frame feGaussianBlur re-raster). Adversarial 3-lens review run; 3 low findings fixed (StrictMode CountUp freeze, filter-on-animating-subtree, center-out inverse permutation). [2026-07-09] Invoice online payment ships as a manual paste-link MVP (invoices.payment_link_url + email button + PDF QR/link) instead of a full Stripe Connect integration: a day of work as a demand probe vs a week for Connect (OAuth onboarding, pay page, webhook auto-booking to 1686). Same column/UI is the upgrade path: Connect would auto-fill payment_link_url later, so nothing is throwaway. Field is PSP-agnostic ("Betalningslänk", any https URL) since the effort is identical and it also covers PayPal/Zettle. Derived documents (credit note, proforma convert, recurring) deliberately do NOT copy the link: a pasted link encodes one amount for one invoice. MCP tools/list token ceiling bumped 45K->45.5K (headroom was <10 tokens; ledger entry in payload-size.bench.test.ts). [2026-07-09] Issue #916 (disconnect orphans ledger accounts): release claims by demoting cash_accounts rows to manual (bank_connection_id = null), never deleting: transactions.cash_account_id and ledger history reference the rows, and upsertFromPsd2 promotes a manual holder in place on reconnect so the bank lands back on its original BAS slot. Orphans predating the fix self-heal via a revoked-status filter in the allocator + collision guard (not data repair). When a promote collides with a duplicate row for the same connection+uid (callback mirrored onto an overflow slot pre-fix), the duplicate is deleted only if it has zero linked transactions, otherwise demoted: preserves FK links while freeing the slot. Picker-save rejections now render inline in the picker instead of routing to the sync-progress modal, whose parent-unmount-on-close made every save outcome invisible. [2026-07-09] #917 fix scoped to the current-year suggestion: "Sedan räkenskapsårets början" now resolves from the fiscal_periods row containing today, but the "Föregående räkenskapsårets start" custom option still derives from the recurring fiscal_year_start_month: the issue only covers the current-year date and a first-year company has no previous period row to resolve against. [2026-07-09] Issue #919 (duplicate guard should steer to matching): the match action lives INSIDE DuplicateBookingDialog (fetch to /api/reconciliation/bank/link + account resolution via /api/cash-accounts + resolveAccount, exactly the MatchVoucherDialog path) rather than in each call site or a new endpoint: both call sites (transactions page runCategorize + TransactionBookingDialog/JournalEntryForm) share one implementation and pass only the transaction context + an onMatched callback mirroring onLinked. Match is primary ONLY for ledger-only candidates (transaction_id null, the SIE-import case); sibling-transaction candidates keep "Bokför ändå" primary since N:1 matching is the edge case. No lib change: the candidate already carries the transaction_id discriminator, covered by existing tests. [2026-07-09] Demo/sandbox users could reach Stripe: an anonymous user on a sandbox company hit POST /api/billing/checkout and created a live Stripe customer (no subscription = no charge; exact tenant/customer IDs kept out of source control, see the incident PR). Root cause: neither billing/checkout nor billing/portal checked is_anonymous or is_sandbox, and withRouteContext lets anonymous users through (they are authenticated, just anonymously). Fix guards BOTH conditions in both routes (is_anonymous is the identity truth; guardSandbox matches the existing lib/sandbox/guard.ts "never charge a token" doctrine), belt-and-suspenders since anon and sandbox happen to co-occur today but are orthogonal. Anon check runs first (in-memory, no DB round trip). Also surfaced isDemo on GET /api/billing/status so the client hides the upgrade CTA instead of showing a button that 403s. Blast radius = exactly one company (no other sandbox/anon tenant had a stripe_customer_id). Left the stray company_subscriptions row + orphan Stripe customer for manual cleanup (prod write / external destructive action, not done unilaterally). [2026-07-09] UI consistency pass: skipped the suppliers/page.tsx card-grid to Table conversion (did PageHeader + space-y-8 only): the suppliers.* i18n namespace has no column-header keys (nothing equivalent to customers.col_name/col_type), the pass forbade new i18n keys, and a data table whose primary name column has no header is worse than the existing card grid. [2026-07-09] supplier-invoices/[id]: replaced the local formatAmount with shared formatCurrency at all direct render sites, but the two i18n-templated amounts (amount_registered_description embeds "kr", remaining_to_pay embeds "{currency}") now use the shared bare-number formatAmount from lib/utils instead: passing formatCurrency output there would double-print the currency, and message files were off-limits. [2026-07-09] common.delete changed "Radera" to "Ta bort": grep proved the key has zero live call sites (every delete dialog uses feature-namespace keys), so this only affects future uses; convention going forward is Ta bort = detach/remove, Radera = irreversible destruction (kept in AccountDangerZone/CompanyDangerZone keys). [2026-07-09] InvoiceEditor customer-card description kept only for the self-billing branch (issuer_card_description adds real info: who issues the invoice); the plain-invoice branch dropped its description as a title paraphrase per design.md forbidden patterns. [2026-07-09] SalaryCalendar absence-type rainbow palette (red/amber/emerald/blue/indigo pills) left as-is in the UI consistency pass: those colors encode absence categories (data), not status chrome, and swapping them for the 3 semantic tokens would collapse 5 distinguishable categories; needs a proper categorical-palette decision instead of a mechanical fix. [2026-07-10] Oresavrundning "fungerar inte" (support): kept the display-only design (booked verifikat stays ore-exact, 3740 absorption at bank match untouched) and fixed the surfaces that ignored it: invoice editor summary + mobile bar, supplier invoice form totals, supplier invoice list Belopp column, and the invoice EMAIL (Att betala used raw invoice.total while the attached PDF rounded; also ignored ROT/RUT deduction). Extracted the PDF's Att betala block to getAmountToPay (lib/invoices/rounding.ts) and pointed PDF + email at it so they cannot drift; behavior-identical refactor verified against HEAD. Supplier list rounds only the total column; "kvar att betala" stays ore-exact (actual outstanding debt), matching the detail page. Deferred (pre-existing, found in review): v1 API send route's invoice projection omits deduction_total, so ROT/RUT invoices sent via the public API already render PDF+email without the deduction; needs its own fix. [2026-07-10] Momsverifikat from momsrapport (#980): the proposal clears each 26xx account at exact öre but books the 2650/1650 net at the FILED whole-krona amount (buildFiledAmounts, öretal faller bort) with the gap on 3740, so redovisningskontot always matches the skattekonto movement; and vat_settlement entries are excluded from the VAT report projection (web calculateVatDeclaration + MCP computeVatReport) because a pure-projection report would otherwise read zero (and Skatteverket submission would file zeros) the moment the settlement is booked. [2026-07-10] VatBookingCard hard-disables "Skapa verifikat" while a POSTED vat_settlement exists in the period (CodeRabbit finding, accepted over the initial warn-but-allow): the proposal is not delta-aware (it re-clears the FULL period), so booking twice corrupts 26xx balances; the sanctioned redo path is annullera (storno restores the balances and re-enables the button). Already-booked detection is by source_type + entry_date within the period, so redating the entry outside the period escapes the gate: accepted v1 limitation. Card copy is hardcoded Swedish per the file's existing momsdeklaration convention (i18n.md). [2026-07-11] Momsrapport after settlement (#984): extended the VAT-report exclusion from tag-only to shape-based. Any entry touching both a declaration account (ACCOUNT_RUTA) and a settlement net account (2650/1650) is treated as a momsredovisning and excluded from the projection (web calculateVatDeclaration + MCP computeVatReport), covering manual momsomforingar booked before #980 shipped, SIE-imported settlements, and stornos of a settlement (which would otherwise double the rutor after annullera, a latent bug in the #983 tag-only filter). Opening-balance entries are exempt from the shape rule: carried-in 26xx balances are unsettled VAT that belongs in the next declaration. Shaped POSTED entries also gate the "Skapa verifikat" button via existing_entries (the proposal re-clears the full period, so booking over a manual settlement would corrupt 26xx); stornos never gate, or annullera could not re-enable booking. Rejected the frozen-snapshot alternative the issue suggested: pure projection heals historical periods retroactively (a snapshot would not exist for them) and needs no migration. [2026-07-11] #984 shape-rule residuals triaged and ACCEPTED (compliance-bot review): a compound verifikat mixing business VAT lines with a 2650/1650 payment/correction line in ONE entry is excluded from the rutor by the shape rule (under-reports). Kept anyway: such compound entries are rare bad practice, and the suggested direction guard (only exclude when 2650 is credited / 1650 debited) would break the storno exclusion, whose reversal carries exactly the flipped sides. Opening-balance concern verified false for app flows: SIE import and set_opening_balances both tag source_type 'opening_balance' (sie-import.ts); only a hand-booked IB verifikat shares the compound-entry residual. [2026-07-11] Paywall conversion pass (Mobbin paywall research applied): (1) checkout now passes subscription_data.trial_end (trial grant expiry, only when >49h out per Stripe's 48h floor) so a mid-trial upgrade charges 0 kr at checkout instead of double-billing days the company already has free; the subscription starts 'trialing', which subscription-sync already treats as access-granting, and billing/status now counts 'trialing' as isPaying (card committed = manage view). (2) Trial countdown became a sidebar touchpoint (CompanyContext.trialEndsAt via getCompanyEntitlements, hidden for sandbox and once any non-trial grant is active) instead of living only inside Inställningar → Abonnemang. (3) Sell view: honest what-happens-when timeline + free-vs-paid comparison table + risk-reversal copy under the CTA. Deliberately NOT copied from the research: fake urgency, last-minute discounts, spin-the-wheel, card-required-to-trial: trust-first product, and the free tier (freeze-and-retain) is a strategic choice, not a leak. External price anchoring ("costs less than an accountant hour") skipped: unverifiable claim. Billing components stay hardcoded Swedish per the file's existing convention. [2026-07-11] Counterparty template learning repair (#865): fixed the dead write path with ALTER COLUMN user_id DROP NOT NULL (kept the column and its data; a column drop is a separate cleanup) instead of re-plumbing user_id through the insert, because scoping is company_id-only since the multi-tenant refactor and RLS never reads user_id. Sign-mismatched matches (refund against an expense-learned template) are MIRRORED + requires_review rather than skipped: the swapped entry (debit bank / credit expense, VAT leg reversed) is the bookkeeping-correct refund shape, and skipping would just fall through to the dumb default; direction_mismatch results and opposite-direction "corrections" never write back into the template so a refund cannot flip the learned accounts. SIE extraction infers the 2641 VAT rate from voucher amounts (snap to 25/12/6% within 1.5pp, else drop the VAT leg) only when the voucher has exactly one deductible-VAT line: with several, each line's base is unknowable and the old 25% hardcode stays. [2026-07-11] Counterparty template follow-ups from the compliance-bot review of PR #989: RC exclusion set extended with import output-VAT accounts (2615/2625/2635); RC credit notes now mirror both fiktiv legs (credit 2645 / debit 2614, income line-builder nets VAT credits against debits) so Ruta 30/48 net to zero instead of leaving the prior RC output unreversed. The 1.5pp snap tolerance is derived from the smallest gap between legal rates (6pp between 6% and 12%): 1.5pp accepts ore-rounding drift on small vouchers while an ambiguous observed rate (e.g. 9%) snaps to nothing and drops the VAT leg. Livsmedel 12->6% transition (April 2026): templates store the LEARNED rate, so one applied to a backdated pre-transition purchase books the new rate; accepted, the booking is review-visible and re-deriving rate-by-date is out of scope here. [2026-07-11] Compliance-review round 2 on PR #989: (1) stale reduced_12 templates are review-gated across the livsmedel 12->6% transition (verified via swedish-vat skill: food dropped 2026-04-01, restaurang/hotell stay 12%) only when last_seen_date predates the transition, so actively-confirmed 12% counterparties keep flowing while pre-transition grocery templates get a human look; chose this over the bot's blanket flag-all-reduced_12 (too much friction) and over rate-by-date re-derivation (needs a rates table keyed on statute dates, out of scope). (2) Import-RC credit-note mirroring books the reversal on 2614 rather than 2615 (ruta 30 vs 60 attribution): accepted as-is because the entry balances, is review-gated, and the FORWARD legacy path has the same limitation (legacy fields cannot carry which output account history used); proper fix is persisting the learned RC output account, filed as future work. [2026-07-11] Momsdeklaration UI overhaul: deleted VatCompositionChart (donut mixed utgående/omvänd/ingående moms as slices of one pie, answering no filing question) and reduced the VAT ReportExportMenu to xlsx-only (XML/PDF are filing artifacts, now owned solely by the "Lämna in" card): both are one-commit reverts if vetoed. [2026-07-11] Hoisted local VAT checks + RC-gap worklist out of SkatteverketPanel into ungated VatChecksCard: the panel's paywall/not-connected early-returns hid compliance errors from exactly the users who file manually. [2026-07-11] NE/INK2 amounts display in whole kronor (matches filed SRU values per SFL); momsdeklaration keeps öre (reconciles against ledger and settlement verifikat). Numbered h2 section headers instead of a stepper component on the VAT page: same sequencing legibility, a tenth of the diff. [2026-07-11] Closed the v1/MCP-facing half of the #985 settlement-account gap (PR #985 itself only fixed the dashboard routes): v1 match-supplier-invoice now resolves paymentAccount via resolveSettlementAccount for the pure-SEK accrual path (was always hardcoded 1930, no call site even read cash_account_id); v1 categorize now calls applySettlementAccount after building mappingResult, which it never did before. Left the FX/foreign-currency branch (createSupplierInvoicePaymentEntry) and the cash-method branch (createSupplierInvoiceCashEntry) on their pre-existing internal 1930 default, matching #985's own scope decision on the dashboard route. Follow-ups tracked separately: #1000 (closing the FX/cash-method gap) and #1001 (detecting/remediating historical mis-bookings). [2026-07-12] Compliance-review triage on the payment-link PR: finding 1 (email pay button on kreditfaktura) verified FALSE: invoice-templates.ts derives isCreditNote from credited_invoice_id and hidePayment already gates both HTML and text builders; no change. Finding 2 was the real deferred v1 gap but misfiled against invoice-columns.ts (which already carries deduction_total): the actual hole was the v1 send route's hand-rolled fetch projection, now replaced with the shared INVOICE_FULL_COLUMNS/INVOICE_ITEM_FULL_COLUMNS so PDF/email inputs cannot drift from the GET shape again (closes the [2026-07-10] deferred ROT/RUT send fix; also gives v1 sends the pay button + deduction box). Finding 3 accepted as a robustness fix only: the non-ok path already reflected true server state, but a thrown fetch left the Godkann spinner stuck; approve handler now try/catch/finally with a server refetch on failure. [2026-07-11] Closed the customer-side half of the PR #985 settlement-account gap: match-invoice (POST + preview), the v1 match-invoice route, and the agent/MCP match_transaction_invoice commit path all hardcoded account_number: '1930' for the bank leg unconditionally (never read cash_account_id at all, worse than #985's stale-setting trigger). Added an optional paymentAccount param (default '1930', preserving every other caller) to buildInvoicePaymentClearingLines, createInvoicePaymentJournalEntry, and createInvoiceCashEntry, and threaded resolveSettlementAccount(transaction.cash_account_id) through the three real-transaction-matching call sites above. Left mark-paid (dashboard + v1, no bank transaction in scope), fix-cash-mismatch (narrow historical repair tool, different bug class), and the agent mark_invoice_paid commit path on default 1930 behavior: none of them have a matched bank transaction to resolve an account from. [2026-07-12] resolveSettlementAccount now throws BookkeepingDatabaseError('resolve_settlement_account', ...) instead of warning-and-falling-back-to-1930 when the cash_accounts lookup itself errors (compliance-bot finding, same change applied identically across #985/#986/#987, shared helper file): an explicit cash_account_id almost certainly resolves to a non-1930 account, so a transient DB blip masking it must not silently misbook a real payment. No route/commit.ts code changes needed: match-invoice (POST + preview) run under withRouteContext, whose existing catch-all converts any isBookkeepingError() throw into a structured 500; commitMatchTransactionInvoice's caller (commitPendingOperationInner) already has identical generic bookkeeping-error handling for every other engine failure (marks the op 'rejected', returns status 'failed'). Added regression tests for all three call sites confirming the abort rather than assuming the shared infrastructure handles it silently. Did NOT add v1 match-invoice test coverage for this (or for the settlement-account fix in general): that route has no existing test coverage in the shared app/api/v1/.../[id]/tests/route.test.ts file at all -- a pre-existing gap from this PR's own scope, not something this specific fix should expand to cover. [2026-07-12] Closed the two remaining gaps from jakobwennberg's adversarial-review triage on #987 (after rebasing onto main): (1) added the v1 match-invoice route-level test coverage that the prior entry above explicitly deferred -- cash-account threading to createInvoicePaymentJournalEntry, the BOOKKEEPING_DATABASE_ERROR abort on lookup failure, and a new ACCOUNTS_NOT_IN_CHART case -- in app/api/v1/companies/[companyId]/transactions/[id]/tests/route.test.ts, mirroring the dashboard route's existing settlement-account-resolution describe block. (2) Added the same findUnresolvableAccounts pre-validation guard against chart_of_accounts that 32c07c4 added to #986's match-supplier-invoice route, to the v1 match-invoice route: gated on !customLines since that is the only branch here that actually consumes the resolved paymentAccount (customLines specify their own accounts directly). The dashboard match-invoice route and the agent/MCP commit path were not given the equivalent guard: jakobwennberg's note named only the v1 surface, and those two paths don't have the same "generic engine error swallows a specific chart violation" failure mode this guard exists to avoid (v1's own catch block already special-cased AccountsNotInChartError; the guard just avoids reaching it via a wasted engine round-trip and gives an explicit pre-check log line). [2026-07-12] resolveSettlementAccount now throws BookkeepingDatabaseError('resolve_settlement_account', ...) instead of warning-and-falling-back-to-1930 when the cash_accounts lookup itself errors (compliance-bot finding, same change applied identically across #985/#986/#987 since it's the shared helper file): an explicit cash_account_id almost certainly resolves to a non-1930 account, so a transient DB blip masking it must not silently misbook a real payment. No route code changes needed here either -- v1 match-supplier-invoice and categorize both already run under withApiV1, whose existing catch-all converts any isBookkeepingError() throw into the correct structured 500 via v1ErrorResponse. Added regression tests for both v1 call sites confirming the abort (status 500, code BOOKKEEPING_DATABASE_ERROR, no JE created) rather than assuming the shared infrastructure handles it silently. [2026-07-12] #986 review follow-up (CodeRabbit + jakobwennberg triage): v1 match-supplier-invoice now pre-validates the resolved settlement account against chart_of_accounts before booking the pure-SEK accrual entry, returning ACCOUNTS_NOT_IN_CHART instead of the generic MATCH_SI_RECORD_PAYMENT_FAILED for a deactivated cash_accounts.ledger_account; same AccountsNotInChartError race-guard added to the catch block, mirroring the categorize routes' existing pattern. [2026-07-11] match-supplier-invoice (POST + preview) misbooked a real bank payment to 2893 (skuld till aktieägare) instead of 1930: both routes defaulted paymentAccount from company_settings.last_supplier_payment_account, a sticky setting only meant to remember the manual mark-paid "betald med privata medel" account choice. Once that setting held 2893 from an unrelated private payment, every subsequent real bank-transaction match reused it. Fixed by resolving the credit account from the matched transaction's own cash_account_id -> cash_accounts.ledger_account (falling back to 1930 when unlinked), mirroring the existing settlement-account lookup in transactions/[id]/categorize/route.ts. last_supplier_payment_account is no longer read by either route; it stays scoped to seeding the manual mark-paid UI's default picker. Did not touch the FX branch (createSupplierInvoicePaymentEntry, still defaults paymentAccount internally to 1930) or the cash-method branch (createSupplierInvoiceCashEntry, called with paymentAccount=undefined): both are pre-existing, separate gaps outside this bug's repro (a pure-SEK accrual match). [2026-07-11] Extracted the cash_account_id -> ledger_account resolution (identical in match-supplier-invoice POST, its preview, and transactions/[id]/categorize) into resolveSettlementAccount (lib/bookkeeping/settlement-account.ts), per CodeRabbit's dedup nitpick on PR #985. Pure behavior extraction, no logic change. Investigated whether other transaction actions should adopt it: bulk-book/book already resolve the account client-side (components' shared resolveAccount in lib/cash-accounts/resolve-account.ts) before the manual lines reach the server, so no gap there. Found two real gaps left open, NOT fixed here (bigger surface, deserve their own review): (1) the customer-side match-invoice route (POST + preview) and the underlying createInvoiceCashEntry/buildInvoicePaymentClearingLines (lib/bookkeeping/invoice-entries.ts, invoice-payment-lines.ts) hardcode account_number: '1930' unconditionally, never reading cash_account_id at all, so any customer receipt landing in a non-primary bank account is misbooked, same defect class as this PR fixed but present unconditionally rather than only when a stale setting fires; (2) the /api/v1 (MCP-facing) match-supplier-invoice route still calls createSupplierInvoiceCashEntry/createSupplierInvoicePaymentEntry with paymentAccount left undefined (defaults to 1930 internally), i.e. the pre-#985 bug's underlying gap is reachable through the public API/MCP tool surface even after this fix merges. The v1 categorize route has the analogous gap: it never calls applySettlementAccount after building its mapping result. [2026-07-11] Closed the remaining items from the Swedish-accounting-compliance bot review on PR #985: (1) the FX branch (createSupplierInvoicePaymentEntry) and cash-method branch (createSupplierInvoiceCashEntry) in match-supplier-invoice/route.ts were already computing paymentAccount via resolveSettlementAccount but not passing it through to those two calls (only the pure-SEK clearing branch used it) -- both functions already accepted an optional paymentAccount param (paymentAccount || '1930' internally), so this was a one-line threading fix per call site, not a new code path; the preview route already threaded it everywhere, confirmed by reading its cash/FX preview branches. (2) resolveSettlementAccount now also warns (and still falls back to 1930) when cash_account_id resolves to a row with no ledger_account, not just on a hard query error: a bound-but-empty ledger_account is a data-integrity gap, not a normal unlinked-transaction case, and previously fell back silently. (3) Added a column comment on company_settings.last_supplier_payment_account (migration 20260711140000) documenting that it must never be read to resolve a matched transaction's settlement account. [2026-07-12] resolveSettlementAccount now throws BookkeepingDatabaseError('resolve_settlement_account', ...) instead of warning-and-falling-back-to-1930 when the cash_accounts lookup itself errors (an explicit cash_account_id almost certainly resolves to a non-1930 account, so a transient DB blip masking it must not silently misbook a real payment -- a failed request the caller can retry beats a wrong verifikat needing a storno). Left the "row found but ledger_account is empty" case as warn+fallback: that's a data-integrity gap, not a query failure, and a prior compliance-bot round only asked for a warning there. No caller changes needed: every route here already runs under withRouteContext/withApiV1, whose outer catch-all already converts any isBookkeepingError() throw into the correct structured 500 via errorResponse/v1ErrorResponse, and lib/pending-operations/commit.ts's dispatcher already has the identical generic bookkeeping-error handling for every other engine failure. Same change applied identically across #985/#986/#987 (shared helper file). Filed #1000 to track the still-open FX/cash-method paymentAccount gap in the /api/v1 and MCP-facing match-supplier-invoice route (the main match-supplier-invoice route's FX/cash-method branches already thread paymentAccount via resolveSettlementAccount, per the entry above; deliberately out of scope, matches this PR's own scope decision) and a new issue to track historical-mis-booking remediation (PR #986 compliance bot finding #3, a distinct detection/correction initiative never claimed in scope by any of these three PRs). [2026-07-12] pain.001 salary dialect hardened to the Swedish Common Interpretation (Bankforeningen "Common Payment Types in Sweden" Appendix 1, Example 4 Salaries; cross-checked vs Nordea Corporate Access pain.001 examples v2.6, 2026-06-22): dropped SvcLvl SEPA (SEPA credit transfer is EUR-only; omitting SvcLvl gets the domestic NURG default), dropped RmtInf (Nordea: remittance info not allowed for SALA; statement text comes from the Dataclearing LON code), creditor addressed as CdtrAgt ClrSysMmbId SESBA + CdtrAcct Othr SchmeNm BBAN with the account WITHOUT clearing, Dbtr now carries OrgId, all ids clamped to Max35Text. The clearing/account split (Swedbank 5-digit shift, Nordea personkonto prefix dedup) is extracted to splitDomesticBankAccount in lib/salary/payment/bank-account.ts and shared by BOTH the bg-lb and pain001 generators so the two formats can never route a payment differently again (pain001 previously concatenated raw digits and duplicated the personkonto clearing). Swedbank MmbId = first 4 clearing digits with the 5th shifted into the account, mirroring the production-proven LB encoding and the appendix's 4-digit MmbId salary example; run a generated file through Swedbank Validex (and the other banks' test uploads) before the 1 Aug Bg Lon campaign. [2026-07-12] ESG/CO2 reporting parked (no build): external revisor review flagged its absence; not a purchase criterion for the target segment (tech-native sjalvbokforare). Revisit on real customer demand; likely shape then is a spend-based CO2 estimate on supplier invoices as an extension, not core. [2026-07-12] "Projektredovisning" split into two scopes after the revisor review: full project accounting (WIP, successiv vinstavrakning, budget follow-up) parked indefinitely; light time-to-invoice (time entries to invoice rows; schema support already exists via project_time_entries and dimension FKs) stays an OPEN 2026 positioning decision, deliberately not committed yet. [2026-07-12] Stripe integration ships as Connect OAuth from day one (Emil's explicit call over the API-key-first recommendation): we store only the connected acct_ id, never a key or token, so no encryption story is needed; revocation works from either side. Payment Link only, never a Stripe Invoice object: a second legal invoice with its own number series would violate the single-faktura principle, and Stripe Invoicing costs extra per invoice. The paste-link MVP column (payment_link_url) is auto-filled exactly as its 2026-07-09 decision anticipated. [2026-07-12] Core reaches the Stripe extension through the existing Extension.services registry field (lib/extensions/payment-links.ts bridge) instead of an event: the link must exist BEFORE the email/PDF render, so an after-the-fact handler cannot work, and a direct import from the send route would break the zero-extensions core build. Link creation failure degrades to a PARTIAL warning: invoice dispatch is the legal act and never blocks on a PSP. [2026-07-12] Stripe payments settle against 1686 (Fordringar for kontokort och kuponger), NOT the traditional 1580: the BAS board moved card/coupon acquirer receivables from 1580 to 1686 (a receivable on the acquirer belongs under Ovriga kortfristiga fordringar, not Kundfordringar), which is exactly why the repo's full BAS 2026 import lists 1580 among the removed non-standard accounts. A first attempt re-added 1580 to bas-data; reverted after checking bas.se, since 1686 already exists in the catalog. Payouts book Dr 1930 net / Dr 6570 fees / 4535+4598 basis pair / 2645+2614 fiktiv moms / Cr 1686 gross, reusing the supplier reverse-charge generators since Stripe Payments Europe is Irish (EU services RC, rutor 21/30/48). The 1930 line surfaces in get_unlinked_1930_lines for bank-rec linking, so the deposit is never double-booked. [2026-07-12] Stripe sync auto-posts WITHOUT pending-operations staging when the match is fully deterministic (exact payment-link id or invoice id + exact remaining amount + currency + livemode; payouts additionally require only charge/payment balance txns, SEK, VAT-registered, gross-fees=net): consistent with the determinism doctrine and the recurring/reminder/accrual cron precedent. Everything else lands as needs_review rows (stripe_payment_events / stripe_payouts) shown in the settings panel: never guessed, never dropped. Non-SEK and refund-bearing payouts are deliberately out of v1 automation scope. [2026-07-12] mark-paid orchestration extracted verbatim into lib/invoices/settle-invoice-payment.ts so the Stripe cron and the manual route share one booking/CAS/orphan-cancel/event path; the route's existing test suite (19 tests) is the regression net. The duplicate-payment guard stays route-side: for the cron the Stripe event IS the authoritative payment. stripe_payments joined PAID_CAPABILITIES with a migration backfilling grants by mirroring bank_sync (existing payers would otherwise stay dark until their next billing webhook). [2026-07-12] Skatteverket hybrid auth: system CCG (org certificate) for background reads, personal BankID flow kept for interactive submissions: SKV per-flow refresh tokens live 65 min so crons structurally cannot run on them; full ombud switch deferred until CCG docs/avtal land (all code behind SKATTEVERKET_SYSTEM_AUTH_MODE=off, stub transport, retiring user-token reads later is a policy change in resolve-auth.ts only). [2026-07-12] Kvittens notifications are email-only from the skatteverket extension (notification_log dedup under new skv_kvittens type), not push: push-notifications is a disabled extension and cross-extension imports are not allowed; wiring an event handler there was speculative. Revisit if push-notifications gets enabled. [2026-07-12] Fixed silently-dead AGI deadline auto-complete (generate-declaration.ts updated non-existent columns type/period/status=completed since inception): replaced with shared lib/deadlines/complete-tax-deadline.ts (tax_deadline_type/tax_period/is_completed), also now called from the kvittens crons and the moms inlamnat/beslutat handlers. [2026-07-12] One-click VAT submit chains kontrollera->utkast->las server-side in vat-submit.ts with a stage discriminator (validation aborts pre-write, lock failure reports draft_saved); the pending-operations commit path reuses the same chain WITHOUT the kontrollera pre-step since staged figures were already reviewed. Step-by-step buttons demoted to the overflow menu, not removed. [2026-07-12] ROT/RUT beslutsfil import matches begaran by stored skv_referensnummer first, then exact name among active undecided requests; arenden match by fakturanummer then personnummer, exactly-one or the beslut errors (all-or-nothing, determinism principle). Never auto-settles: recording the beslut and booking the payout are separate acts. [2026-07-12] AGI tax_paid_at auto-settles from skattekonto sync only when the booked AGI debit row matches the declared total to the ore AND saldo >= 0: deficit or amount drift means something is still unpaid, so those fall back to the manual mark-paid button (determinism over inference). Salary card reconnect hint fires only on needs_reconsent, never on routine 65-min token expiry (that would nag every user). [2026-07-12] Cloud-backup auto-sync defaults ON after Google Drive connect (opt-out), with the first backup kicked off in the background via next/server after(): a backup that defaults to off protects nobody; reconnects keep the user's existing schedule. [2026-07-12] Backup failure alerts email only the schedule-owning user (must still be an active company member), throttled to one per company per 7 days; needs_reauth alerts once per incident: silent backup failure is the worst outcome, weekly nagging the second worst. [2026-07-12] Cloud-backup cron due-logic changed from exact hour match to "daily slot passed and no attempt since it": a time-budget overrun previously skipped the leftover companies for the entire day. Schedule hour now stored as Europe/Stockholm wall-clock (hour_local, DST-stable), hour_utc kept as legacy fallback. [2026-07-12] Backup dump classification is enforced by tests/pg/full-archive-coverage.pg.test.ts (every company_id table must be dumped, covered elsewhere, or excluded with a reason). The dump list had rotted: salary/assets/dimensions/articles/rot-rut/voucher_gap_explanations were never added, invoice_items/supplier_invoice_items/receipt_line_items were queried by a company_id column they do not have, and transactions was ordered by nonexistent booking_date: all three produced silent error stubs in every existing backup. [2026-07-12] Drive backup layout is one "Arkiv .zip" per rakenskapsAr + Grunddata.zip + LASMIG.txt, updated in place with per-file fingerprints, instead of a new timestamped full ZIP per sync: bounds Drive usage and nightly upload size; Drive keeps ~30 days of prior versions of updated files. Old timestamped files are left untouched. [2026-07-12] Per-archive-file size limit is 300 MB (not the plan's ~750 MB) despite resumable/chunked uploads: JSZip builds each archive fully in memory on a serverless function; per-year splitting makes the limit per rakenskapsAr, which is the real unlock. [2026-07-12] Archive reports get CSV twins (semicolon-separated, decimal comma, UTF-8 BOM for Swedish Excel) instead of PDF: zero new dependencies; the JSON stays canonical and a CSV formatting error can never take down the archive (per-file try/catch). [2026-07-12] Kvittens email dedup: notification_log row is now inserted FIRST as an atomic claim (partial unique index 20260712113000 on user_id+reference_id where notification_type = 'skv_kvittens'; 23505 = already claimed, claim released on send failure), and non-uuid reference ids (the VAT cron's composite key) are mapped to a deterministic SHA-256-derived uuid inside kvittens-notification.ts: reference_id is a uuid column, so the old string key silently failed both the dedup select and the insert (22P02); normalizing in-module beats widening the shared column to text or changing the cron's key formula. [2026-07-12] applyPaymentLinkToInvoice (shared send-route payment-link helper) lives in lib/extensions/payment-links.ts, not extensions/general/stripe/lib/payment-links.ts as the review suggested: both send routes reach payment links through the core registry bridge, and a core route importing the Stripe extension directly would break the zero-extensions core build; per-route logging differences are preserved via logPrefix/logContext options. [2026-07-12] Global/app error boundaries recover via a guarded hard window.location.reload(), not React reset(): reset() re-renders against the same stale server payload/bundle and re-throws, whereas a reload re-runs middleware (fresh rotated Supabase auth cookie) and fetches a fresh bundle (ChunkLoadError after a deploy), matching the browser-navigation self-heal these transients already relied on. A per-path, per-tab-session sessionStorage flag (a monotonic one-shot, not a time window, which could still loop when a failing render takes longer than the window) bounds it to one auto-reload per path so a persistent error shows the manual fallback instead of looping. [2026-07-13] Skatteverket post-connect refresh (skattekonto sync + kvittens re-check) is AWAITED in the OAuth callback before responding, not deferred via after(): SKV per-flow tokens live ~65 minutes so right-after-consent is the only reliable personal-token window, and awaiting means popup-close = data fresh, so the salary/skattekonto pages' refetch listeners never race a background job. The SKV client's own request timeouts bound the added latency; failures are swallowed (best-effort) so a refresh error can never fail the connect that just succeeded. [2026-07-13] AGI auto-settlement matches production's split draw as an exact pair (exactly one "Avdragen skatt " == total_tax AND exactly one "Arbetsgivaravgift " == total_avgifter, anchored regexes) rather than summing all rows parsed to the period: summing would let "Beslut ..." correction rows poison the total, and duplicate rows are ambiguous by definition. Anything non-exact falls back to the manual mark-paid button (determinism over inference). [2026-07-13] Settings Skatteverket connect switched to the AGIPanel popup+postMessage pattern (full-page redirect kept only as popup-blocked fallback): the full-page round-trip leaves SKV pages + the consumed callback URL in browser history directly beneath /settings/tax, so closing settings (router.back()/browser Back) walked into a dead OAuth chain and re-prompted SKV auth. Fixing the history stack in-place (location.replace) only shrinks the problem; not navigating at all removes it. [2026-07-13] postMessage hardening uses event.source identity (popup handle in a ref) plus a verified rebroadcast CustomEvent ('skatteverket-connection-updated') instead of the nonce the SOC2 finding suggested: a window reference cannot be forged by same-origin scripts, so the source check is strictly stronger than a nonce threaded through the OAuth flow, and pages that never open the popup (salary dashboard) consume the rebroadcast from the component that did verify it. [2026-07-13] Kvittens cron HTTP response drops companyId per row (GDPR minimization) but keeps declarationId: it is an opaque UUID useless without DB access, the endpoint is cron-secret gated + Cache-Control: no-store, and losing it would make per-run ops debugging blind. The extension_data delete race flagged by the swarm is documented-and-accepted: the agi_submission_ key is period-scoped by design and agi_declarations is UNIQUE per company+period, so no two declarations share a key. [2026-07-13] signeradAv (personnummer in agi_declarations.response_data) is documented in .compliance/ropa.yaml under the existing agi.submit entry (Art.6(1)(c), BFL 7 kap 2 par retention) rather than moved to a dedicated column with column-level grants: it is part of the SKV kvittens payload preserved verbatim as rakenskapsinformation, and submitted_by is now explicitly documented as the technical submitter with response_data.signeradAv as the authoritative legal signatory. [2026-07-13] One-click AGI submission ("Lamna in till Skatteverket") orchestrates the existing panel endpoints client-side (xml -> submit -> kontrollresultat -> granskningsunderlag) instead of reusing the server-side commitSubmitAgi chain: the MCP commit path carries pending-operation semantics (audit rows, monotonic flips, recoverable codes) that the interactive UI does not want to re-plumb, and the client chain preserves the panel's existing per-step error surfaces. Signing tab is opened synchronously at click (placeholder) and navigated on success to dodge popup blockers. [2026-07-13] Dashboard invoice send route rejects non-drafts (409 INVOICE_ALREADY_SENT) and gates JE/archive/event on winning an optimistic-locked draft->sent flip; on a flip DB error the JE is now DEFERRED (previously posted anyway, and v1 still posts): with the row left in draft, a retry re-runs the pipeline and ends with exactly one verifikat, whereas booking on a failed flip sets up a duplicate JE on retry. Cost: a duplicate customer email on retry, judged cheaper than duplicate revenue. [2026-07-13] Enable Banking OAuth callback streams an interim "Slutfor bankanslutningen" HTML page (shell first, work, then script/meta redirect) instead of redirecting to settings and finalizing via a client-called endpoint: keeps the one-time authorization code server-side in a single round trip, needs no new endpoint or polling, and the global CSP already permits inline script. The blank-tab gap during createSession + cash-account mirroring was the reported "no loading state after redirect". [2026-07-13] Failed bank-connect attempts that never activated (status pending/error, no session_id, no accounts_data) are DELETED, not parked as status=error: parked rows rendered forever as "Atgard kravs" cards next to a successful retry, showing duplicate connections to the same bank. Deletion is safe (transactions/cash_accounts FKs are ON DELETE SET NULL; never-activated rows have no dependents) and the stale-pending cron already deletes such rows. Established connections keep the error/expired card via the accounts_data guard. [2026-07-13] Recurring auto-send sandbox enforcement lives inside sendInvoiceFromSchedule (isSandboxCompany at the email chokepoint, freeze-and-retain: invoice still created as draft) instead of route-level guardSandbox on run-now/create: run-now legitimately generates invoices in the sandbox, only the outbound email is forbidden, and one guard at the chokepoint covers both cron and run-now. Cron failure warnings overwrite (not append to) last_run_warning; the stale roll-forward message wins over the per-attempt failure detail because it carries the actionable state (skipped date + next run + "Skapa faktura nu" hint). [2026-07-13] Payroll gap-closure: personnummer stays MASKED on the v1 payslip detail endpoint (deviation from the "full value on detail" convention): a payslip is a pay document, not an identity record; the employee master GET remains the only full-pnr drill-in. On MCP, personnummer is masked on EVERY tool (LLM context is a leak surface), incl. encrypt-at-staging for create_employee so pending_operations.params never persists plaintext. [2026-07-13] Absence v1 API is range-in/per-day-storage: PUT expands [from,to] server-side (weekends skipped by default, 92-day cap) onto the (employee,date,type) natural key. Per-day rows are non-negotiable (karens/aterinsjuknande/hogriskskydd + AGI Franvarouppgift derive from dates); the range payload is pure ergonomics. PUT added to the v1 wrapper's REQUIRES_IDEMPOTENCY set: without it test keys would write through PUT for real. [2026-07-13] UpdateEmployeeSchema rebuilt on a defaults-stripped base (EmployeeSchemaPatchBase): Zod 4 applies .default() through .partial(), so any sparse PATCH body materialized salary_type='monthly' and failed the byte-till-loneform refinement (latent bug: first surfaced by jamkning-only patches), and routes spreading the parsed body silently reset defaulted columns. [2026-07-13] Scope-map audit retrofit ships accept-the-break (Emil 2026-07-13): 4 previously unmapped write tools (link_invoice_to_voucher, undo_sie_import, post_annual_depreciation, import_rot_rut_beslut) now require their scopes; keys relying on the default-allow hole lose access. No grandfathering migration (unlike gnubok_remember_fact): these were security holes, not granted capabilities. Release-note callout required. [2026-07-13] employee_opening_balances lock is DERIVED (trigger checks for a booked run), not a locked_at flag: cannot drift, needs no hook in the two book routes, and self-unlocks when the only booked run is corrected, which is exactly when re-editing cutover state is legitimate. Ongoing sick cases get NO dedicated fields: imported pre-cutover salary_absence_days rows reconstruct segments exactly; only the karens-period count not covered by imports is a field (over-suppression of karens is the softer error). [2026-07-13] Opening-balances bulk PUT is atomic all-or-nothing (validate every item, 400 with per-item errors and zero writes) rather than 207 partial: byra onboarding wants "all imported or fix the file"; partial success forces the caller to diff. Cutover YTD merges into the engine's ytdByEmployee for display/reports only: verified that AGI is per-run and youth/vaxa avgifter caps are per-month, so no calculation reads YTD. [2026-07-13] runSalaryCalculation opening-balance merge has no dedicated unit test (no existing mock harness for the full orchestrator; building one is ~15 brittle queued queries): covered by type-check + the pure-function karens tests + vacation-liability tests; the pg-real suite and the E2E cutover smoke are the integration net. [2026-07-13] MCP tools/list payload ceiling bumped 45.5K -> 50K for the 8 payroll tools: create/update_employee carry the full employee-config schema by design (agent-driveable onboarding is the point); descriptions trimmed to enum-only where self-evident first. [2026-07-13] Arbetsschema-lite divisors keep the LEGACY constants (173/21) exactly at the default 40h/5d schedule and use the exact 52-week formula only for non-default schedules: switching defaults to exact formulas (173.33/21.67) would shift every running company's monthly-to-hourly derivation ~0.2% and sick/VAB daily deductions ~3% mid-year with zero schedule change. Exact-formula migration deferred to a fiscal-year boundary. Precedence: employment_degree prorates BASE SALARY, hours/workdays per week ONLY drive divisors; deliberately not reconciled. [2026-07-13] Vacation ledger (employee_vacation_balances) is DAYS-ONLY and RECOMPUTED (never incremented) from booked runs on every book/correct, with lazy seeding from cutover opening balances or the legacy vacation_days_saved (attributed to the previous year: expires earlier, never later). SEK stays derived: persisting a parallel SEK column would create a reconciliation obligation with zero new information. Ledger sync is non-fatal in all three hooks: a ledger bug must never block a legally required booking. [2026-07-13] Semesterberedning + arsavslut is ONE two-phase verb (dry-run report, then commit), not two: both act on the same year boundary, the review report is only judgeable with both halves, and two verbs would create half-closed states. Days roll books NOTHING (reclassifying days moves no liability); only the 2920/2940 drift adjustment posts, via createJournalEntry with source_type 'salary_payment' + source_id = closure id (no new source_type: avoids a CHECK expansion). Untaken days at/below the 20-day floor are FLAGGED, not auto-saved (Semesterlagen 18 par.); expired 5-year savings become forced_payout_days paid via a normal run. [2026-07-13] Year-close SEK reconcile uses a day-valued computed liability (simplified BFNAR 2016:10: sammaloneregeln dagslön+tillägg, procentregeln annual basis x 12%/14.4% over entitled days, hourly via hours_per_week) against the trial-balance 2920/2940 closing balances: per-run accruals never relieve 2920 when vacation is taken, so drift accumulates BY DESIGN and the annual adjustment is the correction mechanism. Avgifter on the computed liability use flat 31.42% (per-employee reduced rates called out in the report, not silently applied). vacation_year_closures has NO DELETE policy: the frozen report is the underlag for the adjustment verifikat (BFL 7 kap); reopening is a future explicit feature. [2026-07-13] company_settings.salary_vacation_year_basis (calendar default | statutory_apr_mar) can only change while ZERO open ledger rows exist (settings PUT guard): rows are keyed by vacation_year_start and a basis flip would orphan them. Settings UI deferred; calendar is right for the segment. [2026-07-13] Page-load perf: root cause was Vercel functions in iad1 vs Supabase in eu-north-1 (~100ms per DB/auth round trip, measured 530-1900ms for single-query GETs); fix = "regions": ["arn1"] in vercel.json rather than any code-level caching first. Local JWT verification (getClaims + asymmetric keys) deliberately DEFERRED: the region move collapses getUser() to ~1-3ms, so changing auth semantics (revocation window) is not worth it now. [2026-07-13] VAT declaration aggregation moved into get_vat_declaration_totals RPC (totals + settlement-shape detection + source_type counts in one round trip, SECURITY INVOKER). Account lists stay TS-side parameters (ACCOUNT_RUTA remains the single source of truth; mapping changes must never need a migration). The #984 shape-exclusion unit tests moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts per repo convention (RPC behavior is pg-tested, not mocked). [2026-07-13] Client data caching: chose SWR over TanStack Query (lighter, fetch-shaped hooks fit the codebase; MIT). Converted useCompanySettings + nav badges; pending page realtime stampede fixed with a 400ms trailing debounce instead of a full SWR rewrite of the page. [2026-07-13] next-intl message splitting SKIPPED: ~25 of the main pages are client components, so nearly every namespace is needed client-side; pick()-based splitting would save little and risk MISSING_MESSAGE regressions. Real win would be route-level splitting, deferred until more pages are server components. [2026-07-13] Öresavrundning in mark-paid: absorbing the sub-krona residual is gated on the caller lines actually carrying it on 3740 (planInvoicePaymentForLines); mismatch FALLS BACK to the strict plan instead of rejecting, so deliberate sub-krona partials keep working and no-3740 overshoots keep the pre-change 400. Rejecting outright would have broken the documented v1 partial-payment flow. [2026-07-13] planInvoicePayment absorb-band boundary fixed to >= 1 kr (exactly-1-kr overshoot used to slip past both the guard and the absorb branch and over-record paid_amount). Supplier mirror planSupplierPayment deliberately NOT touched (same hole exists, reachable via match-supplier-invoice; separate change to keep this diff scoped). v1 match-invoice + MCP match paths still reject öre overshoot on bank matches (their line builders lack 3740); also deferred. [2026-07-13] Compliance-report V8.2.1 remediation (403 on cross-company v1 access) REJECTED in favor of the existing 404: withApiV1 already enforces key-user-to-URL-company membership, and 404 deliberately avoids leaking which company ids exist. Pinned with route-level tests on the payslip PDF endpoint instead. [2026-07-13] CSP unsafe-inline (ASVS V3.3): fixed with a per-request nonce CSP on the enable-banking finalize page (mirrors mcp-oauth consent page); removing 'unsafe-inline' from the GLOBAL next.config script-src is deferred, it requires an app-wide middleware nonce pipeline covering the Next.js bootstrap plus recapt/Enable Banking scripts and carries real breakage risk. [2026-07-13] Recurring-invoice sandbox defence-in-depth: cron and run-now routes now resolve isSandboxCompany themselves and pass an explicit suppressAutoSend flag to executeRecurringSchedule, keeping the internal email chokepoint as the second layer. A route-level guardSandbox 403 was rejected: the cron is cross-company (no single company context) and sandbox schedules must still draft invoices (freeze-and-retain). [2026-07-13] pending_operations PII chokepoint (ISO 27001 A.8.11): stagePendingOperation now rejects plaintext personnummer-bearing KEYS in params/preview_data. Key-based, not value-based detection: enskild firma org numbers ARE personnummer, so value-pattern matching would false-positive on legitimate counterparty data. [2026-07-13] Enable-banking callback finalize work decoupled from the response stream (eager promise + next/server after()) so a client disconnect cannot abort session persistence or the consent_granted audit emit. A persistent outbox/dead-letter for audit events was rejected as disproportionate; failed emits now log through the structured logger for alerting (A.8.15). [2026-07-13] Declined CodeRabbit suggestion to rewrite migration 20260713100000 (pending_operations CHECK) as NOT VALID + VALIDATE: the migration is already committed (2084a756) and applied to staging, and modifying shipped migrations is forbidden; pending_operations is small enough that the brief lock is a non-issue. [2026-07-13] Vacation year close: 2940 target now uses per-employee age-tier avgifter (0% born <=1937, 10.21% fyllt 67 vid årets ingång at the settlement year, else 31.42%) instead of flat 31.42% (compliance-review finding): per-run accruals already credit 2940 at each employee's actual rate, so a flat target would "correct" a right booked balance to a wrong one for companies with 67+ staff. The temporary youth discount is deliberately NOT provisioned (payment-month- and cap-dependent, expires Sep 2027; the full rate is the prudent target per ÅRL försiktighetsprincipen), so youth accruals show a top-up drift at close. [2026-07-13] calculateAgeAtYearStart is now birth-year based ((year - 1) - birth year) instead of birthday-inclusive age at Jan 1: Skatteverket applies "vid årets ingång fyllt X" rules as birth-year ranges (2026 youth cohort = born 2003-2007), and the old semantics misclassified employees born exactly on January 1 in both directions (born 2008-01-01 wrongly youth-rated, which AGI validation rejects; born 2003-01-01 wrongly standard-rated; born 1959-01-01 wrongly given the 67+ reduction a year early). [2026-07-13] employee_opening_balances created_by preserved via read-then-upsert, not a DB trigger: a BEFORE UPDATE trigger would need a new migration for a pure audit concern; the extra select is one indexed query and the lock trigger already backstops races. [2026-07-13] Opening balances are authoritative for pre-cutover YTD: runSalaryCalculation now excludes booked runs before the cutover month from the YTD aggregation for employees with opening balances, instead of blocking pre-cutover backdated runs (backfill of history is a supported flow). [2026-07-13] Superseded the 2026-07-13 decline of the NOT VALID suggestion for migration 20260713100000: Emil asked to resolve the PR findings, and the migration is branch-only (verified absent from prod schema_migrations), so the never-modify-shipped-migrations rule does not apply; staging already recorded the versions, so edits only change what prod runs at merge. Implemented as ADD ... NOT VALID in 20260713100000 + 20260713121000 with VALIDATE split into 20260713123000: VALIDATE in the same transaction as ADD would be a no-op since Postgres holds the ACCESS EXCLUSIVE lock until commit; a separate migration file gets its own transaction and validates under SHARE UPDATE EXCLUSIVE. 20260713123000 applied to staging (no-op VALIDATE) and version recorded. [2026-07-14] Codex path-specific guidance stays in the single root AGENTS.md, which dispatches to the shared .claude/rules sources: nested AGENTS.md files were declined to keep one project instruction file, and .codex/config.toml has no Claude-style paths matcher. [2026-07-14] MCP company selection is stateless per tool call with shared API-key scopes: membership and viewer write access are rechecked for the selected company, while per-company scope overrides are deferred to avoid stateful connection races and premature configuration complexity. [2026-07-14] Credit notes are blocked from paid and partially_paid states with early TypeScript guards plus a database CHECK: early guards prevent orphan payment vouchers and provide clear errors, while the constraint protects every remaining RPC and legacy caller. Production had zero existing violations in a count-only audit, so ADD NOT VALID and VALIDATE ship as separate migrations to avoid scanning under the stronger ADD lock. [2026-07-14] Automatic invoice reminder timing uses three strictly increasing company settings from 1 through 365 days, defaulting to 15, 30, and 45: separate columns keep validation and settings forms explicit, while the cron falls back to the legacy schedule if it encounters invalid legacy data. [2026-07-14] Credit notes are created as non-editable but hard-deletable drafts and issue through a compare-and-set plus an idempotent bookkeeping repair path: this preserves the original until the reversing voucher and accrual storno are durable, lets failed drafts be recreated with the same KR number, and blocks the generic MCP invoice executors from bypassing the lifecycle. [2026-07-14] Invoice send actions mention bookkeeping only when issuance creates a journal entry: accrual invoices and ledger-reversing credit notes say send and post, while ordinary cash-method invoices say only send because they are posted at payment. [2026-07-15] Article DELETE performs a company-scoped existence check and blocks when any invoice item references the article before hard deletion; legacy inactive articles remain visible in the register so users can reach and delete them: invoice lines retain frozen accounting values, unused article master data has no retention requirement, and the article-number counter is deliberately not rewound because gaps are harmless. [2026-07-15] Invoice copies start as new drafts and omit dates, lifecycle state, payment links, accrual periods, customer references, and recipient-specific ROT/RUT data: copied invoices must never inherit bookkeeping or stale customer-specific data from the issued original. [2026-07-15] Manual supplier invoice uploads reuse the existing WORM document archive and supplier_invoices.document_id: accrual and privately paid invoices link the document to their registration-time journal entry, while cash-method invoices retain it on the invoice until the existing payment flow links it to the payment entry. [2026-07-15] Supplier payment vouchers surface the supplier invoice PDF as a read-only referenced document instead of moving or duplicating its journal link: the retained source stays on the original registration voucher while the full verification chain remains directly reviewable from the payment voucher. [2026-07-15] Stornoing an opening_balance entry now clears fiscal_periods.opening_balance_entry_id inside reverseEntry, rather than making the year-end gate skip reversed IB entries: a status-aware gate alone is strictly worse, because the close would then proceed to generateOpeningBalances, whose bare UPDATE of opening_balance_entry_id is rejected by enforce_opening_balance_immutability while the old pointer is still set, trading a clear blocker for an opaque Postgres exception after the period is already locked and closed. Clearing at storno time also lets getOpeningBalances fall through to the duplicate-safe compute_prior_opening_balances RPC (it has no status filter and would otherwise keep rendering a cancelled IB), and mirrors the bank-transaction unlink already in reverseEntry. The write is two statements (flag, then pointer) because the trigger reads OLD.opening_balances_set; same order as replace_period_opening_balance_link. Not fixed by refusing to storno a linked IB (the other candidate): the user's goal was to remove a bogus IB so bokslut could re-book it, and the IB-correct flow can only replace, never remove. [2026-07-15] Superseded the hard deletion part of the 2026-07-14 credit note draft decision: numbered credit note drafts are now retained as cancelled rows and reopened on retry so the KR series remains complete. [2026-07-15] Customer personnummer uses application field encryption with masked API and UI output rather than a database-only cipher: the existing key custody and AES-256-GCM implementation can protect values before they reach Postgres, while ordinary reads never expose the full identifier. [2026-07-15] Credit note creation uses a completion marker plus unique company guards instead of a large creation RPC: incomplete parents are never returned, concurrent requests converge, and all journal writes remain in the bookkeeping engine. [2026-07-15] Correction account changes refresh a line description only when it is blank or matches the previous company or BAS account name: all company accounts, including inactive ones, provide provenance while custom voucher text stays intact. [2026-07-16] Supabase MCP is project-scoped to the linked erp-base project and read-only by default: the repository treats local credentials as production access, and both Supabase guidance and repository rules require minimizing production mutation risk. [2026-07-16] Tax deadline recovery runs daily and EU sales-list deadlines require explicit opt-in: missing deadline rows must self-heal, while VAT registration alone does not prove an EU reporting obligation. [2026-07-16] Split the SEK 40 million deadline threshold into VAT taxable base and employer turnover settings: Skatteverket applies different measures to VAT and AGI, so one shared answer can produce a wrong statutory date. [2026-07-16] Tax deadline recovery compares every expected type and period key with stored upcoming obligations: the presence of one deadline no longer hides a partial generation failure for the same company. [2026-07-16] Retained the legacy tax_turnover_over_40m column after backfilling the split deadline thresholds: dropping it in the same migration could break an older application instance during a rolling deploy or rollback; new code does not read or write it. [2026-07-16] Superseded the same-day threshold split: the 26th filing day for both VAT and AGI hinges on one statutory measure, the VAT taxable base over SEK 40 million (SFL 26 kap., confirmed against Skatteverket's guidance), so employer_turnover_over_40m is dropped and AGI follows vat_registered plus vat_taxable_base_over_40m. A separate employer flag let a non-VAT-reporting employer be shown the 26th when its binding date is the 12th. [2026-07-16] The 20260716120353 threshold-split migration stays untouched and a follow-up migration drops the employer column: staging already recorded that version, so editing the file would desynchronize environments instead of converging them. [2026-07-16] The over-40m normalization clears the VAT flag for legally incoherent stored combinations (not VAT-registered or not monthly) instead of guessing the intended schedule: every ambiguity resolves to the earlier small-company dates, which can never cause a late filing. [2026-07-16] Storföretag get a dedicated skatteinbetalning deadline row (12th, 17 January): their skattedeklaration is filed the 26th but deducted tax and employer contributions must be on skattekontot by the 12th, and showing only the filing date hides the earlier payment obligation. [2026-07-16] Deadline dates rely on the banking-day adjuster instead of hardcoding Skatteverket's 27 December: annandag jul adjustment produces the published date, and one mechanism keeps VAT and AGI December handling identical. [2026-07-16] Closed #1000 (v1 match-supplier-invoice FX + cash-method settlement account): threaded the already-resolved paymentAccount into createSupplierInvoicePaymentEntry (was gated on isPureSek) and createSupplierInvoiceCashEntry (was passed undefined), and widened the findUnresolvableAccounts chart pre-validation from the pure-SEK accrual path to every !customLines branch, since every non-customLines branch now consumes the resolved account. The dashboard route needed no code change (its FX/cash branches were already threaded inside PR #985 itself); added branch-level regression tests on both routes instead. The entry generators keep their internal 1930 default: it is the documented no-link fallback, reached via resolveSettlementAccount returning 1930 for transactions without a cash_account_id.

[2026-07-16] Two bugs from one customer report (an AB). Bug 1 (acct 2893 showed 2393's "langfristig del" memo after an andringsverifikation): root cause = CorrectionEntryDialog never re-derived line_description on account change (JournalEntryForm does). Fixed forward via a pure helper (correction-line-description.ts) that refreshes the memo only when it is empty or still equals the prev account's name (preserves hand-typed memos). Chose NO prod data repair: the wrong memo sits on a POSTED verifikat (immutable per migration-017 trigger); it is cosmetic (account number + amounts correct, all reports key off the number); ~26 posted lines across 11 cos share this stale-echo pattern, all fix-forward only. Deferred the twin entry-level header fix (#1031). Bug 2 (auto tax-deadlines never appeared): root cause = generation only fired on a settings save where a TAX field CHANGED value (didTaxFieldsChange); settings are filled once at onboarding so re-saving generated nothing -> only 5/776 real cos had system deadlines. Chose count-based self-heal (regenerate when the company has 0 system deadlines) over always-regenerate, because generateTaxDeadlinesForUser deletes+reinserts and would reset is_completed/status on every unrelated save. Also wired the /deadlines empty-state to the existing (dead) /api/tax-deadlines/generate route, and fixed a 1000-row PostgREST cap in the annual cron. Backfilled 771 real cos with zero system deadlines via scripts/backfill-tax-deadlines.ts. Deferred moms_period=yearly config (#1030, 295 filers, largest VAT cohort): helarsmoms deadline (SFL 26 kap. 33-33b) depends on EU-trade status (no flag in CompanySettingsForDeadlines) and, for AB, the income-tax-return date. [2026-07-15] Repaired the single legacy paid credit note blocking invoices_credit_note_not_paid validation by normalizing its invoice metadata to sent, clearing payment fields, setting zero payable remainder, and linking its existing balanced posted V44 reversal: the immutable voucher already exactly reversed V42 and was not edited or duplicated. [2026-07-14] Issue #1016 (create_transactions never binds cash_account_id): fixed forward-only via an optional ledger_account hint on gnubok_create_transactions + commitCreateTransaction, resolved through a new ensureManualCashAccount find-or-create helper (lib/cash-accounts/service.ts). No migration: cash_accounts.bank_connection_id is already nullable and source='manual' already exists (the seeded 1930 is a manual row), so the handoff's premise that manual kassakonton need a schema change was wrong. Pre-creating a manual row does NOT race upsertFromPsd2 (the ingest.ts "never auto-create" worry): a later PSD2 connection promotes a manual holder in place, the intended flow (#916/#56). Hint restricted to ^19\d{2}$ (BAS kassa/bank group) so a transaction can't bind to a non-cash account. Scoped to the MCP create path per user decision; POST /api/cash-accounts + settings UI, relaxing ingest.ts's settlement_account auto-create, and historical backfill of cash_account_id=null rows (deferred to #1001) are follow-ups. [2026-07-14] Wise (TransferWise) CSV import added as a bank-file format plugin (lib/import/bank-file/formats/wise.ts), flowing through the existing upload->preview->confirm->execute wizard. Decisions: (1) parser preserves NATIVE currency per row (multi-currency statement); SEK conversion is left to the downstream FX/booking pipeline (Riksbanken), not done in the parser, so nothing here converts. Booking in the account's own currency via the Wise API rate is a deferred/extended feature. (2) Direction IN/OUT drives the sign; the row is booked on the side that moved (target for IN, source for OUT). (3) Non-zero Wise fees become their OWN negative "Wise avgift" transaction row (source and target fee both handled) rather than being folded or dropped, so the balance ties out and the fee lands in the inbox to categorize (e.g. 6570). (4) Only COMPLETED rows import; pending/cancelled/refunded skipped. (5) external_id keys on the stable Wise ID (TRANSFER-.../PLAN_ORDER-...) carried in raw_line, with a -fee/-tgtfee suffix for fee rows, so re-importing dedups exactly instead of via row hash (generateExternalId gains a 'wise' branch mirroring camt053). Scoped to the format plugin per user decision; per-currency auto-routing to manual kassakonton (would pair with #1016) not done here. [2026-07-16] Merge of the always-regenerate-on-tax-save branch with main's count-based self-heal keeps both triggers: a save containing tax-relevant fields regenerates (the generator now preserves completed rows, so main's is_completed-clobbering objection no longer applies), and a save without them backfills only when the company has zero system deadlines. The daily repair identity gains the due date (type:period:due_date): main's backfill created rows for 771 companies under the superseded schedules, and a type-and-period key alone would never flag their wrong dates for repair. [2026-07-17] Renamed migration 20260716150000_agi_follows_vat_taxable_base.sql to 20260717070000: main merged an unrelated migration under the same version (20260716150000_company_settings_defer_invoice_booking, PR #1040) and the preview branch aborted on schema_migrations_pkey; the branch's version was never recorded remotely, so a rename converges instead of orphaning. [2026-07-17] Rejected the PR-review claim that quarterly momsdeklaration is due the 26th of the month after the quarter: per SFL 26 kap. 26 § and Skatteverket's schedule, quarterly filers (only possible at or below SEK 40M) file by the 12th of the second month after quarter end (17 August for Q2); the 26th applies exclusively to over-40M monthly filers (26 kap. 30 §). The removed 26th dates were the wrong ones; the new 12 May / 17 Aug / 12 Nov / 12 Feb dates stand. [2026-07-17] Confirmed 26 February for enskild firma annual VAT with EU trade (26 kap. 33-33a §§ SFL, Skatteverket helårsmoms schedule); without EU trade the return follows the income declaration date (12 May). Documented in a code comment instead of changing dates. [2026-07-17] Storföretag skatteinbetalning uses the 17th only in January: the 17 August exception belongs to the below-40M schedule; above 40M the payment date is the 12th every month except January. Documented in deadline-config rather than adding an August exception. [2026-07-17] Deadline repair identity split: incomplete rows match on type:period:due_date (stale dates must be repaired) but completed rows match on type:period alone, since the generator never replaces a filed obligation and a date-keyed completed row would re-flag the company on every cron run without converging. The generator's completed-row lookup floor also moved from today to a year before the earliest generated year so a filed obligation with a passed superseded date is not resurrected as pending. [2026-07-17] Settings PUT keeps update(body) without a pick() allow-list: UpdateSettingsSchema is the write boundary (z.object strips unknown keys; company_id/onboarding_complete are not in the schema, org_number is deleted post-onboarding), so the mass-assignment finding does not apply. [2026-07-17] Turning vat_registered off (or vat_has_eu_trade off) now coerces the dependent flags (vat_taxable_base_over_40m, vat_has_eu_trade, periodisk_sammanstallning_enabled) to false server-side instead of 400-ing on the stale stored combination; explicitly enabling PS without registration or EU trade still 400s. PS period/filing-method preferences are deliberately preserved while PS is disabled (they are inert until re-enabled). [2026-07-17] Kept the cron's cross-tenant company_settings/deadlines scans on the service client: a daily all-company repair job is inherently cross-tenant, is cron-secret-gated, and per-company scoping would turn one paginated query into N queries; the per-company writes remain scoped by company_id inside the generator. [2026-07-17] Skatteverket OAuth callback no longer awaits runPostConnectRefresh (eager promise + next/server after(), mirroring the enable-banking finalize decoupling): awaiting held the redirect open up to ~40s while the popup kept showing SKV's consumed consent page, which users read as a hung connect; the connect panels compensate with a delayed (15s) status refetch. The 2026-07-12 "awaited on purpose" rationale is superseded. [2026-07-17] Skatteverket connect switched from a 600x750 named popup to window.open(url, 'blank') (new tab): SKV's consent page did not fit the popup (approve button below the fold; prod logs show authorize attempts with no callback ever arriving), and the named-window reuse made retry clicks appear dead. Parallel flows are prevented UI-side (button disabled while the tab lives) because each /authorize overwrites oauth_state + the PKCE verifier. [2026-07-17] MCP kontoplan + verifikat-note tools stage at LOW risk and tools/list ceiling bumped 54K to 56K: create/update_account are reference data with no journal impact (update_article precedent), set_voucher_note is trigger-guaranteed notes-only (20260608120000 carve-out); the 3 tools inline STAGED_OPERATION_SCHEMA which is wire contract, descriptions were trimmed first. [2026-07-17] Stale-tab recovery for Skatteverket/bank connections uses throttled visibilitychange refetches (settings connect panel, banking panel, /skattekonto not-connected state) instead of listening for the skatteverket-connection-updated CustomEvent on /skattekonto: that event only propagates within one window, and the connect flow always lives on another route or tab, so a listener there is dead code; cross-tab push (BroadcastChannel) was rejected as disproportionate since a tab switch necessarily precedes the user looking at the stale view. [2026-07-17] YearEndPreview.netResult now derived from the closing-lines totals (the signed 2099/2010 transfer) instead of generateIncomeStatement: the income statement excludes source_type='year_end' entries, so bokslut-flow depreciation/dispositioner were missing from the preview summary card while the bokslutsverifikation table included them (issue #766); patching the income statement instead was rejected because its exclusion is load-bearing (post-closing RR would collapse to zero) and executeYearEndClosing never reads netResult. [2026-07-17] Issue #310 vat_rate normalizer lives in lib/vat/supplier-invoice-line-checks.ts (shared by MCP staging and the pending-operation executor) instead of a local helper in mcp-server/server.ts: core cannot import extensions, and the legal-rate set (LEGAL_VAT_RATES) already lives there; duplicating the statutory set in two files invites drift. [2026-07-09] Billing checkout/portal guard BOTH is_anonymous and is_sandbox: a sandbox-company user created a live Stripe customer via POST /api/billing/checkout (no subscription, so never charged) because neither route checked either flag and withRouteContext admits anonymous users. Both conditions are checked even though they co-occur today (they are orthogonal), the anon check runs first (no DB round trip), and GET /api/billing/status exposes isDemo so the client hides the upgrade CTA instead of showing a button that 403s. One orphan Stripe customer and its company_subscriptions row left for manual cleanup (external destructive action, not done unilaterally). [2026-07-09] Wrong-underlag-after-SIE-migration support case (143 wrong files on one company's imported year): recommended the EXISTING replace-import flow (web /import duplicate detection, "Ersatt befintlig import") over building bulk document-version replace or 143 per-voucher swaps; verified the whole path live on prod first (undo/replace RPCs wired, customer's period open with zero non-import entries). Undo/replace detaches documents (journal_entry_id=NULL) so the wrong files become deletable per deleteDocument's linked-only guard: the documented legal carve-out for migration imports, not a breach of never-delete. Deliberately NOT built now: an agent-side replace-document-version MCP tool (promised in the support thread, needs its own issue). [2026-07-09] Consolidated the company-data-quality dev_docs into one master, dev_docs/data_quality_master.md: bank_transaction_ai_normalization.md folded in as Appendix B, ledger_context_resource.md's live parts as Appendix F, both plus mcp_optimization_plan.md archived under dev_docs/archive/. Why: three docs described one program from different ends and had begun double-tracking. Open question flagged in master section 7: Item B Layer D wants an LLM adjudicator plus embeddings, which contradicted the then-standing no-LLM decision (revisited 2026-07-10, see below). [2026-07-10] FX fallback-rate poisoning (pre-#892) repaired for UNBOOKED transactions only via scripts/repair-fx-fallback-rates.ts (idempotent, rate-guarded); the 68 BOOKED rows are deliberately NOT auto-corrected because correcting them means storno/correction vouchers in customer books, a per-company decision (worst single-company overstatement roughly 6.7k SEK). Also deleted one dangling seeded demo document and its inbox row that made the nightly documents-verify cron fail; the seed fix in PR #965 prevents recurrence. [2026-07-10] Preview-deploy Supabase env: widened NEXT_PUBLIC_SUPABASE_URL/ANON_KEY across the whole Vercel Preview environment (pointed at the staging branch project) instead of the Supabase Vercel integration's per-PR branch sync: per-PR branches cost money and reintroduce orphan-migration merge friction. [2026-07-10] Reopened the no-LLM call for account intelligence (founder directive): dev_docs/data_quality_master.md v2.1 revises D6. AI is scoped to where the deterministic engine has zero signal (supplier cold-start classification, no-hard-key residue, fleet-cluster naming) under D8 guardrails: persist-first (no model calls on booking hot paths), approval-gated suggest-band only (never preselect, never auto-book), AI priors in a sidecar table not categorization_templates, reuse the existing eu-north-1 Bedrock client (BFL residency), self-hosted/no-creds degrades to deterministic. The deterministic floor still ships first in every workstream; the AI-subsystem removal migration stays valid for what it removed (the auto-booking agent), not for AI as such. [2026-07-12] Issue #988 (transaction stuck on a removed verifikat): root cause was predicate divergence, the re-booking guards treated ANY non-null transactions.journal_entry_id as linked while the UI counts only status='posted'. Fix is a shared hasLiveJournalEntryLink() so stale pointers (reversed/cancelled/missing) are re-linkable, failing CLOSED on read errors, plus optimistic locks on the exact previous pointer instead of always .is(null). Chose guard self-heal over a null-on-reverse DB trigger because a trigger would fight correctEntry's re-point-to-correction behavior and race the correction insert; reverseEntry/correctEntry re-links stay best-effort. match-invoice/match-supplier-invoice untouched (they key on invoice ids; prod had zero dangling there). [2026-07-15] Issue #735 result-appropriation: did NOT run the mass 2099-to-2098 backfill. Prod audit showed the script read the FROZEN opening-balance 2099, not the CURRENT posted balance: 202 of 360 planned periods already carried their own disposition (mostly via SIE import) and 43 would have double-moved equity (worst case tens of MSEK on a single company), while periods without an OB entry fabricated balances via the cumulative-history fallback. Posted ONE verified entry for the reporting company (unblocking their arsredovisning) and rewrote the script to a current-balance-safe backfill: eligibility decided from current posted 2099, explicit OB entry required, already-disposed and ambiguous periods skipped to a manual-review report. planResultAppropriation/generateResultAppropriation untouched (steady-state year-end path, covered by pg-real tests); the safety lives in the sweep. [2026-07-17] create_account now enforces first-digit vs account_type consistency (superRefine at the commit boundary + fail-fast in the MCP tool): class 8 legitimately allows both revenue and expense (financial items per the BAS catalog), classes 0/9 stay unconstrained (free-use per the BAS standard); prevents contradictory rows like 2999+expense whose derived account_class would misclassify balance sheet vs income statement. PR-review findings on set_voucher_note (posted-entry immutability, BFL 5:5 audit trail) were refuted, not fixed: the notes-only carve-out is migration 20260608120000's whole-row to_jsonb diff and audit_journal_entries already records old/new on every UPDATE. [2026-07-17] Assistenten settings opens on Kunskap (konteringskarta) instead of Minne; dropped the nested Kompetens/Minne tab row inside Kunskap: it duplicated the top-level tabs one row above. [2026-07-17] Support case (double-booked periodiseringsfond, reversed, but dispositions page inflated by exactly the reversed sum): the storno of a year_end entry carries source_type='storno', so excludeYearEndClosing dropped the reversed original while keeping its counter-entry. Fixed by chain exclusion (reverses_id/correction_of_id pointing at a reversed year_end entry), depth 1 only: correction-of-correction chains on year_end entries are not followed (rare, and pre-fix behavior was worse). PostgREST or=(col.is.null,col.not.in.(...)) grammar verified against staging, not just unit-mocked. No prod data repair needed: the user's own stornos had already netted the ledger to zero. [2026-07-17] Periodiseringsfond schablonintakt + avsattning headroom now derive from 212X OPENING vs closing balances (IL 30 kap 6a: fond at beskattningsarets ingang) instead of cohort-year heuristics: one mechanism covers storno pairs, fonds fully aterforda during the year, shortened brutet rakenskapsar sharing a cohort account, and the 2129/2019 decade wrap. The 6-year forced-return flag stays cohort-based (2129 ambiguity accepted until FY2029 closings). Chose opening-balance snapshot over a fonds registry table: the ledger already carries the truth. [2026-07-17] DEFAULT_SCHABLONINTAKT_RATE 0.0355 was SLR+1pp, which is the negativ rantefordelning formula, not IL 30 kap 6a (rate = SLR itself, floor 0.5%): replaced with a per-closing-year map (2025: 1.96%, 2026: 2.55%) per the swedish-year-end-closing reference. Unmapped years FAIL CLOSED (getSchablonintaktRate throws): a statutory rate is never guessed; the table is a mandatory annual update each December, and POST callers can override the rate per request as the escape hatch (CodeRabbit review on PR #1063 overturned the earlier latest-known fallback). [2026-07-17] DISPOSITION_ORDER: SLP moved before avsattning (deductible SLP shrinks the 25% cap base per IL 30 kap 5); SLP commit + proposal are now posted-aware (skip when a 7533 year_end entry exists) closing the same double-booking hole the fond had. Duplicate avsattning POST with an explicit desiredAmount still books the remaining headroom (cap-safe, not intent-idempotent); declined request-idempotency keys as out of scope. [2026-07-17] getActiveCompanyId now throws CompanyContextError('resolution_failed') on query failure instead of returning null (issue #1053): null was indistinguishable from "no companies" and every caller redirects that state to /onboarding, so a transient DB failure showed onboarded users the wizard. Chose throw-at-the-source over a degraded-flag return so all redirect sites are fixed at once; withRouteContext already try/catches the call. The Edge middleware copy keeps a degraded flag instead (middleware cannot throw usefully) and fails open. [2026-07-17] Amount-less invoice rows (quantity 0 and unit price 0) render as text rows on PDF/detail/review via shared isTextLikeLine() instead of printing "0 / 0,00 SEK / 0,00 SEK" (issue #1053): users write free-text lines through the article picker's "Egen rad (fri text)" product row, not only the dedicated textrad button. Display-only; booking and validation semantics untouched. [2026-07-17] Articles default sort is article_number (numeric-aware via Intl.Collator numeric, unnumbered last, name tiebreak) in both the register and the invoice editor picker, replacing name order (issue #1053): users number articles precisely to control listing order, matching Fortnox convention. [2026-07-17] F-skatt deadline gate = preliminary_tax_monthly > 0 instead of a new column: the field was already collected at onboarding and in tax settings but never consumed; f_skatt boolean stays as approval status (drives invoice text, no recurring duty per SFL). The migration also declares the prod-only orphan column so migration-built installs stop failing tax-settings saves. [2026-07-17] System-deadline delete = soft dismiss (dismissed_at) rather than a mute endpoint or hard delete: hard deletes were silently resurrected by the nightly backfill cron; dismissed rows satisfy the generator/backfill like completed rows. [2026-07-17] AGI deadline gate = employer_registered (nullable, pays_salaries fallback) with migration backfill from salary_runs: a registered employer owes monthly AGI incl. nil months (SFL 26 kap. 3 §); companies running payroll in-app are treated as employers (SFL 7 kap. 1 § obliges registration), erring toward a dismissible reminder over a missed statutory filing. Seasonal employers get only the December-period row. [2026-07-17] AGI XML generation no longer completes the arbetsgivardeklaration deadline: SFL 26 kap. deems the duty met only when the declaration reaches Skatteverket; kvittens reconcile remains the confirming path. [2026-07-17] Removed 'bokslut' deadline type (replaced by statutory 'arsstamma', ABL 7:10, 6 months): the 3-month milestone had no legal basis and its broken-FY date math was off by one month (May-start FY got 31 Aug; Nov-start rolled "Feb 31" into March). Completed bokslut rows kept for history; type removed from the union like the earlier 'moms'/'inkomstdeklaration' retirements. [2026-07-17] EU-trade/PS settings stay opt-in flags; a ledger-derived signal (postings on 3108/3308/3107, 15 months) only renders a suggestion callout in tax settings. Auto-flipping registration flags from ledger data would assert a Skatteverket registration we cannot know. [2026-07-19] Voucher-sequence resync run on prod via execute_sql BEFORE the migration merges: data-only idempotent DML (no schema_migrations orphan risk) and a user was hard-blocked on year-end; migration file 20260719100000 ships the same SQL so every environment replays it as a no-op. [2026-07-19] Arsredovisning narrative save freezes only on submission status 'registrerad', not on period lock or 'avslutad': the period closes before the AR text is written (ARL 6 kap. document text, not journal data), and 'avslutad' means the case closed without registration so the text must stay editable for a refiling. [2026-07-19] KU deadline signal reads 2898 (utdelning) + 2393/2893 (agarlan), deliberately NOT 2091 despite the #1028 audit suggesting it: every closed year moves balanserad vinst through 2091 (resultatdisposition), so it would flag nearly all AB and train users to ignore the callout. Precision over recall for suggestions. [2026-07-19] rot_rut_begaran rows are data-driven (paid ROT/RUT invoice years, resolved inside generateTaxDeadlinesForUser) and therefore excluded from backfill expectation keys: expecting a row whose data the pure-settings detection path cannot see would make the nightly cron regenerate (and status-reset) the company every day. [2026-07-19] No auto-complete of rot_rut_begaran when the HUS file is generated: the user still uploads it manually to skatteverket.se, so file generation is not filing. Completing at beslut import is the honest signal but exceeds this change; the row completes manually like other deadlines. [2026-07-19] Deadline generation horizon: 183 days for recurring skattekonto types, 365 for annual; unlisted future types default to annual. Chose horizon-in-generator over UI-collapse per approved plan; regeneration now preserves manual in_progress status because the rolling window regenerates companies routinely. [2026-07-19] Kvarskatt deadline excluded from the long-tail batch: its due date derives from the slutskattebesked date, which the app does not hold; shipping a guessed date is worse than none. Needs a user-entered besked date to model. [2026-07-19] Intrastat modeled as the 10th Swedish banking day of the following month (SCB calendar pattern) with a help-text pointer to SCB's official calendar, rather than hardcoding SCB's published per-year dates. [2026-07-20] skattekonto.connection.expired handler notifies by EMAIL to the token owner only (not company contact): only the owner can redo the BankID consent; kvittens-notification precedent for channel choice (push-notifications is a separate optional extension, cross-extension imports forbidden). [2026-07-20] Connection-expired dedup episode key = (userId, skatteverket_tokens.created_at) hashed to a uuid, claim-first via notification_log 'skv_connection_expired' + partial unique index (20260720090000): a reconnect creates a new token row and legitimately re-arms the notification; the nightly cron re-observing the same dead token stays silent. [2026-07-20] Transactions-page SKV reconnect banner reads /api/extensions/ext/skatteverket/status client-side (parallel to the row fetch) instead of extending the transaktioner payload: keeps the extension route contract untouched and fails closed (any non-200 hides the banner). [2026-07-19] Per-year archive behandlingshistorik completed by record-id union, not by fixing write_audit_log company_id for journal_entry_lines: the trigger+backfill fix is a migration (out of this no-migration change) and would need RLS-visible backfill; the export-side OR filter delivers line rows on the service-role backup path today. Follow-up: migrate write_audit_log to derive company_id for line rows from the parent entry + backfill NULLs, then the plain company filter covers all scopes and the RLS-bound manual download too. [2026-07-20] Menu rename Fakturor -> Kundfakturor scoped to nav label + /invoices page title + command palette only (support tip 2026-07-19): API-key scope labels, AR-ledger xlsx sheet name, and customer/supplier detail section headings keep "Fakturor" since their surrounding context already disambiguates, and renaming API scope labels would churn a stable admin surface. [2026-07-20] Invoice payment links became opt-in via company_settings.invoice_payment_links_enabled (default false, no grandfathering of Stripe-connected companies; Emil's call): the whole payment-link section (manual field + Stripe auto toggle) is hidden in the invoice editor unless enabled on the invoice settings page, and the gate is enforced server-side in maybeCreatePaymentLinkForInvoice (after the provider lookup so the extension-free core build stays DB-free) so dashboard/v1/MCP/recurring sends all obey it; an existing draft with a link still shows the section so old links stay clearable. [2026-07-20] MCP gnubok_book_salary_run walks the whole review->approved->paid->booked chain in ONE staged op instead of mirroring the dashboard's four separate clicks: the human approval of the pending operation (high-risk, confirmed=true) IS the authorization act, and a four-op chain over MCP would just be approval theater. Missing bank details downgrade from overridable block to warnings (dashboard force-approve semantics): the payment-file generators hard-block on them where it matters. [2026-07-20] No gnubok_archive_employee tool: gnubok_update_employee already takes is_active=false (soft-archive, BFL retention) and the v1 REST surface has the DELETE verb; a dedicated tool would only bloat the tools/list budget. [2026-07-20] Booking core extracted to lib/salary/book-run.ts and shared by the dashboard route + book_salary_run executor; the v1 book route intentionally keeps its own strict-mode mirror (optimistic locking, period pre-check, its own envelope) rather than being folded in. [2026-07-20] Skattekonto sync root cause: the ska OAuth scope (the interactive skattekonto API's real scope, requested since the extension's first commit) was removed 2026-05-10 by a "remove unused scopes" cleanup (#431 series); every token issued after that hour gets 403 "The required scopes are not authorized" from the API. skahmst does NOT substitute (separate bulk E-transport service per its tjanstebeskrivning) and skattekonto is not a real SKV scope name (silently dropped from grants). Fix = re-request ska; panel scope checks now gate on ska. Kept requesting skahmst+skattekonto too: over-requesting is free, SKV grants the intersection. [2026-07-20] Missing-bolagsskatt warning lives in the year-end preview (previewYearEndClosing.bolagsskattMissing), not the readiness aggregator: at preflight time tax is legitimately not yet booked (it is booked later in the dispositions step), so an aggregator reminder would always fire and be noise; the preview is computed fresh right before Verkställ. Warning is advisory, never a blocker (zero tax is legit with underskottsavdrag). [2026-07-20] closing_entry_id made detachable via trigger escape hatch (migration 20260720140000) instead of leaving the link and relaxing app validation: the old trigger made an executed bokslut unrecoverable even pre-arsredovisning. Escape hatch demands the real storno chain (posted storno with reverses_id), not just status='reversed' (forgeable via PostgREST), and any replacement must be a posted year_end entry in the same period. [2026-07-20] planResultAppropriation idempotency filter narrowed to status='posted': a reversed omforing is storno-cancelled (net zero on 2099) and must not block the re-run after an administrative year-end undo. Trade-off accepted: a user who deliberately reversed the auto omforing and wants 2099 to keep carrying will get it re-posted on the next year-end/catch-up run. [2026-07-20] Follow-up (not done): delete_last_voucher RPC can delete the closing storno and flip the closing entry back to posted while closing_entry_id is already NULL, leaving an orphaned live closing entry; should refuse to delete stornos of year_end entries. [2026-07-20] Onboarding backdrop reuses marketing-site halftone webp assets copied into public/illustrations/ (not hotlinked, not regenerated): keeps app self-contained and signup->app visually continuous; decorative art uses plain (physics sizes by %, next/image adds nothing for 1-35KB webp). [2026-07-20] Removed Dependabot entirely (.github/dependabot.yml deleted, open PRs #1083/#1082/#1012 closed) on Emil's request: weekly grouped bumps were noise and the #884 bedrock-sdk incident showed the risk profile. Dependency bumps are now manual/deliberate; the bedrock-sdk 0.29.1 exact pin stays enforced by scripts/checks/no-new-antipatterns.mjs. [2026-07-20] Bulk reject (/pending) reuses the exact bulk-approve selection set: high-risk and locked-period ops stay one-by-one for reject too, keeping one selection model instead of per-action eligibility. Server-side bulk-reject has NO high-risk skip (rejecting posts nothing), so the API stays permissive; the UI is the gate. [2026-07-21] Keep Skatteverket audit inserts service-role-only and fix the writer instead of adding an authenticated INSERT policy: regulator audit rows must not be forgeable by a user session. [2026-07-21] Treat an empty year-end period as a structured conflict instead of creating a zero-value voucher: the journal engine correctly forbids meaningless zero entries, while the API now returns an actionable domain outcome and logs it as a warning. [2026-07-21] Store kvarskatt payment dates exactly from the tax notice instead of calculating a 90-day offset: Skatteverket makes the notice date authoritative and reassessments can have shorter payment windows. [2026-07-21] Persist Oasis first-value setup per company and keep it optional inside the dashboard: existing companies are backfilled as completed, and Skatteverket plus assistant setup never block access. [2026-07-21] Enforce zero raw user-facing engine and database errors with an AST guard: aliases are traced while logger-only values remain allowed. [2026-07-21] Persist year-end tax adjustments separately from journal entries and reuse them for tax provision plus INK2: detected 6992/8423 balances remain tax-only, a posted current-year periodiseringsfond is treated as a completed choice instead of new headroom, and a mismatched posted tax requires storno/correction rather than a duplicate voucher. [2026-07-21] Share dashboard auth and active-company resolution with request-local React cache: nested layouts and pages now reuse one verified context while cross-request data remains uncached. [2026-07-21] Progressively render large customer, supplier, article, and invoice lists in batches of 100: complete datasets remain available for filtering, sorting, statistics, and export while initial browser work stays bounded. [2026-07-21] Give company-settings MCP writes a dedicated companies:write scope and medium risk: bank details redirect future invoice payments, so reusing a low-risk customer or generic bookkeeping scope would hide the payment-routing permission boundary. Keep the three new specialized settings and customer tools search-only in the default MCP catalog: gnubok_search_tools still returns and authorizes them while tools/list stays inside its locked context budget. [2026-07-21] Cloud backup audit scaling keeps API-key security and configuration changes in behandlingshistorik but suppresses request-counter-only audit rows, and excludes non-portable extension_data from the Grunddata watermark: existing history stays immutable while telemetry growth and the backup's own state can no longer recreate the timeout. [2026-07-21] Miles staging reproduction copies exact effective ledger balances plus the active periodiseringsfond through the bookkeeping engine, not production documents or obsolete closing audit chains: this preserves the disposition calculation while minimizing personal data and leaving A44/A1/A2 absent for a clean test. [2026-07-21] Keep Bolagsverket connected filing disabled behind explicit public and server release gates: migrations, validator service, agreement, certificate, test-bank acceptance, and remote migration reconciliation must be complete before activation. [2026-07-21] Treat signer rosters as mutable only while unbound and immutable once attached to an annual-report version: corrected versions either clone the latest roster or bind a newly supplied roster, while database serialization advances the version only after the final signature. [2026-07-21] Keep Bolagsverket idnummer server-side for webhook correlation and troubleshooting but omit it from dashboard and MCP responses: the v2.1 contract marks it as a technical identifier that must not be shown to users. [2026-07-21] Store retention_expires_at as the first legally permitted deletion date, 1 January of the eighth following calendar year: BFL 7 kap. 2 § requires preservation through the end of the seventh following calendar year, so period_end plus seven years ends too early for non-calendar fiscal years. [2026-07-21] Retention correction trigger runs after migration 017 on every fiscal-period update, while the period-start validator runs only when company_id or period_start is written: unrelated metadata backfills must neither restore the old expiry formula nor revalidate unchanged historical dates. [2026-07-21] Restrict locked annual-report creation to the server service role and preserve the profile, disclosure, and eligibility snapshots with the validation result: a caller-controlled browser RPC cannot be allowed to assert its own compliance result, and the version must remain independently auditable after the live profile changes. [2026-07-21] Keep the current K3 renderer available only as a review draft and fail closed before version locking or paper-filing readiness: its present note builder is not a complete applicability-driven K3 disclosure matrix, so claiming general K3 compliance would be misleading. [2026-07-21] Bundle Source Sans 3 and Source Serif 4 under OFL-1.1 and store company TTF/WOFF files in a dedicated private bucket: invoice rendering embeds fonts server-side without a new runtime dependency, while tenant-scoped paths, parse validation, size limits, and Helvetica fallback keep uploaded fonts private and reliable. [2026-07-21] Prepared issue #1064's end-to-end fixture on Supabase staging in the existing disposable ZZ Undo-YE Rehearsal 3 AB, renamed ZZ AR Narrative E2E AB: avoided the realistic Box 7 AB and preserved posted history through storno; left FY2025 open with balanced synthetic K2 entries and no closing entry. [2026-07-21] Did not bulk-push the missing 20260721160000 annual-report migration while diagnosing the staging 500: staging has later applied versions plus other local-only gaps, and the 44 KB schema change exceeds fixture-seeding authority until explicitly approved for targeted application. [2026-07-21] Applied only migration 20260721160000 to Supabase staging after explicit approval: used an isolated CLI workdir hydrated from staging history because the configured Supabase MCP targets production and a repository-wide push would include unrelated migration gaps; source and applied copy had identical SHA-256. [2026-07-21] Domain cutover is dual-domain, not full migration: app.gnubok.se stays serving /api + /.well-known forever (MCP connectors, API keys, SKV callback registered in Utvecklarportalen); only page traffic redirects to app.accounted.se, gated on NEXT_PUBLIC_APP_URL so the merge is inert. SKV OAuth callback rewritten cookie-free (state + stored oauth_user_id) because sessions no longer exist on the OAuth host; discovery docs host-reflect (allowlisted) for RFC 8414/9728 self-consistency. [2026-07-21] Fortnox/SIE atomic import timeout fixed by function-scoped statement_timeout (290s) on import_sie_journal_entries, not by chunking the RPC: chunking would reintroduce the partial-import states the atomic RPC exists to eliminate (20260712150000), and the 20260629160100 delete-path precedent already uses the same bound; pg-real ratchet pins the config on all three SIE RPCs because CREATE OR REPLACE silently drops ALTER FUNCTION settings. [2026-07-21] Keep live annual-report narrative editable after a version is locked: immutable version snapshots preserve signing and filing content, while edits must remain possible to create a corrected superseding version. [2026-07-21] Restrict annual-report signature evidence transitions to the server service role and structured opaque references: browser RLS may manage only unbound pending roster rows, so route validation cannot be bypassed and evidence references cannot carry free-text personal data. [2026-07-21] Card-descriptor normalization keys on the pre-star merchant segment (post-star for processor prefixes) plus a token_subset match tier, instead of the deferred AI descriptor normalization (data_quality_master Appendix B): deterministic, mirrors into normalize_counterparty_key() so ledger-context template joins stay exact, and fixes the reported Anthropic no-signal case with no new infrastructure. Merchant history now falls back to description because card purchases never carry merchant_name. [2026-07-22] Use descriptive invoice PDF filenames across downloads, emails, archives, and recurring sends, and omit exact-zero booking rows created by informational invoice text: users get consistent documents and previews contain only accounting-relevant lines. [2026-07-22] Long uploaded filenames use a responsive middle ellipsis with a preserved 16-character tail: users can still see the unique suffix and extension without the filename widening its dialog. [2026-07-22] Record each invoice delivery as an immutable attempt with its exact email payload and archived PDF, and do not backfill legacy sent invoices from updated_at or the 30-day event log: an honest missing-history state is safer than fabricated delivery evidence. [2026-07-22] Omit DELETE RLS and the generic audit trigger from invoice_deliveries: a database trigger must block deletion even for privileged paths, while copying the full immutable email payload into audit_log would duplicate recipient PII without adding evidence. [2026-07-22] Apply only 20260722101319_invoice_deliveries to Supabase staging from an isolated CLI workdir after a one-migration dry run: the repo and applied SQL matched SHA-256 7B9A958E4A941291CCFDD94B4889CE4485491BBB3F3CEEBBA69F4961C51BEFD5, and migration history confirmed the version. [2026-07-22] Reserve invoice delivery history before allocating an invoice number, expose only active-company masked metadata, redact PII after the BFL retention date, and audit metadata only: this preserves exact sent evidence without duplicating recipient content; migration 20260722150000 was applied only to Supabase staging from byte-identical SHA-256 5057E28A18E618CB73781506FF6AF29CB000F94C08438FC006C11B9FA5E61328. [2026-07-22] Issue #313 fix limited to the meals warning; left "Representationsgåvor max 180 kr" on the gåvor line untouched: scope rule (only the inverted-VAT claim and repealed ML 8:9 reference), even though the swedish-vat skill lists 300 SEK as the representationsgåvor base; flagged as follow-up in the PR. [2026-07-22] LEGACY_DISCOVERY_HOSTS drift guard (#1093) is an exported validateLegacyDiscoveryHosts() returning a violations list, exercised only by a unit test that pins the registered prod config (app.accounted.se canonical + app.gnubok.se SKV pin), not a startup assertion: CI does not set the prod env vars, so a runtime assertion would either no-op in CI or crash self-hosted deploys with different domains; the test-pinned constants make any allowlist or pin change a deliberate, reviewed edit. [2026-07-22] Include invoice_deliveries in the full archive master-data dump rather than marking it covered by archived documents: the delivery row carries recipient, status, and timestamp evidence that the PDF manifest cannot reconstruct. [2026-07-22] Stuck-committing recovery sweep (#843) rejects rows without positive evidence instead of reverting to pending, and only three op types (categorize_transaction, link_transaction_journal_entry, match_transaction_invoice) can recover to committed: no generic side-effect -> pending_op linkage exists yet (that is #842's posted-ids work), so evidence is limited to types whose params identify a target row with an unambiguous posted state; reverting to pending risks re-executing side-effects that posted without a trace (duplicate entries/emails). [2026-07-22] MCP briefing recommended_tools (#1098) ships as a STATIC per-workflow loadout list, not state-gated: the briefing does not query workflow state (unbooked counts, open periods) today, so gating would add reads to the session-bootstrap hot path for marginal honesty; drift protection is a module-init assert against the tool registry + workflow-skill slugs, pinned by tests. [2026-07-22] failed_partial (#842) is a TERMINAL, immutable pending_operations status, never released back to pending: the executor already posted an irreversible voucher/credit note, so a retry would double-post and a status rewrite would violate BFL 7 kap.; recovery is a manual storno guided by result_data.posted_ids. Exception kept: AccountsNotInChartError in match_transaction_invoice still releases to pending because that executor is re-entrant past the storno. [2026-07-22] Give statutory annual reports a dedicated final-closing exclusion while preserving the broad year_end exclusion used by operational tax, disposition, and cash-flow reports: the annual report must retain booked depreciation, appropriations, and tax; closed legacy periods without a linked final closing entry are backfilled only when the canonical entry is unambiguous and otherwise fail explicitly. [2026-07-22] Present tax and domestic VAT settlement accounts by net economic sign in statutory annual reports without rewriting posted entries: a debit on 2650 is a current receivable, while a credit remains a current liability. [2026-07-22] Store invoice payment instructions as a currency-keyed company setting and keep legacy bank fields as the SEK compatibility mirror: invoice instructions must work without a bank integration, exact currency matching prevents foreign invoices from inheriting an unsafe SEK account, and archived delivery PDFs remain the historical snapshot. [2026-07-22] Extend the immutable invoice delivery record with BCC instead of adding a second invoice event log: one delivery record now remains the source of truth for channel, status, time, recipients, message, and exact archived PDF. [2026-07-22] Keep exact invoice delivery payloads as immutable server-side evidence while returning only masked To and CC domains plus operational metadata in the routine list: BCC and message content are confidential, and archived PDFs provide active-company-scoped document evidence without widening the list response. [2026-07-23] Resolve the invoice currency's payment account inside the shared invoice email template: every dashboard, API, recurring, and queued send path must render the same payment instructions as its PDF, and central resolution prevents a foreign-currency email from leaking the legacy SEK account when a caller passes raw company settings. [2026-07-23] Add a new migration before the currency-account backfill instead of editing the failed migration: immutable migration history is preserved, clean preview branches gain the legacy SEK columns before backfill, and environments that already have the columns remain safe through IF NOT EXISTS. [2026-07-23] Restrict fixed and per-send invoice CC and BCC changes to owner and admin roles: an external copy is a separate disclosure decision, so both the API and database reject lower-role configuration changes before persistence, rendering, or invoice-number allocation. [2026-07-23] Do not rewrite the already-replayed invoice recipient and payment-account migrations to add NOT VALID: immutable migration history takes precedence, and a later migration cannot remove the original deployment-time validation scan. [2026-07-23] Exact invoice delivery payload is sender-only under RLS: all other active-company members use a SECURITY DEFINER summary that masks To and CC and omits BCC and message content. [2026-07-23] Route invoice delivery writes through service-only RPCs and allow another sender to reclaim only a payload-free reservation older than 15 minutes: browser PostgREST clients cannot forge evidence, while a crashed render cannot permanently block vacation cover. [2026-07-23] Keep complete invoice delivery evidence in the statutory archive through an owner/admin-only server client, while deferred booking receives only the latest sent document ID: company-wide accounting workflows remain complete without reopening exact browser payload access. [2026-07-23] Treat fixed invoice CC and BCC addresses as owner/admin-approved company routing that applies to every authorized sender, while per-send additions require owner/admin on each request: configured routing supports vacation cover without letting members introduce a new recipient. [2026-07-23] Require a usable payment account for every payable invoice currency, including SEK, before rendering or number allocation, and restrict payment-instruction changes to owner/admin: a numbered invoice must not be issued with blank or member-controlled remittance details. [2026-07-23] Identify statutory current-year result rows with stable semantic keys while retaining label fallback for older snapshots: K2 and K3 presentation wording cannot bypass the annual-report result-integrity gate. [2026-07-23] Keep exact invoice delivery payload in the owner/admin statutory archive instead of applying routine-list masking, and verify membership again with explicit user and company predicates before using the stateless service role: the archive preserves BFL evidence while the second guard limits cross-tenant impact from an RLS regression. [2026-07-23] Preserve the legacy sender-email CC fallback and load the authenticated email in the send dialog preview: the user sees the same fixed recipient that the dashboard send route will resolve. [2026-07-23] Require the current currency payment account on resends as well as first sends: resends render a new PDF from current settings, so allowing a missing account would distribute newly generated payment instructions that are blank or invalid. [2026-07-23] Return only To and CC counts from the normal invoice-send response: fixed BCC routing applies to authorized senders but its existence and cardinality remain confidential outside the exact delivery archive. [2026-07-23] Treat an unexpected delivery ID from a terminal delivery RPC exactly like an RPC error: the email outcome is already irreversible, so callers receive a reconciliation warning and failed-send cleanup never deletes an archive unless the expected row was actually finalized. [2026-07-23] Cap the final de-duplicated To, CC, and BCC set at 20 before any delivery reservation, render, or number allocation: separate per-field limits could exceed the provider-safe total when fixed and per-send recipients were combined. [2026-07-23] Mark invoice delivery, PDF, and statutory archive responses private and non-cacheable on success and failure, and omit BCC from both dashboard and v1 send responses: operational identifiers and blind recipients belong only in the restricted exact archive. [2026-07-23] Block payment-account saves after server values change while local edits are dirty until the user explicitly reloads: silently preserving and later saving stale form state could overwrite another administrator's update. [2026-07-23] Reconcile annual-report rounding residuals independently for each balance-sheet side and fail closed when a side cannot reach its own rounded exact total: cross-side netting could hide an incorrect reported fact behind a balanced grand total. [2026-07-23] Validate preview-PDF payment settings before fetching customer data using the requested currency and document type: this preserves the same exemption semantics while minimizing personal-data processing for requests that cannot render. [2026-07-23] Retain an exact pending delivery snapshot when the provider succeeds but the terminal evidence RPC cannot be confirmed, and keep it outside the preparing-only reservation lock: inventing a sent state would be unsafe, while immutable payload, PDF, operator warnings, and later explicit resend availability preserve evidence and recovery. [2026-07-23] Keep the invoice-delivery DPIA as a documented screening rather than fabricating a full Article 35 assessment or DPO sign-off: the screened processing does not meet the high-risk threshold, and the implemented controls minimize routine access while preserving statutory evidence. [2026-07-23] Invoice PDFs normalize the sv-SE U+2212 minus to ASCII hyphen-minus and retain signed per-rate VAT values: standard PDF fonts can drop U+2212, and absolute VAT groups make deductions and credit notes fail to reconcile with their rows. [2026-07-23] Invoice-line and article account overrides accept active BAS classes 1-3 while retaining the legacy revenue_account wire and database field: balance accounts cover deposits, advances, and genuine customer outlays without a migration or API break; classes 4-8 remain excluded so the invoice editor does not become an unrestricted journal editor, and reverse-charge/export mappings remain mandatory. [2026-07-23] Derive kvotvärde (aktiekapital / antal_aktier) in the annual-report note instead of adding the kvotvarde column the builder used to read: a stored third value could desync from the other two and file an internally inconsistent Bolagsverket note; ABL 1 kap 6 § makes it purely derived. [2026-07-23] Class 1-2 posting overrides are rejected on VAT-bearing invoice lines (INVOICE_CREATE_POSTING_ACCOUNT_VAT_CONFLICT) instead of narrowing the schema back to 3xxx: keeps the deposit/advance feature while guaranteeing the tax base for ruta 05 always books to a revenue account (ML 17 kap 24 §). [2026-07-23] The posting-account regex now lives in one shared constant (lib/invoices/posting-account.ts) imported by server Zod schemas, pending-operation re-validation, and both client forms, so the layers cannot drift. [2026-07-23] Declined the compliance-swarm suggestion to add DROP COLUMN kvotvarde to the share-capital migration: the column never existed in any migration, so there is nothing to drop. [2026-07-23] Kept the missing-aktiekapital annual-report path as a warning rather than a hard block on generation: users must be able to preview an in-progress arsredovisning; the warning surfaces in the wizard/validation before filing, and no placeholder text lands in the filed PDF. [2026-07-23] Declined moving ArticleForm's class 1-3 account filter server-side: the chart of accounts is company-scoped and non-sensitive, the authoritative gate is server-side at booking time, and the combobox intentionally sees the full chart for the activate-account flow. [2026-07-23] requireAuth switched to supabase.auth.getClaims() local JWT verification with getUser() fallback: supersedes the 2026-07-13 deferral; prod telemetry shows authMs 28-162ms per request even in arn1, and proxy.ts middleware getUser() already performs the per-request revocation check, so route-level local verification no longer changes auth semantics. [2026-07-23] resolve_active_company() RPC returns (company_id, locale, used_fallback) rather than the pinned 2-column shape: without used_fallback the middleware cannot know when to fire its user_preferences write-back; role text deliberately NOT added (no JS caller consumes it). JS callers fall back to the query path on PGRST202/42501/zero rows because mcp-oauth token and events routes resolve companies with createServiceClientNoCookies() (NULL auth.uid(), no EXECUTE grant). [2026-07-23] Expose attachment_filename in invoice delivery summaries: filename derives from company/customer/invoice number already visible to all members, so returning it does not widen the 20260723003000 minimization boundary; addresses stay masked, BCC server-side. [2026-07-23] Account-scoped "already matched" applies to get_account_gl_lines_for_matching (manual match dialog + reconciliation table) only; get_unlinked_gl_lines stays voucher-scoped: it feeds auto-reconcile, where surfacing the transfer's second leg could auto-link ambiguous same-amount rows. Manual matching keeps the human in the loop (issue #1026, migration 20260723160000). [2026-07-23] mark_entry_as_opening_balance now refuses entries with linked bank transactions: the account-scoped matching change made "Mark som IB" reachable on half-settled transfer vouchers, and re-tagging one would strand its linked transaction against a movement-excluded entry (permanent phantom reconciliation difference). [2026-07-23] Downgraded the 4 firing eslint-plugin-react-hooks v7 compiler rules (set-state-in-effect, static-components, purity, preserve-manual-memoization) to "warn" instead of refactoring 33 legacy component sites: effect restructuring is behavior-sensitive per-component work, and eslint-baseline.json shows the repo already accepted these as burn-down debt after the plugin bump (#1013). Fixed the 16 mechanical legacy errors (no-explicit-any in tests/scripts, prefer-const, no-assign-module-variable) for real and ratcheted the check:lint baseline to 0, so any new error-severity violation now fails CI immediately. [2026-07-23] bookkeeping.accounts.list latency: chose a JSON-aggregating SECURITY INVOKER RPC (list_company_accounts, 20260723170000) over count-first or speculative parallel fetchAllRows pages: count-first parallelism still costs 2 sequential waves at the p95 company (1243 rows) and speculative dual-page fetch doubles request volume for the ~92% of companies under 1000 accounts; the RPC returns one json scalar (bypasses PostgREST db-max-rows=1000) so every company size pays exactly 1 round trip, with the old paged fetch kept as the PGRST202/42883/42501 fallback for self-hosted and the deploy-ordering window. [2026-07-23] report.kpi hot path moved to one get_kpi_report_aggregates RPC (20260723180000) + pure builders instead of three PostgREST journal-line scans; accepted failure-path delta: an aggregates RPC error now 500s the whole route where a monthly-breakdown DB error previously degraded silently to months: []; also new rounding goes through roundOre (antipattern guard forbids raw Math.round(x100)/100), identical to the legacy expression within float epsilon, and monthly rounding became round-once-per-bucket instead of per-line (same tolerance). [2026-07-23] Edited unmerged migration 20260723160000 in place (re-added 42501 tenant guard to mark_entry_as_opening_balance) instead of adding a new migration: the file is branch-local (fix/fdb-fr-usrs, not on main), its function body silently reverted to the pre-20260619130100 definition and dropped the tenant guard (caught by securitydefiner_write_rpc_tenant_guards.pg.test), and staging was re-synced with the corrected CREATE OR REPLACE so no drift remains. [2026-07-23] requireAuth getClaims fast path now pins iss (project URL + /auth/v1) and aud ('authenticated') and logs every fallback: PR review (ASVS V9.1) asked for defense-in-depth beyond signature/expiry; a mismatch degrades to the authoritative getUser() round trip instead of rejecting, so a config drift can never lock users out, only slow them down (visibly, via the new console.error). [2026-07-23] Removed the accountingMethod parameter from calculateVatDeclaration (and the dead company_settings.accounting_method reads in the xlsx/pdf/eskd routes) instead of restoring the settings read: the value was verifiably unused (declared accountingMethod, zero body references) because the method is baked into journal entry timing, and keeping an ignored parameter invites a future branch that silently sees the caller's hard-coded 'accrual'; the v1 API still accepts accounting_method for wire compat but its docs now state it has no effect. [2026-07-23] Closed the mark_entry_as_opening_balance TOCTOU (link committing between the RPC's EXISTS check and commit) with a transactions-side trigger (20260723190000, FOR KEY SHARE on journal_entries) instead of a shared advisory lock in every linking code path: the trigger enforces the invariant from both directions in one place, needs no app-code changes, and FOR KEY SHARE conflicts with exactly the RPC's FOR UPDATE and nothing weaker; prod verified to have zero pre-existing violating rows. [2026-07-23] Declined the suggested composite index (company_id, is_active, account_class, sort_order, id) on chart_of_accounts for list_company_accounts: the RPC exists to eliminate cross-region HTTP round trips, per-company row counts (p95 ~1250) make the filter+sort a few ms via the existing company_id index, and a 5-column index taxes every account write for no user-visible gain. [2026-07-23] Stripe transaction sync provisions a real cash_accounts row on ledger 1686 ("Stripe-saldo") instead of importing balance transactions as accountless inbox rows: booking/categorize/match routes resolve the settlement leg from cash_account_id -> ledger_account, so binding to 1686 makes user booking settle against the clearing account by construction, gives the dedup account guard a scope, and lets the feed reconcile against the actual Stripe balance. [2026-07-23] Stripe feed rows use the two-row gross+fee split with fee rows claimed by the payout booking (linkPayoutFeedRows at processPayoutPaidEvent time AND from the sync for already-booked payouts) rather than holding fee rows back until payout: the feed stays complete intra-week; the short window where a user could manually book a fee row before its payout is accepted and idempotently guarded (only journal_entry_id IS NULL rows are claimed). Payout rows import pre-linked instead of being skipped so the feed sums to Stripe's real balance movements. [2026-07-23] Stripe feed row dates use balance_transaction.created, not available_on: created is the economic event (payment date) that booking, invoice matching, and month boundaries need; available_on is Stripe's internal settlement schedule days later. First-run backfill is 90 days floored at the day after bookkeeping_locked_through: rows behind the lock can never be booked and would be permanent inbox noise. [2026-07-23] The transaction-sync toggle lives on stripe_connections (transaction_sync_enabled), not company_settings: the cursor lives there, the crons iterate connections, and a future second connected account wants independent toggles. [2026-07-23] Import dedup extended with a booked-hand-entered mirror (lib/transactions/ingest.ts): an incoming feed row is skipped when a BOOKED manual/mcp row shares its (date, ore) bucket count-symmetrically, gated on currency + cash-account compatibility; consumption stamps the batch cash_account_id onto a null-account hand row (one-consume-ever across accounts). Chosen over (a) strict account match, which misses the real MCP-then-connect-bank case (mcp rows have null cash_account_id), and (b) external_id adoption, because agents supply their own external_ids (prod: TRANSFER-... on mcp rows) that must not be overwritten. Unbooked hand rows stay excluded: staged intent is not ledger evidence. Symmetry uses the Layer-1-unmatched incoming count and the count map applies the same guards as consumption (adversarial-review findings). [2026-07-23] Inline verifikat rattelse (founder-approved both cards): built Fortnox-style strike-and-replace inside posted verifikat + metadata (text/date) edit WITHOUT andringsverifikat, as a second sanctioned correction track under BFL 5 kap 5/9 pp. One SECURITY DEFINER RPC per operation (correct_entry_metadata, correct_entry_lines_inline), transaction-local GUC carve-outs in the immutability triggers (precedent: notes edit, source_type retag, dimension retag), append-only WORM journal_entry_rattelse_log holding full struck-line snapshots (classified as rakenskapsinformation in full-archive-export, NOT excluded like dimension_retag_log). Envelope: posted entries, open+unlocked periods, company lock date, same-fiscal-period date moves only, structural source types excluded (storno/opening_balance/year_end/vat_settlement), FX lines and doc-attached lines excluded (storno path), reconciliation guard = per-account net on 19xx/cash_accounts (bank-linked), 15xx (invoice_payments-linked), 24xx (supplier-linked) must be preserved. CLAUDE.md hard rule 1 + swedish-accounting-compliance skill amended in the same change to state the two-track rule (adversarial reviewer: feature was otherwise condemned by its own repo docs). [2026-07-23] Inline rattelse deliberate accepts from the adversarial pass: (a) direct INSERT into journal_entry_lines on posted entries is unguarded platform-wide (pre-existing; blocking it blind risks breaking match_batch_allocate/bulk_book SQL RPC booking flows, needs its own audited change) - FLAGGED FOLLOW-UP; (b) strike racing an in-flight multi-statement storno can produce a stale-mirror storno (storno-service holds no row lock until its final CAS) - rare, pre-existing race class, follow-up = FOR UPDATE in storno-service step 0; (c) RPCs granted to authenticated bypass the withRouteContext MFA gate like every existing RPC (retag precedent) - platform-level posture; (d) replacement lines never carry tax_code (nothing reads journal_entry_lines.tax_code in reports today); (e) VAT-declared months are gated by dialog copy + the existing period-lock culture, same as the storno flow - follow-up: banner on VAT report for post-declaration rattelser; (f) rattelse-log WORM trigger blocks cascaded company hard-deletes (shared latent defect with dimension_retag_log, test-company cleanup only). [2026-07-23] Resultatrapport signedAmount moved from closing to period: trial balance rolls pre-window activity into opening, so closing on P&L rows silently showed YTD for month/quarter windows; period activity is the correct windowed value and equals closing in the full-period case. Enables same-window prior-year comparison (#862). [2026-07-23] Assistant FAB is now user-hideable (user_preferences.hide_assistant_fab, toggle under Installningar > Assistenten): deliberately overrides the keep-visible-as-conversion-surface design for users who opt out; a collapsed chat session keeps its reopen handle even when hidden. [2026-07-21] Floden & agenter concept (dev_docs/flows_agents_concept.md + interactive artifact): founder locked hybrid builder (chat builds, linear flow view edits; no free node canvas), trust-ladder autonomy on pending_operations risk tiers, per-company before byra, ships alongside Assistenten. Engine recommendation is a hand-rolled Postgres state machine in lib/flows/ generalizing the webhook-dispatcher pattern; every external engine (Inngest/Trigger/Temporal/Vercel Workflow/Restate/DBOS/pg-boss) fails zero-extra-services self-host, Supabase-only durability, AGPL compatibility, or free days-long approval pauses. Flows compile deterministically at build time; LLM steps are explicit, marked step types. Awaiting founder visual sign-off before any implementation PR. [2026-07-21] Official Claude Code plugin ships in-tree (claude-plugin/ + root .claude-plugin/marketplace.json), not in the claude-for-swedish-small-business marketplace repo: the wrapper-reference sync test must live next to the MCP server source, and the official plugin belongs with the product. Wrappers are thin by design: regulatory knowledge stays server-side in agent_atom_registry and is loaded via gnubok_load_skill, never bundled. Plugin licensed MIT matching the gnubok-mcp precedent. [2026-07-22] Swish inflows via Swishrapport file import (#1114), not Swish API: the Commerce API cannot list incoming payments (only API-created ones) and prod data shows banks strip payer/message from PSD2 remittance info. [2026-07-22] Förening chart: hand-maintain from free BAS 2026 PDF, ship Idrottens BAS as a separate optional template (not merged): BAS machine-readable JSON is a paid proprietary product unfit for AGPL vendoring, and IBK 38xx/35xx collide with standard BAS meanings. [2026-07-23] UI-migration plan authored (dev_docs/ui_migration_plan.md): shell-first sequence (tokens/nav/primitives) then Bokforing/Transaktioner/Granskning/Kund+Lev-fakturor; concept artifact is the reference; 14 locked design conventions codified: chose PR-per-page over big-bang to keep founder visual sign-off per merge. [2026-07-23] Frame layout (UI-migration PR 1) is md:-gated and the panel owns desktop scroll: mobile keeps document flow + bottom nav (concept is desktop-first), and since Next's window scroll-to-top never fires for an inner scroll container, MainContainer resets panel scroll on route change. Button default size drops fixed h-10 for natural pill height (7px/16px padding per locked convention 3); sm/lg/icon keep their heights. [2026-07-23] Nav PR 2: ui_state persisted as one jsonb bag on user_preferences (founder-approved migration 20260723120000) rather than per-preference columns: cosmetic, never load-bearing, grows with PR 3/4 split-button modes. Sidebar width driven by an inline --nav-w CSS variable on #dash-shell instead of a [data-nav-collapsed] attribute rule: the Tailwind 4/Lightning CSS pipeline silently dropped the top-level attribute-selector rule from compiled output, and the inline variable is pipeline-proof. Register/Bokslut folds default closed (concept tidiness), forced open by an active child route. Discord-community row skipped: no invite URL exists in the repo; add when one lands. [2026-07-23] PR 3 primitives: new --attn token pair (38 50% 34% light / 38 45% 62% dark) for the one-sentence AttnLine instead of reusing --warning: the warning tone fails WCAG AA as 12.5px body text on the page background; chips/charts keep --warning. Stagger applied via className on the five target pages' DataList/TableBody containers (plan item 7), not baked into the DataList primitive: remaining pages adopt it in their own migration PRs where their skeletons are aligned at the same time. [2026-07-23] PR 4 template booking books directly through the normal journal-entries endpoint without the editor's account-activation retry flow: template accounts are standard BAS accounts that are active in practice, and an inactive-account error surfaces as a normal error toast pointing at the full editor. Voucher preview in the commit ConfirmDialog is indicative (voucher-sequences/next for today's period/default series); the atomic number assigned at commit is what the success toast shows. [2026-07-23] Onboarding swap plan (dev_docs/onboarding_migration_plan.md): journey UI replaces wizard as pure UI swap (server action/lookup/BankID contract untouched); JourneyOrb = own canvas component, NOT thinking-orbs (needs comet+check morphs, reverses earlier npm intent); /companies/new folded into same component mode='add'; rollout behind NEXT_PUBLIC_ONBOARDING_JOURNEY flag. [2026-07-24] BankID company picker in journey onboarding: searchable list (name + form/role + orgnr, "finns redan" section, filter with single-match Enter) is the standard at ANY engagement count; chips variant cut (founder call on concept c82c9358). Picking a company still runs zero Lens lookups: CompanyRoles prefill only, F-skatt/moms/rakenskapsar asked as questions per the plan's lookupRan rule. [2026-07-24] BankID onboarding lookup (SUPERSEDES the zero-Lens line above, founder call): picking a company in the BankID picker DOES run one Lens lookup and prefills facts (address, F-skatt, moms, fiscal year, SNI), same per-onboarding cost as typing an orgnr; the roster list itself stays free CompanyRoles data, never prefetched. lookupRan=false becomes the degradation path (TIC disabled/429/504 -> ask as questions). Implementation note: the journey must NOT carry over Step2's preverifiedOrgNumber lookup-suppression; the deep-linked orgnr triggers the same single lookup as manual entry. [2026-07-24] Rest-of-nav PR 1 (Viktiga datum/Skattekonto/Periodiseringar/Import) deviations from concept scenes 17/24/32/33: skattekonto Saldo column dropped (SKV API stores no per-row running balance; computing one client-side would be dishonest math); Periodiseringar ships without the concept's "Ny periodisering" header button (no standalone create flow exists, accruals are born from invoice rows, help popover explains); Import keeps FY selection inside the SIE-export dialog instead of a header FyPicker (FY only affects SIE export, a page-level picker would lie); concept's "Hela arkivet" export row omitted (no zip-archive endpoint exists). [2026-07-24] Journey reducer lives in lib/onboarding-journey/, not components/onboarding/journey/ as the plan sketched: pure logic belongs in the vitest scope (lib/ + app/api/), and the plan's reducer test matrix requires it. Components stay copy-agnostic; i18n keys land with their consumer in PR C. [2026-07-24] Missing-underlag predicate made reference-aware (BFL 5 kap 7 § hänvisning): an entry referenced by a supplier invoice whose document is retained AND anchored (document_attachments.journal_entry_id set) counts as covered; anchoring required because every WORM deletion guard keys on journal_entry_id, so an unanchored doc is deletable and must keep the warning alive (adversarial-review finding). match_batch_allocate RPC left without tx-doc propagation on purpose: its payment JEs are silenced via the supplier_invoice_payments reference arm, and touching that large RPC for a data-cosmetic link was judged out of scope for the /fix. [2026-07-24] Stripe UI re-homed to /import (mode=stripe); kept /settings/payments as a redirect instead of deleting the route: old links and in-flight OAuth bounce-backs must not 404. Kept the settings_payments i18n namespace for the panel to avoid rename churn. [2026-07-24] Anlaggningstillgangar note (ARL 5:8) anchored to posted depreciation_schedules with computeAnnualDepreciation as fallback, over (a) fixing the old continuous day-based formula or (b) deriving the note from ledger 10xx-12xx account movements: posted schedules ARE the booked amounts (and exactly what disposeAsset reverses), the engine fallback previews what would be booked with full method dispatch, while (a) cannot represent declining-balance/K3 components and caused the 20 kr RR/BR-vs-note drift, and (b) needs fragile account-to-category mapping. Pre-onboarding ibAck iterates synthetic 12-month windows through the engine (approximation surfaced by a new note-vs-TB tie-out warning). K3 adaptAsset months-based per-component approximation left untouched (scoped follow-up). [2026-07-24] Accounted MCP naming is an additive namespace selected with tool_namespace=accounted: internal gnubok_ ids, authorization maps, API-key prefixes, and the gnubok-mcp package remain canonical compatibility surfaces, while new clients advertise accounted_* aliases through accounted-mcp so existing connections never invalidate. [2026-07-24] PR-review pass on bug/invalid-imports: declined CodeRabbit's ask to fill un-posted prior years in asset-note-figures ibAck via the engine when priorPosted.length>0. The note must tie out to the ledger-driven balansrakning, which reflects posted-only accumulated depreciation; estimating a skipped year would over-state ibAck and BREAK the tie-out this module exists to preserve. A posted-history gap is a real books gap that the build-data tie-out warning correctly surfaces (fix = post the missing year, not paper over it). Engine fallback stays gated to priorPosted.length===0 (pre-onboarding, nothing booked). [2026-07-24] Kept the leading-! Tailwind important syntax (!p-0/!pl-0/!pr-0) in the transaction tables over CodeRabbit's trailing-! (p-0!) rewrite: verified against the installed tailwindcss 4.1.18 compiler that !p-0 alone emits .\!p-0{padding:… !important}, so both forms work in v4 and the flush-edge columns are not broken. Declined the 18-site churn. [2026-07-24] BankID signup email guard = createUser email_exists error mapping, not a profiles.email pre-check: profiles is not a reliable mirror of auth.users (anonymize tombstones scrub it), and the error-based check has no TOCTOU window. Deleted-account emails stay blocked for re-signup by design (tombstone keeps auth.users.email). [2026-07-24] Journey onboarding stack MERGED to main (#1141, #1145 ex-#1142, #1143) after founder preview click-through + "safe to merge". Production flag NEXT_PUBLIC_ONBOARDING_JOURNEY deliberately NOT set at merge time: BankID roles-prefill path is reducer-tested but not yet live-verified, so the flip is an explicit founder step, followed by one BankID smoke and then PR D (wizard deletion, /companies/new mode='add', picker restyle). [2026-07-24] Onboarding journey migration COMPLETE with PR #1150: wizard deleted, /companies/new on journey mode='add', BankID picker = searchable list, flag conditional removed (env var cleaned from Vercel post-merge). Bot-review triage: compliance findings on getUser()/redirect()/ensure_user_team skipped as App Router misreadings or pre-existing patterns; fixed the real ones (stale select_company keys, unused hasExistingCompanies plumbing). [2026-07-24] Invite recovery on onboarding surfaces = cookie retry + hint, NOT accept-by-email: a BankID signup's email is confirmed via a client-delivered magiclink (no mailbox proof), so auto-joining on email match would let anyone who registers the invitee's address claim the membership. The cookie/token path keeps mailbox possession required; cookie-less invitees get pointed back to the mailed link, with no company name leaked. [2026-07-24] Share-capital pair rule validated in the PUT route, not UpdateSettingsSchema: the all-or-nothing check needs the stored row (a partial update may send only one key), which Zod cannot see; the route already owns the other cross-field effective-value checks. [2026-07-24] Declined compliance-bot ask to restore internal check codes (RC_BASIS_MISSING et al) in VAT check rows (#1161): the codes are local pre-flight rule IDs, not statutory references; the Swedish messages already cite the rutor and SKV felkod (FK004), and the founder explicitly flagged the raw codes as visual noise. [2026-07-24] Declined compliance-bot ask to re-box the bolagsskattMissing warning (#1161): text-attn IS the locked house attention idiom (one ochre sentence, banners forbidden by design.md); PreviewStep keeps the inline action to the dispositions step, so salience + remediation path both remain. [2026-07-24] VAT RC checks proportional (0.5% + 1 kr tolerance) + latched stepper landing: the binary present/absent RC_BASIS_MISSING check cleared after one korrigering and hid a 38-voucher worklist behind "klart"; tolerance absorbs per-voucher basis rounding (moms/sats vs invoiced amount) without hiding a missing voucher; landing step latches once per period so a mid-work refetch cannot navigate the user off Kontrollera. [2026-07-24] Stripe for Arcim's own books = self-connection row, not Connect OAuth: the Connect platform account IS Arcim Technology AB (acct_1TQrB0Qj4cYcnWY9), and Stripe refuses to let an account connect to itself. Verified that Stripe accepts a self-referencing Stripe-Account header as a no-op, so the whole sync path works unchanged against the platform account; only the OAuth handshake that creates the stripe_connections row is blocked. Row inserted by hand in prod (company ed461bc1, connection b77b6619) as an interim unblock; the durable fix is a gated STRIPE_PLATFORM_OWNER_COMPANY_ID self-connect path, which also unblocks self-hosted installs whose operator owns the platform key. The row must stay pinned to that one company: it grants read access to the platform account's live Stripe data. [2026-07-24] Invoice delivery outcome stored per message on invoice_deliveries, not in a per-recipient events table: Resend reports delivered/bounced/complained per message (one event carries the whole to[] and names the failing address only in free text), so per-recipient status would require one email per recipient and would break CC. The three columns are the only mutation allowed on a sent row; the WORM trigger proves nothing else changed by subtracting them from the row image, and the provider reason text is treated as recipient PII (masked on read, cleared by the daily redaction job). [2026-07-24] next-intl timeZone pinned to Europe/Stockholm globally (i18n/request.ts + explicit prop on NextIntlClientProvider) rather than per call site: unset, formatting falls back to the runtime zone, which is UTC on Vercel and the visitor's zone in the browser, so timestamps both lied and disagreed across hydration. [2026-07-24] Stripe integration is feed-only (Emil's product call): "Synka nu" and the crons import balance transactions to the inbox, nothing auto-books. The event/settlement sync (lib/sync.ts, lib/payouts.ts + tests) stays in the repo dormant: not deleted, not wired to any cron or route; the 15-min sync cron was removed from vercel.json. Payment links on invoice send stay (payments arrive as feed rows, matched manually). transaction_sync_enabled defaults true at OAuth activation. Also parked leg 1 on Arcim's prod connection (last_event_created_at=2100-01-01) so the still-deployed old cron cannot auto-book payouts before this change ships; the field is dead code after deploy. Side effect: the contested reverse-charge-vs-exempt VAT question on Stripe fees no longer has an automated code path deciding it; the inbox flow (template: momsfri) owns fee booking. [2026-07-25] /api/v1 articles list now returns currency: the dashboard, importer, export and MCP article surfaces all learned about a non-SEK article price (#1166, #1183, #1184), but the v1 projection still omitted it, so an API or agent caller read a EUR price with nothing marking it as EUR and would copy the number straight onto a SEK invoice line. Additive field plus a pitfall on the endpoint; no FX conversion is implied. [2026-07-25] Removed invented 6-month minimum for first räkenskapsår: BFL 3 kap 3 § sets no floor (Bolagsverket: "hur kort som helst", max 18 months); the check only existed for isFirstPeriod, exactly the case the law exempts, and blocked a customer shortening an autumn-registered first year to Dec 31. [2026-07-25] Article EUR-price support bug: root cause was the edit dialog omitting currency from initialData (form defaulted SEK and PATCHed it back) plus kr-hardcoded formatCurrency calls; export gets a Valuta column + suffix-free decimalColumn instead of extending CURRENCY_FORMAT, importer Valuta detection deferred as follow-up to keep the diff scoped. [2026-07-25] Reinstated article deactivation as an explicit PATCH active-toggle button on the detail page (support: odinaero.se) instead of reverting DELETE to soft-delete: 8a9a930f intentionally made DELETE hard-delete for unused articles, but that left invoice-referenced articles (ARTICLE_IN_USE) with no retire path; the old deactivate i18n keys were still in messages/ and are reused. [2026-07-25] Momsdeklaration period selection fused into one chip (year+quarter/month, reverse-chron, 5 years) instead of separate year and period pickers; cadence stays behind the Period chip. Standalone report pages now render their own PageHeader so Exportera sits on the title row per convention 9. [2026-07-25] Currency-honesty batch (#1177-#1187): aggregates over mixed-currency rows group per currency instead of converting (supplier stats, bank-file totals): read paths must not depend on live FX fetches, and a per-currency line is honest where a converted single number would hide the mix. Where a stored SEK conversion exists but is NULL (invoices.total_sek after a failed rate fetch), aggregates skip-and-flag (unconvertedCount + one visible note) rather than fall back to the raw foreign amount. [2026-07-25] Editing a draft ROT/RUT invoice keeps the stored encrypted personnummer when the field is left empty and deduction lines remain (#1186): the plaintext is not client-rehydratable by design, so empty-means-keep is the only edit semantics that neither blocks the edit nor wipes the ciphertext; typed value replaces, removing all deduction lines clears. [2026-07-25] Article delete was broken globally by a phantom invoice_items.company_id filter (42703 -> ARTICLE_DELETE_FAILED) that mocked route tests cannot catch; fixed in #1188 with a source-pin test. Lesson: supabase-mock tests validate flow, never schema: any new filtered column needs a schema-level check or pg-real coverage. [2026-07-25] Popup-after-await fix uses a pre-opened tab (AGIPanel pattern) via lib/browser/deferred-tab, not an anchor-download fallback: pre-opening about:blank keeps the user gesture and works for blob and signed URLs alike; the helper severs window.opener, except the Arcim OAuth popup which keeps it for postMessage. [2026-07-25] Declined the review suggestion to 200-ack the Resend delivery webhook when RESEND_DELIVERY_WEBHOOK_SECRET is unset; kept 503. The endpoint is only ever called because an operator pointed Resend at it, so a missing secret at that moment is a live misconfiguration: Svix retry then endpoint-disable is a visible signal, whereas a silent 200 loses every delivery outcome with only a log line. The "optional" wording in docs/WHITELABEL.md describes not wiring the webhook at all, not wiring it half way. [2026-07-25] Reverted the settings panel-sheet redesign on bug/resend-and-invoices back to main: Emil prefers the settings UI as it stands on main. The routed sheet, the sheet/ primitives (SettingsMasterDetail, SettingsAccordion, SettingsFieldRow), the Subsections.tsx decompositions, the cold-load sheet and the settings_sheet i18n namespace were removed; every app/(dashboard)/settings/ page, components/settings/** file and MainContainer scroll exception now matches origin/main byte for byte. Unrelated branch work (invoice delivery outcomes, Stripe feed-only, article currency/deactivation, PDF logo) is untouched. [2026-07-25] Settings UI on bug/resend-and-invoices now comes from feat/settings-fonster-redesign (dbae8792, Jakob) instead of the panel-sheet work reverted earlier the same day: Emil chose the Fonster concept (flat hairline rows, help behind "?", sticky dirty-only save bar, 920x680 modal, switches instead of checkboxes). Applied as a patch rather than a merge because the redesign branch forks from b5e3c476 and merging would have dragged that older main in; every file applied cleanly since no settings file changed on main since that fork point. The 10 settings_payments keys the redesign still carries (needs_review_, reason_, sync_done_description/transactions) were deliberately NOT restored: the Stripe feed-only commit on this branch deleted both them and their call sites. [2026-07-25] Scheduled Image Vulnerability Scan has been red since 2026-07-22 08:45 UTC (last green 07-21 22:45, same code): Vercel published 7 CVEs against next 16.2.10 on 07-22, two HIGH SSRF (CVE-2026-64649 Server Actions on custom servers, CVE-2026-64645 rewrites destination hostname) plus five MEDIUM, all fixed in 16.2.11. Bumped next and eslint-config-next to 16.2.12 (latest 16.2.x, no advisories against it) rather than the minimum 16.2.11, since both are patch-level and 16.2.12 avoids an immediate second bump. Nothing auto-caught this because Dependabot was removed in #1084 and Dependabot alerts are off on the repo, so npm CVE bumps are now a manual chore. [2026-07-25] Added limit-severities-for-sarif: true to docker-image-scan.yml. trivy-action v0.36.0 entrypoint.sh unsets TRIVY_SEVERITY whenever format is sarif unless that input is true, so the workflow's documented "block on fixable CRITICAL/HIGH" policy was silently running as "block on any fixable CVE down to LOW". Kept format: sarif (the Security-tab upload depends on it) and narrowed the gate instead. Side effect: the Security tab now receives only CRITICAL/HIGH findings from this workflow; docker-publish.yml is untouched and still uploads all severities, so nothing is lost. [2026-07-26] GitHub Actions cache hit the repo's 10 GB quota (11.09 GB across 272 entries). Two causes, two fixes. (1) docker-publish.yml's buildx layer cache was 6.89 GB of type=gha blobs, and since buildx refreshes every blob's access time on each run it never aged out under LRU, starving the other workflows instead. Moved to type=registry in GHCR (free for public repos, off the quota) rather than dropping to mode=min, which would have dropped the deps/builder stages and made every push redo npm ci + next build. Needs image-manifest=true,oci-mediatypes=true, which GHCR requires. Per-branch scope dropped at the same time: the workflow only runs on main and v*.. tags, so there was no untrusted ref to isolate, and per-tag scopes were cutting a fresh cache per release. (2) test-pg-real.yml's setup-node cache: npm produced 3.98 GB as 14 byte-identical 284 MB copies in one day: the workflow is pull_request-only, so the cache was never written on main, and GitHub scopes caches by ref, meaning no PR could ever restore another PR's copy. Removed outright; re-adding requires actions/cache/save on main plus restore here. The 6.89 GB of stale gha blobs stay until one post-merge main build populates ghcr.io/erp-mafia/gnubok:buildcache, since until then they are the only layer cache. [2026-07-26] swedish-compliance-review.yml now installs the Bedrock SDK OUT OF TREE (npm install --prefix "$GITHUB_WORKSPACE/..") instead of adding --legacy-peer-deps to the in-repo install: with --no-package-lock npm discarded the lockfile and re-resolved the whole tree from package.json, floating @hookform/resolvers to 5.4.3 whose valibot ^1 peer conflicts with the pinned valibot 0.39.0, so the Swedish accounting compliance gate had posted nothing for 10 consecutive runs. Out of tree npm resolves only that one package, so an unrelated peer conflict can never take the gate down again, and Node still finds it because ESM bare specifiers walk up parent node_modules (NODE_PATH is CommonJS-only, so it is not an option); --legacy-peer-deps was rejected because it would mask future genuine peer conflicts and still install the full tree. Same step's SDK version aligned from 0.31.0 (undocumented drift) to the 0.29.1 that package.json and check:guards enforce after the 0.32.0 streaming outage. docker-image-scan.yml deliberately left unchanged: its red runs are the next 16.2.10 CVEs already fixed by the 16.2.12 bump on this branch, not an unfixable advisory, so a suppression would only hide a real patchable HIGH. [2026-07-26] replace_sie_import got the undo_sie_import owner/admin guard (COALESCE(p_user_id, auth.uid()), fail closed when the role is NULL) plus REVOKE FROM PUBLIC, anon: prod held EXECUTE for anon on a SECURITY DEFINER function with no authorization check at all that sets gnubok.allow_delete, so any caller with a company_id and an import id could hard delete another tenant's verifikationer past the BFL triggers. p_user_id is optional rather than required because app/api/import/sie/[id]/replace/route.ts passes only two args and the session client resolves the actor via auth.uid(); EXECUTE is granted to authenticated as well as service_role because rpcClientForBulkDelete falls back to the caller's session client when SUPABASE_SERVICE_ROLE_KEY is unset, and the fail closed guard makes that safe. [2026-07-26] arcim-migration OAuth reuses the existing provider_otc table as its state store instead of a new one: the table was created in 20260402010000 for exactly this ("One-time codes for OAuth callback validation") and never wired up, with 139 rows in prod and zero ever consumed. State is now an opaque randomBytes(32) pointer consumed by a single atomic UPDATE ... WHERE used_at IS NULL AND expires_at > now() RETURNING, provider is read from provider_consents rather than trusted from the client, and no grandfathering window was added for legacy base64url state because accepting it for even a minute keeps the critical forgery exploitable. [2026-07-26] v1 idempotency hash folds dry_run only when true, not as an unconditional boolean: including dryRun:false would change the hash of every ordinary write, and with a 24h idempotency TTL any key in flight across the deploy would fail the request_hash comparison and 409 on a legitimate retry. Both hash call sites now go through one shared buildRequestHash so they cannot drift into a permanent cache miss. [2026-07-26] documents storage policies compare the company path segment as text instead of casting ::uuid the way the sie-files precedent does: the bucket holds keys whose second segment is not a UUID (MCP audit packages write {userId}/audit-packages/...), and Postgres does not guarantee the bucket prefix qual is evaluated before the cast, so a planner reordering would raise 22P02 and fail the whole query instead of filtering the row out. INSERT is scoped to company membership only, not additionally pinned to auth.uid(), because e-post inbox, bank sync and Bolagsverket legitimately upload on behalf of another member. [2026-07-26] The journal_entries!inner sweep left the two free text ilike legs of the MCP DISPLAY_SELECT on the embed: each is capped at legLimit and that cap drives the legCapHit/truncated contract, and fetchEntryLines has no limit, so converting would turn a bounded fetch into an unbounded one. app/api/bookkeeping/accounts/[number] moved to the existing get_account_usage_counts RPC rather than the helper, because the embed there was a head:true count and the helper returns rows. [2026-07-26] Error tracking landed as a provider agnostic sink with a no-op default and zero dependencies rather than a vendor SDK: no dependency was approved and an AGPL-3.0 project audits its surface, and lib/logger.ts already emits structured JSON so a Vercel log drain or a 60 line fetch adapter may make the dependency unnecessary. Redaction moved from lib/logger.ts to lib/observability/redact.ts so logger and sink share one denylist and no path to a third party can skip the personnummer regex; cron failure reporting uses threshold 1 and a 15 minute throttle rather than backup-alert's 3 and 7 days, because suppressing the first occurrence of a failure is the exact bug this work exists to fix. [2026-07-26] buildInvoicePaymentClearingLines now throws InvoiceBookingRateMissingError (new code MATCH_INVOICE_BOOKING_RATE_MISSING) instead of defaulting a missing invoice.exchange_rate to 1 on the cross-currency path: ?? 1 valued a 1000 EUR receivable at 1000 kr, so an 11 496,70 kr settlement posted 10 496,70 kr to 3960 as a phantom kursvinst with revenue understated by the same amount, and the verifikat balanced so no trigger fired. Kursvinst/kursförlust is by definition the difference between the settlement value and the booked value (ML 8 kap 21-23§, K3 kap 30), so with no booking rate there is no second number and nothing may be posted. A new code was added rather than reusing BATCH_FX_RATE_MISSING, whose Swedish text says "innan du fördelar" and belongs to the batch-allocation flow; the guard bounds (null, <=0, >=100000) deliberately mirror that RPC. "Currency is SEK" stays a separate condition from "rate is missing": a SEK invoice never consults the rate and cannot throw. [2026-07-26] verifikation.draft's period gate now calls resolvePeriodStatusForDate instead of an inline fiscal_periods query: it had selected fiscal_periods.status and locked_through, neither of which exists, so the whole select errored, period_status was always null and the BFL lock guard never once fired. The shared helper is the same two layer source (company_settings.bookkeeping_locked_through, then is_closed/locked_at) the DB triggers, the MCP server and the v1 REST gate use, so this surface cannot drift from them. A local 'unknown' status was added rather than widening the helper: the helper swallows PostgREST errors and returns 'open', and this intent must fail closed, so a thrown lookup now renders an explicit "do not assume the period is open" instruction plus, for a posted entry, the storno only rule from BFL 5 kap 5 §. [2026-07-26] Supplier-invoice booking now refuses a foreign-currency invoice with no exchange rate (SI_FX_RATE_MISSING) instead of inheriting resolveSekAmount's "return the raw foreign amount" fallback: the fallback scaled every leg by the same wrong factor, so a 1000 EUR EU reverse-charge invoice booked 250,00 kr of fiktiv moms on 2614/2645 instead of 2875,00 kr at 11,50 SEK/EUR, the entry still balanced, no trigger fired, and rutorna 20/21 + 30 of the momsdeklaration were understated (oriktig uppgift, SFL 49 kap 4 §). Precedent: the match_batch_allocate RPC hard-fails with BATCH_FX_RATE_MISSING and lib/reports/supplier-ledger.ts skips such invoices rather than faking 1:1. The guard was applied to every conversion in the file, not only the three VAT/basis helpers, so no leg (expense, moms, basbelopp, 2440) can post at a fabricated rate; resolveSekAmount itself was deliberately left alone as a separate root fix. SEK invoices and any invoice with a positive stored or settlement-derived rate are byte-identical to before. [2026-07-26] voucher_gap_explanations inserts are centralized in recordVoucherGapExplanation (lib/bookkeeping/cancel-orphaned-entry.ts): the four copies all named nonexistent gap_number/created_by and omitted the NOT NULL user_id/gap_start/gap_end, so no CAS-race gap has ever been documented. A single stranded voucher N is written as the closed range gap_start=gap_end=N because every reader (voucher-gaps UI, gnubok_list_voucher_gaps, checkYearEndReadiness) keys on voucher_series:gap_start:gap_end. A failed insert is logged at error level with the full payload rather than failing the request: the caller is already returning the correct CAS-conflict response and a 500 would be a worse answer; 23505 counts as already-documented. The two v1 routes keep their "skip the gap row when the orphan has no voucher_series" policy and cancelOrphanedPaymentEntry keeps its 'A' fallback; only the insert was unified. [2026-07-26] Declined to promote the "Aktiekapitalnoten saknas" source warning to a filing-stage error in lib/bokslut/arsredovisning/completeness.ts: the swedish-financial-reporting skill puts antal aktier/kvotvärde under ÅRL 5:34 § (större företag only) and does NOT list a share-capital note among the K2 mandatory noter (BFNAR 2016:10 kap. 18-19); ÅRL 5 kap 14 §, which build-data.ts:646/662/935 cites for that note, is Ställda säkerheter, a different note the builder always emits and completeness.ts already gates with AR-NOTE-SECURITIES-UNCONFIRMED (error). ok:true is only reachable for K2 + aktiebolag + size_classification 'smaller' (K3 always errors via AR-K3-DRAFT-ONLY, larger/unknown via AR-K2-LARGE/AR-K2-SIZE-UNKNOWN), i.e. exactly the mindre-företag population ÅRL 5:34 § does not reach, so no mandatory note is in fact missing on a passing filing and promoting it would over-block filings against a hard 7-month Bolagsverket deadline. Separately noted, not fixed: report.warnings reaches completeness.ts as untyped string[], so per-warning severity would require coded warnings from build-data.ts (not owned in this pass). [2026-07-26] lockPeriod's pre-lock guard now counts the canonical worklist "att bokföra" predicate (is_business IS NULL AND is_ignored = false) instead of journal_entry_id IS NULL AND is_business = true: triage is what sets is_business, so the old pair could never describe an untriaged row, and prod confirmed it (18 342 untriaged transactions, none matched; 74 of them are already stranded behind 3 locked periods where enforce_period_lock now makes them unbookable in place, a BFL 5 kap 2 § problem). The journal_entry_id clause was dropped rather than kept alongside, because lib/transactions/is-booked.ts documents that bulk-booked and multi-allocated transactions keep journal_entry_id NULL while anchored to a verifikat, so it blocked on already-booked rows (1 756 matches in prod, mostly sandbox-seed fixtures) while missing every real one. Kept as a hard block, not the bypassable-dialog pattern from the soft-guard rule: that rule covers advisory heuristics whose premise can be genuinely false, whereas "these rows are untriaged" is a deterministic fact, the two triage outs (mark private, ignore) are one click each and both clear the guard, and unlockPeriod already provides an audited escape, so it is not a dead end. Message now names the count and points at Transaktioner, keeping the words "saknar bokföring" the lock route regex-maps to PERIOD_HAS_UNBOOKED_TRANSACTIONS. Not fixed: the byte-identical dead guard in extensions/general/mcp-server/server.ts gnubok_lock_period (not owned in this pass), so the MCP lock path still stages a lock over untriaged rows. [2026-07-26] bulk-book's mixed-currency guard was hoisted above the branch split in app/api/transactions/bulk-book/route.ts rather than copied into each branch: the check only existed on the template branch, so manual_lines and existing_journal_entry_id reached the RPC, which summed v_tx.amount across currencies and turned 100 EUR + 100 SEK into the scalar 200 on a samlingsverifikat whose belopp matched no affärshändelse. Legal basis cited as BFL 4 kap 6 § (one redovisningsvaluta) plus BFL 5 kap 7 § ("belopp"), NOT the BFL 5 kap 2 § the MCP twin cites: 5 kap 2 § is the bokföringstidpunkt rule (kontant senast påföljande arbetsdag, övrigt så snart det kan ske) and says nothing about currency. Kept as a hard block with no "book anyway" escape, unlike the soft-guard-dialog rule: that rule covers advisory heuristics whose premise can be false, whereas there is no correct amount to bypass to here, so the dialog shows honest per-currency subtotals instead of one summed scalar and never renders the booking UI. The same guard was added to the SQL RPC (20260726100000) even though the route now blocks it, because EXECUTE is granted to authenticated (a browser session can call the RPC straight over PostgREST) and lib/pending-operations/commit.ts commitBulkBookTransactions is a thin pass-through with no currency check. NULL currency is COALESCEd to 'SEK' in all three layers, since transactions.currency is text default 'SEK' and nullable; the MCP twin's un-normalized new Set(txs.map(t => t.currency)) therefore still false-positives on a legacy NULL + SEK batch (server.ts not owned in this pass). [2026-07-26] Finished the half-applied match-invoice FX conversion instead of reverting it: the v1 route's switch to buildInvoicePaymentClearingLines was already complete end to end (guard, Riksbanken/manual rate, shared line builder, invoice_payments amount+currency+exchange_rate, match-log provenance), and only the test's engine mock had never been updated, so the two red tests were a stale mock (createInvoicePaymentJournalEntry) and not a broken route. The mock now exports createJournalEntry + findFiscalPeriod and the assertions check the emitted clearing lines (bank leg on the transaction's own resolved account, 1510 credit) rather than a delegate call that no longer happens. Also fixed as part of making the two doors agree: (a) the dashboard match-invoice route soft-failed the new InvoiceBookingRateMissingError, which would have marked an invoice paid with no verifikat while 1510 still carried the receivable, an unrecoverable half-state since mark-paid rejects 'paid' invoices; it is now fatal and returns the registered MATCH_INVOICE_BOOKING_RATE_MISSING 400, matching v1. (b) the dashboard route now re-propagates invoice.default_dimensions onto the clearing lines, which createInvoicePaymentJournalEntry did and the shared line builder does not, so a project's kursvinst/kursförlust stays in the project P&L on both surfaces. Both FX-missing dispatches key on the error's code string literal, not instanceof, for the same reason the supplier routes do: route tests vi.mock the defining module away, and an imported-but-mocked-away constant would degrade code === undefined into a catch-all. [2026-07-26] Left the six naive Math.round(x100)/100 calls in the supplier-invoice match preview alone rather than swapping them for roundOre to shrink the check:guards naive-ore-round delta: roundOre applies a Number.EPSILON nudge and therefore disagrees with the naive form at exact-half öre, and the POST handler plus createSupplierInvoicePaymentEntry both use the naive form. Converting only the preview would reintroduce the exact bug the preview rewrite exists to fix (user approves one number, a different one is booked). Converting both sides is a wider money-math change than this pass owns; the honest fix is a shared amount-resolution helper for preview and POST. [2026-07-26] AGI filing state now resolves per salary run in lib/salary/agi-submission-state.ts, not per period, and the fix is application-code only: no migration. The extension_data cache key agi_submission_{period} really is period-scoped by design (see the 2026-07-13 entry) and agi_declarations really is UNIQUE per company+period, but salary_runs is not: migration 20260414130000 replaced the period unique constraint with a partial index WHERE status != 'corrected', so a correction run legitimately coexists with the run it corrects in the same month and the two share one cache record. Consequence before the fix: deriveAgiFilingState returned 'signed' for a correction run off the ORIGINAL run's record, so the rail rendered the AGI step done and displayed the original's kvittensnummer while nothing had been filed for the correction. Per swedish-payroll references/agi-filing.md a correction is a complete resubmission for the same redovisningsperiod with the same specifikationsnummer, i.e. its own submission with its own kvittens, so run-scoping is the correct data model and no schema change is needed to express it. The record is now matched to the run by salaryRunId when the extension recorded one (/agi/submit and the MCP submit path do), else by signeradTid == run.agi_submitted_at for signed records on an already-filed run, else by refusing any record older than the run's own agi_generated_at (and any record at all when the run has never generated XML). Declined to widen the agi_declarations unique index to one row per salary run: it would need generate-declaration.ts to insert per run instead of upserting per period (not owned in this pass) and the reported bug is fully fixed in derivation. Known residual, not owned here: the original run's kvittensnummer is genuinely overwritten in agi_declarations when the correction is filed (one row per period), so a superseded run renders as filed without a receipt number rather than with the wrong one. [2026-07-26] previewCurrencyRevaluation now prices each currency through fetchExchangeRate (per currency, with the supabase exchange_rates cache) instead of fetchMultipleRates, and executeCurrencyRevaluation throws ClosingRateUnavailableError before posting when any balansdagen rate is missing: fetchMultipleRates pads its Map with getFallbackRate, whose EUR 11.5 / USD 10.5 / GBP 13.5 constants riksbanken.ts:211-214 documents as display-only and "the booking path never returns them", yet the revaluation posted a real 3960/7960 verifikat computed from them, so a Riksbanken 429 at 05:00 silently produced an orealiserad kursvinst off a hardcoded number. The permitted rate sources are the Nasdaq OMX mid-rate published by Riksbanken or the ECB rate (ML 8 kap 21-23 §), and monetary items must be revalued to balansdagskurs (ARL 4 kap 13 §), so a constant is not a rate and refusing is the only correct outcome; fetchExchangeRate returns null rather than a constant, which is why it is the booking path. A new code FX_CLOSING_RATE_UNAVAILABLE (502, retryable) was added rather than reusing SI_FX_RATE_MISSING, MATCH_INVOICE_BOOKING_RATE_MISSING or TX_EXCHANGE_RATE_UNAVAILABLE: the first two are 400s about a missing stored rate on a row that the user fixes by typing it in, whereas this is the market observation not being published, is not user-fixable, and belongs in the existing FX_ namespace next to FX_PERIOD_NOT_FOUND / FX_PERIOD_CLOSED / FX_FAILED. The refusal is all or nothing and is checked ahead of the empty-preview null shortcut, because a partial post would understate the FX result while looking like a complete balansdagen valuation, and returning null when every currency lacks a rate would report "nothing to revalue" for "we could not value it". Separately, the .not('exchange_rate','is',null) filter was dropped from both fetchers: it hid the rows with the largest unmeasured exposure, which are now partitioned out and returned as unconvertedFx / unconvertedFxCount exactly as lib/reports/supplier-ledger.ts surfaces unconverted_fx_count (same "present and > 0" rule, so a 0 rate no longer values the whole receivable as a kursvinst). Those rows report but do not block; only a missing closing rate blocks. Preview never throws so the year-end readiness page still renders. Known residuals, not owned in this pass: errorResponse dispatches this class on path 4, which passes no details, so the REST envelope carries the registry's generic Swedish sentence and the per-currency naming only appears where getErrorMessage sees the instance (MCP getStructuredError) or the envelope carries details.missingRates; and the v1 operation result plus lib/pending-operations/commit.ts commitRunCurrencyRevaluation drop unconvertedFxCount from their payloads. [2026-07-26] lib/invoices/supplier-invoice-matching.ts now refuses to compare amounts across currencies in passes 2-4: same currency compares raw magnitudes, different currencies compare stored SEK values only, and an invoice with no stored conversion is skipped as a candidate rather than scored. Without this a 1000 EUR supplier invoice "exact matched" a -1000 SEK bank debit at 0.85 confidence on every bank import, offering the user a confident match between amounts differing by a factor of about 11. Two deliberate deviations: (a) the local sekValue helper is lib/bookkeeping/currency-utils.ts#resolveSekAmount minus its final "no conversion info, return the amount as-is" fallback, because that fallback is safe for booking legacy rows but here would hand the matcher a raw EUR total dressed up as kronor, i.e. the exact false match being fixed; (b) an excluded candidate is dropped silently instead of being surfaced with a reason, because findSupplierInvoiceMatch returns a single SupplierInvoiceMatch | null and both callers (lib/transactions/ingest.ts, lib/bookkeeping/handlers/supplier-invoice-handler.ts) only ever write a potential_supplier_invoice_id, so there is nowhere to carry a reason without a breaking signature change. Pass 1 (OCR/payment reference) stays currency-agnostic on purpose: the reference identifies the invoice on its own and no amount is involved. Pass 4's 5-unit fuzzy tolerance is expressed in whatever unit the comparison settled on (SEK when cross-currency, the shared currency when both sides already agree), which preserves the pre-existing behaviour for same-currency rows. SEK-only companies take the same-currency path and are byte-for-byte unaffected. [2026-07-26] The ingest content-dedup currency fix was applied as a per-entry guard inside consumeBridgingTwin (lib/transactions/ingest.ts), NOT by adding currency to contentBucketKey: the bucket keys on (date, öre) only, so a stored 250,00 SEK row and an incoming 250,00 EUR row shared a bucket and the text bridge or the cross-channel mirror could consume either for the other, dropping a real affärshändelse that is then never bokförd (BFL 5 kap 2 §) with only an aggregate duplicate count as evidence. Only the booked-hand-entered mirror had ever carried a currency check; that check was hoisted to the loop so all three match paths (text bridge, cross-channel mirror, hand mirror) share it. Widening the key was rejected for two reasons: contentBucketKey lives in lib/transactions/external-id.ts next to buildStableExternalIds, whose eb_{iban}_{date}_{öre}_{n} template is a frozen persisted key, and a currency component in the key would send legacy currency-NULL rows to a different bucket than incoming SEK rows and stop deduping them, re-opening the fleet-wide re-import the June 2026 incident was about. The guard keeps the established null-tolerance (null on either side is compatible), so SEK-only and legacy companies are byte-for-byte unaffected. The cross-channel mirror's count maps were deliberately left currency-blind: a coarse count can only turn the mirror ON, after which the per-entry guard still refuses the match and the row inserts, so the failure direction is a visible deletable duplicate rather than a silent drop. A log.info was added on the content-bridge drop because result.duplicates reaches the API response and bank_file_imports.duplicate_count but no import UI renders it and it cannot distinguish this judgement call from an exact Layer-1 id collision; surfacing skipped rows in BankFileResultStep and the Enable Banking sync toast is left as a follow-up (not owned in this pass). [2026-07-26] Removed the surviving 6-month first-year floor from lib/onboarding-journey/fiscal-options.ts (abFirstYearEndOptions) and rewrote the four copy sites that still stated it as law. PR #1165 (2026-07-25) already deleted the same invented floor from validatePeriodDuration: BFL 3 kap sets no minimum for a first räkenskapsår, only the 18-month maximum, so an autumn-registered AB may end its first year at 31 Dec. The onboarding journey is the surface a brand new AB actually walks through, and it silently withheld exactly those short options while efFirstYearEndOptions offered them, so AB and EF disagreed for no stated reason. The floor became months >= 1, a structural guard (it drops end months that fall before the start month, which would otherwise produce zero or negative spans), not a legal one. journey_fyend_ab_info and journey_fy_info are the worst of the copy: they cited "enligt bokföringslagen" / "under the Bookkeeping Act" for a constraint that does not exist. They now cite the Act only for the 18-month cap, which is real, and state plainly that there is no minimum. The 18-month maximum is enforced twice on this path (option generation never emits > 18, and computeFiscalPeriod runs validatePeriodDuration before submit), so removing the fake floor left the real ceiling intact. [2026-07-26] lib/reconciliation/bank-reconciliation.ts now resolves every ledger amount through one local helper, ledgerLineAmountIn(line, currency), shared by the matcher, the status card and the auto-link path, rather than each site re-deriving debit/credit. journal_entry_lines.currency labels the DOCUMENT, not the debit/credit columns: lib/bookkeeping/currency-utils.ts converts a foreign amount to SEK for debit_amount/credit_amount and then stamps currency + amount_in_currency onto the same line, so every "the currencies match, so these amounts are comparable" guard passed on exactly the FX rows it existed to catch. The helper returns debit - credit on SEK (never null, so SEK-only companies are byte-for-byte unaffected), and on a foreign currency takes the magnitude from amount_in_currency and the direction from the debit/credit side (some rows store a negatively signed amount_in_currency; the ledger side is authoritative), returning null when the row carries no amount in that currency. Null is reported, never converted with an invented rate: getReconciliationStatus counts those rows as unconvertible_gl_line_count and sets not_reconcilable_reason = 'gl_lines_missing_currency_amount' instead of publishing a difference computed over a subset, and autoReconcileTransactionForLinkedVoucher (which PERSISTS a link) returns null and leaves the row for manual matching. Separately, is_reconciled now requires BOTH a zero net difference AND unmatched_transaction_count === 0: a net-zero difference alone made two offsetting unmatched rows read as avstämt while both were still unbooked affärshändelser each owing its own verifikation identifying belopp and motpart (BFL 5 kap 1-2 §, 6-7 §), and ÅRL 2 kap's individuell värdering and bruttoredovisning forbid treating two offsetting unknowns as knowledge. unmatched_gl_line_count is deliberately NOT in that condition: it is scoped and windowed differently (it counts vouchers not settled on THIS account, so the far leg of an own-account transfer belongs there by design). Declined to extend get_unlinked_gl_lines / get_account_gl_lines_for_matching with currency + amount_in_currency: that needs a migration this pass does not own, so on a foreign account the batch matcher now proposes NO candidates instead of wrong ones (fail safe), while getReconciliationStatus reads journal_entry_lines directly and does reconcile in EUR. Follow-ups not owned here: those two RPCs, a UI surface for not_reconcilable_reason (needs sv+en strings), and lib/bokslut/readiness-aggregator.ts, whose "differens på X kr" fallback message reads oddly when the reason code is set. [2026-07-26] customers_personal_number_check was re-pointed at the ciphertext shape (migration 20260726110000) instead of moving the encrypted value into a new differently-constrained column. The column was added as plaintext by 20260522130000 with a CHECK on the personnummer format; since the 2026-07-15 encryption change the write path stores AES-256-GCM hex (76-82 chars), which no personnummer regex can ever match, so every write was rejected and prod holds 0 non-null personal_number rows across 4957 customers (1355 of them individuals). Widening is honest rather than a loosening: the format guarantee was destroyed by the encryption change, not by this migration, and a CHECK cannot validate a personnummer it can no longer read; plaintext format validation stays in CreateCustomerSchema/UpdateCustomerSchema, which run before the cipher. The constraint now enforces the opposite and still-enforceable guarantee, that the column must never hold a bare personnummer: lowercase hex only, 76 to 255 chars (255 is the highest repetition count a Postgres POSIX regex accepts), so every plaintext form is too short and/or carries a separator and an unencrypted write fails loudly instead of persisting PII. A separate personal_number_encrypted column was rejected: it would add a third storage shape for the same data next to employees.personnummer and invoices.deduction_personnummer_encrypted, which are both encrypted TEXT with no format CHECK, and would touch every read site for zero rows of benefit. Separately, the "a masked value means leave it alone" rule moved from components/customers/CustomerForm.tsx into the PATCH write path, which required accepting the '********-1234' sentinel in UpdateCustomerSchema (the only edit to lib/api/schemas.ts) so the route can see and ignore it; CreateCustomerSchema stays strict because on create there is no stored value to preserve. Known residual, not owned in this pass: app/api/export/customers/route.ts writes c.org_number ?? c.personal_number into the "Org-/personnummer" column of the kundregister export, which will emit raw ciphertext now that rows can exist, and lib/customers/protect-personal-number.ts lets a decrypt failure throw out of maskCustomerRow, which would 500 the whole customer list. [2026-07-26] The three inline underlag-link loops (JournalEntryForm handleConfirm and handleSaveDraft, QuickReviewDialog handleConfirm) now go through lib/documents/link-documents.ts#linkDocuments, which checks res.ok and returns a per-document verdict; all three previously counted only thrown errors, so any 4xx/5xx from POST /api/documents/{id}/link (PERIOD_LOCKED, DOC_LINK_ENTRY_NOT_FOUND, a 500) left the verifikat without the underlag BFL 5 kap 7 § requires it to reference and BFL 7 kap requires archived with it, while the user got a green "Verifikation skapad" and the files were cleared. The success toast is now branched rather than preceded by a separate warning toast: use-toast has TOAST_LIMIT 1, so the destructive toast the two counting sites did emit was immediately evicted by the success toast added right after it, meaning the warning was never actually visible. Files that failed to link are kept in the upload zone (the failed ones only; the linked ones are dropped) because clearing them erases the user's only pointer to the underlag they believed was filed; the accepted residual is that a leftover file would attach to the NEXT entry booked from the same open form, which is loud and visible rather than silent. No new retry feature was invented: /bookkeeping/{id} already renders JournalEntryAttachments (upload plus "Välj från inkorgen") for both posted entries and drafts, so the toast names the missing files and carries a ToastAction that navigates there. TransactionBookingDialog was left untouched as the correct reference (it already checked res.ok); it still clears its files unconditionally, which is the one behaviour the new sites deliberately do not copy. [2026-07-26] extensions/general/arcim-migration/lib/entity-mapper.ts now resolves a real SEK conversion for migrated invoices instead of the dead ternary exchange_rate: dto.currencyCode === 'SEK' ? null : null, which returned null on BOTH branches and left every foreign-currency invoice the migration imported unconverted, alongside null subtotal_sek / vat_amount_sek / total_sek. The provider DTOs (lib/providers/dto.ts) offer NO rate and NO SEK amount: a SalesInvoiceDto/SupplierInvoiceDto carries only currencyCode plus amounts already expressed in that currency, so the rate had to be fetched. It is fetched for the invoice's OWN issueDate, never today, via the in-house pattern from lib/transactions/ingest.ts: fetchExchangeRate(currency, new Date(date), supabase) with the supabase client so the shared exchange_rates cache absorbs repeat dates, pre-resolved once per unique (currency, date) pair at concurrency 4 so a wide historical backfill is not rate-limited by Riksbanken. An imported invoice is rakenskapsinformation (BFL 7 kap) and its SEK value is part of the record, so stamping it with an import-day rate would misstate it. Three deliberate choices: (a) an invoice whose rate cannot be established (currency outside Riksbanken's series, or no observation for that date) is still IMPORTED, not skipped, because dropping it would lose rakenskapsinformation; it keeps exchange_rate = null so lib/bookkeeping/supplier-invoice-entries.ts and lib/bookkeeping/invoice-payment-lines.ts refuse it loudly (SupplierInvoiceFxRateMissingError / InvoiceBookingRateMissingError) rather than posting at a fabricated 1:1, and it is counted into MigrationResults.{salesInvoices,supplierInvoices}.fxUnresolved plus a per-invoice console.error so the migration reports it instead of passing it off as an ordinary import; (b) the "needs attention" marker was NOT written into invoice.notes, which was the obvious in-product surface, because lib/invoices/pdf-template.tsx renders notes on the customer-facing PDF and a Swedish exchange-rate warning would be printed on a real faktura; (c) the fxRates parameter is optional rather than required so the existing entity-mapper tests and any other caller keep compiling, and the failure mode when it is omitted is a reported rate_unavailable, never a silent 1:1. The SEK branch now writes a plain commented null for exchange_rate (a domestic invoice has no rate to record) while still filling the sek columns via a sekFactor of 1, and dto.currencyCode undefined now normalises to SEK consistently, which also fixes the old mismatch where currency was written as 'SEK' but subtotal_sek came out null. Follow-ups not owned in this pass: components/extensions/general/ArcimMigrationWorkspace.tsx duplicates the MigrationResults type and does not render fxUnresolved, so the count reaches the API response but no UI yet; mapSalesInvoice still writes your_reference/our_reference as null although SalesInvoiceDto carries buyerReference/orderReference; mapSupplier hardcodes default_currency 'SEK'; and mapSalesInvoice's paid_amount lacks the Math.max(0, ...) floor its supplier-invoice counterpart has. [2026-07-26] The momsdeklaration import pair (IMPORT_BASE_WITHOUT_OUTPUT / IMPORT_OUTPUT_WITHOUT_BASE) was converted from a binary presence test to the proportional form PR #1164 gave the RC pair, comparing ruta 50 against ruta60/0.25 + ruta61/0.12 + ruta62/0.06 with the same max(1 kr, 0.5%) tolerance; the sales pair (TAXABLE_SALES_WITHOUT_OUTPUT / OUTPUT_VAT_WITHOUT_SALES_BASE) was deliberately LEFT binary. Binary was unsound on both: a period where one import voucher carries both halves cleared the check while the rest shipped tullvardesunderlag with no utgaende importmoms, i.e. undeclared moms and skattetillagg 20 % under SFL 49 kap 4 §. The import implied base is exact because ACCOUNT_RUTA feeds ruta 50 only from 4545/4546/4547 (Import av ravaror och material, 25/12/6 % moms) and rutorna 60/61/62 only from 2615/2625/2635, a closed one-to-one set at exactly those three rates, with VAT-free import on the unmapped 4540. The same arithmetic is NOT exact on the sales side, which is why it was not applied there: rutorna 07 (VMB) and 08 (frivillig uthyrning) have no source accounts in ACCOUNT_RUTA at all while their output moms (2616/2626/2636, 2613/2623/2633) does feed rutorna 10-12; ruta 05 recognises only 3001/3002/3003 while lib/bookkeeping/invoice-entries.ts lets any per-line revenue_account override land the moms on 2611/2621/2631 anyway; and periodiserade lines credit 29xx with the moms left undeferred on 2611, then credit 3001 with no moms on dissolution, so the two sides drift in both directions by design. Any of those would make a proportional sales check a permanent filing-blocking ERROR (isFilingBlocked) on a correct declaration. Mapping rutorna 07/08 and reconciling the revenue_account override is the prerequisite for converting the sales pair. [2026-07-26] Addendum to the currency-revaluation entry above: the remaining_amount <= 0 skip for payables was moved out of the item loop and into the new partition loop, so a fully settled foreign-currency leverantorsskuld is now dropped before its currency is collected. Consequence, and the reason for the move: such a row has no exposure to revalue, so it must neither be counted into unconvertedFx (it is not unmeasured exposure, it is nothing) nor pull a closing rate whose absence would then refuse the whole balansdagen posting. Keeping the old order would have let a settled row block a valuation it cannot affect. [2026-07-26] components/salary/AGIPanel.tsx narrows the AGI submission record for the FILING RECEIPT only (a locally derived runSubmission = resolveRunAgiSubmission({ id: salaryRunId, agi_generated_at, agi_submitted_at }, submission)) instead of receiving an already-narrowed record as its submission prop, and app/(dashboard)/salary/runs/[id]/page.tsx therefore keeps passing the raw period record down while routing the progress rail through resolveRunAgiKvittensnummer. The panel computed isSigned = submission?.status === 'signed' || !!agiSubmittedAt independently of deriveAgiFilingState, so on a corrected month (salary_runs is unique per period only for non-corrected runs, migration 20260414130000, while the cache key is agi_submission{YYYYMM}) the correction run read the original's signed record, rendered as already filed, printed the superseded declaration's kvittensnummer, and hid every filing action behind its !isSigned gate: the correction could not be filed from the UI at all. Swapping the whole prop for the narrowed record was rejected because the in-flight machine is genuinely period-scoped: Skatteverket's granskningsunderlag lock and lasUpp address a redovisningsperiod, not a run, and the signing-link card, the stale-draft warning and the "Lås upp" button all hang off awaitingSigning. Narrowing that too would delete the only recovery path in a reachable state, since lib/salary/agi/generate-declaration.ts re-stamps agi_generated_at on every call, so one click on "Ladda ner AGI-fil" while a draft is locked pushes the run's XML past the record and resolveRunAgiSubmission stops claiming it; it would also strand a correction whose period is still locked by the original's draft, which is exactly the case where the unlock button is the required next step. A correction is a complete replacement declaration for the same period (same specifikationsnummer per employee, references/agi-filing.md) filed on its own and receiving its own kvittens, so an original whose cached receipt a later correction has replaced now renders as filed WITHOUT a number rather than with the successor's. No AGIPanel props changed and no new UI strings were needed. Not owned in this pass: the gnubok_agi_status MCP tool (extensions/general/mcp-server/server.ts) returns the same raw period record as local_state keyed by salary_run_id and has the identical bug. [2026-07-26] The momsdeklaration filing gate was extracted into lib/reports/vat-filing-gate.ts (withRcBasisGapFindings + isFilingBlocked) and the per-verifikat rc-basis-gaps scan is now folded into the SAME check array the "Kontroll av underlaget" banner, the stegen counters and "Skicka till Skatteverket" all read, because checksBlocked previously derived from runVatDeclarationChecks alone: that aggregate compares period totals against max(1 kr, 0.5 % of the implied basis), so at a 400 000 kr RC basis up to 2 000 kr of missing basbelopp hides inside the tolerance and the green "Inga fel hittades i underlaget for perioden" rendered directly above the worklist of the very verifikationer that made the declaration wrong, with Skicka enabled. The per-voucher scan is authoritative because each gap is fiktiv moms on 2614/2624/2634 with no basbelopp on 44xx/45xx, which understates rutorna 20-24; vid omvand skattskyldighet ska bade beskattningsunderlaget och den fiktiva momsen redovisas (tyst kvittning ar inte tillaten), so the declaration is incomplete, not merely suspect, and the finding blocks exactly like the aggregate ERROR it stands in for. Blocking rather than warning is not a dead end: the one-click Korrigera worklist sits under the finding and VatManualFilingCard (eSKD-XML + PDF, and the /api/reports/vat-declaration/eskd and /pdf routes behind them) is deliberately ungated, so only the direct SKV submission is gated. The 0.5 % tolerance itself was left untouched in vat-declaration-checks.ts. Three scan states are kept apart: only a settled count of zero means "inga brister", a FAILED scan adds a non-blocking WARNING row (a network hiccup must not lock a user out of a statutory deadline, but an empty list would render as an all-clear the scan never earned), and an in-flight scan says nothing. Because "says nothing" is only safe while the card is unmounted, the scan result is tagged with a PERIOD key rather than the retryKey-bearing fetchKey: steg 1 stays mounted across a korrigering, so re-tagging on retryKey dropped the gate open for one round trip after every Korrigera click and re-exposed the exact banner-above-worklist regression. Stale-but-closed is the safe direction and matches the declaration, which stays on screen dimmed while the next one loads. [2026-07-26] Both mark-paid surfaces (app/api/invoices/[id]/mark-paid/route.ts and app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts) now resolve paymentAmountInInvoiceCurrency BEFORE the duplicate-payment guard instead of after it, and refuse a non-SEK invoice that carries no usable exchange_rate with 400 MATCH_INVOICE_BOOKING_RATE_MISSING instead of dividing by a ?? 1 fallback. Two separate defects sat in the old shape. First, the guard compared the raw SEK sum of the custom lines against remaining_amount, which is stored in the INVOICE currency: total/paid_amount/remaining_amount have no _sek twins (core_schema 20240101000001 gives subtotal/vat_amount/total a _sek column each but paid_amount none, 20260323120001 backfills remaining_amount as GREATEST(0, total - COALESCE(paid_amount,0)), and match_batch_allocate 20260531120000 writes exactly v_paid_in_inv_currency into both), so a 5 748,35 kr payment on a 1 000 EUR invoice at 11,4967 read as a full settlement, ran the advisory on a genuine 500 EUR partial, and 409'd INVOICE_PAID_LIKELY_DUPLICATE on the matching bank row. Second, the ?? 1 fallback made an 11 496,70 kr payment on a rate-less 1 000 EUR invoice arrive at planInvoicePayment as 11 496,70 EUR, i.e. remaining 1 000 - 11 496,70 = -10 496,70, which only survived as a MATCH_AMOUNT_EXCEEDS_REMAINING that blamed the amount rather than the missing rate; below the remaining nothing caught it at all, so 500 kr booked as 500 EUR paid against a customer who had really paid ~43 EUR. MATCH_INVOICE_BOOKING_RATE_MISSING was chosen over BATCH_FX_RATE_MISSING because the latter is the match_batch_allocate RPC's own code (its Swedish text says "innan du fordelar", allocation wording that does not fit a manual mark-paid) while the former is the app-layer code buildInvoicePaymentClearingLines already throws for the identical condition on the bank-match path, and its registry remediation (structured-errors.ts:467) already tells the user to set invoice.exchange_rate: one condition, one code across every invoice-settlement surface. The gate is deliberately narrowed to isForeignCurrency && customLines !== undefined: the default no-lines path pays remaining_amount, which is already in invoice currency, so a rate-less foreign invoice can still be settled in full and needs no rate at all, and a SEK invoice never consults exchange_rate so paidRounded stays byte-identical to the old expression. The raw SEK paymentAmount is still what reaches findDuplicatePaymentCandidatesForInvoice, because that helper scans transactions.amount, which is kronor; only the comparison moved into invoice currency. Two pre-existing divergences between the two routes were left as found rather than widened into this diff: v1's guard reads remaining_amount ?? total while the dashboard route reads remaining_amount ?? total - paid_amount (differs only for legacy rows with a NULL remaining and a non-zero paid), and v1 validates custom-line balance before the FX gate while the dashboard route validates it inside settleInvoicePayment after the gate, so a request that is both unbalanced and rate-less gets INVOICE_PAID_LINES_UNBALANCED from one and MATCH_INVOICE_BOOKING_RATE_MISSING from the other (both 400, neither books). Not adopted: buildInvoicePaymentClearingLines' MAX_PLAUSIBLE_FX_RATE upper bound (rate >= 100000), so an absurd stored rate still divides the payment down to a near-zero partial on both routes; the shared error message already claims to cover "out of range", so closing that is a follow-up. Parity is guaranteed structurally, not by convention: both routes feed the converted amount into the same planInvoicePaymentForLines (lib/invoices/apply-invoice-payment.ts, FX-agnostic by contract), v1 directly and the dashboard route via settleInvoicePayment. [2026-07-26] lib/invoices/rot-rut-file.ts now converts every belopp to SEK with the invoice's booking rate before rounding to whole kronor, and blocks a foreign-currency invoice that has no usable rate with a new MISSING_EXCHANGE_RATE blocker instead of emitting a guessed figure. Begaran.xsd V6 (vendored, dev_docs/skatteverket/husavdrag/V6/BegaranCOMPONENT.xsd) types BetaltBelopp and BegartBelopp as BeloppTYPE = xs:long 0..99999999999 and PrisForArbete as its own xs:long with minInclusive 2, and carries no currency attribute anywhere, so the begaran is a kronor document by construction; the file previously emitted raw invoice-currency amounts while the ledger leg (generateRotRutLines, lib/bookkeeping/invoice-entries.ts) already debited BAS 1513 in SEK, so a 625 EUR avdrag at 11,40 stood as 7 125 kr on 1513 while the begaran asked Skatteverket for 625 and the receivable could never clear. The conversion is single-sourced through deductionSekConverter() in rot-rut-rules.ts, which reproduces the ledger's per-amount Math.round(amount * rate * 100) / 100 exactly, so the two can only ever disagree by the whole-kronor rounding the XSD forces. Three deliberate choices: (a) a new per-invoice blocker code rather than reusing SI_FX_RATE_MISSING or MATCH_INVOICE_BOOKING_RATE_MISSING from structured-errors.ts, because this surface returns RotRutBlocker[] (code plus Swedish message) through ROT_RUT_INVOICES_BLOCKED and the eligible-list API, never a structured envelope, and both existing codes name a different document (supplier invoice, payment matching); (b) validateInvoice() takes an OPTIONAL fourth DeductionCurrencyContext argument so every existing caller keeps compiling and the SEK path stays byte-identical, with lib/invoices/build-invoice-write.ts passing only { currency: input.currency } because the Riksbanken rate is fetched further down the same function (the write needs the totals first) and reordering a shared write-builder for a warning string was not worth the risk; a foreign invoice therefore reports "kan inte stammas av mot arsmaximum, fakturan saknar vaxelkurs" rather than measuring a foreign figure against the 50 000 kr ceiling; (c) the missing-rate case is a WARNING in validateInvoice and a BLOCKER in rot-rut-file, because invoicing ROT work in a foreign currency is legal (ML lets any currency be used) and must stay creatable, while the statutory filing must not go out with a number nobody can defend. requested_total is now safe to sum because no unconvertible invoice can reach it. Left alone on purpose: the MIXED_DEDUCTION_TYPES rule at rot-rut-file.ts:177, whose remedy ("dela upp i separata fakturor") is impossible for an issued and paid invoice, stays UNRESOLVED pending a reading of whether two begaran may reference one fakturanummer; and computeDeduction() still takes 30 percent of the line total EXCLUDING moms while HUSFL and the skill both say ROT is 30 percent of arbetskostnaden INKLUSIVE moms, a separate under-deduction that predates this pass. [2026-07-26] Supplier-invoice creation now REFUSES a foreign-currency invoice it cannot translate (400 SI_FX_RATE_MISSING) instead of persisting exchange_rate = NULL. All three writers (POST /api/supplier-invoices, POST /api/v1/companies/:id/supplier-invoices, invoice-inbox /items/:id/convert) previously took body.exchange_rate ?? null with no server-side fetch, which is what produced the permanently unconverted rows the booking path now rejects. Storing NULL only relocates the same failure to the booking step, where the user no longer has the invoice in hand, so the refusal happens at creation, before the ankomstnummer sequence is touched. The three surfaces share ONE resolver (lib/currency/supplier-invoice-rate.ts) rather than three copies of the policy: that shared function, not a convention, is what keeps them from drifting apart again. [2026-07-26] FX rate for a supplier invoice is fetched for the INVOICE DATE, not delivery_date. ML 8 kap 21-23 § anchors the translation at the taxable event, which is delivery_date when it differs, so this is knowingly the looser of the two anchors; chosen because invoice_date is already the date the registration verifikat is posted on and the date the period-lock check runs against, and splitting the money anchor from the verifikat anchor for the minority of rows that carry a delivery_date is a bigger change than this fix. Flagged as an open refinement rather than changed silently. Source stays Riksbanken's SWEA series (the Nasdaq OMX mid-rate), the first of the two sources ML 8 kap 21 § permits. [2026-07-26] supplier_invoices.total_sek is now written for SEK invoices too (rate 1, so total_sek = total). The old writers gated all three _sek columns on an exchange rate existing, and a SEK invoice never has one, so 1 238 of 1 595 prod supplier invoices across 540 companies had total_sek NULL. Migration 20260726120000 backfills the SEK rows and any foreign row that already carries a rate; the 9 foreign rows with no rate are left NULL on purpose because inventing a rate after the fact lands in rutorna 20-24 + 30-32 of the momsdeklaration. That migration is written but NOT applied. [2026-07-26] Reminder paminnelseavgift is quoted in SEK on foreign-currency invoices instead of being converted or relabelled: Lag (1981:739) 2 § plus forordning (1981:1057) fix the fee at 60 kronor with no conversion rule, and Accounted books it 1510/3990 in SEK, so a converted figure would demand money the books do not record. The swedish-invoice-compliance skill only maps the fee to BAS 3930 and does not settle the foreign-invoice question, so the conservative option was taken. Reminder emails and the public /invoice-action page now show "1 010,00 EUR + 60 kr" rather than one mixed-currency scalar. [2026-07-26] The KPI reader now resolves the SEK amount per supplier invoice instead of reading supplier_invoices.total_sek: for a SEK invoice the SEK total IS total (exact, not an approximation), so "Storsta leverantorer" no longer depends on the writer fix or on the 20260726120000 backfill landing. Prod evidence: 1 238 of 1 547 SEK rows and 9 of 48 foreign rows had total_sek NULL, and only 23 of 552 companies with supplier invoices had a single row where it was set, so the panel and the "Topp leverantorer" xlsx sheet were empty for essentially every company. The conversion goes through resolveSekAmount (lib/bookkeeping/currency-utils.ts) for parity with the ledger, EXCEPT its last branch: that branch returns the raw foreign amount as if it were SEK, which is exactly the silent wrongness this sweep is about, so a foreign invoice with neither total_sek nor an exchange_rate is counted in topSuppliersUnconvertedFxCount / the "Ej omraknade valutafakturor" xlsx row instead, mirroring unconverted_fx_count in lib/reports/supplier-ledger.ts. Both the query and the aggregation live in ONE place (topSupplierInvoicesQuery + aggregateTopSuppliers in lib/reports/kpi.ts, the module both routes already imported): a shared function, not a convention, is what stops the JSON route and the xlsx export from disagreeing about the same company, and it is asserted by a cross-route test that runs both handlers over identical rows. [2026-07-26] Both worked-hours writers now persist start_time/end_time and the GET selects them back. UpsertWorkedDaySchema and BatchUpsertWorkedDaysSchema validated and documented the shift window, salary_worked_days.start_time/end_time exist in prod (added by 20260526120900_ob_overtime_premiums.sql, verified via information_schema, so no migration was needed), run-calculation.ts already SELECTs both and feeds them to computePremiumLines, and no other code path writes the table: the two API routes simply never included the fields in their INSERT, so every row in production carries NULL times. shift-premium-engine.ts then falls back to an assumed 08:00-17:00 day, which gives a pure-night OB rule (22:00-06:00) zero overlap and prices weekend work on nine fabricated daytime hours instead of the hours actually worked; a night shift was paid as office hours with no OB-tillagg at all. The batch route additionally reads the rows it is about to replace BEFORE deleting them and carries forward every per-day value the body omits (notes, salary_run_employee_id, the shift window), because the batch has ONE shared body for N dates and no way to express per-day values: the UI sends notes as notes.trim() || undefined, so "mark Mon-Fri as 8 h" wiped every note the user had written on those days. The single-day route deliberately keeps full-replace semantics rather than adopting the same preserve-on-omit rule: it addresses exactly one day and the caller can describe every field of it, so an omitted field there genuinely means "not set", and preserve-on-omit would make the window unclearable given the schema accepts optional-but-not-null (lib/api/schemas.ts was out of scope to change). NOT settled: whether the 08:00-17:00 fallback is defensible for the legacy NULL-time rows. .claude/skills/swedish-payroll/references/ob-overtime.md is a byte-identical copy of sick-pay.md (md5 68e7aba1..., wrong since the file was added in #202), so the skill carries no OB-tillagg or Arbetstidslagen content at all and the question was left unanswered rather than guessed from training data. Note for whoever fixes that file: the engine treats WorkedDayShift.hours as a documented "sanity cap" but never applies it (the only use is hours <= 0), so premium hours come purely from the window and a 4 h shift stored with a 12 h window would be over-paid. [2026-07-26] The supplier_invoice.review "KANDA FAKTA ... fraga INTE om dessa" block no longer carries an AI-extracted amount whose currency is unknown or disagrees with the registered invoice: those values move to a separate "EJ BEKRAFTADE BELOPP" block that says the currency is missing (or names the conflict) and tells the agent to read the underlag before attesting. A number without a unit is half a fact, and half a fact under a "do not ask about this" header reads to the model as confirmation of the registered amount, which is how a 1 200 EUR invoice was being attested on the number 1 200. The alternative (keep the amount in the known-facts block and merely append a warning) was rejected: the header's whole function is to suppress questions, so the fix has to be removal from that block, not annotation inside it. The supplier name / org.nr / VAT-nr stay known facts, since they need no unit. Registered amounts now print their own currency plus the SEK leg from total_sek/exchange_rate when one exists, and an explicit "SEK-BELOPP SAKNAS" when it does not: the same shape inbox.bulk-book uses for the bank leg, and deliberately WITHOUT resolveSekAmount's last branch, which returns the foreign face value as if it were SEK. [2026-07-26] The inbox "Bokfor valda" dialog (components/extensions/general/BulkBookInboxDialog.tsx) now shows per-currency subtotals instead of one scalar, but deliberately does NOT block submit on a mixed-currency selection, diverging from the sibling fix in components/transactions/BulkBookDialog.tsx. That dialog builds ONE samlingsverifikation, which must sit in a single redovisningsvaluta (BFL 4 kap 6 §), so refusing is the only correct outcome there. This dialog posts to POST /api/extensions/ext/invoice-inbox/items/bulk-book, which runs bulkBookMatchedInboxItems -> categorizeMatchedTransaction per item: ONE verifikat per underlag, and the booked belopp is read off that item's matched bank transaction, never off extracted_data.totals.total (lib/transactions/categorize-core.ts header: "Booking is always in SEK off the bank transaction's own amount"). A EUR invoice settled by the bank in SEK is therefore booked correctly, and a mixed selection yields a set of individually correct SEK verifikat, so hard-blocking would refuse a legal everyday batch (a receipt run where some suppliers invoice in EUR) and would also contradict the decision already documented in that same file, that currency must not drive behaviour because a Swedish seller can invoice in EUR and still debit 25% moms. The real defect was display-only and is fixed: the old totalSek summed totals.total across currencies AND rendered it through formatCurrency()'s SEK default, so a mixed batch added 100 EUR to 100 SEK and stamped "kr" on it, and even a homogeneous EUR batch read as kronor. Currency normalisation (missing/blank -> SEK, matching pickCurrency() and emptyExtraction()) lives in the pure helper components/extensions/general/bulk-book-inbox-totals.ts so a legacy null-currency underlag is not split away from its SEK siblings into a phantom currency mix; a malformed code also falls back to SEK because the value comes from AI extraction and Intl.NumberFormat throws RangeError on a bad code. NOT done: no server-side guard was added, correctly, since the server has nothing to refuse here. [2026-07-26] buildInvoiceWriteData now fetches the customer-invoice rate for delivery_date ?? invoice_date and passes the supabase client, where it previously called fetchExchangeRate(currency) with neither. Two separate defects sat in that one call. The missing date stamped TODAY's rate on every invoice, so a back-dated or historical invoice booked 1510 and 2611 at the wrong SEK value; the swedish-invoice-compliance reference (ML 8 kap 21-23 §) is explicit that the rate to use is the one "at time of taxable event (delivery/supply date or advance payment date, not invoice date unless same)", and delivery_date is exactly that date whenever it is set, because ML 17 kap 24 § p.7 requires the delivery date on the invoice precisely when the two differ. The missing client meant the shared exchange_rates cache was consulted on NEITHER leg: not the read-through before Riksbanken, and not the last-cached-observation fallback after a 429, so one transient rate limit during invoice creation left exchange_rate permanently NULL and resolveSekAmount() then books the raw foreign number as if it were kronor (1 000 EUR posted as 1 000 kr). lib/transactions/ingest.ts has always passed the client; this is the invoice path catching up. Knowingly DIVERGES from the supplier-invoice entry above, which anchors on invoice_date and flags the taxable-event anchor as an open refinement: on the customer side the anchor was the finding under repair and the skill quote settles it, so the looser choice was not carried over. The verifikat's entry_date stays invoice_date on both sides, so a customer invoice with a delivery_date in a different month now carries a rate from a different day than its verifikat date; that is what the law asks for, and it is the same split every Swedish system has. [2026-07-26] POST /api/invoices/[id]/refresh-exchange-rate REFUSES a booked invoice (409 INVOICE_FX_REFRESH_BOOKED) rather than routing the re-rate through storno or the inline rattelse RPCs. The endpoint exists because a sent invoice is no longer a draft, so PATCH /api/invoices/[id] cannot reach it and several structured errors (MATCH_INVOICE_BOOKING_RATE_MISSING, BATCH_FX_RATE_MISSING) told the user to "komplettera fakturans exchange_rate" via an endpoint that did not exist; both remediations now name it. Refusing beats auto-correcting for three reasons: once the invoice has a verifikat the SEK amounts ARE bokforda poster, and BFL 5 kap 5 § allows exactly two tracks to change those, both of which are decisions about the ledger that the user must make deliberately (a storno consumes a voucher number and an inline rattelse writes an immutable who/when row); the correct rebooking is not mechanical, since a changed rate on a settled invoice also moves the kursvinst/kursforlust leg on 3960/7960 that the payment entry already posted; and a silent UPDATE of the invoice row behind a committed entry would desync the invoice from the ledger it produced, leaving the reports showing the old wrong value while the invoice claims the new one. The booked check is deliberately belt-and-braces (invoice.journal_entry_id, which was backfilled late, PLUS a journal_entries lookup by source_id, PLUS an .is('journal_entry_id', null) guard on the UPDATE for the TOCTOU window). Period locks are checked against invoice_date, the date the verifikat would carry, through resolvePeriodStatusForDate, which fails closed. The route writes ONLY exchange_rate, exchange_rate_date and the three _sek columns: subtotal / vat_amount / total / remaining_amount are denominated in the invoice currency and re-rating them would silently reprice a sent invoice. No manual-rate override was added: ML 8 kap 21-23 § permits only the Nasdaq OMX mid-rate via Riksbanken or the latest ECB rate, so an unreachable Riksbanken returns 502 and stays retryable instead of accepting a typed-in number. NOT wired to any UI yet: the transaction twin is reachable from QuickReviewDialog, the invoice one is API/agent only. [2026-07-26] The customer-invoice matcher's currency guard stays exactly as strict, but stops being silent: findInvoiceMatchCandidates() is a new sibling of findMatchingInvoices() that returns { matches, unconvertedFxCount, unconvertedFxInvoices } so a caller can say WHY the EUR invoice that obviously pays the bank row is missing from the suggestions, and point at POST /api/invoices/{id}/refresh-exchange-rate. Shape copied from unconverted_fx_count in lib/reports/supplier-ledger.ts. A sibling function was chosen over changing findMatchingInvoices' return type because three callers consume the array (lib/transactions/ingest.ts via getBestInvoiceMatch, app/api/transactions/batch-match-invoices, and the gnubok_auto_match_period MCP tool) and only the MCP tool has an output schema that could render the reason today; a breaking change to all three to serve one is not worth it. getBestInvoiceMatch keeps returning InvoiceMatch | null: same "no room for a reason" constraint the supplier-side twin reported, and widening it is left to whoever has a surface to render it on. Also fixed while in there: the conversion fell through to return invoiceAmount when total_sek was missing, handing a raw EUR figure to a SEK comparison. It was reachable, not theoretical, via total_sek = 0, which passes the != null currency guard and then fails the total_sek && total truthiness check: a 1 000 EUR invoice matched a 1 000 kr receipt at 0.80 confidence, asserted by a test that fails against HEAD. It now returns null. DIVERGES from lib/invoices/supplier-invoice-matching.ts in two known ways, both deliberate because the fix is about visibility and must not make the matcher match more: the supplier side falls back to the invoice's stored exchange_rate when the sek column is absent and normalises currency codes (case, and NULL meaning SEK), the customer side reads only total_sek and compares codes raw, so a legacy transaction with a NULL currency is still silently excluded against a SEK invoice. [2026-07-26] lockPeriod's unbooked-transaction guard now blocks on TWO predicates, not one, and the shared resolvePeriodStatusForDate fails closed. The old guard was journal_entry_id IS NULL AND is_business = true, which is close to backwards: triage is what sets is_business, so it could not describe an untriaged row at all (prod: 18 289 rows with is_business IS NULL and is_ignored = false, exactly ONE of which has a journal_entry_id), while the journal_entry_id leg falsely matched already-booked rows whose verifikat lives in transaction_voucher_links or the two payment tables. Locking therefore succeeded on periods full of untriaged transactions and enforce_period_lock then froze them unbookable in place, a BFL 5 kap 2 § problem with no exit but an unlock or a rattelse. Leg 1 is now the canonical worklist predicate (is_business IS NULL AND is_ignored = false) so the number in the error reconciles with the "N st att bokfora" badge. Leg 2 was added after prod evidence contradicted the first draft of this fix: 1 758 rows have is_business = true with journal_entry_id NULL, and only 5 of them are genuinely booked per is_transaction_booked(), so dropping the old leg entirely would have silently stopped guarding ~1 750 rows (about 136 outside the sandbox seed) that the user has already CONFIRMED are affarshandelser and that still have no verifikat: the stronger stranding case, not a weaker one. Leg 2 therefore reproduces all three anchoring locations from lib/transactions/is-booked.ts rather than trusting journal_entry_id, which is what keeps it from over-blocking the bulk-booked and multi-allocated rows the old guard tripped on. Hard block, no bypass: locking is a voluntary internal control with no legal deadline, the affarshandelser it strands do have one, and all three escape hatches (bokfor, mark privat, mark ignored) are exits from the predicate rather than overrides of it, so a "lock anyway" button would only add an audit_log control override. is_business = false and is_ignored = true are triaged-and-excluded and never block. Message keeps the two load-bearing phrases: "saknar bokforing" for both lock routes' 400 mapping, and an unconditional "affarstransaktioner" for inferCode()'s /Kan inte lasa period:.*affarstransaktion/ regex, which the per-leg breakdown alone does not satisfy in the untriaged-only case; the infra-failure message deliberately matches NEITHER so an unreachable DB does not send an agent off remediating transactions. Separately, resolvePeriodStatusForDate swallowed PostgREST errors and returned 'open' with period_id null, a fail-open in a helper that six write paths consult; it now reports status 'locked' with an additive optional lookup_failed: true, kept distinguishable from the legitimate "verified, no covering period" case (open + period_id null). lookup_failed is a new optional FIELD rather than a fourth PeriodStatusValue because lib/agent/intents/bokslut-step.ts consumes the union as an exhaustive Record<PeriodStatusValue, string> and widening it would break an unrelated caller at compile time. NOT fixed, both out of the owned diff: extensions/general/mcp-server/server.ts (~line 11133) carries a verbatim copy of the old dead guard as gnubok_lock_period's staging-time pre-check (harmless to the legal guarantee, since commit goes through the fixed lockPeriod, but it lets an agent stage a lock that then fails at approval), and lib/api/v1/check-period-lock.ts, the documented mirror of resolvePeriodStatusForDate, still drops both query errors and returns locked: false. [2026-07-26] The composer's banking and SIE counterparty summaries now carry a CurrencyMagnitude (SEK total of the rows whose SEK value is actually known + native total per currency + count of rows with no SEK equivalent) instead of one abs_amount number, and lib/agent/composer/atom-selection.ts renders that magnitude per currency rather than stamping "kr" on it. loadBankingSummary() selected only description, amount, date, journal_entry_id, but transactions.amount is denominated in transactions.currency, so a 30 000 EUR row was summed with SEK rows and printed to the model as "30 000 kr". That text is the INPUT to the call that writes the company's standing agent profile, so a wrong magnitude is baked into durable instructions rather than misleading one answer, which is why this is rendered honestly instead of approximately. Chosen: per-currency rendering with an explicit unknown, over three alternatives. Not a single SEK total (that is the bug), not a live FX lookup at compose time (the composer must stay a cheap read of stored data, and Riksbanken's rate today is not the rate at transaction date), and not silently excluding foreign rows (that would understate a EUR-heavy consultancy and read as "small" rather than "unmeasured"). Resolution order per row mirrors lib/bookkeeping/currency-utils.ts resolveSekAmount() but deliberately DROPS its final fallback, which returns the raw foreign amount when no rate is stored: correct for booking legacy data, fatal here, since it is exactly what turns 500 EUR into "500 kr". Same choice lib/agent/intents/inbox-bulk-book.ts txSek() already made. Rows with no stored rate are surfaced twice: inline as "SEK-motsvarighet OKÄND", and, when the SEK ranking would have dropped them below the top-20 slice (they rank as 0 kr), in a separate unconvertible_counterparties block labelled as omatt, not small. monthly_volume counts only convertible rows and is labelled GOLV when any row is missing, or OKÄND when none convert, instead of the old total that silently added foreign amounts. Uncertainty is flagged deterministically, not left to the model: buildCurrencyUncertaintyNotes() appends the caveats to uncertainty_notes inside selectAtoms() after Zod validation, the same belt-and-braces pattern already used for hallucinated atom ids, so the durable profile carries the caveat even when the model ignored the prompt rule. The field is still named abs_amount rather than abs_amount_sek on purpose: SourceSignalsSchema in lib/agent/composer/schemas.ts persists this shape into agent_profiles.source_signals, and renaming it would change what already-written snapshots mean, so schemas.ts was left untouched and the richer object rides along as extra JSONB keys. SIE top_accounts keeps its bare kr rendering, correctly: journal_entry_lines.debit/credit are booked SEK per BFL 4 kap 6 §, with any foreign original kept separately in currency/amount_in_currency. NOT done: no per-company redovisningsvaluta support (BFL 4 kap 6 § allows EUR bookkeeping; the whole codebase assumes SEK and this fix does not change that), and no backfill of exchange_rate on existing rate-less transactions. [2026-07-26] The four auth redirects in lib/supabase/middleware.ts now go through one bounceToAuth(request, target) helper that records the destination, and safeReturnTo was HARDENED in the same change because preserving a destination without it would have shipped a live open redirect. Three of the four sites dropped the destination although the pages they land on read it, and the protected-route bounce cloned request.nextUrl and overwrote only pathname, so /settings/billing?success=1 arrived as /login?success=1: the stray parameter leaked while the thing worth keeping was thrown away. The helper builds the URL fresh from the request origin (an absolute-path reference clears query and fragment), which fixes the leak structurally rather than by deleting known keys. Parameter name is per target, not global: /login reads next (app/(auth)/login/page.tsx) and the MFA pages read returnTo, so AUTH_DESTINATION_PARAM maps it explicitly instead of guessing, since sending the wrong name is a silent no-op. safeReturnTo re-checked: its prefix guards and sentinel-origin check both PASS /..//evil.com, /.//evil.com and /%2e%2e//evil.com, all of which URL normalisation collapses to //evil.com, and the login page then does window.location.assign on it. That was already exploitable at /login?next=/..//evil.com before this change; the fix re-validates the NORMALISED path, which cannot be re-normalised around, and a middleware test proves the new site-4 code emits Location: http://evil.com/ without it. Site 4 (authenticated user on an auth page) deliberately honours next for /login and /register ONLY: the same block also covers /auth and /sandbox, and /auth/callback is the PKCE exchange, where honouring a destination would skip the code exchange, so those keep bouncing to / unchanged. /register does not itself read next today (it reads invite), so consuming it there is symmetry, not a live path. MFA semantics untouched: same conditions, same targets, only a query parameter added, and the pages navigate to the destination only after the step-up succeeds. [2026-07-26] The two booking-time duplicate guards (lib/transactions/booking-duplicate-detection.ts, lib/invoices/duplicate-payment-detection.ts) now compare in SEK and say so, instead of putting a raw transactions.amount next to an always-SEK journal_entry_lines.debit_amount/credit_amount. Basis chosen: SEK, resolved by a single new exported helper resolveTransactionAmountSek() (currency SEK/null -> abs(amount); else amount_sek; else amount * exchange_rate; else NULL). Deliberately the OPPOSITE choice from ledgerLineAmountIn() in lib/reconciliation/bank-reconciliation.ts, which resolves ledger lines in the ACCOUNT's currency: there the whole statement being reconciled is foreign, whereas the twin these guards hunt is usually an ordinary SEK verifikat (invoice markera som betald, salary net-wage payout, a hand-posted entry) that carries no amount_in_currency at all, so comparing in EUR would resolve every one of those to null and reopen the double-booking hole. line.currency is never read as evidence of comparability, because currency-utils.ts stamps it onto lines whose debit/credit is SEK. Null (never the raw foreign number, which is what currency-utils' resolveSekAmount() falls back to) is the point: guessing the unit IS the bug. When the bank line has no SEK value the amount test is SKIPPED rather than failed, and the surviving candidate is returned with amount_verified: false + unverified_reason: 'transaction_missing_sek_value'. Returning null there would read as "no duplicate, go ahead" and mint a second verifikat for one affarshandelse (BFL 5 kap 1-2 §); hard-blocking would refuse a booking the software cannot judge, and an unbooked affarshandelse breaks lopande bokforing just as surely, so the guard warns and leaves the call to the user via the existing force / allow_duplicate bypass. The existence half of the question stays unit-free (direction + settlement account + date window + unlinked + not storno), so an empty candidate list is still a genuine verified pass. Both entry points take currency as a REQUIRED field rather than an optional one: optional reads as undefined for any caller projecting a narrow column list, undefined would have to default to SEK, and that default silently disables the FX half of the guard: required turns the mistake into a compile error, which is what caught app/api/transactions/[id]/duplicate-payment-check/route.ts (narrow GDPR-minimal select) and the gnubok_categorize_transaction MCP tool (select had currency but not amount_sek/exchange_rate). Also fixed in the sibling-transaction half: it compared transactions.amount to transactions.amount with no currency check, so 100 EUR matched 100 SEK on the same date; it now requires the two currency labels to agree and compares in that shared currency, which is exact, rather than routing both through amount_sek. lib/invoices/duplicate-payment-detection.ts imports the helper from the booking module rather than duplicating it, accepting the cross-directory import so the two documented mirrors cannot drift. NOT done: the client dialogs (DuplicateBookingDialog, InvoiceMatchDialog) still render copy asserting the amounts matched; amount_verified/unverified_reason ride along in the candidate payload and the server-generated Swedish messages (categorize-core, MCP) were made honest, but the two dialogs need an i18n string pair before an unverified candidate reads correctly in the UI. [2026-07-26] The invite cookie is now consumed through one lib/auth/consume-invite-cookie.ts and is deleted ONLY on a definitive outcome, replacing four hand-rolled copies (login BankID + login password + register BankID signup + mfa/verify) that cleared it on the !res.ok fall-through AND on the throw. A network blip or a 500 from /api/team/accept therefore destroyed the browser's only copy of a token whose invitation was still pending, and stranded the invitee in the app with no company, no message, and no recovery short of the inviter re-sending the invitation. classifyInviteAcceptStatus() reads the definitive/transient split straight off app/api/team/accept/route.ts: 2xx and 409 ("Du är redan medlem.") are accepted because the invitee IS in the company; 400 ("Inbjudan är ogiltig.", covers unknown and non-pending), 404 and 410 ("Inbjudan har gått ut.", which the route also marks expired) are spent; everything else, notably 401 (the session cookie had not propagated yet), 429 and 5xx, is retryable. 403 email-mismatch is its own disposition and deliberately RETAINS the token: the invitation is untouched and still pending server-side, so it is not spent, and the user's only remedy ("log out, log in as the invited address") requires the token to survive the logout, which is the same reasoning app/invite/[token]/page.tsx handleSignOutAndRetry already encodes by re-writing the cookie for exactly this case. Retention is a real recovery path rather than a deferral, which is why keeping it beats clearing-plus-apologising: /onboarding and /select-company both re-run acceptance server-side from the surviving cookie via acceptPendingInviteByToken(), and the dashboard root redirects a company-less user to /onboarding, so a retained token is retried on the very next page load. No widening of the token's exposure: the cookie's max-age, path, samesite and secure flags are set at write time in the invite page and are untouched here (the 3600s-vs-7-day-TTL mismatch is a separate queued finding, deliberately not adjusted in either direction), its authority is bounded server-side by company_invitations.expires_at plus the single-use status transition, and acceptance is re-authorized on every POST by requireAuth() plus an email equality check, so a surviving cookie confers nothing on its own. Non-definitive outcomes now toast a Swedish message (invite.accept_retry, accept_wrong_email_, accept_spent_); the Toaster is mounted in the root layout with TOAST_REMOVE_DELAY 1000000, so it survives the router.push that follows, though NOT the window.location.assign taken when ?next= / ?returnTo= is set, which the invite flow never sets. NOT converted, and not part of this finding: the fifth copy at app/(auth)/register/page.tsx ~:259 (auto-confirmed local-dev signup) already clears only on success and hard-navigates afterwards, so a toast there would be destroyed on the spot; app/invite/[token]/page.tsx handleJoinNow (token comes from the route param, which the helper's options.token covers when someone converts it); and app/(auth)/auth/callback/route.ts, which accepts server-side against the DB rather than over fetch and can reuse the classification but not the transport. [2026-07-26] The invoice download on app/(dashboard)/invoices/[id]/page.tsx now distinguishes three states instead of two, via the new pure helper lib/invoices/invoice-pdf-source.ts. A failed /api/invoices/[id]/deliveries read used to collapse to [], which is byte-identical to "nothing was ever sent", and the download then silently switched from the ARCHIVED PDF the customer received (invoice_deliveries.document_attachment_id, WORM, rakenskapsinformation kept 7 years per BFL 7 kap) to a freshly re-rendered one built from today's invoice row, customer row, company_settings and logo, under the same unqualified "nedladdad" toast. The three states are now: archived (serve it, plain toast), unavailable (delivery history unreadable: nothing is downloaded, a dialog offers Forsok igen / Ladda ner nyskapad PDF anda / Avbryt), and rerender (no archived copy can exist: served, but the toast title says "Nyskapad PDF" and names the reason). Chosen over the simpler alternative of just retrying the deliveries fetch, because a retry that also fails lands back in the same ambiguity; and over blocking the re-render entirely, because for invoices predating invoice_deliveries (2026-07-22) and for manually marked-sent invoices the re-render is the only document that exists and refusing it would be a dead end. The consent path deliberately uses ONE dialog and ONE toast per download: components/ui/use-toast.tsx has TOAST_LIMIT = 1, so a warning toast emitted next to a success toast would be evicted and never render. invoiceStatus gates ONLY the unreadable case, so a draft is never blocked by an unrelated fetch failure; when the history did load it is the data that decides, because "cancelled" does not prove nothing was sent (a proforma can be emailed and makulerad afterwards, and its archived copy is still what the recipient got). [2026-07-26] Every async handler on app/(dashboard)/salary/runs/[id]/page.tsx now releases actionLoading on a throw, and the bulk payslip ZIP reports partial failures by name through the new pure helper lib/salary/payslip-zip-report.ts: actionLoading gates the header button, the progress rail AND the employee table, so the one handler with no try/catch (Ladda ner AGI) froze the entire page on any rejected fetch until a reload, and the ZIP loop did if (!res.ok) continue and only warned when the counter reached zero, so 17 of 20 payslips downloaded under a plain success toast. The partial archive is still handed over rather than withheld: 17 real lonespecifikationer are worth more than none, and the employer can fetch the rest individually. It is reported with ONE destructive toast naming who is missing, never success-plus-warning, because components/ui/use-toast.tsx has TOAST_LIMIT = 1 and ADD_TOAST does [newToast, ...state.toasts].slice(0, 1), so the last dispatch wins and a warning emitted before a success is evicted unrendered (same reasoning as the invoice-download entry above). handleDelete and handleCorrect deliberately keep no finally: their success path navigates away and the buttons should stay disabled across the route change, so only the paths that stay on the page release. The verdict logic was extracted to a pure module rather than tested through the component because the suite is node-env with no component tests (CLAUDE.md Testing), and the extraction is what makes never-call-a-partial-archive-complete an assertion instead of a comment. Not owned in this pass, same TOAST_LIMIT eviction: handleCalculate loops toast() over every warning and handleSendPayslips emits a success toast followed by one toast per failed recipient, so in both only the last dispatched toast is ever seen. Also unresolved: .claude/skills/swedish-payroll documents the AGI individuppgift and its 12th-of-month deadline but says nothing about what the employer owes the employee on a lonespecifikation or when, so the loudness of the ZIP fix was argued from the artefact (the employer hands out what the archive contains and has no other signal that someone is missing) rather than guessed from training data; note references/ob-overtime.md and references/sick-pay.md are still byte-identical (md5 68e7aba1), so that skill has known holes. [2026-07-26] The PUT upsert in app/api/import/sie/mappings/route.ts now writes source_name, resolved as (client-supplied sourceName if a non-empty string) else (the value already stored for that company_id + source_account) else null, and its onConflict target was corrected from the dropped 'user_id,source_account' constraint to the one that actually exists, 'company_id,source_account'. source_name is the label the exporting system wrote into the SIE file's #KONTO record ('#KONTO 1910 "Kassa"'), it is per-file and cannot be recomputed from the BAS chart, and lib/import/account-mapper.ts ~:144 reads it straight back for display during the mapping review, so omitting it from the INSERT path left the reviewer a bare 4-digit number with no context on the one screen where the whole migration's account mapping is approved. Chosen: read-then-carry-forward over requiring the client to resend the field, because a client that does not know about the field is exactly how it got erased; the extra round trip is acceptable on a single-mapping override endpoint. Re-reading the label from the archived SIE file was rejected: sie_account_mappings is company-scoped and outlives any one file (unique on company_id + source_account), the PUT payload carries no import id, and recovering it would mean fetching from the sie-files bucket and re-running encoding detection (CP437/UTF-8) plus parsing for one text field, with no defined answer when two imported files label the same account differently. The onConflict fix is bundled deliberately rather than as scope creep: prod has no unique index on (user_id, source_account) (dropped by the multi-tenant refactor 0dd1f5eb, which repointed saveMappings() but not this route), so the statement could only raise 42P10 and the source_name fix would have been inert; prod has 7323 mapping rows and zero with match_type='manual', which is what this endpoint writes, corroborating that it has never once succeeded. NOT fixed, same file, separate finding: POST passes user.id into saveMappings(supabase, companyId, mappings), writing a user UUID into company_id and failing the FK, so POST 500s too; left alone to keep the diff on one finding, and its existing test asserts the broken call. [2026-07-26] Verified the SEK-basis duplicate-guard sweep (entry above) and declined to widen it into the two client dialogs, which is now the only gap left: components/transactions/InvoiceMatchDialog.tsx renders formatCurrency(candidate.amount, transaction.currency), and since candidate.amount is deliberately the 19xx leg's SEK figure, a EUR bank line makes it print a kronor number under a EUR symbol. That mislabel predates this sweep (it is byte-identical at HEAD), it needs an i18n string pair in messages/sv.json + messages/en.json to fix properly alongside the missing amount_verified: false copy, and both dialogs are outside the two files this pass owns, so the fix is left as the follow-up the sweep entry already names rather than smuggled in as a drive-by. Also confirmed not to be a regression: with a non-finite transactionAmount, detectDuplicatePaymentVoucher now warns (unverified) where it previously returned null, because resolveTransactionAmountSek rejects NaN before the zero-guard can; transactions.amount is NOT NULL numeric so no production row reaches it, and warning is the safe direction anyway. [2026-07-26] data/.json files in the full archive now carry the parent row's currency (and exchange_rate where the parent has one) as invoice_currency / supplier_invoice_currency / receipt_currency, via a declarative denormalize: { prefix, columns } on MasterDataTableSpec, rather than leaving the unit recoverable only by joining data/invoices.json. invoice_items, supplier_invoice_items and receipt_line_items carry money (unit_price, line_total, vat_amount) and no currency column of their own, and their "unit" column is the quantity unit ("st"), so a revisor opening one file alone could not tell 1000 EUR from 1000 SEK; the parent dumps were never affected because select() already carries currency, exchange_rate and the *_sek twins side by side. BFL 7 kap does not literally require each archived file to be self-describing (it requires varaktigt lasbart skick, preserved structure/metadata and a producible whole, with BFNAR 2013:2 kap 8 putting traceability from verifikation to bokslut in systemdokumentation), so a documented join would have been defensible; the denormalisation was chosen anyway because it is strictly additive, costs one extra column per parent row on a query that already runs, and removes a reading that depends on the reader still having the sibling file. Deliberately NOT applied to rot_rut_payout_request_items (parent rot_rut_payout_requests has no currency column; HUS-avdrag is SEK by statute, and asserting a unit that no column holds would fabricate rakenskapsinformation) and not needed for journal_entry_lines (its own table carries currency, exchange_rate and amount_in_currency, and grundbok/SIE amounts are SEK by construction). Only the conversion basis is copied, never the parent totals: a line's SEK value is not the invoice's total_sek. Collisions are avoided by never overwriting an existing child key, and the "?? null" fallback is load-bearing because JSON.stringify drops undefined keys. Three new assertions in tests/pg/full-archive-coverage.pg.test.ts keep it from rotting (parent columns exist, no collision with a real child column, every child of a currency-carrying parent denormalises it). Known gap left open: recurring_invoice_schedule_items is not in MASTER_DATA_DUMP_TABLES at all (it has no company_id, so the coverage contract cannot see it) even though its parent recurring_invoice_schedules is dumped and does have a currency column; it holds invoice TEMPLATES for affarshandelser that have not occurred, so it is arguably not rakenskapsinformation, but the exclusion is undocumented.

[2026-07-26] app/api/bookkeeping/fiscal-periods POST no longer refuses to create the next rakenskapsar while a prior period is still fully open (the PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS 409 is gone); the same detection now rides along as a non-blocking warnings: [{ code: 'PRIOR_FISCAL_YEAR_STILL_OPEN', message }] on the 200, matching the {code,message} warning shape already used by app/api/invoices/[id]/book. The old gate had no support in BFL and inverted two rules that bind at once: BFL 5 kap 2 § requires the new year's affarshandelser to be bokforda "sa snart det kan ske" (per BFNAR 2013:2 senast manaden efter), which needs a rakenskapsar covering January within weeks, while BFL 6 kap gives the arsbokslut 6 months (AB filing 7), so the prior year is legitimately unfinished and must stay UNLOCKED for bokslutsposter. Concretely: one untriaged December bank transaction makes lockPeriod refuse (correctly, lib/core/bookkeeping/period-service.ts), and the route then refused to create the new year, so all bookkeeping stopped; with that guard now failing closed on a lookup error, the pair was a hard deadlock no user action could clear. The gate also pushed users into locking the prior year before bokslut, and every later unlock lands in audit_log as a control override. What still blocks is unchanged and narrower: the 18-month BFL 3 kap duration cap, predecessor/successor date adjacency (BFNAR 2013:2 continuity chain), and the overlap 409 backed by the no_overlapping_fiscal_periods exclusion constraint. Prior-year protection was never this route's job: it lives in fiscal_periods.locked_at (enforce_period_lock) and company_settings.bookkeeping_locked_through (enforce_company_lock_date), and creating the next period writes no row into the prior one. Opening balances are not made worse: executeYearEndClosing already reuses a pre-existing next period and posts the IB verifikat (entry_date = nextPeriod.period_start) into it, refusing only when opening_balance_entry_id is already set, so the IB lands by itself later either way. Follow-up left open (not this route's files): components/bookkeeping/CreatePeriodDialog.tsx still carries the now-unreachable "Las aret och skapa" branch keyed on the retired code, and does not surface the new warnings.

[2026-07-26] validatePersonnummer (lib/salary/personnummer.ts) now accepts samordningsnummer by stripping the +60 day offset before the 1-31 range check, and the v1 REST employee create route runs it (it previously parsed only CreateEmployeeSchema's ^\d{12}$, so a check-digit-invalid number entered payroll and surfaced weeks later as a Skatteverket AGI rejection). The offset is stripped into a local birthDay only: the Luhn check still runs over the printed digits including the +60, which is load-bearing rather than cosmetic, since the check digit for day D and day D+60 differ (verified: day 01 -> 2, day 61 -> 9). The agreement test in lib/salary/tests/personnummer.test.ts reads IDENTITET_PATTERN out of lib/salary/agi/xml-generator.ts at test time with readFileSync and lifts the regex literal, rather than copying the pattern or exporting it: the const is module-private, and a copy would keep passing after the generator changed, which is the exact drift the test exists to catch (the loader throws if the const is renamed, so it cannot silently degrade into testing nothing). Two divergences from Skatteverket's pattern are asserted rather than fixed, so whichever side moves fails loudly: the generator also accepts day 60 (unknown day of month) and month 00 with day 60-99 (unknown birth date entirely), which the employee validator rejects and which registering an employee has never supported; and the validator's plain 1-31 range is looser than the generator for short months (April 31, February 30), pre-existing and untouched. MCP gnubok_create_employee (extensions/general/mcp-server/server.ts ~:10669) still encrypts and stages without any validation and remains the one surface out of step, queued separately. Report-only, not fixed: CreateCustomerSchema.personal_number is format-only (^(\d{6}|\d{8})[-+]?\d{4}$, no Luhn, no date check, 10-digit short form allowed), unlike the ROT/RUT invoice path in lib/invoices/build-invoice-write.ts ~:358 which runs the full validatePersonnummer.

[2026-07-26] components/bookkeeping/AccrualPeriodControl.tsx compared the K2 5 000 kr vasentlighetsgrans against a line amount that is denominated in the invoice's currency, so a 500 EUR line (about 5 750 kr, i.e. ABOVE the limit) rendered "Belopp under 5 000 kr behover normalt inte periodiseras (K2)" and a 6 000 NOK line (about 4 800 kr, BELOW it) rendered nothing. The threshold is now applied to a SEK value resolved by a new pure helper, components/bookkeeping/accrual-k2-hint.ts, and the hint is suppressed entirely when that value cannot be determined. Suppression, not a guess, was chosen because the K2 rule (BFNAR 2016:10) is a simplification the company MAY use and not an obligation: "Individual recurring costs below 5,000 SEK (not fluctuating >20%) need not be accrued (except personnel costs)". Nothing is blocked or booked differently by the hint, so an advisory nudge measured in the wrong unit is strictly worse than no nudge, and the proportionate fix is to stay silent. Deliberately NOT reusing resolveSekAmount() from lib/bookkeeping/currency-utils.ts: its documented fallback returns the foreign amount untouched, which is exactly the silent wrong-unit branch this fix exists to remove; the helper returns null instead. The two call sites differ and the asymmetry is real, not an oversight: components/supplier-invoices/NewSupplierInvoiceForm.tsx carries a Riksbanken-fetched or hand-typed exchange_rate on the form, so its hint is now correct in every currency, while components/invoices/InvoiceEditor.tsx has no exchange_rate field anywhere in its zod schema, defaultValues or submit payload (invoices.exchange_rate exists as a column and the edit page selects * , but the editor never reads or writes it), so its foreign-currency lines get currency only and the hint stays hidden until the customer-invoice editor grows a rate. SEK behaviour is byte-for-byte unchanged, including the amount > 0 guard that keeps discount lines silent. Also fixed in the same prop: the "N manader x X" preview called formatCurrency(amounts[0]) with no currency and therefore printed a kronor symbol on a EUR installment; it now passes the line currency. No new i18n keys were needed, and all nine keys the component already references exist in both messages/sv.json and messages/en.json.

[2026-07-26] POST app/api/import/sie/mappings now passes the withRouteContext-resolved companyId into saveMappings(supabase, companyId, mappings) instead of user.id, which had been writing a user UUID into sie_account_mappings.company_id (NOT NULL, FK to companies). Both are strings, so TypeScript could not see it; the fix that would prevent recurrence is a branded CompanyId/UserId pair or a named-argument object on saveMappings, which lives in lib/import/sie-import.ts and is outside this pass's files, so it is reported rather than implemented. Correction to the finding as filed: the endpoint did not 500. supabase-js returns {data, error} and only throws on transport failure, and saveMappings discards the return value entirely, so the FK violation was swallowed and POST answered 200 {success:true} while persisting nothing, which is why nobody reported it. The fix is necessary but NOT sufficient and the endpoint is still inert: sie_account_mappings.user_id is NOT NULL with no default and no BEFORE INSERT trigger, and saveMappings never supplies it, so ExecConstraints rejects the INSERT before ON CONFLICT can turn it into an UPDATE. Prod proves the two defects are independent: lib/import/sie-import.ts:2368 inside executeSIEImport has always passed the correct companyId, 834 SIE imports ran between the multi-tenant refactor and today, and sie_account_mappings has zero rows created after 2026-03-29 (6506 exact + 817 bas_range, all pre-refactor). Fixing user_id and surfacing the discarded error belong to lib/import/sie-import.ts; the same wrong-argument call also survives at extensions/general/arcim-migration/index.ts:914. The route-level test that pinned the bug (toHaveBeenCalledWith(supabase, 'user-1', mappings)) was repointed to 'company-1', and because a mocked saveMappings can only show WHICH value was passed and never which COLUMN it reaches, a second test now runs the real saveMappings against a recording Supabase double and asserts the upserted row carries company_id: 'company-1'. Element-level validation of body.mappings (blind-cast to AccountMapping[], so matchType and confidence reach the DB unchecked) was deliberately left alone: the endpoint has no caller anywhere in the repo, so no payload shape could be verified against a real client, and the match_type CHECK constraint is the existing backstop.

[2026-07-26] Two reskontra FX/visibility fixes. (1) app/api/reports/supplier-ledger/supplier/[supplierId]/invoices was the last remainingSek ?? remaining in the ledger family: for a foreign-currency supplier invoice with no exchange_rate it put the raw foreign amount into the ReportSourceLine credit field, which components/reports/ReportRowExpansion renders under a bare "Kredit" column in kronor, i.e. 1 000 EUR read as 1 000 kr against 2440. credit now falls back to 0, never to the foreign number, and the row keeps its own-currency amount in a new remaining field alongside the existing remaining_sek (mirrors ARInvoiceDetail's outstanding / outstanding_sek pair, so no information is lost by refusing to lie about the unit). A consumer distinguishes the two states on remaining_sek: null = SEK value unknown and excluded from every SEK total, 0 = genuinely settled; the response envelope also carries unconverted_fx_count, the same contract the ledger reports already expose. Chose 0 over omitting the row: BFL 5 kap 4 § sidoordnad bokföring means the open item must stay visible, it just must not be counted in kronor. (2) lib/reports/ar-ledger.ts's .filter(total_outstanding !== 0) conflated two different zeros: a customer settled by credit notes (suppress, that is why the filter exists) and a customer whose open invoices were ALL unconvertible, whose buckets never accumulated so the total is 0 by absence. The second was still counted in unconverted_fx_count, so PDF, XLSX and web view claimed "N fakturor saknar växelkurs" while the rows appeared nowhere, contradicting the "always: even if unconvertible, so it's visible" comment 40 lines above. The filter now keeps an entry when total_outstanding !== 0 OR any of its invoices has outstanding_sek === null. The predicate is deliberately derived from outstanding_sek rather than from a new per-customer counter, because outstanding_sek === null is the exact same condition that increments unconverted_fx_count, so the invariant "every invoice in the count is reachable in entries[].invoices" holds by construction. Not fixed, reported: the supplier side has no such filter (a supplier netting to zero still shows a 0,00 row there, unlike AR), but it hides all-unconvertible suppliers by a different mechanism, the continue at lib/reports/supplier-ledger.ts ~:107 fires before the bySupplier entry is created, so the supplier never enters entries at all and, having no per-invoice detail array in SupplierLedgerEntry, has nowhere to surface either.

[2026-07-26] SIE import now REFUSES a #RAR longer than 18 months instead of warning and continuing: ensureFiscalPeriod (lib/import/sie-import.ts) validated the #RAR start day and end day but never the BFL 3 kap duration cap, so a 24-month rakenskapsar imported cleanly while every other period-creation path (app/api/bookkeeping/fiscal-periods POST and PATCH, period-service createNextPeriod/createPreviousPeriod, onboarding computeFiscalPeriod) rejected the same span through validatePeriodDuration. Reused monthsBetween() from lib/bookkeeping/validate-period-duration.ts rather than writing a third month-counting variant, so the boundary is bit-identical: an 18-month forlangt rakenskapsar still imports, 19 does not. Applying the 18-month ceiling to EVERY #RAR is the loosest correct bound and blocks no legal import: 18 is the maximum for an extended or re-laid year and 12 is the norm otherwise, so nothing legitimate is longer. No minimum was added (BFL 3 kap sets none, per the 2026-07-25 and 2026-07-26 removals of the invented 6-month floor). Refuse over warn even though refusing a migration import is heavy and the accountant cannot edit the file: no reading of BFL 3 kap permits a rakenskapsar past 18 months, so this is a malformed or merged #RAR rather than historical data booked under older rules, and continuing would stamp every imported voucher with a period that no UI path can repair afterwards (the fiscal-period editor rejects the identical span), leaving undo_sie_import as the only exit. The check sits before the destructive delete of an empty onboarding-seeded period, so a refused import leaves the company untouched, and the Swedish message names the real recovery: re-export from the source system with one rakenskapsar per file, which is the standard SIE migration path anyway. Not fixed, reported: only #RAR 0 reaches ensureFiscalPeriod, so prior-year #RAR -1/-2 records are parsed into header.fiscalYears and never date-validated at all (they create no period, so they are inert today), and sie-parser.ts parses the #RAR year index with a bare parseInt that can yield NaN without complaint. [2026-07-26] Tax deadline regeneration now inherits the superseded row's user-owned columns (notes, due_time, priority, customer_id) and manually reported status (in_progress/submitted/confirmed), generalizing the old in_progress-only key-match. One rule: the generator owns what the statute decides (title, due_date, deadline_type, linked report), the row owns every mark a person put on it. The statutory columns always take the template value because the schema cannot tell "user edited this" from "template changed under an untouched row" (nothing records the template value at creation, and the deadlines_updated_at trigger fires on every automatic status sweep); a preserved divergent due_date would also make the backfill cron flag the company forever, since its identity is type:period:due_date. An obligation the settings no longer produce is still deleted with its edits: the settings change is the user's own statement that it does not apply.

[2026-07-26] InvoicePicker and SupplierInvoicePicker now rank candidates through the shared pure helper components/transactions/invoice-candidate-ranking.ts instead of Math.abs((remaining_amount ?? total) - Math.abs(tx.amount)), which compared two numbers in different units: a 1 000 EUR invoice sorted above the genuine 1 000 SEK invoice for a 1 000 SEK bank row, same number, roughly eleven times the money. Ranking fix, not a filter: the manual pickers deliberately list every open invoice regardless of currency because the user must still be able to settle a foreign invoice by hand, so nothing is ever removed and three tests pin that (list length preserved, unconvertible row present but last). Deviated from BankTransactionPicker, which the finding said to mirror, on one point: it puts same-currency rows strictly first and lets cross-currency rows keep date order, whereas these two rank same-currency and SEK-converted rows against each other by their difference and only tie-break on basis. Justified because the two targets differ in kind. BankTransactionPicker's target is a half-typed invoice form whose amount has no SEK equivalent yet, so its converted tier is unreachable and the tie-break never arises; here both sides are persisted rows carrying their own conversion data, and an 11 496,70 kr deposit settling a 1 000 EUR invoice is the entire reason cross-currency matching exists, so burying that invoice under every wildly-off SEK invoice would have traded one bad ranking for another. The SEK conversion is remaining * invoices.exchange_rate guarded by the shared isValidExchangeRate, deliberately identical to match_batch_allocate (20260531120000_match_batch_allocate_cross_currency.sql), which values a cross-currency settlement the same way and refuses it outright with BATCH_FX_RATE_MISSING when the rate is missing: ranking on the same rule means the order the user sees agrees with what the booking path will accept, and a foreign invoice with no booked rate reports incomparable rather than being scored on a meaningless number. exact/close stay false on a converted comparison because that figure values the invoice at its own booking rate, not at what the bank moved, so agreeing to the öre is a coincidence, not a confirmation. Currency is normalized to SEK before every comparison (transactions.currency and invoices.currency are both nullable text default 'SEK'; a plain === reads NULL as "some other currency" and demotes an ordinary SEK invoice into the unranked tier). The new row indicators are deliberately language-neutral, the ISO code on a deviating currency plus ≈ {formatCurrency(sek)}, so no i18n keys were added: SupplierInvoicePicker has no useTranslations at all and one t() call amid six hardcoded Swedish strings would render half-localized, while converting the whole file was out of scope. Not fixed because it is not the same bug: components/inbox/TransactionMatchPicker.tsx already scores with receiptCurrency/receiptSek and MatchAllocationDialog sorts by selection then due date, so neither ranks currency-blind.

[2026-07-26] The pre-auth invite cookie (gnubok-invite-token, written by app/invite/[token]/page.tsx) now lives 7 days instead of 3600 seconds, matching the invite TTL rather than guessing one: INVITE_TTL_DAYS = 7 in lib/auth/invite-tokens.ts stamps company_invitations.expires_at, prod confirms it (77 invitations, min and modal expires_at - created_at = 7 days, no DB default on the column so app code is the only writer), and the max of 63 days is just re-invites refreshing expires_at while created_at stays put. The one-hour cookie could not survive the hop it exists for: an invitee who registers at 17:00 confirms the signup mail the next morning, by which point the cookie, the only copy the browser holds, was six hours dead and acceptance was impossible with no recovery short of the inviter re-sending. Widening a non-httpOnly cookie is a security decision, so it was argued against OWASP ASVS v5.0.0 rather than assumed: the caps that look relevant do not govern (V6.4's 60-minute ceiling is for account-recovery tokens that BYPASS authentication, V9.4's 15 minutes is for access tokens that carry authority on their own, and this token does neither, since POST /api/team/accept re-authorizes every attempt with requireAuth() plus an email equality check and the status transition is single-use). What does govern is V7.3's rule that the client-side lifetime is compared against the documented server-side bound, whose failure pattern is a client lifetime EXCEEDING it; 7 days against 7 days is equality, and the test asserts <= inviteTtlSeconds() so over-widening fails too. The old 3600 bought nothing anyway: by V7.4.1's logic it was client-side expiry over a server-side record that stayed pending for the full week, so it shortened the invitee's window without shortening the attacker's. Chose widening over the alternatives (keeping it short and leaning on the ?invite= query parameter, which only survives the register hop and not the mail round-trip, or on the /onboarding + /select-company server-side healing path, which reads the very cookie that had expired) because those signpost the cookie-less class rather than removing it. The value is one constant feeding one buildInviteCookie() builder, replacing three duplicated literals, and the flags are untouched (path=/, samesite=lax, secure only on https, still not httpOnly since it is written from document.cookie). Shared-device exposure is bounded by the same email equality check: a later user on the same browser cannot redeem the surviving token, they get 403 wrong_email, which the consume side deliberately retains rather than clears; residual and accepted is that they learn an invitation exists for the other address, which was equally true at one hour. Tests read the TTL back out of getInviteExpiry() instead of restating 604800, so raising INVITE_TTL_DAYS without moving the cookie fails CI.

[2026-07-26] Replaced the credit-note COUNT cap with an AMOUNT cap. 20260715120000's uq_invoices_company_credited_invoice (UNIQUE on company_id, credited_invoice_id) permitted at most one kreditfaktura per invoice ever, which forbids the partial andringsfaktura that ML (2023:200) 17 kap 22-23 SS explicitly allows ("Partial credits: Fully permitted", swedish-invoice-compliance references/invoice-rules.md:77-79) and that nothing in ML caps in number. The concern the index encoded is real but different: two credit notes that each mirror the full original would post two reversing verifikat and double-reverse revenue plus utgaende moms, since createCreditNoteJournalEntry derives its lines from the credit note's OWN items. Migration 20260726130000 therefore enforces the actual rule, sum(ABS(total)) over all non-cancelled credit notes for one original <= ABS(original.total), which still refuses two full mirrors while allowing several partials that fit. Chose a trigger over an index or CHECK because the invariant is a cross-row aggregate: an index cannot express it and a CHECK may not read other rows, so a trigger is the honest option rather than a workaround. The trigger takes FOR UPDATE on the original invoice before summing (without it two concurrent inserts under READ COMMITTED both read the pre-insert sum and both pass) and skips that locking read entirely for updates that cannot move the sum (journal_entry_id link, creation_complete flip, draft->sent), so ordinary credit-note traffic is not serialized. Cancelled credit notes are excluded from the sum, which is what keeps route.ts's "reopen a cancelled unissued draft" path working. A currency-equality assert rides along because an amount cap across two currencies is meaningless; both creation paths already copy the original's currency, so it pins existing behaviour rather than adding a restriction. Deliberately NOT built: the app cannot create a partial credit note today (CreateCreditNoteSchema is {credited_invoice_id, reason} only, createCreditNote mirrors -Math.abs of every original total, and the v1 endpoint documents "To credit only part of an invoice, credit the full invoice first then reissue"), so this is a constraint correction plus headroom, not a partial-credit feature. Two latent blockers for whoever builds that feature: app/api/invoices/route.ts uses .maybeSingle() on the existing-credit-note lookup (throws once two credit notes share an original) and its 23505 race-recovery branch no longer fires now that the unique index is gone. The status guard in app/api/invoices/route.ts was briefly widened to accept status='partially_paid' and then REVERTED in the same pass, along with the INVOICE_CREDIT_NOT_SENT wording that had been changed to promise "delvis betalda ... kan krediteras". The premise is right (whether the customer paid nothing, part or all of an issued invoice has no bearing on the right to issue an andringsfaktura, and part-paid is where a credit note is most often needed) but that door is not where the flow ends. lib/invoices/issue-credit-note.ts ~:253 flips the original to 'credited' with .in('status', ['sent','paid','overdue']), and it runs AFTER createCreditNoteJournalEntry has posted the reversing verifikat: on a part-paid original the voucher is committed, the status flip matches zero rows, and mark-sent/send return INVOICE_CREDIT_REPAIR_REQUIRED with a fully credited invoice left at 'partially_paid', open in the AR ledger and still chased by the reminder processor. An immutable voucher in a half-state is worse than the clean 400 it replaced, and the widened error text promised a capability no surface delivered (app/(dashboard)/invoices/[id]/credit/page.tsx ~:76 pre-gates on the same three statuses, so the dashboard never reached the widened route guard at all; only a hand-rolled authenticated POST did). Lifting the gap is one coordinated change over six sites, issue-credit-note.ts first, then the dashboard credit page, app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts ~:211 (plus its OpenAPI description), lib/pending-operations/commit.ts ~:3104, extensions/general/mcp-server/server.ts ~:12380 and its kreditfaktura-process skill text. A route test now pins the refusal and names the reason, so lifting it in one place fails loudly instead of passing. [2026-07-26] Three plus-minus DUPLICATE_AMOUNT_TOLERANCE_PCT bands were built in one currency and applied, in SQL, to a column denominated in another: findDuplicatePaymentCandidatesForInvoice and POST /api/supplier-invoices/[id]/mark-paid banded the invoice-currency payment against transactions.amount, and POST /api/transactions/[id]/categorize banded transactions.amount against supplier_invoices.remaining_amount / invoices.remaining_amount. At roughly 11,50 SEK/EUR a 2 % band is off by a factor of eleven, so each guard either selected nothing (a second verifikat for one affarshandelse then posts unopposed, BFL 5 kap 1-2 paragraf) or selected an unrelated row of the wrong magnitude. All three now route through the new lib/invoices/duplicate-guard-currency.ts: planAmountSweeps issues ONE SQL sweep per currency (each sweep restricts rows to a single currency via .or('currency.is.null,currency.eq.SEK') / .or('currency.eq.XXX') and carries the band expressed in that same currency) and magnitudesWithinTolerance re-checks every returned row in a shared unit. Chosen over the alternatives: banding on the SEK column instead was rejected because transactions.amount_sek and a SEK-derived predicate on invoices would need a second column in the same range filter and would drop legacy rows where amount_sek is null (4 of 695 foreign transactions in prod); widening the band by the max plausible rate was rejected because a band wide enough for JPY matches everything. Same-currency compares raw, cross-currency compares only STORED conversions (amount_sek / total_sek pro-rated to the remaining amount / exchange_rate), and no stored conversion means the candidate is EXCLUDED, never compared as a raw number: guessing the unit is the bug. That exclusion is the common case for foreign customer invoices in prod (257 of 285 have total_sek null-or-zero and no exchange_rate), so all three sites log it as a warn rather than let an unevaluated candidate set pass as a clean "no duplicate" (BFNAR 2013:2 kap 8); the customer-side lookup got a module-level logger for that purpose since it takes no ctx logger. A SEK reference yields exactly one sweep whose band values are unchanged, and prod carries zero non-uppercase and zero NULL currency codes in transactions / invoices / supplier_invoices, so a SEK-only company runs the identical single query. Band arithmetic moved from the naive Math.round(x * 100) / 100 to roundOre() per CLAUDE.md guard rail 9. Verified against HEAD: the currency tests fail there at all three sites. The voucher-gap-explanation refactor that shares app/api/transactions/[id]/categorize/route.ts (cancelOrphanedPaymentEntry) is a separate agent's change and was left untouched.

[2026-07-26] Split "the VAT rate the picker defaults to" from "the VAT rate an invoice line may lawfully carry". lib/invoices/vat-rules.ts getAvailableVatRates() returned a single locked 0% option for a VAT-validated eu_business and for non_eu_business, and six server-side gates plus the invoice form treated that one option as the only permitted rate. The 0% default is right: huvudregeln (ML 6 kap. 34 SS, Article 44 VAT Directive) taxes a B2B service where the buyer is established, so a Swedish consultant invoicing a German or a US company charges no Swedish VAT and reverse-charges instead. The refusal was not right: the swedish-vat reference lists five exceptions "(taxed where performed)" that carry Swedish VAT no matter where the buyer sits, being fastighetstjanster (property location), persontransporter (where transport occurs), korttidsuthyrning of transport vehicles (pickup location), restaurang/catering (where performed) and admission to cultural/sports events (event location). A Stockholm hotel invoicing a German company (12%) or a conference organiser selling a ticket to a US company (6%) was refused outright with INVOICE_CREATE_VAT_RULE_VIOLATION and had no workaround. Added getPermittedVatRates(), which appends 25/12/6 after the customer's 0% option for those two customer types, and pointed the build-invoice-write.ts gate at it. Chose widening the PERMITTED set over widening getAvailableVatRates() because the invoice form derives three behaviours from that array being length 1 (picker disabled, new-line default availableRates[0].rate, article-rate adoption suppressed) plus a snap-all-lines-to-0% effect on customer change; widening it in place would have silently left a stale 25% line at 25% after switching to an EU customer, which books the opposite error. Also rejected adding an explicit per-line "taxed where performed" flag: nothing on invoice_items distinguishes consulting for a German company from a hotel night sold to one, so a flag is the honest long-term model, but it needs a migration plus schema, form, MCP and v1 surfaces and cannot be set anywhere today. Because no single non-zero rate covers the exceptions (they span 25%, 12% and 6%) nothing narrower than the full Swedish set would do. The default is untouched on purpose: getVatRules().rate stays 0 and is still the fallback for a line that omits vat_rate, and the 0% option stays first in the permitted array, so a Swedish rate reaches such an invoice only when set explicitly on that line. Reverse-charge notation now follows the lines rather than the customer: when a special-treatment customer's invoice has no zero-rated line at all, the header takes the domestic treatment/ruta and drops reverse_charge_text, because "Omvand betalningsskyldighet" (ML 17 kap 24 SS p.11) printed next to charged Swedish VAT tells the buyer to self-assess tax the seller already collected and blocks their deduction. Mixed invoices keep the notation, since their zero-rated lines genuinely are reverse-charged and generatePerRateLines already applies the invoice-level treatment only to rate-0 lines, so 3308 and 3002/2621 land in the right ruta on their own. Text-only documents count as zero-rated so their header is not restamped. Not fixed here (same invented rule, other owners): lib/pending-operations/commit.ts:1063, extensions/general/mcp-server/server.ts:4475, app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts:185, lib/invoices/recurring-schedule-service.ts:199, lib/invoices/self-billed-sale.ts:123, and components/invoices/InvoiceEditor.tsx:718/790/792 where the picker must render getPermittedVatRates() while keeping getAvailableVatRates() for the default and the snap. [2026-07-26] lib/api/validate.ts validateBody() now builds the top-level error from the first three Zod issues ("Valideringsfel: : . ... (+N till)") instead of the constant 'Validation failed'. errors[] already carried the actionable detail and lib/errors/get-error-message.ts:223-234 already knows that shape, but the many clients that forward only body.error collapsed it: a raw English "Validation failed" in a Swedish payroll UI (components/salary/SalaryOverridePanel.tsx:78) or, once through getErrorMessage, the generic "Nagot gick fel. Forsok igen." fallback, because the constant matches no pattern the function knows. The 'Valideringsfel' lead-in is load-bearing, not decoration: it is what makes isSwedishUserMessage() recognize the sentence as an already-Swedish user message and pass it through verbatim, so no per-call-site change was needed anywhere. Fixed centrally in validate.ts rather than in get-error-message.ts (adding a 'Validation failed' -> Swedish entry to ERROR_PATTERN_MAP would have produced one generic sentence for every field error, losing the field name, and would not help the sites that render data.error raw without going through getErrorMessage at all) and rather than at the three reported pages (the same collapse exists at 8+ call sites). The machine-readable contract is untouched: type: 'validation_error' stays the discriminator and errors[].code stays the per-field code, so components/settings/SettingsFormWrapper.tsx:73 and every other consumer branches on structure, never on prose; a repo-wide search found zero consumers comparing against the literal string, only producers emitting it. The v1 REST envelope is a separate shape ({ error: { code, message } } via v1ErrorResponseFromCode) and no route under app/api/v1/** calls validateBody, so public API clients see no change. Deliberately left English and out of scope, same bug class, different constants: validateQuery()'s 'Invalid query parameters', validateBody()'s 'Invalid JSON in request body', and the nine hand-rolled error: 'Validation failed' responses that bypass the helper (app/api/agent/composer/route.ts:47, app/api/agent/profile/verify/route.ts:25, app/api/invoices/route.ts:73/88, app/api/invoices/recurring/route.ts:50, app/api/invoices/recurring/[id]/route.ts:57, app/api/invoices/self-billed/route.ts:97, app/api/onboarding/state/route.ts:74, app/api/tax-assessment-notices/[id]/route.ts:65). The summary is Swedish-only in both locales while the Zod field messages behind it are still mostly English (lib/api/schemas.ts), so an English-locale user now sees one Swedish word plus English field detail instead of a fully English but contentless sentence: accepted as strictly more information, and consistent with .claude/rules/i18n.md keeping regulatory error text Swedish in both locales. Translating lib/api/schemas.ts is the real fix and is not owned here. [2026-07-26] Declined to route gnubok_set_employee_opening_balances through the shared lib/api/sparse-patch.ts helper; kept the inline whitelist merge instead. sparsePatch() parses the caller's body against the full schema and THEN narrows the write set to the keys literally present, which is right for the UPDATE ... SET col = $1 sinks it was written for but wrong here for two reasons. First, the sink is setOpeningBalancesBulk()'s 9-column upsert shared with the v1 REST and internal UI routes, so "absent" is not expressible at the write: the stored values must be materialized into the item before it reaches the service, which is a merge, not a narrowing. Second, openingBalancesShape's refinement is cross-field (ytd_tax <= ytd_gross, sparade-dagar origin years validated against cutover_date), so parsing a sparse patch on its own sees the .default() zeros and gets both directions wrong: {ytd_gross: 1000} against a stored ytd_tax of 48000 passes when the merged row is illegal, and {ytd_tax: 50000} against a stored ytd_gross of 210000 is rejected because the default ytd_gross is 0. The tool therefore builds the patch from an explicit MERGEABLE_FIELDS whitelist, layers it over the stored row, and validates the MERGED item, which is what the four new tests in extensions/general/mcp-server/tests/payroll-staged-tools.test.ts pin. Empirically reconfirmed on zod 4.4.3 that .partial() does not strip .default() (z.object({amount: z.number(), is_taxable: z.boolean().default(true)}).partial().parse({amount: 5500}) yields {amount: 5500, is_taxable: true}, byte-identical to the non-partial parse), so this is the fourth site of the same trap rather than a new one. Omission versus explicit clear is distinguished by item[field] !== undefined over the raw MCP args, before Zod runs: absent means carry the stored value, present-with-0 (or {} for vacation_saved_days_by_year) means clear, and the staging preview reports fields_provided/new_rows/updated_rows so the approver can see which columns the caller actually described. cutover_date was deliberately left in the inputSchema's required even though every other field became optional-by-merge: it carries no .default() so it cannot be silently zeroed, and forcing the caller to restate which cutover it is amending is worth more than the convenience. The real remaining gap is that no MCP tool reads employee_opening_balances back (gnubok_get_employee omits it, gnubok_get_vacation_balance reads the ledger), so an agent must already know the stored cutover_date; the staged params are the only read-back today. [2026-07-26] Finished the components/bookkeeping/CreatePeriodDialog.tsx half of the fiscal-period 409 retirement, the follow-up the entry two above left open. The unreachable PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS branch and its "Las aret och skapa" button are gone together with handleLockAndRetry, so the dialog no longer POSTs to /fiscal-periods/{id}/lock at all and cannot push a user into locking a rakenskapsar before the bokslut. The route's new non-blocking warnings array is surfaced as ONE ochre AttnLine (UI-migration convention 6: 12.5px, --attn tone, single line, max one per surface) inside a done-state titled "Rakenskapsar skapat" with the created range under it, never a banner and never a toast: the period WAS created, and a toast fired while the dialog closes flashes past the one sentence that explains why the ingaende balanser are still pending. Reading the warnings off the response lives in the new pure lib/bookkeeping/fiscal-period-warnings.ts (extractFiscalPeriodWarnings + fiscalPeriodAdvisoryText) rather than inline in the component, because the repo has no component tests and lib/ is the scope vitest actually covers; it collapses a missing key, a non-array value and malformed entries to [] so a shape change can never render a blank ochre line, and joins several messages into one sentence run instead of stacking attn lines. onCreated() is deliberately deferred to the close handler on the advisory path: it refetches periods in both call sites (JournalEntryForm, FiscalYearsManager), which changes the suggested period and resets the form, which would pull the advisory off screen before it was read. Deleted the orphaned registry entry in lib/errors/structured-errors.ts outright instead of keeping it with a corrected comment: nothing emits the code (a repo-wide grep including ignored files finds only the retirement comment, this log, and one regression test asserting the legacy 409 envelope yields no advisory), StructuredErrorEntry has no deprecation field, and a live entry keeps the code selectable by errorResponseFromCode; a comment in its place records the retired "prior year must at least be locked" theory as wrong so it does not come back. The advisory renders as the route's own Swedish and is not keyed: the route emits no message_en, and bokslut/rakenskapsar domain copy stays Swedish in both locales per .claude/rules/i18n.md. Only the added chrome is keyed (bookkeeping.period_created_title, bookkeeping.period_created_range, common.close, all present in sv+en); the dialog's pre-existing hardcoded Swedish form copy was left untouched as out of scope.

[2026-07-26] enforce_credit_note_total_within_original() (migration 20260726130000) is NULL-safe at every term rather than only at the sibling SUM, because invoices.status and invoices.currency are both nullable columns with a default and no NOT NULL, and every NULL in this trigger fails in the permissive direction: a sibling that drops out of the sum hands out credit capacity that has already been spent. Concretely: COALESCE(c.status, '') <> 'cancelled' counts a NULL-status sibling (a bare <> is NULL and excludes it); c.id IS DISTINCT FROM NEW.id instead of <> so a NULL NEW.id could not exclude every sibling at once and make the cap unconditionally pass (column defaults fire before BEFORE-INSERT triggers, so NEW.id is never actually NULL: this removes the dependency on that reasoning rather than restating it); COALESCE inside ABS and around SUM so an empty sibling set and an all-NULL total read as 0 instead of NULL. The currency assert was changed from "skip when either side IS NULL" to COALESCE(currency, 'SEK') on both sides: opting out on NULL let a NULL-currency credit note be capped against a EUR original in a unit nobody had agreed on, and 'SEK' is what the column default and every read path already assume. Two comparisons are left deliberately three-valued in the SAFE direction and commented as such: NEW.status = 'cancelled' (a NULL status falls through and IS capped, because treating an unknown status as cancelled would be free capacity), and the sibling sum is NOT scoped by company_id even though the index it replaces was, since credited_invoice_id is a globally unique FK and counting any cross-company row is the conservative side.

[2026-07-26] LedgerGraph now weights the account ring through the pure helper components/agent-knowledge/ledger-graph-magnitude.ts, which measures the WHOLE ring in one unit (kronor if every account has a usable amount, booking volume otherwise) instead of the old per-group spend > 0 ? spend : occurrences fallback whose results were then summed into one denominator: 250 000 kr plus 4 bookings gave the amount-less account a wedge of 0,0016 percent of the circle and ranked it last, so the top-9 cap dropped it from the map entirely. Verified against HEAD: replaying HEAD selection verbatim reproduces both the sliver and the silent drop, and the new tests fail there. Deliberately did NOT reproduce the sibling resolution order from lib/agent/composer/inputs.ts (SEK row short-circuits, else amount_sek, else amount * exchange_rate, else null): the component cannot, because get_ledger_deep_context (20260708130000) already collapses each entity to one scalar as abs(coalesce(t.amount_sek, t.amount)) and coalesce(si.total_sek, si.total, 0) and projects no currency column, so a 500 EUR invoice with no stored rate arrives as the number 500, indistinguishable from 500 kr. Forming a per-currency total on the client would mean inventing one. Refused to paper over it: the raw-foreign fallback stays reachable IN THE RPC and the real fix is a migration projecting total_amount_sek plus per-currency totals plus a count of rows with no SEK equivalent; entityMagnitude() is written as the single place that has to learn about them. Accepted the exposure because this surface is display-only (deep context is read solely by GET /api/agent/knowledge, rendered by AgentKnowledgePanel, never cached, never persisted, and lib/agent/** does not import lib/agent-context at all), so a wrong wedge misleads a human reading "Vad din agent vet" and cannot reach a verifikat or the standing agent instructions. The legend switches to a new key legend_size_volume when the ring is volume-weighted rather than claiming "Storlek = belopp" for a unit nothing is drawn in, and the detail card got its own card_amount label since legend_size no longer names the amount; both keys added to sv.json and en.json. Nothing is dropped from the ring: an account with no usable amount keeps a proportional wedge, which is what the truncation test pins. [2026-07-26] Handelsbanken + Länsförsäkringar CSV parsers switched from the transaction date (Transaktionsdatum / Datum) to the booking date (Reskontradatum / Bokföringsdag), making them agree with every other parser (SEB, Swedbank, Nordea Företag, Northmill, camt053 BookgDt) and with the PSD2 feed. Why: the emitted date feeds generateExternalId AND the exact-date contentBucketKey, and enable-banking/lib/sync.ts keys every row on the ASPSP booking_date and cannot move (Berlin Group has no stable transaction date; the value_date path caused the June 2026 fleet-wide re-import). A card purchase swiped the 14th and booked the 16th therefore landed in two different dedup buckets and inserted twice, since the plus-minus-one-day drift bridge is shadow-only. Accepted cost, stated not incidental: rows where the two dates DIFFER get a new external_id, so a user who re-uploads an overlapping (not byte-identical) export can get one visible duplicate; rows where the dates are equal (the large majority) keep byte-identical ids and are not re-orphaned. Bounded by the hard (company_id, file_hash) block on the dashboard parse route, by CSV import being a manual action with no cron, and by the rows being user-visible; the v1 REST import has no such hard block. Chose this over adding date tolerance to the dedup bucket (would need the shadow drift bridge promoted to enforcing, which is a separate fleet-validated decision) and over moving the PSD2 side (impossible). Not applied: generic-csv.ts pickDateHeader still ranks transaktionsdatum above reskontradatum for the manual "Annan CSV" mapping, same bug, left to the owning change.

[2026-07-26] Manual-entry FX metadata (JournalEntryForm) now resolves its carrier line through a pure pre-pass, lib/bookkeeping/fx-line-slot.ts#resolveFxLineSlot, instead of the inline "first line whose account starts with 19, unless a latch already closed" rule. Three defects in one expression: (a) the 19xx prefix is not the rule the generators follow (invoice-entries.ts stamps 1510, supplier-invoice-entries.ts stamps 2440, transaction-entries.ts stamps the settlement leg), so a SEK/EUR växling booked 1930 then 1932 stamped the SEK leg and every consumer that looks the metadata up BY ACCOUNT then read the wrong one: bank-reconciliation ledgerLineAmountIn() reconciles a foreign cash account against amount_in_currency on that account's own lines, and voucher-matching narrows on journal_entry_lines.currency under the 1510/2440 prefix; (b) the latch closed only when the map reached a hydrated FX line, so an agent-created EUR draft with the bank leg first got the entry-level metadata stamped onto a SECOND line and two lines claimed the same foreign amount; (c) with no 19xx leg at all (a EUR supplier invoice booked 4010/2641/2440) the rate was dropped silently, and journal_entries has no currency or exchange_rate column, so it was unrecoverable. The rule adopted is the generators' rule stated explicitly: the monetary leg (ÅRL 4 kap. 13 § fordringar/skulder/kassa, so 15/16/17/19/23/24/25/27/28/29 and deliberately NOT 10-14 lager och anläggningstillgångar, 18 placeringar, 26 moms) whose SEK amount equals foreignAmount × rate, tie-broken by the account's own conventional denomination (1932 for EUR) and never by position. Chose to REFUSE (three new sv+en strings, thrown before the POST) over inventing a journal_entries.currency/exchange_rate column: a migration would be the honest place for an entry-level rate but the metadata is per-line by design and the schema change is not this pass's to make, and refusing loses nothing because the form still holds the values. Accepted cost: an entry whose 19xx leg is only PART of the foreign amount (two partial bank legs) is now refused where it previously stamped the full foreign amount onto a partial SEK leg, which was a mislabel rather than a feature. Falsification-checked rather than assumed: a faithful copy of the pre-fix rule was run against the new test file, 13 of 18 expectations fail against it, and the 5 that pass are labelled in the test as no-regression guards (SEK entry, missing rate, hydrated-line preservation, the SEK-cash-leg common case, the rounding tolerance the old rule "passed" by never comparing amounts). The two order-independence tests were each rewritten to assert BOTH orders in one test, because the reversed order alone also passed the old rule and therefore pinned nothing.

[2026-07-26] get_ledger_deep_context (migration 20260726150000) stopped counting foreign amounts as kronor, closing the RPC-side half of the LedgerGraph entry above. Both amount expressions fell back to the row own-currency column when no SEK value was recorded: abs(coalesce(t.amount_sek, t.amount)) and coalesce(si.total_sek, si.total, 0). Correct for SEK rows, where amount_sek/total_sek are NULL by design and the invoice-currency column already IS kronor; wrong for a non-SEK row with no stored SEK value, where the same expression yields the raw foreign magnitude, which was then summed into total_amount AND used as the total_amount sort key, so a 500 EUR invoice ranked as 500 kr instead of ~5750 kr and displaced a genuinely smaller counterparty. AP side is the worse one: total_sek is NULL whenever the caller merely omitted exchange_rate, the exact cohort 20260726120000 deliberately left NULL. Adopted the TypeScript rule verbatim (resolveSekAmountOrNull in mapping-engine.ts, resolveTransactionAmountSek in booking-duplicate-detection.ts, the unconverted_fx_count contract in ar-ledger.ts): SEK -> the existing coalesce untouched; non-SEK with a stored SEK value -> use it; non-SEK with a rate on the row -> multiply (deterministic arithmetic, statement 2 of the backfill); non-SEK with neither -> NULL, so sum() skips it. The SEK-vs-foreign discriminator is upper(coalesce(nullif(btrim(currency), empty-string), literal SEK)) = SEK, tested FIRST, which is what makes SEK rows byte-identical rather than merely equivalent. Chose per-entity unconverted_fx_count over a NULL total_amount: the field is typed number in DeepEntity, and a 0 paired with a nonzero count is the ar-ledger reading of a zero total, unknown rather than nothing. Deliberately did NOT exclude the rows from occurrences, cadence_days or the dominant-account pick: which account a booking went to is currency-free and is the part of this payload the agent leans on hardest, so only the money magnitude is withheld. Not yet rendered: DeepEntity in lib/agent-context/ledger-deep.ts does not declare the new field and LedgerGraph.tsx still prints formatCurrency(e.total_amount) unqualified at :268 and :744, so an all-unconvertible entity reads 0 kr with no explanation until that one-line wiring lands; the ring geometry is already honest because ledger-graph-magnitude.ts flips the whole ring to volume basis when any shown account has no usable amount. Migration NOT applied to any database and the pg test is UNRUN (no local Postgres).

[2026-07-26] employee_benefits validity period: the DB CHECK (valid_to IS NULL OR valid_to >= valid_from) is now mirrored in BOTH CreateEmployeeBenefitSchema and UpdateEmployeeBenefitSchema, and the message lives in one exported constant (BENEFIT_PERIOD_ORDER_MESSAGE) so the schema 400 and the route 400 cannot drift. The bound is inclusive and a NULL/omitted valid_to stays legal, matching run-calculation.ts, which selects benefits with valid_from <= payment_date AND (valid_to IS NULL OR valid_to >= payment_date). For the PATCH the schema refine can only fire when the body carries BOTH dates: a single-date PATCH has nothing in-body to compare against, so rather than leave that hole or reject single-date patches, the route recomputes the merged stored+patched pair against the row it already fetches for the bike-benefit check (one extra column on an existing SELECT, no extra round trip). Chose that over a DB-error-to-message mapping because the constraint is unnamed in the migration and matching on the generated employee_benefits_check name is not a deterministic guarantee. Also split the PATCH error mapping: it collapsed every post-update error into 404 'Förmån hittades inte', so a check_violation on the date range sent users hunting for a record that demonstrably exists (the route had just SELECTed it); now PGRST116 alone means not-found, 23514 means 400, everything else is an honest 500, and the same split was applied to the pre-update existence lookup. POST maps 23514 to 400 too: a CHECK violation is bad input, and 500 told the user to retry an insert that can never succeed. NOT fixed because the file was out of scope: components/salary/EmployeeBenefitsPanel.tsx renders the two date inputs with no min/max linkage and no client check, which is why the bad range was trivially reachable.

[2026-07-26] resolveSekAmount() in lib/bookkeeping/currency-utils.ts got a strict sibling, resolveSekAmountOrNull(), instead of having its return type changed to number | null (option b) or made to throw (option c). The root defect: the last resort returned the RAW FOREIGN AMOUNT, so no caller could distinguish 1150 SEK from 100 EUR relabelled, and 58 call sites inherited it. Chose the sibling because it CANNOT silently shift behaviour at a call site nobody read: the lenient function is now a one-line wrapper (resolveSekAmountOrNull(...) ?? amount), so its output is byte-identical to before for every input, and a delegation test pins that the two agree on every resolvable case. Option b would have touched ~58 sites in one pass, including roughly twenty that other agents had already fitted with local honest guards this session (SI_FX_RATE_MISSING, MATCH_INVOICE_BOOKING_RATE_MISSING, FX_CLOSING_RATE_UNAVAILABLE), and would have risked double-guarding or un-guarding them; option c would have broken lib/reports/, which legitimately needs the lenient arithmetic for pre-FX legacy rows. The lenient path is now documented READ-ONLY and every remaining lib/reports/ caller was confirmed to establish convertibility BEFORE calling, so for them the fallback is already unreachable. The pinned test at currency-utils.test.ts:34 was NOT inverted: it still asserts the lenient fallback, now relabelled "legacy readers only" and paired in the same test with the strict sibling returning null for the identical input, so the file states the contract of both rather than leaving it ambiguous.

[2026-07-26] The four duplicated private toSek closures on the sales booking paths (invoice-entries.ts generatePerRateLines + generateRotRutLines, propose-send-lines.ts, propose-payment-lines.ts) now route through resolveSekAmountOrNull() and refuse, via a new INVOICE_FX_RATE_MISSING code registered in structured-errors.ts (sales-side twin of SI_FX_RATE_MISSING). Each closure had its own inline "return amount // fallback for legacy data" branch, and because the 1510/1930 debit on the FX branch is DERIVED from the sum of the credits, every leg was scaled by the same wrong factor: the verifikation balanced, no trigger fired, and a 1 000 EUR sale posted 1 000 kr to 3001 and 250 kr to 2611 instead of 11 500 kr and 2 875 kr at 11,50, understating ruta 05 and ruta 10 (oriktig uppgift, SFL 49 kap 4 §). amountSek is passed as null on purpose: an InvoiceItem has no per-item SEK column, so exchange_rate is the only honest source at item granularity. Deliberate asymmetry in how the refusal surfaces: propose-payment-lines THROWS (PaymentBookingDialog resolves the proposal inside a try/catch and turns it into a translated toast), while propose-send-lines RETURNS [] (proposeSendLines runs inside a useMemo during SendInvoiceDialog's render, where a throw takes out the page; [] is the signal that function already uses for a text-only invoice, and editable is SEK-only so an empty proposal cannot disable the submit button). The [] bail is scoped to the item-driven path only: the invoice-level fallback in both propose files reads subtotal_sek / vat_amount_sek and has a genuine second source, so killing it would blank the preview for rows that CAN be expressed in kronor. Deliberately NOT touched, and reported instead: the ~10 invoice-level resolveSekAmount() call sites inside those same four files (invoice-entries.ts:390/402/438/513/653/761/797, propose-payment-lines.ts proposeAccrualLines) all pass a real _sek second source and changing them would shift the most common FX path (accrual payment of a booked receivable) on a call site this pass did not own. [2026-07-26] The two voucher-link matchers (lib/invoices/voucher-matching.ts, supplier-voucher-matching.ts) now resolve a ledger line's amount through the shared rule instead of reading debit_amount/credit_amount raw, and ledgerLineAmountIn was PROMOTED out of lib/reconciliation/bank-reconciliation.ts into lib/bookkeeping/ledger-line-amount.ts (which adds ledgerLineSideAmountIn for the one-sided sub-ledger case) with bank-reconciliation.ts re-exporting it unchanged, so the two sides cannot drift; a second implementation was declined for exactly that reason. journal_entry_lines.currency labels the DOCUMENT, so the "currencies match, safe to compare" guard passed on precisely the FX rows it existed to catch. The customer side was worse than a bad score: the amount band is pushed into SQL, so a foreign band bounded the always-SEK credit_amount and the one correct voucher was excluded server-side BEFORE scoring, leaving a same-magnitude decoy as the only candidate (reproduced against HEAD: the assertion fails with "expected [ 'je-decoy' ] to include 'je-correct'"). On a foreign invoice the band therefore moves onto amount_in_currency as |amount| <= ceil (some rows store the foreign figure negatively; direction comes from the debit/credit side) plus an equality filter on the currency label; NULL amount_in_currency drops out of both comparisons by SQL NULL semantics, which is correct because such a row carries no rate. The label guards in scoreCandidate/validate were KEPT unchanged in shape but re-documented as counterparty discriminators rather than unit checks. Both validators fail CLOSED (CURRENCY_MISMATCH) on a matched-side line that carries no figure in the invoice's currency, rather than summing only the readable lines and understating a voucher that settles more than can be seen. invoice.currency is normalised through a new documentCurrency() because the column is text default 'SEK' and therefore NULLABLE while the TS type is a non-null union: a NULL would test !== 'SEK' and send a plain domestic invoice down the FX path where nothing converts. Confirmed the fix is NOT inert: both matchers read journal_entry_lines directly (a PostgREST embed on the customer side, fetchEntryLines on the supplier side) and both column lists were extended, so unlike the bank path they do not go through get_unlinked_gl_lines / get_account_gl_lines_for_matching, which project neither FX column. bulk-reconcile-supplier-vouchers.ts needed comments only: remainingOf() and ap_debit_amount are now both in the invoice's currency. A TypeScript-only fix is NOT sufficient: link_invoice_to_voucher and link_supplier_invoice_to_voucher are the authoritative commit path (the web routes and bulk reconcile call them with no TS pre-validation) and both summed the SEK column against a remainder in the invoice's currency, so the correct EUR voucher was rejected as AMOUNT_EXCEEDS_REMAINING and LEAST() wrote a payment_amount in the wrong unit; migration 20260726140000 rewrites only the foreign branch of both, leaving the SEK branch verbatim so SEK-only companies are byte-identical in SQL as well as in TS. Migration NOT applied to any database and its pg test (lib/invoices/tests/link-voucher-currency.pg.test.ts) is UNRUN: no local Postgres, and applying it to staging would contradict the instruction to leave it unapplied.

[2026-07-26] Removed the "eu_business suppliers must have a vat_number" rule from lib/pending-operations/schemas/create-supplier.ts instead of relocating it to the point reverse charge is applied. The rule cited ML 17 kap 24 §, which lists what a SELLER must put on an invoice it issues (p.3 the seller VAT id, p.4 the buyer VAT id when reverse charge or intra-EU applies); it says nothing about what a BUYER key supplier register must hold, so it cannot make a register field mandatory. Checked whether the requirement had a real basis under a wrong label: it does not. A Swedish buyer self-assessing omvand betalningsskyldighet on an EU purchase reports the purchase in ruta 21 and the output VAT in ruta 30-32, then deducts it in ruta 48, on the strength of being a taxable person established in Sweden (ML 16 kap 6 § with 6 kap 34 §); the VIES-validated counterparty VAT number is a condition for zero-rating an intra-community SUPPLY, i.e. the seller side, which is why only customers carries vat_number_validated / vat_number_validated_at and suppliers never has, and why lib/reports/periodisk-sammanstallning.ts reads customers only. Nothing downstream consumes supplier.vat_number: lib/bookkeeping/supplier-invoice-entries.ts and generateReverseChargeBasisLines() select 4535/4531/4425 from supplier_type plus invoice.reverse_charge alone, suppliers.vat_number is a nullable text column, and both other create paths (components/suppliers/SupplierForm.tsx and CreateSupplierSchema behind /api/suppliers and the v1 bulk-create) already treat it as optional, so the same EU company could be created through the dashboard and refused through the staged/agent path. An EU supplier below its national registration threshold has no VAT number at all. Also corrected two attribution errors in the same file header: Peppol SE-R-008/009 mandate a 7-8 digit Bankgiro length, not the modulus-10 check digit (that is Bankgirot own spec; SE-R-013 is the Luhn rule, and it is for organisationsnummer), and SE-R-001 only fixes Swedish VAT ids at 14 chars rather than supplying the per-country patterns, which come from lib/vat/vies-client.ts and happen to agree for SE. Left the file "ASVS V4.5" input-validation reference alone despite verifying it is wrong (ASVS v5.0.0 puts centralized validation at V2.2 and uses V4 for API and Web Service; 4.0.3 has no V4.5 either): it is a 12-site repo-wide convention and a one-file correction would only make the set inconsistent.

[2026-07-26] PATCH /api/salary/employees/[id] no longer spreads personnummer into its UPDATE payload, and the four internal salary read surfaces (employees list, employee detail GET+PATCH, employee POST, salary run GET, salary run employee GET) now return the mask as personnummer_masked with the encrypted column dropped from the payload entirely. Two halves of one hazard. Write half: { ...body } carried personnummer verbatim while the encrypt branch was gated on the truthy if (body.personnummer), so an empty string skipped the encrypt but was already in the payload; the UPDATE would have replaced the AES-256-GCM ciphertext with '' and left personnummer_last4 stale, then 500'd on the response decrypt AFTER the write committed. The only thing preventing that was the /^\d{12}$/ regex in lib/api/schemas.ts, i.e. a guard in another file, and house style there adds .or(z.literal('')) to optional string fields (clearing_number, account_number, bankgiro, tax_contact_email), so one routine edit would have destroyed encrypted PII. The guard now lives in the write path: absent/undefined means identity unchanged, '' and null are 400 (the column is NOT NULL and Skatteverket FK215 filing needs the value; omit the key to leave it alone), a masked value is 400 (validatePersonnummer strips non-digits, so a decorated 12-digit value would otherwise sail through), 12 digits validate and re-encrypt. Read half: these salary routes were the ONLY surface returning a masked value under the writable key name, the mirror that lets a read-modify-write client post the mask into the encrypt path; v1 (EmployeeWriteResponse), the MCP tools and lib/salary/employee-commands.ts already use the _masked suffix. Kept the personnummer-update capability rather than rejecting it outright like v1 and MCP do (identity immutable post-create): this route is the only place a mistyped personnummer can be corrected, and removing that silently would be a behaviour change nobody asked for. New exported type EmployeeMasked (Omit<Employee,'personnummer'> & { personnummer_masked: string }) so the six client consumers are typed against what the API actually returns instead of a field that no longer exists. NOT changed: extensions/general/mcp-server/server.ts:9578 and :9609, two MCP RESOURCE readers (not tools) that still mask under the writable personnummer key; that file was out of scope this pass.

[2026-07-26] The two arsredovisning persistence services (lib/bokslut/arsredovisning/profile-service.ts, narrative-service.ts) now rethrow the PostgrestError untouched (if (error) throw error) instead of re-wrapping it as new Error("Failed to save ...: " + error.message). The re-wrap kept only .message and dropped .code, .details and .hint, so errorResponse()'s isPostgresError() branch never fired and every constraint failure on those two tables became INTERNAL_ERROR / 500. Reachable cases, both real caller mistakes: 23514 on annual_report_profiles_parent_consistency (PATCHing parent_group_size or prepares_consolidated_accounts onto a row already stored with is_parent_company = false, since the upsert writes only the columns present in the payload) and 23514 on arsredovisning_narratives_agm_decision_consistency (the route's Zod superRefine only catches it when outcome and decision arrive in the SAME body; a partial save leaves the merged row inconsistent). Chose plain rethrow over wrapping in a typed error that carries code because three services already do exactly this and document it (lib/articles/validate-revenue-account.ts, lib/bookkeeping/account-validation.ts, lib/invoices/issue-credit-note.ts): a second convention for the same job is the thing that lets code drift. The lost "which operation" prose is recovered from the bound operation on the log line plus PostgREST's own message, which names the relation and the constraint. !data was split out and left as a plain Error so the belt-and-braces branch still maps to 500 rather than borrowing a 400. Verified no over-correction: postgresCodeToStructured() maps only 23502/23503/23505/23514/22P02/22003/42501/42P01/40001/40P01, so an unmapped SQLSTATE (XX000 internal_error) and a non-Postgres throw both still produce 500. Also verified no data leak: errorResponse puts only pgCode in the envelope, and redact() reduces an Error to { name, message, stack, code }, so PostgREST's details ("Failing row contains ...") reaches neither the client nor the log. DECLINED to build a constraint-name-to-message table for the 163 named CHECKs, on the same reasoning as the employee_benefits entry above: the two constraints here are explicitly named, but the useful ones elsewhere are anonymous column-level CHECKs whose generated names are not a deterministic guarantee, and both constraints reachable here are already mirrored in Zod for the full-payload case, so the DB CHECK is the partial-update backstop rather than the primary user-facing validator.

[2026-07-26] app/(auth)/reset-password/page.tsx now consumes the pre-auth invite cookie, closing the last session-establishing path that never did. It calls the shared consumeInviteCookie() (lib/auth/consume-invite-cookie.ts) through a small co-located module, app/(auth)/reset-password/invite-handoff.ts, rather than inlining an eighth copy of the accept-and-clear logic. Three non-obvious calls. (1) The call site is AFTER POST /api/account/password returns 2xx, not at mode === 'set-password' where the recovery session already exists: that response comes out of requireAuth(), so its success is proof of a server-validated session, and waiting means no membership is ever created off a half-finished credential reset (ASVS v5.0.0 V6.4 account recovery must not weaken the factor, V7.5 sensitive operations need recent authentication). (2) The handoff DEFERS instead of attempting when the session still owes an MFA step-up, mirroring the middleware predicate exactly (shouldEnforceMfa plus aal1-with-aal2-required). POST /api/team/accept is not in apiPathSkipsMfaGate, and a recovery session is AAL1, so on hosted the exact user shape the finding is about (a consultant who already has a company, and whom middleware therefore forced to enroll TOTP) would have received a bare 403 from the middleware, which classifyInviteAcceptStatus reads as 'wrong_email': a legitimate invitee told their invitation belongs to someone else. Deferring leaves the cookie untouched and the same predicate bounces them to /mfa/verify, which consumes it after the second factor. BankID-linked and self-hosted users are deliberately never deferred, because nothing would bounce them and the token would sit unused. (3) A token that survives a transient failure routes to /select-company, not '/': it re-runs acceptPendingInviteByToken() server-side on every load and, unlike /onboarding, is reachable for a user who already has a company, which is precisely why the existing safety nets did not cover this shape. 'wrong_email' and 'spent' deliberately do NOT take that detour (the email equality check is deterministic for the same account, and a spent token is gone), so they just toast and keep the normal navigation. The flow lives in a sibling module rather than page.tsx because a Next.js page file may not export anything but the component and the route segment config (tsconfig includes .next/types), which would have left it untestable in a repo with no component harness; app/(dashboard)/request-context.ts and app/api/sandbox/seed/customers.ts are the existing precedent. No new i18n keys: the toast reuses INVITE_PROBLEM_MESSAGE_KEYS so all five surfaces word the same failure identically.

[2026-07-26] The three staged-operation FX writers in lib/pending-operations/commit.ts (create_transaction, create_invoice, create_supplier_invoice_from_inbox) now REFUSE at commit rather than persisting a foreign-currency row with a NULL rate, and all three anchor the Riksbanken fetch on the row's own date with the supabase client passed so exchange_rates is the read-through cache (the lib/transactions/ingest.ts call shape). ingest.ts is allowed to store NULL because it is a bulk bank feed where one unreachable rate must not abort the batch and its rows stay repairable via /api/transactions/[id]/refresh-exchange-rate; a staged operation is a single row with the approver present, and the categorization path does NOT refuse a rateless row (it books through the lenient resolveSekAmount, so a 1500 USD transaction debited 1500 kr while buildCurrencyMetadata stamped the same line currency USD / amount_in_currency 1500). Commit is therefore the last boundary where the contradiction is still visible to a human, so it is where the refusal belongs. create_supplier_invoice_from_inbox reuses resolveSupplierInvoiceExchangeRate + supplierInvoiceSekAmounts rather than a fourth private copy, and resolves the rate BEFORE get_next_arrival_number so a refusal never burns an ankomstnummer; create_transaction likewise resolves before ensureManualCashAccount. A caller-supplied positive rate is still trusted verbatim on the supplier-invoice path, which is what makes the number the approver saw in the staged preview the number written. Both invoice paths also stopped gating the *_sek columns on exchangeRate ? ... : null: a SEK invoice resolves to rate 1 so total_sek === total, because that guard left total_sek NULL on every ordinary Swedish invoice. lib/invoices/build-invoice-write.ts (the web/REST customer-invoice path) still leaves the SEK columns NULL for SEK invoices and on a failed fetch: not changed here because it is outside this fix's scope, so the two customer-invoice writers are knowingly divergent until it is aligned. No new structured-error code was added for the transaction refusal (SI_FX_RATE_MISSING and INVOICE_FX_RATE_MISSING already cover the two invoice cases): a literal Swedish message matching the register's wording keeps the diff inside the one file this fix owns.

[2026-07-26] generic-csv.ts pickDateHeader (the auto-guess that seeds the manual "Annan CSV" column-mapping UI) re-ordered from transaktionsdatum-first to booking-date-first, closing the third site of the CSV date finding logged above. Tiers are now bokföringsdag/bokföringsdatum/bokfdag, then reskontradatum, then a bare datum/date, then transaktionsdatum/transdag; every tier still skips headers containing 'valuta' so Valutadag and a plain Valuta column can never be picked. transaktionsdatum was DEMOTED, not removed: a file carrying only a transaction date must still map without user input, which is now pinned by a test. Two non-obvious calls. (1) A bare 'datum' was placed ABOVE transaktionsdatum rather than below: in every single-date export in this directory (Nordea, Skandia, Nordea Business format D) the bare Datum column is the posting date the Saldo ties to, while transaktionsdatum is explicitly labelled the swipe date, and keeping bare datum high also leaves behavior byte-identical for the large majority of files that carry exactly one date column. Files carrying both plus a real booking column resolve at tier 1 or 2 before either is reached. (2) The booking regex was widened from /bokf(ö|o)ringsda(g|tum)/ to /bokf((ö|o)rings)?da(g|tum)/ so Swedbank's abbreviated Bokfdag matches by LABEL. Swedbank is the decisive precedent (Transdag is present in the file and formats/swedbank.ts deliberately picks Bokfdag), and at HEAD a Swedbank file forced through this path only landed on Bokfdag by accident: no tier matched, so the value-based fallback took the leftmost date-shaped column, which an exactly-8-digit Kontonr would have stolen (SUGGEST_DATE_PATTERNS accepts /^\d{8}$/). Severity is deliberately stated as a bad DEFAULT, not a correctness gate: suggestColumnMapping is reachable only from components/import/BankFileColumnMappingStep.tsx, the "Datum *" dropdown lists every column with its own header label, and the live preview renders the chosen date column, so the user can see and change the guess before confirming. The v1 REST import cannot reach it at all (parseBankFile with format generic_csv uses genericCSVFormat.parse's fixed date-0/description-1/amount-2 mapping). external_id consequence is the same bounded one as the parsers: the date is an input to generateExternalId's composite, so only rows where the two dates DIFFER get a new id; the (company_id, file_hash) block at app/api/import/bank-file/parse/route.ts:49 stops a byte-identical re-upload from reaching the parser, but it does not cover an overlapping non-identical export, and the v1 route has no such block (it upserts on the same key and parses regardless). NOT fixed, reported instead: parseGenericCSV hardcodes currency 'SEK' and GenericCSVColumnMapping has no currency field at all, so a foreign-currency row imported through "Annan CSV" is booked as SEK where nordea-business.ts honors its Valuta column and wise.ts refuses a row with no currency; fixing that needs a type, mapping and UI change, which is a different class from the date tier list this pass owns.

[2026-07-26] The three payment-file handlers in components/salary/PaymentFilePanel.tsx and components/salary/TaxPaymentPanel.tsx (LB / pain.001 salary payment file, skattekonto payment file, mark-as-paid) had try/finally with no catch, so a fetch that threw escaped as an unhandled rejection and the user was told nothing at all on the one screen where "nothing happened" means wages or the skattekonto payment do not move. All three now report exactly one toast per outcome. Four non-obvious calls. (1) The two download handlers adopt downloadFile() unchanged at its default 15s deadline (these routes are 3 to 4 indexed queries plus in-memory string generation, so the deadline is many times the realistic cold-start-plus-one-eu-north-1-round-trip worst case), but mark-paid is a POST and downloadFile exists to save a blob, so it got a sibling, lib/browser/post-action.ts, with the same discriminated union and the same bounding. POST_ACTION_TIMEOUT_MS is deliberately not shorter than DOWNLOAD_TIMEOUT_MS: aborting a mutation early reports failure for a write that may have landed, and that ambiguity is worse than a few extra seconds of spinner. postAction does not parse a success body, following download-file.ts's rejection of body-shape validation as coupling the client to the route's payload. (2) The timeout and network copy is supplied by the call site through lib/browser/action-failure.ts failureDescription(), while a 'server' failure keeps the route's own sentence: the routes name the exact missing field ("Bankgironummer saknas i företagsinställningar"), which beats any panel copy, whereas getErrorMessage has only the generic "Något gick fel" for a fetch that produced no response, and a user cannot act on that. mark-paid's timeout copy says to reload and check rather than claiming the change failed, because on a mutation a timeout genuinely does not know. (3) Moving into downloadFile/postAction drops the old context: 'salary' argument, since neither helper takes one. This changes nothing for any status in HTTP_STATUS_MAP (400/401/403/404/409/422/429/500/502/503 all answered before the context fallback was ever consulted) and only swaps "Kunde inte hantera löneuppgifterna" for "Något gick fel" on statuses outside that map, e.g. a raw 504; both are generic and the toast title is more specific than either. The three *_failed_fallback keys are now unreferenced but were left in place: this pass owned only the keys it added. (4) NOT fixed, reported instead: nothing in the codebase stops a second payment file being generated for the same salary run. Both salary routes accept status in (approved, paid, booked) and only overwrite payment_file_format / payment_file_generated_at, the tax route does not consult tax_paid_at, and the LB format carries no batch identifier at all (TK 11 has sender bankgiro plus a YYMMDD creation date, so two files made the same day are byte-identical), so cross-format bg_lb-plus-pain001 duplicates for one run share no id any dedup could correlate. pain.001 is the partial exception: MsgId is ACCOUNTED-{orgdigits}-{YYYY-MM} and every InstrId/EndToEndId derives from it, so a regenerated file repeats them, which is the key a bank duplicate-file check uses, but that control is bank-side and not ours to rely on. Inside these panels the fix is the click-race guard (if (downloading) return, plus the existing disabled state) and the catch itself, since a silent failure is what invites the human-speed retry that produces the second file; a real regeneration guard is a server change to routes this pass did not own.

[2026-07-26] lib/api/sparse-patch.ts sparsePatchBody forwards zod issues by SPREAD (ctx.addIssue({ ...issue })) rather than through a double cast, and declares its return as z.ZodType<Partial<z.infer<S>>, unknown> rather than casting the pipe. Both casts turned out to be avoidable on zod 4.4.3. addIssue is typed against $ZodSuperRefineIssue, whose members flatten to { [k: string]: unknown } & <the raw fields>; a finalized $ZodIssue is declared as an interface, so it gets no implicit index signature and is rejected, but a spread produces an object-literal type, which does get one, so the same value passes with no cast. Verified that the forwarded issues are byte-identical to what a plain validateBody(request, schema) produces (same code, same path, same expected, same message, all issues, not just the first), pinned by three tests in lib/api/tests/sparse-patch.test.ts including a direct equality assertion against the bare-schema 400 body: flattening to code: 'custom' would have been a silent API break for clients that branch on it. The return-type change works because ZodType's Input parameter is declared out, so the pipe's unknown input does not have to be laundered to satisfy validateBody's z.ZodType<T>. Also made the contract total by throwing a TypeError when the schema's output is not an object, instead of letting Object.entries(null) surface as an opaque "Cannot convert undefined or null to object" 500 from a misuse like sparsePatchBody(Schema.transform(() => null)). DECLINED to add a check:guards rule for this antipattern. A syntactic rule can only see "validateBody called with an Update*-shaped schema" (19 sites), which cannot distinguish the three cases that need three DIFFERENT fixes: narrow-after-parse is right for a SET col = $1 sink, wrong for a fixed-column upsert (which needs a stored-row merge, as app/api/kpi/preferences/route.ts now does), and wrong again for a cross-field refinement (which needs a defaults-stripped patch base, as EmployeeSchemaPatchBase does). A ratchet that pushed all 19 toward sparsePatchBody would therefore manufacture bugs in the second and third class, which is worse than noise; the trap is pinned by an executable test of the zod behavior itself instead.

[2026-07-26] components/extensions/general/InvoiceInboxWorkspace.tsx: the inbox-address copy button now awaits the clipboard write through a new copyInboxAddress() (components/extensions/general/inbox-address-copy.ts, wrapping lib/browser/copy-to-clipboard.ts) and shows an icon-swap plus one ochre AttnLine, and the "Adress kopierad" toast was REMOVED rather than made conditional. Two reasons: TOAST_LIMIT is 1 (components/ui/use-toast.tsx ADD_TOAST slices to one), so a warning emitted next to any other toast is evicted and never rendered, and the CopyBlock in components/settings/ApiKeysPanel.tsx already established the inline idle/copied/failed treatment for exactly this problem; a second, toast-shaped idiom for the same event would have to be learned separately. On failure the address drops its truncate for break-all and keeps select-all, because with no clipboard, reading it off the screen is the only way to get it. The document-preview pane grew a four-state DocumentLoadState because "Inget underlag bifogat" was being rendered for rows that DO carry a document_id: both the swallowed catch and the missing !res.ok branch left docUrl null, and so did the entire in-flight window on the happy path. none is now the only state allowed to claim nothing is attached; the metadata fetch also moved to fetchWithTimeout at 15s so the new loading state cannot spin forever. Third fix, same class, different surface: a non-404 answer from /inbox/address (500, 503 with no RESEND_INBOUND_DOMAIN, an HTML error page, offline) previously left inboxAddress null and offered "Aktivera inkorgsadress", and handleRotateAddress skips its confirm() precisely when inboxAddress is null, so one click silently retired a live address that suppliers and forwarding rules point at; a 404 still means "none provisioned" and still offers activation, anything else now shows a retry line, suppresses the EmptyPreview activation CTA, and forces the confirm dialog. A failed items list read no longer renders "Inkorgen är tom" or the "ladda upp ditt första underlag" onboarding step. NOT fixed, reported instead: FieldsRail's handleRetry (retry-extraction) has try/finally with no catch, so a throwing fetch clears the spinner and says nothing at all; that is a missing-handler bug rather than a swallow-and-claim, and it belongs with the other salary/payment no-catch handlers fixed the same day.

[2026-07-26] The invoice editor's per-line Moms picker now RENDERS getPermittedVatRates() while the DEFAULT, the article-rate adoption test and the customer-switch snap all keep reading getAvailableVatRates(). The picker previously rendered the default set, which for a VAT-validated EU business or a non-EU business is a single locked 0% option, so a Stockholm hotel could not put 12% on a hotel night sold to a German company even though that supply is taxed where it is performed (ML 6 kap.) and carries Swedish VAT. Widening getAvailableVatRates() itself was rejected (again): it would leave a stale 25% line at 25% after switching to an EU customer, and it would make the new-line default and the snap follow the widest lawful rate instead of the lawful DEFAULT (0% under huvudregeln, ML 6 kap. 34 §). The two sets therefore live in one plain module, components/invoices/line-vat-rates.ts, so the four behaviours are named and unit-testable (the repo renders no components in tests). Three non-obvious calls. (1) The customer-switch snap moves ONLY the lines still sitting on the previous customer's default rate, instead of every line. Snapping all of them destroys the lawful case the widening exists for (a deliberate 12% hotel line becomes 0% the moment the customer is picked), while snapping none of them keeps both stale-rate bugs. An inherited rate was never chosen by the user, so following the new customer is safe; a rate the user moved off the default is the only signal that a taxed-where-performed supply is on the line, and overwriting it would be guessing. A prompt was rejected as a dialog on every customer change for a case that is almost always a no-op. The rule is symmetric, which also fixes the reverse direction the old code never handled: 0% lines inherited from an EU customer now follow to 25% when the customer becomes Swedish, where before they silently under-charged VAT. Free-text rows are excluded (no amounts, never book). Edit and copy mode only RECORD the baseline on the first customer resolution and never snap, since the pre-filled lines already belong to that customer. (2) Article-rate adoption still gates on the DEFAULT set, so a foreign business customer keeps the line's rate when an article is picked. An article's stored vat_rate is its domestic rate and nothing on it says the supply is taxed where performed, so adopting 25% from the article would put Swedish VAT on a reverse-charge invoice by itself; the hotel picks the article for name/price/unit and sets 12% explicitly. (3) Attention is one ochre AttnLine sentence (UI convention 6) that renders only once a non-zero rate is actually selected for a customer whose default is 0%, never a banner and never for the normal 0% case. The four remaining server gates (lib/pending-operations/commit.ts, lib/invoices/self-billed-sale.ts, lib/invoices/recurring-schedule-service.ts, app/api/v1/.../invoices/bulk-create/route.ts) were pointed at getPermittedVatRates so every write surface accepts exactly what buildInvoiceWriteData accepts, pinned by lib/invoices/tests/vat-rate-gate-parity.test.ts. NOT fixed, reported instead: extensions/general/mcp-server/server.ts around line 4475 still builds its allowedRates from getAvailableVatRates, so gnubok_create_invoice still refuses a 12% line to an EU business; that file is owned elsewhere.

[2026-07-26] The sharpened RC_INPUT_VAT_MISMATCH is now WIRED on both surfaces; before this it was dead code, because runVatDeclarationChecks(rutor, accountTotals?) had no caller passing the second argument and every caller therefore ran the weak ruta48 + 0.5 < rcOutput fallback. MCP: computeVatReportWithRutor returns the accountTotals map it already builds, runVatCompletenessChecks threads it, and both callers pass it (gnubok_vat_close_check from the report aggregate, gnubok_vat_declaration_validate from the declaration). Web UI: calculateVatDeclaration now carries ONLY the 2645/2647 debit/credit pair on the response as VatDeclaration.rcInputAccountTotals, and components/reports/views/index.tsx rebuilds a 2-entry Map through the new rcInputTotalsFromDeclaration(). Four non-obvious calls. (1) The pair, not the whole totals map: the check reads nothing but 2645 and 2647, so shipping every VAT account balance would grow the response (and the v1 REST body, which returns the declaration verbatim) for no behavioural gain; lib/reports/tests/vat-declaration.test.ts asserts the 2-entry projection and the full map produce identical findings, on a fixture that carries a balance on every OTHER ruta 48 account, which is also what pins the mirrored RC_INPUT_VAT_ACCOUNTS list to the private RC_INPUT_ACCOUNTS inside vat-declaration-checks.ts. (2) The field is OPTIONAL and rcInputTotalsFromDeclaration returns undefined (never an empty Map) when it is absent: an empty map reads as "0 kr beräknad ingående moms" and would turn a correct declaration from an older deploy into a false warning, whereas undefined falls back to the ruta 48 form. (3) The severity stays WARNING on both surfaces: limited avdragsrätt (blandad verksamhet, ML 13 kap 18/24-25) makes a shortfall legally correct for some filers and no SKV gateway rule rejects it, so blocking would invent a rule and risk a 625 kr forseningsavgift for a legal filer. isFilingBlocked reads ERROR only, so the sharper check cannot disable Skicka. (4) The noInputVatAtAll escalation in computeVatCloseCheck was KEPT even though its stated reason ("cannot isolate the RC share of ruta 48") no longer holds: its import half (rutor 60-62 against ruta 48) is coverage the shared checks still do not have, since they compare import output only against the tullvärdesunderlag in ruta 50. Comment corrected in place instead of deleting the block. No MCP schema changed, so the tools/list payload stays at 57 475 of 57 500 tokens.

[2026-07-26] The phantom-column net (tests/schema/no-phantom-columns.test.ts + tests/schema/schema-guard.ts) takes its ground truth from REPLAYING supabase/migrations/*.sql, not from information_schema in a pg-real test and not from a checked-in schema snapshot. pg-real is authoritative but only runs in the test-pg-real CI job, so a developer's npm test would never see the guard that exists precisely because mocked tests cannot see this class; a snapshot rots and needs a generator to keep honest. Replaying 523 migrations costs ~250ms, has no artifact that can go stale, and the migration files are already the repo's contract with prod ("never leave a remote DB ahead of the repo"). The cost is parser fidelity, so every unparsed construct degrades to "unresolved" rather than to an accusation, and fidelity is pinned three ways: the tables that deliberately lack company_id, the tables that got company_id from an EXECUTE format('ALTER TABLE %I ADD COLUMN ...') loop, and full agreement with MASTER_DATA_DUMP_TABLES in lib/reports/full-archive-export.ts, whose own contract is asserted against a live database by tests/pg/full-archive-coverage.pg.test.ts. Two parser features exist only because the repo needs them and their absence produced 1 554 false accusations on the first run: dynamic ADD COLUMN inside DO $$ blocks over an ARRAY[...] table list (40 tables got company_id that way), and plain ALTER TABLE inside DO $$ idempotency guards (supplier_invoice_payments.user_id, and the automation_webhooks -> webhooks rename, without which 14 v1 webhook routes read a table the model denied existed). Failure mode is split deliberately: named columns, tables, CHECK values and onConflict targets that resolve confidently are hard assertions minus a 24-entry documented baseline of pre-existing breakage, while expressions the scanner cannot resolve (145 runtime payloads, 116 interpolated selects, 48 dynamic or-strings, 32 spreads) are counted against a ceiling of 420 instead of failing, because failing on every unresolvable expression is the noise that gets a guard disabled. A floor of 12 000 resolved references guards the guard: if the scanner stops following builder chains the test fails rather than going quietly slack. Test files are OUT of scope (a phantom column in a mock is not a production bug) and scripts/ is IN (those run against prod). DECLINED to also ship a pg-real companion that diffs the replayed model against information_schema: it is the obvious next step and would close the fidelity loop permanently, but with no local Postgres it could not be run in-session, and shipping an unverified test is how a green tree becomes a red one.

[2026-07-26] A blocked OAuth popup in ArcimMigrationWorkspace now falls back to a full-page flow instead of getting a toast, matching SkatteverketConnectPanel.tsx:205-213 rather than inventing a second treatment. The return value of window.open was discarded at both provider-auth sites (the first-connect button and the retry arm of handleReconnect), so a blocked popup looked exactly like a successful one: nothing opened and nothing was said. A toast was rejected because the flow is recoverable: the callback route already handles a missing window.opener by redirecting to /import?migration=connected&consentId=... (extensions/general/arcim-migration/index.ts:527-535, and the mirror error arm at :452-475), ?migration= sets mode='migration' on the import page, and handleOAuthReturn resumes the wizard at the preview step, so the user finishes the migration instead of being told it failed. That also keeps the fix free of a new i18n key and free of the TOAST_LIMIT=1 eviction trap. lib/browser/deferred-tab.ts is deliberately NOT used here: it severs tab.opener to preserve noopener semantics, and this flow's success signal IS window.opener.postMessage. No activation problem exists at either site (both opens are synchronous in the click, or already pre-opened), so no pre-open indirection was added. Not testable as a component: the repo has no jsdom/testing-library/E2E and vitest runs node-env over lib/ + app/api/, and the only extractable seam would be if (!popup) navigate(url), a test of itself. Instead the fallback's precondition is pinned, since this change promotes the callback's previously near-dead no-opener arm into the only path a popup-blocked user has: two cases in extensions/general/arcim-migration/tests/oauth-callback-state.test.ts assert the exact /import URL and params for both success and failure. Those pass at HEAD by design; they are a contract guard, not proof of the fix.

[2026-07-26] The "Ångra" ToastAction on app/(dashboard)/deadlines/page.tsx now goes through a real handler (handleUndoComplete) and the /api/deadlines/[id]/complete route honours an explicit is_completed boolean instead of blindly toggling. The finding was that the inline onClick had no res.ok check and no refetch, so a successful undo of a Skatteverket deadline looked exactly like a failed one. Two non-obvious calls. (1) DECLINED lib/browser/post-action.ts even though it exists for precisely this shape: postAction sends { method: 'POST' } with no body, and this fix needs a body, because the honest undo is an explicit target state rather than a second toggle. Editing post-action.ts to grow a body option was off-limits this session, so the handler hand-rolls the fetch in the same shape as the five sibling handlers already in the file, using getErrorMessage(body, { statusCode }) for the server arm (envelope-aware, unlike the siblings' new Error(result.error) which renders "[object Object]" when the canonical envelope is returned) and the existing load_failed_description key for the never-completed arm. No new i18n keys. (2) The route change is load-bearing, not scope creep: the client picks its toast from the state it asked for, so a route that ignores the body can persist the opposite of what the toast claims, and an undo click landing after the row was already un-ticked in another tab or by an MCP agent would re-complete the deadline. Explicit state makes it idempotent; a body-less or non-boolean body still toggles, so nothing else has to migrate (the only two callers are both on this page). fetchData now returns whether the list was really refreshed, and the success toast is withheld when it was not, because TOAST_LIMIT=1 means a success sentence would evict fetchData's own load-failure sentence. Proven at the route seam: the two idempotency cases in app/api/deadlines/[id]/complete/tests/route.test.ts fail against HEAD (expected false, got true). The component itself is untestable here (no jsdom/testing-library, vitest is node-env over lib/ + app/api/).

[2026-07-26] Sweep 5.5's two "weaker" settings toggles split: one fixed, one declined. FIXED components/settings/sections/AssistantSettingsContent.tsx FabVisibilityRow, which already checked res.ok and already rolled the switch back, so the defect was never the divergence the sweep described (UI new, DB old) but pure silence: a switch that flips and flips back reads as a broken control, so the user re-clicks instead of re-authenticating, and 401 on an expired session is the likeliest refusal on a route that is requireAuth() plus one upsert. It now emits exactly ONE destructive toast on both failure arms, mirroring the same-directory sibling DimensionsToggle.tsx:48-55 rather than inventing a treatment. Four non-obvious calls. (1) lib/browser/post-action.ts was NOT adopted: it hardcodes { method: 'POST' } with no body, this route is PATCH with a JSON body, and widening the helper was out of bounds. (2) The optimistic flip plus rollback was KEPT instead of moving to DimensionsToggle's derive-only-on-success model; the rollback was already correct and a laggy switch is a worse trade than a responsive one that explains itself. (3) router.refresh() moved OUT of the try: inside it, a refresh that threw would roll the switch back and announce a failed save for a write that had already landed. (4) statusCode: res.status is passed to getErrorMessage on purpose, because the route hand-builds { error: 'Could not save preference' }, un-localized English that isSwedishUserMessage correctly declines, so without the status the description would degrade to the generic "Något gick fel"; with it, 401 reads "Din session har gått ut. Logga in igen." One new key pair, settings_assistant.fab_save_failed (sv + en). NOT testable: no component or E2E harness exists (vitest is node-env over lib/ + app/api/), and the server half of the contract is already pinned by app/api/user/preferences/__tests__/route.test.ts:106-110 (500 when the upsert fails), which passes at HEAD, so a test there would assert someone else's correct code, not this change. DECLINED components/settings/PeriodiseringAutoDetectToggle.tsx:72-79: the swallowed setItem catch cannot produce the lie the sweep alleges, because the switch is checked={enabled} off useSyncExternalStore(subscribe, readStored), components/ui/switch.tsx is a pure controlled Radix pass-through with no internal state, and a setItem that throws leaves readStored() returning the old value, so the notifyChange() re-read snaps the switch back on the same tick; there is no persisted state to misreport. Its real defect is one the sweep did not see and is a product decision, not a silent-failure fix: periodisering_autodetect_enabled has no reader anywhere in the repo, and the wizard consumes data.autoDetected unconditionally (app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx:171,243,457,481), so the preference is inert and a "storage blocked" toast would be noise attached to a control that changes nothing.

[2026-07-26] The kassaflödesanalys PDF button (app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx) now downloads through lib/browser/download-file.ts with its own busy state instead of assigning window.location.href. Sweep 5.5 called this a missing busy state whose disable tracked the report fetch; the site is worse than that reading and worse than the ten neighbouring download sites it was grouped with. A location assignment has no res to check and no promise to catch, so a busy state cannot be bolted on: it has to become a fetch. And where the neighbours save an error envelope to disk and leave the app running, this one navigates the whole SPA to the route's { error: string } body, because the error responses carry no Content-Disposition to cancel the navigation, so a failed statutory report costs the user the page they were on. Three non-obvious calls. (1) The client now names the file itself, duplicating the route's kassaflodesanalys-<period_start>.pdf; downloadFile has no Content-Disposition seam and lib/browser/** was off-limits this session, so the duplication is forced. It is safe because both sides read period_start from the same generateKassaflodesanalys output (the JSON route returns it verbatim), and it is now pinned by an exact Content-Disposition assertion in the new app/api/reports/kassaflodesanalys/pdf/__tests__/route.test.ts. (2) The route's hand-rolled { error: 'Företagsinställningar saknas' } / 'Räkenskapsperioden kunde inte läsas...' bodies were left alone rather than migrated to the canonical envelope: getErrorMessage passes both through on isSwedishUserMessage, so the toast already shows the route's own sentence, and rewriting an untouched route's error shape is scope the finding does not ask for. Those two bodies became user-facing with this change, so the same new test asserts they survive getErrorMessage instead of degrading to the generic status text. (3) The shared 15s deadline is used unchanged rather than widened: this is one fiscal year of aggregation plus a single-page stock-font render, and the route sets no maxDuration, so a longer client deadline would only keep the spinner alive past the point the function itself is killed. No success toast (the saved file is the feedback), which also keeps the handler clear of the TOAST_LIMIT=1 eviction trap. Three new reports.pdf_download_* key pairs; the page's Swedish body copy was left hardcoded, since useLocale() was needed for getErrorMessage anyway and translating the statutory section headings is not this fix's business. The fix itself is NOT testable here: the entire delta is in a .tsx client component, and vitest runs node-env over lib/ + app/api/ with no component or E2E harness. The new route test passes at HEAD by construction (the route is unchanged); it is a contract guard for the filename and error bodies the client now depends on, not proof of the fix. [2026-07-26] Deleted lib/core/tax/tax-code-service.ts and its test (phantom tax_codes table found by tests/schema/no-phantom-columns.test.ts): the table was never created by any migration, migration 20240101000012 is an explicit placeholder recording that the tax-code engine was "planned but never deployed", the seed_tax_codes_for_user RPC it called does not exist either, and the only importer was its own mock-backed test. The need is covered, and covered more completely, by the account-driven route: ACCOUNT_RUTA in lib/reports/vat-declaration.ts mirrored by ACCOUNT_TO_BOX in lib/vat/moms-box-mapping.ts, which spans every ruta 05-62 with debit/credit sides, matches the swedish-vat reference (which maps rutor to BAS accounts, not to skattekoder; a per-line tax-code dimension is a Fortnox/Visma implementation detail, not a legal requirement), and is already the documented contract in the MCP tool schema ("tax_code: free-text tag, does NOT drive momsdeklaration ruta"). The dead code was also arithmetically wrong: it added the same abs(net) amount to basis and tax boxes alike, so an MP1 line put its VAT amount into ruta 05 as well as ruta 10. Kept: the TaxCode/TaxCodeId types in types/index.ts and the makeTaxCode fixture in tests/helpers.ts, left in place only to stay out of files other agents were editing; both are now orphaned and can go with the next sweep of those files.

[2026-07-26] The MCP staging tool gnubok_create_invoice (extensions/general/mcp-server/server.ts) now gates line VAT rates on getPermittedVatRates instead of getAvailableVatRates, making it the seventh and last write path to agree with the other six; it is pinned in lib/invoices/tests/vat-rate-gate-parity.test.ts, whose exclusion note for this file is removed. It gates at STAGING time, so gating on the picker default refused a lawful invoice ("VAT rate 12% is not allowed for customer type eu_business. Allowed rates: 0%") before the executor's own already-widened gate in lib/pending-operations/commit.ts was ever reached. Law verified against the swedish-vat reference, not from memory: huvudregeln (ML 6 kap. 34) taxes a B2B service where the buyer is established, but supplies taxed where they are performed carry Swedish VAT even to a foreign business (hotel/restaurang 12%, persontransport and event admission 6%, fastighetstjanst and korttidsuthyrning 25%), so the lawful set spans 0/25/12/6 and no single non-zero rate can be whitelisted instead. Defaults are untouched: an item that omits vat_rate still falls back to getVatRules().rate. Booking is already correct for the widened case (lib/bookkeeping/invoice-entries.ts keeps reverse_charge/export only for 0% lines, so a 12% line on a reverse-charge invoice books 3002 + 2621 into ruta 05/11 while the 0% lines stay 3308/ruta 39). Non-obvious call on the advertised surface: extensions/general/mcp-server/resources/vat-treatments.ts now publishes BOTH sets per customer type, renamed default_rates (the picker default) alongside permitted_rates (the lawful set), plus a swedish_vat_to_foreign_business note naming the exceptions. Publishing only the default told an agent 0% was the only lawful rate and it would never attempt the hotel invoice; publishing only the lawful set would invite 25% on a plain consulting invoice to the same German company. The pair plus the note is the only variant that makes an agent more likely to be correct in both directions. This costs zero of the tools/list payload budget (57475 of 57500 tokens across 129 tools, 25 tokens of headroom, unchanged by this change) because resources are read on demand via resources/read, not serialized into tools/list. Also corrected the two now-false bullets in the customer-onboarding MCP skill, which documented a refusal message the code can no longer produce and told agents to always use 0% for a validated EU business.

[2026-07-26] The Nyckeltal save (app/(dashboard)/kpi/page.tsx) had an empty catch commented "Silently fail: user can retry" over both the !res.ok throw and every thrown fetch, and components/kpi/KPISettingsDialog.tsx called onSave without awaiting and closed immediately: together, a failed save was pixel-identical to a successful one (dialog gone, grid still rendering the draft) until the next page load read the untouched extension_data row back and reverted the layout. The comment was wrong on its own terms: "the user can retry" presumes the user knows there is something to retry. Five non-obvious calls. (1) The decision moved into components/kpi/save-preferences.ts rather than reusing lib/browser/post-action.ts, which is a bodyless POST that deliberately does not parse the success body, whereas this is a PUT carrying the preferences whose echoed row is what the page renders next; the failure union is still ActionFailure, so the page describes a failed save through the same failureDescription() as a failed download. Colocating the module next to the component (the inbox-address-copy.ts precedent) keeps it inside vitest's include glob, which is how a client-side decision gets tested at all in a repo with no component tests. (2) onSave now returns Promise and the dialog closes only on true, so the draft survives a failed save and the parent's saving state finally has a render window; Esc and click-outside are blocked while saving, bounded by the request's own 15s deadline. (3) One destructive toast, never two (TOAST_LIMIT is 1), and a toast rather than an inline AttnLine: the toast viewport is z-[100] over the dialog's z-50, and CreatePeriodDialog already reports a failed create exactly this way while keeping its dialog open. AttnLine in this codebase carries success-path advisories, not failures. (4) A 2xx whose body carries no usable row returns ok with the payload the caller sent, not a failure: the row was written, and claiming "not saved" for a save that landed is the same lie as the silent catch pointing the other way. It also closes a real crash path, since the old code assigned the destructured undefined straight into page state that both the grid and the dialog read .visibleKpis off. (5) The busy state covers the save only; the follow-up report refetch is deliberately not awaited, because it is an unbounded fetch and holding saving across it would lock the dialog on a request that is not the save. NOT fixed, reported instead: the preferences GET at the top of the same page still falls back to defaults silently, so a failed read followed by an open-and-save overwrites the stored layout with defaults, which is the same symptom through a different door and needs a UI decision (block the Anpassa button, or show the read failure) rather than another toast.

[2026-07-26] The ten push-notifications defects found by tests/schema/no-phantom-columns.test.ts were fixed as CODE bugs, not by adding company_id to notification_settings and push_subscriptions: both tables are deliberately user-scoped, and the guard's own root-cause note ("both tables predate multi-tenancy and were left out of the company_id backfill") is wrong. 20260330130000_multi_tenant_company_refactor.sql enumerates 40 tables by hand for its ADD COLUMN, backfill and NOT NULL passes, and that list includes notification_log, the third table created by the very same migration (20240101000008) as the other two: an author who had forgotten push notifications would have dropped notification_log too. 20260415000000_schema_sync.sql then files both under an explicit "User-scoped tables (no company_id, use auth.uid())" heading and gives them DELETE policies on auth.uid() = user_id. The domain agrees: a push endpoint is issued per browser profile per origin (hence endpoint text unique, which is also why the only legal onConflict target is endpoint alone and the shipped user_id,endpoint raised 42P10 on every subscribe call), one human has one set of quiet hours while belonging to several companies, and a company-scoped layer already exists separately as the extension's own extension_data blob behind ctx.settings. Company-scoping would have required one subscription row per (user, company) for the same physical device, which the endpoint unique constraint forbids. So every company_id reference in the extension was the bug, including the DELETE in api-routes.ts that passed a user id into company_id and therefore unsubscribed nothing. The single migration written (20260726174500, NOT APPLIED) adds only notification_settings.missing_underlag_enabled, the sixth per-event toggle whose five siblings 20240101000035 already created and which the code has read and written since the missing_underlag notification type shipped in 20260712090000. Because PostgREST rejects the whole select on one unknown column, that one missing column disabled all six toggles and saveSettings alike: every notification honoured defaults and the settings PUT silently wrote nothing while returning the merged object as if saved. No pg test ships with it, deliberately: adding a column touches no trigger, RPC, RLS policy or DEFERRABLE constraint, and the migration replay in the schema guard already proves both that the column exists and that all ten sites now resolve, which an unrunnable pg test in this session could not. Fixed unasked because it is the same find/replace in the same file: wasNotificationSent() filtered notification_log on company_id while logNotification() writes only user_id, so the duplicate check could never match a row it had itself written and every cron pass re-sent the same notification; that column does exist (nullable, from the refactor's list), so the phantom-column guard was structurally unable to see it. NOT fixed, reported instead: app/api/extensions/push-notifications/cron/route.ts is core code importing from @/extensions/, so this extension's query surface compiles into the core build whether or not the extension is enabled, which is why "the extension is off" was never the containment it looked like.

[2026-07-26] The Stripe settings panel (extensions/general/stripe/components/StripeSettingsPanel.tsx) had try/finally with no catch on "Synka nu" and on Koppla från, so a rejected fetch skipped every toast line and the spinner just started and stopped. Four non-obvious calls. (1) The classification moved into extensions/general/stripe/lib/settings-actions.ts rather than reusing lib/browser/post-action.ts: three of the four calls need a request body or a non-POST method (transaction-sync requires {enabled} or 400s, disconnect is a DELETE carrying connection_id), and the sync arm needs the parsed success body for its counts, all three of which postAction deliberately does not do. The failure union is still ActionFailure, so the panel describes a failed click through the same failureDescription() as the salary payment panels. (2) The sync deadline is STRIPE_SYNC_TIMEOUT_MS = 310s, not the 15s used for the writes, because a first sync backfills 90 days and up to 10000 balance transactions; aborting earlier would report failure for a run that keeps going server-side and advances last_balance_txn_synced_at, so the retry would find nothing. The only honest bound is the route's own maxDuration = 300 plus margin. (3) A non-2xx keeps the route's own Swedish sentence in preference to getErrorMessage's status map: "Inget anslutet Stripe-konto." carries none of the heuristic's trigger words, so routing it through getErrorMessage alone would have downgraded it to "Resursen kunde inte hittas." while fixing a bug about teaching the user nothing. error_en is honoured first for an English UI because capabilityBlockedResponse emits that top-level pair and getErrorMessage only reads message_en inside a structured envelope. (4) Two failures were being delivered inside a 200 and are now reported as failures: a connection Stripe revoked upstream comes back as {revoked: true} with zero counts and was shown as a finished sync that "returned no transactions, check that the right account is connected", and a success body that never parsed became fetched=0 and the same sentence. Also fixed outside the cited handlers: loadStatus swallowed its error into configured=false, which rendered "Stripe-integrationen är inte konfigurerad på den här installationen. Kontakta administratören." on a correctly configured installation; it now has its own load-failed state with a retry.

[2026-07-26] formatCurrency's SEK default (lib/utils.ts) got a RATCHET GUARD, not a signature change: scripts/checks/format-currency-sek-label.mjs, wired as check 6 of npm run check:guards, hard-failing at 0 with no baseline entry. Making the second argument required was rejected outright: .claude/rules/i18n.md fixes formatCurrency(amount) as SEK with sv-SE conventions in BOTH locales because that is a Swedish accounting convention rather than a UI string, and 171 of the 385 call sites are single-argument on values that genuinely ARE kronor (ledger columns, KPI aggregates, salary, tax). The guard therefore judges only the one shape the default cannot defend: a single-argument call whose value is read off an owner the SAME file also reads .currency from (the file demonstrably knows the unit and dropped it), plus any single-argument call on amount_in_currency, which is the foreign figure by definition. Owner paths are matched in full (invoice.currency does not license a verdict on line.amount), *_sek twins and the ledger columns debit_amount/credit_amount are excluded by rule because journal entry line amounts are ALWAYS SEK and line.currency labels the DOCUMENT (structural root 4.1, lib/bookkeeping/ledger-line-amount.ts), and multiplicative expressions are excluded because amount * rate is a conversion INTO kronor. Number()/Math.abs()/unary minus wrappers and both branches of ??/||/?:/+/- are followed, so the total_sek ?? total fallback is caught. Deliberately NOT judged: bare locals like formatCurrency(totalDebit), which would need whole-program dataflow; guessing there is how a guard earns its way onto an ignore list. Re-derived the true current count before baselining: 171 single-argument calls, 96 property-rooted, 5 on a currency-bearing owner, and all 5 format a *_sek twin under a currency !== SEK guard, so the count is 0 and the sweep's latent-not-active reading holds for the call sites. The sweep is nonetheless incomplete on the hazard: lib/transactions/booking-duplicate-detection.ts:291 returns the sibling candidate amount in the TARGET transaction currency while the ledger-voucher branch at :488-496 documents the opposite invariant (always the leg's SEK figure, so the UI never prints a foreign number with kr after it), and components/transactions/DuplicateBookingDialog.tsx:163 prints it single-argument, so a 1 000 EUR duplicate warning reads 1 000,00 kr. No guard of this shape can see that, because the candidate record carries no currency at all: the unit is erased at the producer, one module away from the call site. Reported, not fixed: the fix is a producer change with a decision about the unconvertible case.

[2026-07-26] "Har bolaget anställda" for the agent onboarding review card and the composer now resolves through lib/agent/composer/employee-facts.ts instead of company_settings.employee_count / company_settings.has_employees, neither of which is a column on that table (employee_count is on agi_declarations per 20260414120000; has_employees appears in no migration at all), so PostgREST answered 42703 and rejected BOTH selects whole: the composer's loadCompanySettings returned null for the entire row, silently discarding city, moms_period, fiscal_year_start_month, f_skatt, vat_registered, pays_salaries and accounting_method as well, and the onboarding page lost is_sandbox and the address fields on top. Four non-obvious calls. (1) The replacement ranks a live count of active employees rows ABOVE the TIC employeeRange, contrary to the "TIC wins because it is authoritative Bolagsverket data" precedence that page.tsx states for the other fields: that comment is about TIC versus user-entered settings, and an employee register is neither. The count is what a salary run actually enumerates and is current by construction, where the snapshot is only as fresh as the last TIC fetch. In practice the ordering rarely bites at onboarding (a new company has zero rows and falls through to TIC anyway); it matters when the composer re-runs later. (2) A ZERO count is never read as "nej". Every company arriving at agent onboarding has zero employee rows because nobody has opened the payroll module yet, so treating zero as the negative would answer a question nobody was asked. (3) pays_salaries === false is likewise NOT evidence: the column is NOT NULL DEFAULT false (20260401000000), no onboarding step sets it, and its only writer is the tax settings form, so false is indistinguishable from "never answered". Only employer_registered, which is deliberately NULLABLE so that "never attested" is representable (DECISIONS.md 2026-07-17), settles the question in the negative direction. Reading the default as "nej" would have suppressed the composer's "Har bolaget anställda?" verification question for every company that never opened /settings/tax. (4) Being a registered employer and having employees are not the same predicate, since a registered employer still files nil AGI months with nobody on payroll, so the resolver only ever reports a NUMBER when actual employee rows back it and otherwise reports the employer fact as a plain ja/nej. The employees-table derivation is not new: extensions/general/mcp-server/server.ts:2464 already computes its has_employees as count(employees where is_active) > 0, and this aligns the composer with the pattern that already ships. Behaviour is covered by lib/agent/composer/tests/employee-facts.test.ts (4 of its cases fail against the old derivation) and the column names by tests/schema/no-phantom-columns.test.ts, whose baseline loses the four company_settings.employee_count / has_employees entries.

[2026-07-26] The deadlines page's overdue-invoice attn line (app/(dashboard)/deadlines/page.tsx) filtered invoices on status IN ('sent', 'unpaid'); 'unpaid' is not in invoices_status_check, whose live value set is draft / sent / paid / partially_paid / overdue / cancelled / credited (20240101000001 plus 20260323120001, the only two migrations that touch that constraint). The filter therefore read as ('sent') alone and dropped exactly the invoices the reminder run had already flipped to 'overdue' (lib/invoices/reminder-processor.ts:393), i.e. the ones most certainly overdue, so the count and the SEK total both under-reported with no error anywhere. Fixed to ('sent', 'overdue'). partially_paid was deliberately NOT added, though a part-paid invoice past its due date is genuinely overdue for its remainder: this query sums total_sek / total, not remaining_amount, so including it would swap an under-count for an over-count by reporting money already received as still owed, and the line's own action link goes to /invoices?status=unpaid whose tab filter is exactly ['sent','overdue'] (app/(dashboard)/invoices/page.tsx:171), so the sentence would stop describing the list it leads to. Counting the open remainder is a real reporting question (it would need remaining_amount, the partially_paid status on the invoices tab, and the same call in lib/reports/ar-ledger.ts:88, which also omits partially_paid today) and is left to the owner of that surface. The credit-note and proforma / delivery_note rows that the same query counts but the destination tab excludes are noted for the same owner and not touched here. Also in the same file: all five sibling mutation handlers did throw new Error(result.error || '...') and then getErrorMessage(error), which stringifies the canonical envelope { error: { code, message } } into "[object Object]" and falls through to the generic "Något gick fel", discarding the route's own Swedish reason, its Zod field list and its 403 read-only refusal. They now share describeFailure(response), which hands the parsed body plus statusCode to the mapper, the shape handleUndoComplete already used. Create and edit still rethrow so a failed submit keeps the form open, but they toast first and the thrown message is a developer signal that is never displayed, so the catch cannot re-toast a generic sentence over the specific one (TOAST_LIMIT is 1). No page-level test exists or was invented: this repo has no component tests, and describeFailure cannot be exported from a Next.js page file; the behaviour it depends on is pinned in lib/errors/tests/get-error-message.test.ts and the status literal by tests/schema/no-phantom-columns.test.ts, whose baseline loses the invoices.status = 'unpaid' entry.

[2026-07-26] company_settings.invoice_default_notes was fixed by RESTORING the column via a new migration (20260726181500, ADD COLUMN IF NOT EXISTS, NOT applied to any database in-session), not by dropping it from the InvoiceEditor select, and the guard baseline entry was then removed. The guard's baseline note said "never existed", but git shows the inverse of the orphan case: 20260407120100 added the column, landed on main in #188 (so branching applied it to prod), and #244's migration consolidation then deleted the file without carrying the column into 20260415000000_schema_sync.sql, leaving the REPO behind prod. The feature is whole and live on hosted prod (settings textarea, save payload, UpdateSettingsSchema, CompanySettings type, makeCompanySettings fixture all reference the column at HEAD), so dropping it from the select would have killed a working feature to satisfy the guard. Only databases built from the repo's migrations (self-hosted Docker, pg-real CI, migration-built preview branches) lack the column, and on those PostgREST's 42703 rejects the WHOLE 11-column select whose error the editor discards, so ten real settings silently read as null: hasBankDetails forced false (bank-setup dialog detour before every invoice review plus a standing "bank details missing" bar), accounting_method stuck on 'accrual' (kontantmetoden companies got the accrual review text and accrual periodisering controls; books stayed correct since the server reads settings itself), ore_rounding stuck true (rounding written onto every new invoice against the company setting; display-only but persisted), vat_registered stuck true (non-momsregistrerade companies got the Moms column, 25% line defaults and VAT in totals), dimensions_enabled and invoice_payment_links_enabled stuck off (affordances hidden for companies that opted in), logo_url null (first-invoice logo prompt for companies that have a logo; sent PDFs unaffected, pdf-template reads live), and invoice_default_notes plus default_our_reference never prefilled. IF NOT EXISTS is load-bearing: prod already has the column and a bare ADD COLUMN would 42701-abort the migration batch behind it. No pg test, deliberately: a plain column addition touches no trigger, RPC, RLS policy or DEFERRABLE constraint, and the schema guard's migration replay already proves the select resolves.

[2026-07-26] The push-notifications cron route (app/api/extensions/push-notifications/cron/route.ts) is NOT a core-imports-extensions violation: .github/workflows/core-build.yml's "Check no core imports from extensions" step exempts app/api/extensions/ by design, because Vercel crons and OAuth callbacks need physical file routes the ext/[...path] dispatcher cannot provide. The real defect was that such routes compile into every build while extensions.config.json only edits the runtime registry, so the DISABLED push-notifications extension still exposed a live cron surface. Fixed with a registry gate (loadExtensions() + extensionRegistry.get('push-notifications'), 503 EXTENSION_DISABLED when absent) rather than moving the cron behind the dispatcher, which would break the concrete paths vercel.json crons and generated Docker crontabs address. The pattern is now enforced by scripts/checks/extension-route-guards.mjs in check:guards: cross-extension imports from these routes hard-fail at 0, and the 10 pre-existing ungated routes (cloud-backup, enable-banking, skatteverket, stripe) are a shrink-only allowlist; new physical extension routes must ship the gate. The push cron itself remains unscheduled: it is absent from vercel.json, so nothing invokes it in prod; adding it there is Emil's call.

[2026-07-26] API-gap report triage: five agent-facing gaps were implemented as MCP/v1 surfaces (update_invoice staged tool + v1 items PATCH, widened company-settings tools, invoice-deliveries read tool + service RPC, recurring-schedule tools), but a company_id override on cookie-session routes was REJECTED: the reported blocker ("/api/settings PATCH ignores X-Company-Id") is a wrong-door problem, since both real API surfaces already carry per-request company routing (v1 via the URL path with membership re-check in with-api-v1.ts, MCP via company-routing.ts), and adding an override to 100+ withRouteContext handlers would duplicate the company-authz check per handler instead of keeping it in one wrapper. The Resend deliverability item (report #5) was deliberately NOT built: it is verification work (RESEND_DELIVERY_WEBHOOK_SECRET in Vercel, webhook registration in the Resend dashboard, SPF/DKIM/DMARC alignment for M365) requiring dashboard access only Emil has, and whether "2624" is an invoice number or a mail count is still unconfirmed with the reporter.

[2026-07-26] gnubok_update_company_settings widened to email/phone/website/invoice_email_texts but invoice_email_cc_addresses and invoice_email_bcc_addresses were EXCLUDED: the enforce_invoice_email_recipient_settings_admin trigger (20260723003000) requires owner/admin, so a member-role API key would fail at commit with a raw Postgres exception instead of a clean envelope; exposing them needs tool-level role gating first. Accounting-behaviour and legal-registration flags (defer_invoice_booking, agent_auto_commit_*, default_voucher_series, dimensions_enabled, ai_flow_enabled, vat_registered, moms_period, f_skatt, entity_type, org_number, fiscal_year_start_month) stay out of agent reach by design, and the exclusions are pinned by .strict() schema tests. invoice_email_texts placeholders are validated against INVOICE_EMAIL_PLACEHOLDER_KEYS so agents cannot invent placeholders that render literally in customer mail.

[2026-07-26] gnubok_get_invoice_deliveries required a NEW service-role RPC (list_invoice_delivery_summaries_for_service, migration 20260727100000) rather than reusing list_invoice_delivery_summaries: the existing function rejects when auth.uid() IS NULL and when p_company_id differs from current_active_company_id(), and the MCP server runs on createServiceClientNoCookies() with cross-company routing, so it can never satisfy either check (same trap that broke undo_sie_import). The sibling takes an explicit p_user_id, requires service_role, re-checks company_members, and returns the identical masked shape; the tool never selects invoice_deliveries directly because the masking is the point.

[2026-07-26] Recurring-schedule MCP tools shipped WITHOUT a run-now tool and WITHOUT v1 REST routes, both deliberately deferred: run-now creates and may immediately send a real customer invoice, which should not arrive via a side door; auto_send keeps its schema default of false and must be set explicitly so it appears in the staged approval preview (an auto-sending schedule is recurring outbound customer email that never sees approval again). All four new MCP tools (update_invoice, get_invoice_deliveries, and the three recurring ones minus the read tools where applicable) are catalogVisibility 'search' to stay under the 57500-token payload-size bench ceiling, following the update_customer/update_company_settings precedent, rather than bumping the ceiling.

[2026-07-26] The pending_operations operation-type CHECK gained update_invoice (20260727090000/090001) and create/update_recurring_schedule (20260727110000/110001) in two paired add+validate migrations; because the constraint is re-created wholesale from a full value list, the second pair explicitly carries update_invoice forward, otherwise it would silently revoke the first pair's value at apply time. [2026-07-26] Declined a new check:guards ratchet for the "new Error(body.error)" toast-error class: whether a given site is broken depends on the backing route's error shape (canonical envelope object vs hand-rolled Swedish string), which is a cross-file dynamic property (fetch URL -> route module -> which code path answered) that a static AST guard cannot resolve, especially through the extension catch-all dispatcher; a syntactic ratchet would flag the fine string-route sites the audit explicitly excludes and its count would not track user-visible breakage. Broadened isSwedishUserMessage instead (kan inte/hittades/redan/låst) so real Swedish route sentences survive the correct getErrorMessage(body, { statusCode }) treatment verbatim. [2026-07-26] Fixed cross-user attachment access (Odin Aero support case) at the call sites with service-role clients after company-scoped authorization, instead of rewriting the documents bucket storage policy to be company-scoped like sie-files got in 20260416120000: the documents path layout (documents/{userId}/...) carries no company_id, so a company-scoped policy needs a per-object join against document_attachments on every storage op, and the authorize-then-service-client pattern was already the established model (inline proxy route, v1 download route, MCP tools). Sweep found and fixed the same defect in the metadata/sign route, the integrity probe, verifyIntegrity, invoice-inbox retry-extraction, and cloud-backup archive generation. Known leftover, deliberately unfixed: deleteDocument and the upload-failure cleanups call storage remove() with a user-bound client, which silently no-ops (no DELETE policy, WORM), orphaning storage objects; harmless for compliance, needs a separate decision on whether files should ever be hard-deleted. [2026-07-26] reverseEntry now blocks only source_type='storno', no longer 'correction': BFL 5 kap 5 § requires traceability, not immunity for rättelseverifikat, and blocking corrections left users with no sanctioned exit when a rättelse duplicated an affärshändelse booked elsewhere (support case 2026-07-26); it also broke uncategorize-after-rättelse since transactions are relinked to the correction entry. Storno-of-storno stays blocked (chain ambiguity). [2026-07-26] Supplier-invoice DELETE allows 'approved' (not only 'registered'/'overdue'): the overdue cron flips BOTH registered and approved invoices to 'overdue', so excluding 'approved' would make deletability depend on whether the cron ran yet; the orphan-safety checks (no registration verifikat, no payments, no accrual schedule) are the real guard, and an attested but unbooked, unpaid invoice deletes nothing from the books. [2026-07-26] Hydrating approval cards on resume re-links pending_operations via agent_metadata->>conversation_id rather than persisting the staged_operation stream events: the metadata stamp already exists for the BFL trail, so no migration and no second source of truth for a proposal. Cards render from the operation row, which is also what commit/reject act on, so a hydrated card and a live one cannot disagree. [2026-07-26] Regenerate now rejects the discarded turn's staged operations through the normal reject endpoint instead of leaving them pending: the alternative (silently deleting) would break the append-only audit trail, and leaving them (previous behaviour) produced two live proposals for one booking, each with its own 30-day expiry. [2026-07-27] replace_sie_import and undo_sie_import honor p_user_id only when auth.role() = 'service_role'; every other caller is pinned to auth.uid(): the prior COALESCE(p_user_id, auth.uid()) with EXECUTE granted to authenticated let any member pass an owner's UUID over PostgREST and impersonate them into the owner/admin gate that disarms the immutability triggers (pre-publish review finding; same guard shape as list_invoice_delivery_summaries_for_service). [2026-07-27] bulk_book_transactions refuses homogeneous non-SEK batches (BULK_BOOK_FOREIGN_CURRENCY) instead of writing foreign magnitudes into the always-SEK debit/credit columns: a 100+200 EUR batch previously produced a verifikat whose 300 was read as kronor by balansrakning, moms and SIE export; foreign transactions book individually through the FX-aware flow. The pg test that pinned the old outcome as correct was rewritten to assert the refusal. [2026-07-27] Same-currency foreign settlements (EUR invoice paid by EUR bank tx) now clear 1510 at the invoice booking rate and book the realized diff to 3960/7960, mirroring the cross-currency path; rate-less foreign invoices refuse with MATCH_INVOICE_BOOKING_RATE_MISSING. The old arSek = bankSek shortcut stranded the kursdiff on 1510 and broke reskontra-to-GL tie-out; a branch test had pinned that behavior and was rewritten. [2026-07-27] Balansdagen revaluation of receivables includes status partially_paid and revalues outstanding (total - paid_amount) rather than face value, mirroring the payables side per ARL 4 kap 13 §; the year-end readiness check follows the same status list. [2026-07-27] deleteDocument and the upload-failure cleanups now remove storage objects via the cookieless service client after company-scoped authorization, revisiting the 2026-07-26 "deliberately unfixed" entry: the phase-A company-scoped SELECT policy changed the premise, since an orphaned object is now readable by every company member while its DB row and audit context are gone, so silent retention became a leak rather than a harmless orphan. WORM stays: no DELETE RLS policy exists, and documents linked to journal entries still cannot be deleted. [2026-07-27] The credit-note cap trigger treats a cross-company credited_invoice_id as a rejection rather than not-found-so-uncapped, and no longer prints the original invoice's total or currency in exception text: with a SECURITY DEFINER lookup and no company match those messages were a cross-tenant amount oracle. [2026-07-27] KPI preferences stay company-scoped (one row per company, last writer wins): the read path filters on (company_id, extension_id, key) with no user filter, so the broken upsert arbiter was aligned to the real unique constraint instead of introducing per-user semantics nothing reads. [2026-07-27] Booking-template updates scope-check company templates against the active company and team templates against the active company's team_id, instead of a blind company_id filter which would have broken team-shared template editing (team templates carry company_id NULL). [2026-07-27] Deferred from the pre-publish review, each needs its own decision: arcim OAuth state is not yet bound to the initiating browser session (TTL cut from 60 to 10 minutes as mitigation); customer invoices can still be created rate-less while supplier invoices refuse (the engine now refuses at booking instead); the v1 journal-entries dry-run commit still burns voucher numbers (pre-existing audit P0, route untouched by this branch).

[2026-07-27] Compliance-bot finding "getPermittedVatRates not applied at 6 sites" verified already resolved: all six write gates (commit.ts, mcp-server, bulk-create, recurring-schedule, self-billed, InvoiceEditor via line-vat-rates.ts) gate on the permitted set, pinned by vat-rate-gate-parity.test.ts; no code change needed. [2026-07-27] computeDeduction() base changed to arbetskostnad INKLUSIVE moms (HUSFL 2009:194 6-9 par., skill-confirmed: ROT 30%/RUT 50% of labor incl. VAT; Skatteverkets example 18 000 kr excl = 22 500 incl, ROT 6 750): ItemForDeduction gained vat_rate, all five call sites (build-invoice-write both sites, invoice-entries 1513 legs, propose-send-lines preview, InvoiceEditor both previews) pass the line rate, and the per-line VAT is reproduced with the exact stored-vat_amount rounding so the 1513 debit, deduction_total and the HUS-file BegartBelopp stay ore-consistent; missing/null vat_rate deliberately means 0% (momsfri labor) so unaware callers under- rather than over-deduct. Already-issued invoices keep their stored exkl.-based deduction_amount; correcting those is a separate decision for Emil. [2026-07-27] sie-parser #RAR validation: every #RAR record (prior years included) now validates index/dates/ordering and skips malformed records; the 18-month BFL 3 kap. overrun is warn-and-keep rather than skip, because ensureFiscalPeriod's hard refusal for #RAR 0 needs the real dates to produce its precise Swedish error, and dropping the entry would degrade it to "no fiscal year defined". [2026-07-27] Rewrote .claude/skills/swedish-payroll/references/ob-overtime.md (was a byte-identical copy of sick-pay.md since PR #202): content sourced from the swedish-payroll skill and shift-premium-engine.ts semantics; CBA-dependent figures (OB windows, divisors 600/400/300/150, 94/72) are labeled as examples, never statutory; skills:generate re-run because the atom manifest tracks reference files too (emitted 20260727121001_seed_agent_atom_bodies.sql). [2026-07-27] Momsdeklaration sales pair got a proportional check in ONE direction only (SALES_OUTPUT_VAT_SHORTFALL, WARNING tier): reported base above the output-implied base (ruta10/0.25 + ruta11/0.12 + ruta12/0.06, max(1 kr, 0.5%) tolerance) now warns instead of hiding inside the binary check, closing the 2000-kr-on-400000 green-banner gap from the compliance review; it stays a WARNING and the other direction stays binary because the 2026-07-26 entry's drift sources (VMB/uthyrning rutor 07/08 unmapped, revenue_account overrides, periodisering) all inflate the output side, so they can suppress but never false-positive this direction, while periodisering dissolution months legitimately trigger it and must not block filing. [2026-07-27] SCA alerting restored as a Trivy lockfile job in docker-image-scan.yml, not Dependabot: Dependabot was deliberately removed in #1084, so the daily fs-scan sca job (CRITICAL/HIGH, SARIF category trivy-sca) provides the automated CVE detection ASVS V13.1/SOC2 CC3.2 require without reversing that decision. [2026-07-27] swedish-compliance-review.yml now fails on empty review.md: workflow_run jobs never appear on PR checks, so the 10-run silent outage could recur invisibly; an explicit non-empty-output assertion turns "no signal" into a red run. [2026-07-27] Created docs/security/ (authorization-policy.md, logging-and-observability.md) as the authoritative record for the SIE bulk-delete RPC authorization model and the sink redaction contract: no authorization-matrix file exists in the repo, so the policy doc is the inventory until one is introduced. Declined the swarm's generic "CI check for SECURITY DEFINER grants to anon" ratchet for now: the concrete RPC is pinned by has_function_privilege assertions in sie-import.replace.pg.test.ts; a repo-wide ratchet is a separate piece of work. [2026-07-27] arcim migration FX-unresolved logging routed through lib/logger createLogger instead of console.error: invoice identifiers in log output now pass the observability redaction pipeline; the compliance finding named entity-mapper.ts but the call lived in migration-orchestrator.ts (logFxUnresolved). [2026-07-27] build-invoice-write.ts populates subtotal_sek/vat_amount_sek/total_sek = ore-rounded invoice-currency values for SEK invoices (was NULL): the staged-operations commit path already wrote sekRate=1 twins, so the same invoice row differed by creation path and blanked SEK-reporting readers; foreign-currency invoices with no obtainable rate still store NULL because a made-up rate is worse than an absent one. [2026-07-27] gnubok_set_employee_opening_balances null-carry finding closed as false positive: all eight mergeable columns are NOT NULL DEFAULT per migration 20260713101000, so the stored-value carry can never drop a legitimate NULL; documented at the merge site with a guard note for future nullable columns. [2026-07-27] pending-operations risk flatten ({ ...params, ...changes }) verified safe, no restriction added: only the two recurring-schedule op types are param-sensitive (auto_send), update's strict schema has no top-level auto_send to mask, create's strict allowlist admits no changes bag, and the approve gate consumes the stage-time risk_level so no unflattened re-derivation exists. [2026-07-27] Kept the '********-1234' mask sentinel in UpdateCustomerSchema against the swarm's V4.5 suggestion to strip it in the route pre-validation: the mask shape (asterisks) can never collide with a valid personnummer, CreateCustomerSchema stays strict so the sentinel only exists where a stored value exists to preserve, and moving the strip to the route would split the contract across two files for zero behavioral change. [2026-07-27] Restored 20260726140000 to its preview-recorded content (60b14193) and restated the NULL-safe tenant guard as 20260727130000: the PR #1215 preview branch recorded that version before review hardening edited it, an applied version never re-runs, and a bare rename would have orphaned the preview's version row; CREATE OR REPLACE makes both replay orders converge on identical prosrc. [2026-07-27] Voucher-link guard conflict, final resolution across the two parallel sessions: 20260726140000 is kept byte-identical to HEAD (NULL-safe caller_is_company_member form) instead of being reverted in place to the raw NOT-IN shape, because the guard edit landed pre-publish (60b14193 at 02:08, PR #1215 opened 02:14) so the preview most plausibly recorded the NULL-safe content, and reintroducing the ratchet-banned raw shape into a migration file was the costlier wrong-guess; 20260727130000 stays, restating the same final bodies via CREATE OR REPLACE, which converges preview, prod-at-merge and CI replays under either hypothesis about what the preview actually recorded. [2026-07-27] Sonnet 5 no-thinking output ceiling kept separate (MAX_TOKENS_NO_THINKING 5400): max_tokens now caps thinking and reply together, so one shared ceiling would have quadrupled what a non-thinking intent may emit; 5400 is the old 4096 scaled for the new tokenizer. [2026-07-27] Assistant panel docks via a --agent-dock-w CSS custom property rather than restructuring the frame: the dashboard layout is a server component, so a client-owned class on

would have meant lifting it to a client boundary for a margin. [2026-07-27] Agent status is a reducer in a React-free module with an unused 'detached' state: durable background runs must publish to the same channel later (plan seam 8.6), and this repo's unit project is node-only so the state machine has to be testable without a component harness. [2026-07-27] Compliance review script raises max_tokens to 16000 but does NOT send an explicit thinking parameter: on Sonnet 5 an omitted parameter already means adaptive, so sending it would add request surface on the pinned legacy Bedrock SDK 0.29.1 for zero behavioral change, and the workflow_run trigger checks out the base repo so no change to this script is testable before merge. [2026-07-27] Core-build PR jobs restore the npm cache but never save it, with the save half in a separate main-only workflow (ci-cache.yml): a cache written from a pull_request run is scoped to that PR's ref and unreadable by anything else, which is how fourteen dead 284 MB copies once ate 40% of the repo's cache quota; only default-branch entries are restorable from every PR. [2026-07-27] Deliberately did NOT cache .next/cache alongside the npm cache: keeping it useful needs a fresh entry per main push, and at a few hundred MB each that recreates the same quota churn, so the ~60s it would save off the build stage is deferred to its own change. [2026-07-27] zizmor installed from PyPI at a pinned version rather than via zizmorcore/zizmor-action: a workflow whose job is auditing the supply chain should not widen it by adding another third-party action. [2026-07-27] zizmor gates only on high-severity high-confidence findings while reporting everything to the Security tab: the first run surfaced 56 findings, and a scanner that blocks every merge on day one gets disabled rather than triaged. [2026-07-27] dangerous-triggers suppressed for swedish-compliance-review.yml and docker-image-scan.yml in .github/zizmor.yml rather than left unresolved: both are workflow_run, but the first is the base-repo-only pattern GitHub recommends INSTEAD of pull_request_target and the second checks out no source at all, so leaving two permanent unexplained errors in the Security tab would just train reviewers to ignore it. [2026-07-27] Docker layer cache tag is now per-architecture (buildcache-amd64 / buildcache-arm64): with native runners each job builds one platform, so a shared tag would leave the two racing to overwrite a cache manifest describing layers the other cannot use. [2026-07-27] Assistant screen-reader announcement fires on turn boundaries, not on the streaming text: a live region over token deltas re-announces on every delta, so the finished answer is announced once (capped at 400 chars) instead of the stream being narrated. [2026-07-27] Did NOT self-host the dicebear avatars in the PR7 polish pass despite it being on the plan: the Notionists set is third-party artwork with its own licence terms, and vendoring it into an AGPL-3.0 repo is a licence decision for the founder, not a polish item. [2026-07-27] SIE export keeps escaping ONLY quotes, against CodeQL js/incomplete-sanitization: SIE 4B defines the backslash purely as a marker before a quotation mark, defines no \ sequence, and excludes that marker from the #KSUMMA control total, so doubling backslashes would invent a sequence the format lacks, land as a literal double backslash in conformant readers (Fortnox/Visma/BL) and skew the checksum, in a file kept under BFL 7-year retention. [2026-07-27] hashApiKey stays SHA-256 against CodeQL js/insufficient-password-hash: the input is 32 CSPRNG bytes, not a user-chosen password, so no KDF work factor is meaningful against 256 bits; the hash is also the primary-key lookup on every MCP request, and changing it would invalidate every live gnubok_sk_ key since the hash IS the stored credential. [2026-07-27] mcp-oauth consent form action is HTML-escaped even though the CodeQL js/reflected-xss finding is not exploitable (WHATWG URL parsing already percent-encodes " < > in the query component): & is not in that encode set so the attribute was emitting invalid raw ampersands, and resting the page on an unstated parser-normalisation invariant is one refactor away from being wrong. [2026-07-27] Compliance-review artifact unpacks to runner.temp instead of the workspace root: extracting fork-influenced content over the trusted checkout, with AWS secrets in scope, was safe only because stage 1 happens to write fixed filenames; moving it makes overwrite unreachable by construction. [2026-07-27] Supplier-invoice 'overdue' stays a stored status, made symmetric instead of derived (#1206): added approved_at as the durable attest marker and an un-flip branch in update_overdue_supplier_invoices(), rather than computing overdue at read time. Computing it would have touched every list/filter/report query that reads status plus the v1 API contract; the symmetric-cron fix is the same user-visible outcome at a fraction of the blast radius. [2026-07-27] In-browser preview (#1190) opens a new tab against an inline-disposition URL instead of an in-app viewer surface for invoice PDFs: the browser's native PDF viewer already does the job, and reusing resolveInvoicePdfSource keeps the archived-vs-rerender distinction intact, which a separate preview path would have had to duplicate. [2026-07-27] Routed PostHog through a same-origin rewrite (/rl -> eu.i.posthog.com, next.config.ts) instead of allowlisting .posthog.com in the CSP, which is what PostHog's own docs suggest. Same-origin means connect-src 'self' and script-src 'self' already cover ingestion and the lazy-loaded replay/survey bundles, so replacing Recapt removed two CSP hosts and added none; it also leaves tracking blockers no third-party host to match. Cost is one global setting, skipTrailingSlashRedirect: true (PostHog sends trailing-slash API requests), verified not to break trailing-slash URLs on normal routes: /login/ still resolves 200, it just no longer 308s to /login. [2026-07-27] /rl is excluded from the proxy.ts middleware matcher. Next.js runs middleware BEFORE next.config rewrites, so without the exclusion updateSession() treats a PostHog ingestion POST as an unknown protected path and 307s it to /login. Verified with a control on a production build: /zz/flags/ -> 307 /login, /rl/flags/ -> 200 from PostHog. This is the failure mode worth remembering because it is silent: asset loads and flags keep working through the rewrite while no events arrive, so the integration looks healthy. Any future change to the proxy prefix must touch next.config.ts, proxy.ts and instrumentation-client.ts together. [2026-07-27] Analytics runs cookieless (persistence: 'memory') rather than shipping a consent banner. Nothing is written to the device, so no ePrivacy consent is required, and everything post-login stays accurate because AnalyticsIdentify re-identifies on every dashboard load. Accepted cost: anonymous identity does not survive a hard reload, so logged-out funnel stitching (/login -> /register) and cross-reload replay continuity are lost. Escape hatch if that bites is persistence: 'sessionStorage', which is device storage and puts consent back on the table. [2026-07-27] PostHog surveys write seenSurvey_ straight to localStorage with a direct setItem that bypasses the persistence config (verified in the shipped survey bundle). Kept anyway: without it a dismissed survey would re-prompt on every page load under memory persistence. Position recorded deliberately so it is not re-litigated in an audit: a flag whose only purpose is "do not show this person this survey again" is functional UI state, not tracking, in the same category as a dismissed-banner flag. It carries no identity, and resetAnalyticsIdentity() deliberately does NOT clear it (clearing would re-prompt the next person on a shared device). [2026-07-27] Session replay masks ALL text (maskTextSelector ''), not just inputs. PostHog masks inputs by default but records on-screen text in the clear, and this app renders org numbers, customer names, balances and invoice amounts as ordinary text; for an enskild firma the organisationsnummer IS the owner's personnummer. Replays therefore show layout, clicks and where a user stalls, never what their books say. buildGroupProperties() additionally refuses to send org_number at all, with a unit test pinning it. [2026-07-27] PostHog error tracking registers as an adapter on the existing lib/observability sink (lib/analytics/posthog-observability.ts, wired in lib/init.ts) rather than capturing directly. That way every error-level createLogger() line is captured already redacted by lib/observability/redact.ts, which is far broader coverage than instrumentation.ts onRequestError alone (that only sees what escapes uncaught, and is kept as a complement). The sink stays a no-op when analytics is off, so core, CI and self-hosted builds still run with zero third-party runtime code. [2026-07-27] Analytics is hosted-only and explicitly so: isAnalyticsEnabled() short-circuits on NEXT_PUBLIC_SELF_HOSTED and no NEXT_PUBLIC_POSTHOG*_ sentinel was added to Dockerfile/docker-entrypoint.sh. Recapt reached the same outcome only by accident (its env var was simply missing from the sentinel list), which meant a self-hosted operator could never have configured it and nobody had decided that on purpose. An AGPL operator's users should not be reported to our project. [2026-07-27] Removing Recapt made lib/support/submit-feedback.ts single-channel: a failing /api/support/contact now surfaces as a real error instead of being masked by Recapt reporting success on its own channel. That is the correct behaviour (silently "succeeding" while the message reached nobody was worse) and the Resend path is solid. A non-blocking posthog.capture('support_feedback_submitted') replaces the useful half of the Recapt channel by putting the submission on the user's timeline next to the session replay; it deliberately carries no message body, since free text is user content and would be PII in an event property. [2026-07-27] vitest.config.ts now aliases 'server-only' to tests/stubs/server-only.ts. Its real entry point throws unconditionally (Next.js swaps it out at bundle time; Vitest cannot), so the moment a server-only module entered the test graph it broke 48 test files at import. app/(dashboard)/request-context.ts was already carrying the same latent trap and had simply never been imported by a test. [2026-07-27] Self-hosted the dicebear Notionists avatars under public/agent-avatars instead of loading api.dicebear.com per render: the licence turned out to be CC0 1.0 (verified on dicebear.com/licenses AND in each file's own RDF metadata), so there was no licence decision to escalate, and the CDN was sending every authenticated page view's IP and referer to a third party while breaking firewalled/self-hosted installs entirely. [2026-07-27] Chat thumbs emit the existing agent.feedback event with actorType 'user' rather than a new table or event type: the product team already queries event_log for that type from the MCP tool, so a second store would be a second place someone has to remember to read, and event_log's jsonb payload needs no migration. [2026-07-27] A cast vote does not toggle off: it emits append-only telemetry and there is no un-emitting one, so an undo control would be a button that lies. Changing your mind sends the opposite sentiment, which the backlog can actually see. [2026-07-27] Dropped the free-text comment field from /api/agent/feedback instead of documenting a retention policy for it (compliance-swarm V14.1): no caller ever sent it, and an unused free-text parameter in an accounting product is a PII sink into event_log's telemetry retention (client names, personnummer). A comment box needs its own classification and redaction decision made with the UI in front of it, not as dead API surface. [2026-07-27] PostHog Support enabled with the Direct API (posthog.conversations) and the in-app WIDGET deliberately off. The widget is a third-party floating chat bubble: that is exactly what Recapt was, it would sit next to the existing Assistenten FAB (which already has a hide_assistant_fab preference because users wanted it gone), it cannot follow the locked editorial-monochrome design system, and its copy is not ours to keep Swedish. The conversations API gives the same tickets from our own on-design dialog (components/ui/support-link.tsx, already reachable from 8 surfaces). Email channel also on, so the support mailbox becomes tickets with two-way replies. [2026-07-27] A PostHog support ticket is NOT treated as delivery: submitFeedback returns ok only when the Resend email actually went out, even though the ticket may have opened. Recapt's exact failure mode was reporting success on its own channel while /api/support/contact was dead, and nobody is watching PostHog at 02:00. The ticket is a complement that links the message to the session replay; email remains the guarantee. [2026-07-27] Identity verification via posthog.setIdentity(distinctId, hash) at runtime rather than the identity_distinct_id/identity_hash init options PostHog's settings page documents. init runs from instrumentation-client.ts app-wide, before the user is known and including logged-out pages, and PostHog fixes init values for the session; setIdentity is a method on the SDK (verified typed in posthog-js 1.407.3) so the hash can be applied from AnalyticsIdentify once the dashboard layout knows who the user is. Without it tickets are browser-scoped and recovered by email. [2026-07-27] POSTHOG_SECRET_API_KEY is server-only (no NEXT_PUBLIC_ prefix) and the HMAC is computed in the dashboard layout; only the derived per-user hash reaches the browser. The key signs identity hashes AND authenticates external API requests, so it is a real credential unlike the phc_ project token, which is public by design and embedded in the client bundle. [2026-07-27] Support ticket free-text is declared as its own data category (user.content.support) in .compliance/ropa.yaml and named on the privacy page, separate from analytics. Analytics events deliberately carry no message body (the breadcrumb sends only the subject), but a ticket carries what the user wrote, because that is the point of writing to support. Keeping the two purposes distinct is what stops the privacy page drifting out of date the way the Recapt row did. [2026-07-27] Removed the granular "BFNAR 2013:2 punkt 9.x" citations from public/docs/systemdokumentation-mall.md instead of renumbering them to kap 8: the swedish-accounting-compliance skill and our own route comments both place systemdokumentation in kap 8, the template asserted kapitel 9 with a full point-by-point mapping (9.1, 9.2, 9.3-9.5, 9.6-9.8, 9.9, 9.16), and neither could be verified against BFN's actual text from here. A downloadable statutory template is the wrong place to guess a legal citation, and a wrong precise cite is worse than a correct general one, so the doc now cites BFL 5 kap. 11 § and BFNAR 2013:2 without pinning points. Restore the points once someone confirms the chapter against the standard. [2026-07-27] Abonnemang masthead uses the halftone Stockholm skyline, not key-adding-machine and no new asset: the wide engraving crops cleanly into a banner strip (waterline pinned to the bottom edge, same physics as the onboarding backdrop) while the machine vignette read noisy and lopsided at banner crop; screenshots of both compared before choosing. [2026-07-27] The requested Claude/OpenAI logos went into the API tab's "Anslut MCP-klient" group as a quiet works-with strip, using the marketing site's monochrome halftone marks instead of the raw colored brand tiles: full-color trademark tiles would fight the editorial monochrome chrome, and the halftone versions already exist as shared brand assets. [2026-07-27] Dropbox added as a second cloud-backup target behind a CloudStorageProvider interface rather than by copying the Drive sync: the archive-building half of performSync (fingerprints, per-year layout, size fallback, progressive persistence) is where the compliance-relevant behaviour lives, and a fork of it would drift silently. [2026-07-27] Dropbox uses App folder access, not full Dropbox: it matches the drive.file scope's "only what the app created" story, which is the privacy claim the backup card makes, and it is what Dropbox production approval expects for a backup app. [2026-07-27] Each provider keeps its own extension_data keys (google_drive_* / dropbox_) and its own schedule, failure counter and alert throttle: Emil chose independent per-provider schedules, and it means a dead Dropbox token cannot pause a healthy Drive backup. [2026-07-27] The Google storage keys and the /oauth/callback path were left untouched: they are wire format (already-connected companies' records, plus a redirect URI registered with Google), so Dropbox got additive keys and its own /oauth/dropbox/callback. [2026-07-27] isConfigured() gates /connect only, not /disconnect, /schedule or /sync: a deployment losing its OAuth credentials must not trap users with a connection they cannot remove or a schedule they cannot switch off. [2026-07-27] Dropbox web links point at /home/Apps unless DROPBOX_APP_FOLDER_NAME is set: app-folder scoped calls cannot discover where the app folder sits in the user's account, and a link into the wrong folder reads as a lost backup, so the app-name deep link is opt-in rather than guessed. [2026-07-27] performSync keeps provider optional, defaulting to Google Drive: it preserves the pre-Dropbox call shape (and its test suite) as the documented legacy path while all three production call sites pass a provider explicitly. [2026-07-27] Privacy page corrected after verifying prod: PostHog's conversations module writes ph_conv_ = {"widgetSessionId": uuid} to localStorage at PAGE LOAD, not when support is contacted, bypassing persistence: 'memory' exactly like surveys' seenSurvey_ flags. The page previously claimed enkäter were the only local storage, which was wrong the moment Support was enabled. Both values are functional UI state without personuppgifter (do-not-ask-again, and find-my-own-ticket), so the no-consent-banner position holds, but "inga kakor eller annan lagring" did not and is now stated accurately. Worth remembering that enabling any new PostHog product can silently add device storage: check localStorage in prod after each one rather than trusting the persistence setting. [2026-07-27] Documented the two client-side storage keys as having DIFFERENT lifecycles, after the compliance swarm flagged ISO 27001 A.8.10 (no deletion mechanism) on the storage inventory. ph_conv_ IS deleted on logout: posthog.reset() calls the conversations manager's own reset, which removes that single known key, and resetAnalyticsIdentity() already runs in both logout handlers. That is what stops a shared device carrying one user's support-ticket session into the next user's session. seenSurvey_ genuinely has no deletion trigger, and cannot have one from the SDK: no PostHog bundle enumerates localStorage (zero occurrences of localStorage.key( or Object.keys(localStorage) across module.js, surveys.js, conversations.js), so nothing can discover the keys to remove them. Accepted as the retention position rather than an oversight: the value is "true" under an opaque survey id with no personal data, and clearing it on logout would re-prompt every survey to the next person on the device and produce false survey responses. [2026-07-27] Fixed the "Underlag saknas on a verifikat that shows the invoice" contradiction by ANCHORING the floating document, not by loosening the missing-underlag predicate. The predicate's anchoring requirement (migration 20260724090000) is legally right: block_document_deletion keys on journal_entry_id, so an unanchored doc is deletable and must not silence the warning. The bug was that nothing ever anchored it, and delete_last_voucher actively un-anchors (it must: the FK is ON DELETE RESTRICT). So the fix re-anchors after a voucher delete, at every supplier-invoice payment path (dashboard + v1 mark-paid, dashboard + v1 match-supplier-invoice, which between them had zero, partial and cash-only coverage), and backfills the 5 prod rows. getJournalEntryUnderlagReferences now also withholds an unanchored document, so the verifikat view and the list can no longer state opposite things about the same row. Rejected the alternative (extend the WORM deletion guard to protect docs referenced by a supplier invoice, then drop the anchoring requirement everywhere): it touches an enforcement trigger for a strictly larger blast radius and leaves the document outside the guard until the trigger ships. [2026-07-27] Claude Code plugin homepage points at the GitHub README, not /docs/api/connect-claude: next.config.ts redirects /docs/api/ to the separate docs.gnubok.se repo, where the connect-claude page was never ported, so the documented URL 404s and the in-repo page is dead code. Submission to the Claude plugin directory cannot wait on a cross-repo docs port. [2026-07-27] Applied to anthropics/claude-plugins-community, not claude-plugins-official: the official marketplace is curated by Anthropic at its own discretion with no application process, and the submission form explicitly does not feed it. Community listing is the only route we control. [2026-07-27] support_feedback_submitted now reports BOTH channels (email + ticket) with a derived lost flag, not just email delivery. Both channels fail silently from the user's side: email is the guarantee so the UI still shows success when only the ticket failed, and a ticket that never opened leaves nothing in PostHog Support to look at either. Answering "did the ticket open?" previously required reproducing it with devtools open, which is exactly what happened the first time Support shipped. ticket: 'unavailable' is kept distinct from 'failed' because unavailable is the expected steady state (Support off, analytics off) while failed means conversations were live and the call still did not land; only the second is worth alerting on. lost (neither channel worked) is the single property to alert on. Still carries no message body, pinned by a test. [2026-07-27] support_feedback_submitted reports both channels (email + ticket) with a derived lost flag, and the ticket call runs concurrently with a 4s cap instead of being awaited after the email: both channels fail silently from the user's side, so "did the ticket open?" was previously only answerable by reproducing the submission with devtools open, and awaiting the ticket sequentially let a hung sendMessage hold the confirmation dialog open despite the code comment claiming it could not. ticket: 'unavailable' stays distinct from 'failed' and 'timeout' because unavailable is the expected steady state (Support off, analytics off, self-hosted) while the other two mean conversations were live and the call still did not land; only those deserve an alert. lost (neither channel worked) is the single property to alert on. Still carries no message body, pinned by a test. [2026-07-27] Supplier-invoice edits (#1230): block invoice_date / supplier_invoice_number once registration_journal_entry_id is set, rather than propagating the change into the verifikat via correct_entry_metadata: the friendlier propagate option turns a metadata PUT into a bookkeeping write (voucher rättelse, rattelse-log, period-lock checks) and needs a deliberate product call; blocking is the minimal legally correct behaviour and leaves due_date/payment_reference/notes editable for the aged-invoice flow (#1206). [2026-07-27] SKV 403 classification (#1155 item 1): the MuleSoft APIGW body "The required scopes are not authorized" is checked BEFORE the token-scope patterns and maps to ACCESS_DENIED (user mode) / SYSTEM_AUTH_FAILED with a subscription message (system mode). Substring matching on 'required scope' collided with it and produced MISSING_SCOPE, which is a RECONSENT code, so every reconnect re-flagged the token row. Token-scope detection is now a positive match on invalid_scope or SKV's documented "required scope has been requested" sentence, not a loose substring. [2026-07-27] Docs export (#1247): scripts/export-docs-to-website.mts stubs server-only via a Module._load hook instead of untangling the import chain. The chain is real (lib/api/v1/load-routes -> every v1 route -> lib/init -> posthog-observability -> posthog-server) and the script only reads exported markdown builders, so breaking the chain would mean restructuring route imports for a build-time script's benefit. [2026-07-27] Webhook delivery latency (#1201): implemented option (a), an emit-triggered kick of the existing dispatchDueDeliveries, not option (b), an authenticated SSE stream over event_log. The kick is a new lib/webhooks/dispatch-kick.ts wired into fanOutToWebhooks plus the two routes that enqueue a delivery directly (the :test verb and the manual delivery retry), and it does NOT close #1201: the issue asks for a realtime stream for API consumers and that remains open. Three constraints shaped it. It is never awaited: eventBus.emit is awaited at ~99 call sites including journal_entry.committed, and each delivery can burn a 10 s receiver timeout, so awaiting would put a stranger's HTTP endpoint on the critical path of committing a verifikat. It is coalesced per function instance, because a bulk operation emits once per row and would otherwise schedule one claim round trip per row. Its batch size is 5 rather than the cron's 50, because this work runs on the tail of a user-facing request. The SKIP LOCKED claim keeps a kick and the cron from claiming the same row at the same moment, but that is a claim-time guarantee only: the RPC autocommits before any POST, so a later cycle's recoverStuckInFlight sweep can re-arm a row still queued behind an earlier serial loop. Delivery stays at-least-once as the public docs already promise, and the kick's batch of 5 opens a narrower window than the cron's existing batch of 50 against the same 20 s stuck threshold; an early draft of this change claimed double delivery was impossible, which was wrong and is now corrected in the code comments. Scheduling uses next/server after() with a deferred-microtask fallback outside a request scope, mirroring the enable-banking callback; the fallback must stay deferred rather than inline, or the coalescing flag clears before the next kick in the same tick can see it. No API_V1_VERSION bump: no new event types and no payload change, only latency. [2026-07-27] Bolagsskatt add-back (#1051): sumPostedYearEndDispositions now adds back 78xx planenlig avskrivning alongside 88xx and 7533, and excludes fiscal_periods.closing_entry_id from its fetch. Shipping only this "Stage 1" half of the issue: it corrects the tax base and the periodiseringsfond 25 % cap with no migration and no displayed-figure change. The issue's other half (making /rapporter show bokslut entries by moving generateIncomeStatement to excludeFinalClosingEntry) is deliberately NOT done here: it duplicates the exclusion in the kpi_report_aggregates RPC (so it needs a migration plus a pg test), it changes displayed profit for every company that ran the bokslut flow, and it requires removing the add-back at four call sites, including the one that caused the original too-high-tax customer bug. The closing-entry exclusion is part of Stage 1 rather than a follow-up because closing verifikat do carry 78xx/88xx/7533 reversal lines on production, so without it the new add-back silently cancels itself once the year is closed. The issue's stated constraint that source_type='year_end' is load-bearing for the iXBRL RR/BR split is stale: build-input.ts and arsredovisning/build-data.ts already moved to excludeFinalClosingEntry. [2026-07-27] Documents bucket WORM (#1208): dropped the production-only users_delete_own_documents DELETE policy on storage.objects and pinned the invariant with a name-agnostic pg-real test, rather than adding a storage DELETE policy for authenticated users or building the orphan-cleanup script the issue asks for. The policy existed in no migration (dashboard drift, alongside users_read_own_documents / users_upload_own_documents, which production has INSTEAD of this repo's documents_select_own / documents_insert_own) and let any user delete, with a normal browser token, the storage bytes of documents linked to posted verifikat: rakenskapsinformation under BFL 7 kap 2 §. Neither deleteDocument()'s linked-check nor block_document_deletion() reaches that far; both protect the row, and the row survives pointing at nothing. Verified reproducible against a local replay of the full migration stream: with the policy present the uploader's own DELETE removes a legacy-layout object, with it dropped the DELETE matches zero rows. Dropping it breaks nothing because every in-app remove() on this bucket has run service-role since #1215. The two legacy read/insert policies are deliberately left alone: the Phase B backfill from 20260726092000 has not run, so dropping the legacy SELECT would make most existing documents unreadable. That is Phase C. The 326 orphan objects (49.5 MB) the issue also describes are NOT cleaned up here: irreversible deletion against 7-year-retention data is not worth 49 MB without a separate report-only pass. The test asserts no DELETE and no UPDATE policy over the bucket under ANY name, because the hole arrived under a name this repo never used. [2026-07-27] Foreign 0 % supplier invoices (#1042): shipped as an advisory banner keyed on supplier_type + reverse_charge, NOT as the "confirm the reason for 0 % VAT (exempt vs import vs other)" picker the issue asks for. Two reasons. First, supplier_invoices.vat_treatment is pure metadata: createSupplierInvoiceRegistrationEntry branches on reverse_charge + supplier_type + per-line vat_rate and never reads it, and get_vat_declaration_totals maps rutor from journal account numbers alone, so a stored reason would change no accounting output. Second, an "import" option would be false confidence: nothing in the codebase can book import VAT (no generator emits 2615/2625/2635 or the 4545-4547 basis accounts), so offering it would imply ruta 50/60 were handled when they are not. The real defect underneath the issue is narrower and is what this fixes: a foreign supplier at 0 % with reverse charge left off books no 26x4 leg and no 44xx/45xx basis, emptying ruta 20-24/30-32/48. The check is deliberately silent for swedish_business, where 0 % is a genuine exemption belonging in no ruta, and never blocks submission, because a non-EU goods purchase cleared at customs is legitimately 0 % without reverse charge and forcing the switch there would book a wrong verifikat. Import VAT support stays a separate, larger issue.

[2026-07-28] /submit-pr removed at Emil's request; /resolve-pr is now the single PR skill and absorbed submit-pr's full contents (publish stage included, skipped when a PR already exists). Folding rather than plain deletion was necessary because /fix lane mode and /resolve-pr both called into it, so deleting alone would have left two skills pointing at nothing and lanes unable to push. All rules survive verbatim, including the one-fix-commit discipline and the bots-edit-comments-in-place trap. Backup of the old file kept in the session scratchpad only.

[2026-07-28] /orchestrate deleted the same day it was built; parallel work is now one /fix per terminal, each isolated by EnterWorktree. Two things killed the coordinator: a Claude instance cannot observe another instance's terminal, so it could only ever track lanes via a file the lanes themselves wrote (making it a reader, not a controller), and Emil says "it works" in the lane's own terminal, so the coordinator was never in the core loop. EnterWorktree then removed its last real job. The merge lock, shared board and file-scope collision holds went with it: /fix now stops at the commit and suggests /resolve-pr, so Emil approves each merge by hand and serialises them himself. Residual hazard (another session merged while this branch was tested) is handled where it already was, in /resolve-pr Stage 0: fetch, merge origin/main, migration-collision check, re-verify, and void the certification if the merge touched the feature's own files. Trade accepted knowingly: this drops the full-autonomy-including-merge grant from earlier the same day, in exchange for a much leaner /fix. Branch protection on main is OFF; enable it when a second human joins.

[2026-07-28] /fix reverted to its 2026-07-07 shape and trimmed further: issue -> understand -> plan card gate -> implement -> self-verify -> test card -> "it works" -> commit, and it ends there. Same-day additions removed: the Stage 0 worktree/bun-install/port-probing block, the git merge origin/main sync step, the tone-of-voice reply draft, and the closing /resolve-pr suggestion; the CLAUDE.md "Worktrees" section went with them, since it re-injected EnterWorktree into every /fix run. Emil's call, verbatim: an end-to-end skill that takes an issue, runs the steps, and commits when he says it works; nothing else. This reverses the /orchestrate post-mortem entry above, which assumed worktree isolation would stay. Parallel /fix sessions now share whatever branch their terminal is on, which is the cost accepted for the leaner skill; reply drafts move to a separate /tone-of-voice invocation.

[2026-07-28] Issue #1258 (supplier invoice line description): chose Option A, keep the fixed invoice-level verifikat text and fix only the preview, over Option B, propagating each item's typed description to its journal line. Deciding fact: the customer-invoice side already writes invoice-level descriptions too ("Forsaljning faktura {tag}", "Kreditfaktura {tag}" in lib/bookkeeping/invoice-entries.ts), with the only per-line variation being a voucher cross-reference suffix. Invoice-level text is therefore a system-wide convention, not a supplier-side oversight, so B would have created an inconsistency between the two invoice sides rather than removing one, while landing in the journal engine and needing an aggregation-collision policy for items that share an (account, dimensions) bucket. Accepted cost, stated plainly: the per-item description the user types stays UI-only and never reaches the books. Revisit if users ask for per-line ledger detail; the collision policy is the only hard part.

[2026-07-28] Preview honesty over prettier labels in the supplier-invoice voucher preview: the BESKRIVNING column now renders the exact line_description the engine will post, and the hardcoded ACCOUNT_LABELS map (11 accounts) was removed. That map made the column silently mix "friendly account label" (for its 11 entries) with "raw account number" (every expense account, the reported bug), and neither was the posted text. Account identity was not lost: AccountNumber already shows the BAS name on its hover card. The ankomstnummer suffix the engine appends is absent from the preview because it is assigned on save and does not exist yet at preview time.

[2026-07-28] The "senaste bokförda verifikat" line in the balans-/resultatrapport header (#1267) reads MAX(voucher_number) over posted entries, never voucher_sequences.last_number. The sequence counter is an allocation high-water mark that provably drifts from the books in both directions: next_voucher_number burns a number when the follow-up insert fails (the reversal path in engine.ts does exactly that), delete_last_voucher decrements blindly by one instead of resetting to the new MAX, and pre-RPC SIE imports left it behind MAX. Since the whole point of the line is avstämning, printing an allocated number would send a reconciler chasing a gap that does not exist, so the label states plainly that the number is the last posted one. Scoped to the report's own date range rather than the fiscal year, so a Q1 report printed in November says something true about Q1; the balansrapport keeps the fiscal-year start as its lower bound because it accumulates. Skipped entirely on a dimension-filtered resultatrapport: that report already discloses it is partial, and an unfiltered voucher range beside a filtered result invites the wrong conclusion. No new i18n keys: both report views and the PDF template are hard-coded Swedish, per the "stays Swedish" report surfaces in .claude/rules/i18n.md, so the issue's acceptance criterion asking for sv+en strings does not apply here.

[2026-07-29] FX revaluation population fixed as-of-balansdagen + per-row booked gate: the revaluation queried LIVE open invoices with no date scoping, so rows issued after balansdagen (or never booked) were revalued into the closed year, fabricating 1510/2440 write-downs. Chose reconstruction via the existing reskontra-payments helpers over snapshotting live status. First attempt gated on accounting_method (cash -> skip the company entirely); adversarial review overturned that: BFL 5 kap 2 § 3 st requires kontantmetoden companies to book outstanding fordringar/skulder at balansdagen, so a company-level skip would leave real 1510 exposure unvalued. The predicate is therefore per row (is this invoice booked?) not per company. The invoice_date ceiling is unconditional, not historical-only, because post-dated invoices make the bug reachable for a current period too. The year-end readiness WARNING is deliberately left ungated: an unbooked FX row still has a remedy (/book posts at invoice_date, into the year being closed) that lockPeriod/closePeriod then destroys forever. fetchFxExposureScope never throws, preserving previewCurrencyRevaluation's read-only render-always contract. ROT/RUT 1513 overstatement and splitting the close into two staged approval runs deliberately left out (own fixes).

[2026-07-29] Parallel issue work runs in three permanent lane worktrees (erp-lanes/lane-1..3, dev ports 3001-3003) with one GitHub issue per branch per PR, rather than per-issue worktree provisioning inside /fix (tried and removed 2026-07-28 as too heavy) or several issues stacked on one shared branch (which corrupted commits across sessions the same day: swept files, a 33-file amend, HEAD moving mid-task). Lanes are one-time infrastructure so no install/port cost is paid per issue, /fix stays issue -> steps -> 'it works' -> commit and only gains a branch step, and small single-issue PRs keep each CI cycle and each origin/main re-sync short. npm ci only in lanes: bun-isolated trees rendered a wrong settings UI on 2026-07-28 and pnpm ignores the npm overrides.

[2026-07-29] Semesteravsattning under sammaloneregeln: the tillagg is now valued on the MONTHLY salary per vacation day (Semesterlagen 16a §) and accrues one month's earned share (dagar/12) per run, replacing dagslon x rate x hela arets dagar in both calculation-engine call sites. Two errors that partly cancelled, which is why the monthly figure looked plausible. Decisive evidence that the old base was wrong: absence-calculator.ts already paid the tillagg out of 2920 on the monthly base, so provision and payout disagreed and the liability drifted negative on every taken day; a regression test now pins accrual-per-day == payout-per-day. Deliberately forward-only: posted runs are not re-touched (BFL 5 kap 5 §, and correcting them is not the sanctioned path), and semesterberedning.ts already books a 7290/2920 drift adjustment against the actual booked balance at vacation-year close, so history self-corrects through existing sanctioned mechanics rather than a migration. dailyDivisor dropped from calculateVacationAccrual entirely: semestertillagg does not vary with workdays per week, and the old signature invited exactly that error.

[2026-07-29] Betalda semesterdagar pro-rated per Semesterlagen 7 § (anstallningsdagar / dagar i intjanandearet x semesterdagar, rounded UP) applied ONLY on the statutory Apr-Mar basis; sammanfallande calendar-year companies keep the flat entitlement. Under sammanfallande the employee earns and takes in the same year, commonly with forskottssemester, so how a mid-year hire is treated is a collective-agreement question rather than a statutory one, and guessing there would understate a real entitlement. Two terms of 7 § left unmodelled and documented at the call site: unpaid full-day absence (no day source in the ledger) and the 4 § five-day cap (which bounds semesterledighet, a different quantity from paid days). Both omissions can only overstate, never understate, so neither can silently deny an employee a paid day. A cutover opening balance still outranks the computation.

[2026-07-29] pain.001 to SEB: shipped an honest warning plus corrected per-bank instructions instead of a SEB CSV generator. SEB Business Arena rejects .xml on upload and its SEPA-CSV is EUR-only, so the previous copy walked the user into a failure discovered on the pay date. Declined to build the bank-specific CSV without SEB's actual file specification: a guessed payment format either fails at the bank or, worse, pays the wrong amounts, and no primary spec was available in-session. Bankgirot LB is the working path for inrikes SEK today and the corrected copy points there, but it is a bridge, not a fix: the banks sunset LB during 2026 (Swedbank 1 Aug 2026), so the CSV generator is still owed once the spec is in hand.

[2026-07-29] Konto 8022 in a verifikat rattelse: made the account reachable instead of adding it to the shared BAS catalog. Verified against the official bas.se BAS 2026 v1.1 kontoplan that 8022 appears nowhere in it (kontogrupp 80 is exactly the 13 accounts lib/bookkeeping/bas-data already ships, and the full catalog matches BAS 2026 1:1 at 1290 vs 1286 accounts); it existed in BAS 2018 together with 8013, 8014, 8019, 8023 and 8073, all since retired. Seeding a retired account into the reference would push it onto every company's picker, so the fix instead wires the existing "Skapa konto" affordance into StrikeLinesDialog and CorrectionEntryDialog, which were the only account pickers in the app without it. BAS permits company-specific underkonton and correct_entry_lines_inline validates chart membership rather than BAS membership, so an own 8022 posts and exports to SIE unchanged. Separately noted, not fixed here: .claude/skills/modifier/holding-ab/SKILL.md still names 8022 as a bas_account_signal and instructs booking dotterbolagsforsaljning on "8020 / 8022", so our own domain skill points agents at an account the catalog lacks.

[2026-07-28] Recurring-schedule dims PR ships API/MCP/generator only, no schedule-dialog pickers: UI needs visual sign-off per house rule; substrate stops the cron-spawned-invoices-born-untagged leak now.

[2026-07-29] Retired the generic design skills now that emilkowalski/skills is installed globally (animation-vocabulary, apple-design, emil-design-eng, find-animation-opportunities, improve-animations, pick-ui-library, prototype, review-animations in ~/.claude/skills). Deleted .claude/skills/mobile-ux-core (52 lines of universal mobile UX whose file triggers are .dart/.swift/Activity.kt, paths that do not exist in this repo; superseded by design.md's accessibility section plus apple-design) and .claude/skills/scout-design (a design scan that filed Linear tickets via mcp__claude_ai_Linear__save_issue, while this project files GitHub issues and loop-design-scan is the same scan with the right output; loop-design-scan's sibling reference updated). Kept web-design-guidelines: it is a Vercel-plugin symlink, cheap to keep, and may regenerate anyway. Also removed the global ui-ux-pro-max skill, a 67-style/96-palette catalogue that pulls against a locked editorial-monochrome system. [2026-07-29] Consent-expiry follow-up sent from invoiceservice@arcim.io, not a new sender: matching the address the original batch came from lets the two mails corroborate each other; RESEND_FROM_EMAIL alignment to accounted.se stays a separate ops task. [2026-07-29] Approval-queue MCP App widget (render_ui on list_pending_operations): high-risk confirmed=true now comes from a human click in-widget instead of agent-asserted; payload ceiling 58K->58.5K per the in-test trim-first convention. [2026-07-29] PSD2 sessions are now shareable across a user's companies (extensions/general/enable-banking/lib/session-sharing.ts) rather than one authorization per company: SEB (and several other ASPSPs) allow one active AIS session per PSU, so each new company's BankID silently killed the previous company's feed. Prod confirms it: all 7 SEB customers holding connections in more than one company have had an earlier company stop syncing at the moment the next was authorized, 5 of them while consent was still formally valid, versus 2/11 at Lunar and 0/3 at Nordea. Enable Banking supports this shape already (POST /auth carries no account restriction, GET /accounts/{uid}/transactions takes no session id) and bank_connections has no unique constraint on session_id, so no migration. Only session_id and consent_expires are shared; accounts, cash_accounts and transactions stay per-company. [2026-07-29] Only ENABLED cash_accounts rows count as claiming an IBAN for reuse offers. The connect callback mirrors every account in a consent into cash_accounts, deselected ones included, so counting any row as a claim would mean the first company to connect speaks for the whole bank and no account is ever offerable. Enabled-only also matches the real workflow: sign once, uncheck the other companies' accounts in the picker, and those become what the next company gets. [2026-07-29] The superseded session_id is kept on a reconnecting row through the bank round-trip instead of being nulled at /connect, because the callback needs it to move sibling companies onto the renewed consent. Renewal also re-points sibling account uids by IBAN: several ASPSPs reissue uids on re-authorization, so carrying the session id alone would have left siblings calling retired uids and re-broken them every quarter. [2026-07-29] Reuse-offer strings are hardcoded Swedish, not messages/.json: BankingSettingsPanel.tsx uses no next-intl at all, so adding two t() keys would either strand them or force converting the whole component, a refactor outside this change's scope. [2026-07-29] INK2R reads two different views of the same period: the balance sheet from the closed books (so 7302 fritt eget kapital carries årets resultat via 2099) and the income statement from the pre-closing books (excludeFinalClosingEntry). The resultatavslut zeroes every P&L account, and INK2 is always filed after bokslut, so a single view cannot serve both sides. Rejected excludeYearEndClosing: tax, avskrivningar and bokslutsdispositioner also carry source_type 'year_end' and belong on the form (7525, 7528). [2026-07-29] The sign-reclassification rules (1630-1659 credit to skatteskuld, 2500-2599 and 2610-2659 debit to fordran) were extracted to lib/reports/sign-reclassification.ts as a DATA table only. k2-mapper keeps its own sumOre arithmetic rather than calling a shared apply(): the iXBRL path sums in exact öre while INK2R works in kronor and truncates per SFL 22:1, so sharing the arithmetic would have risked öre drift in already-shipped årsredovisningar for no gain. [2026-07-29] INK2R applies sign reclassification by relocating whole account rows between SRU codes instead of moving a net amount like k2-mapper does, so breakdown[code].accounts always sums to the code total in the UI drill-down. Exact for 'net' rules too: every account in the range moves together, so the moved rows sum to the deviating net by construction. [2026-07-29] generateTrialBalance now takes a REQUIRED closingEntry: 'include' | 'exclude-final' | 'exclude-all-year-end' instead of two optional booleans, and the options argument is no longer optional. Rationale: picking wrong is silent (a resultatavslut posts the mirror image of every P&L account into 2099 inside the same period, so a caller that forgets reads ZERO across class 3-8 while the balance sheet still ties out and nothing warns). The same defect shipped on the arsredovisning 2026-07-23, on INK2R and NE-bilaga 2026-07-29, and was found sitting unreported on Resultatrapport and dimension-pnl in the same sweep. A required union turns each of those into a compile error at the call site. All 40 call sites were audited individually and every one preserves its current behaviour EXCEPT resultatrapport/dimension-pnl, which were reading zero for any closed year and now pass 'exclude-all-year-end'. [2026-07-29] Resultatrapport and dimension-pnl use 'exclude-all-year-end', NOT 'exclude-final', so they keep reporting the same profit as the formal Resultaträkning. Moving generateIncomeStatement to 'exclude-final' is Stage 2 of #1051, deliberately deferred per DECISIONS.md:632; picking 'exclude-final' here would have made the operational report disagree with the formal one by the full bokslutsdisposition + skatt, which is the exact class of cross-surface disagreement this work is meant to remove. When Stage 2 lands, these call sites move with it. [2026-07-29] The KPI route and its xlsx twin keep closingEntry: 'include', which leaves the expense-composition KPI blank for a closed year. Not fixed here: the RPC path reads agg.tb (equally unexcluded), so changing only the fallback would make the two paths disagree, and the RPC exposes tb_ex_year_end already, so the real fix is a migration plus a displayed-figure change for every company that ran bokslut. That is Stage 2 territory per DECISIONS.md:632. Recorded as an open follow-up. [2026-07-29] get_vat_declaration_totals (migration 20260729110000) excludes the fiscal period's POSTED closing_entry_id, keyed on fiscal_periods.closing_entry_id rather than on source_type = 'year_end'. The resultatavslut debits revenue accounts, which drive rutor 05/39/40, so any VAT period containing the fiscal-year end reported negated turnover once the year was closed: verified read-only on production as ruta 39 = -794 734 kr for December of a closed year, 0 after the fix, with the January period carrying the real sale unchanged. source_type would have been the blunter filter but avskrivningar, periodiseringsfond and skatt share it and must keep whatever VAT effect they carry. A REVERSED closing entry is deliberately retained together with its storno so the pair still nets to zero, mirroring closingEntry: 'exclude-final' in trial-balance.ts; dropping only the reversed original would leave the storno behind and negate turnover a second time. [2026-07-29] periodisk-sammanstallning.ts needed NO closing-entry fix: it filters source_type with an ALLOWLIST (invoice_created, credit_note), so a year_end entry can never reach it. An earlier sweep flagged it as broken by heuristic (raw ledger read, no year_end filter); reading the query disproved that. Recorded so the next sweep does not re-flag it. [2026-07-30] The KPI monthly chart now excludes year-end entries, in BOTH paths: migration 20260730090000 points get_kpi_report_aggregates' monthly section at the existing tb_ex_ye_entries CTE instead of period_entries, and lib/reports/monthly-breakdown.ts gains the matching source_type + reversed-chain exclusion. 20260723180000 had documented the omission as deliberate ("year_end entries are NOT excluded here: the JS scan never excluded them either"), which faithfully mirrored the JS but the JS was itself wrong: the resultatavslut posts the mirror image of every P&L account, so the fiscal-year-end month charted the whole year's revenue as NEGATIVE income. Measured read-only on production: 28 companies over 34 month-rows, worst case a single month's income reading -10 347 459,81 kr instead of +12,88 kr. Both paths are changed together so the RPC hot path and the dimension-filtered fallback keep agreeing. This IS a displayed-figure change, but unlike the Stage 2 income-statement question it is not a choice of convention: a negative revenue spike is simply wrong. [2026-07-30] New check:guards ratchet "ledger-scanning-report": a statement generator under lib/reports or lib/bokslut that aggregates journal_entry_lines itself instead of going through generateTrialBalance. Tracked as a file-set so a NEW generator fails CI even if an old one migrates. Four existing files are grandfathered into the baseline (asset-service, periodiseringsfond-service, bolagsskatt-calculator, sarskild-loneskatt-calculator) rather than migrated now. Voucher and line LISTINGS (general-ledger, journal-register, SIE export, reconciliation, diagnostics) are sanctioned in LEDGER_SCAN_SANCTIONED because they must show the ledger as posted and therefore have no closingEntry decision to get wrong. [2026-07-30] OPEN FOLLOW-UP found by that guard: sarskild-loneskatt-calculator.ts sums accounts 7410-7419 with status='posted' and no year-end exclusion, so the pensionskostnad basis reads ~0 if the calculator runs against an already-closed period (as happens on an undo-then-redo bokslut). Not changed here for the same reason as getBookedBolagsskatt: it is a tax figure whose call chain DECISIONS.md:632 records as having already caused a too-high-tax customer bug, so it deserves its own change with its own verification rather than riding along in a sweep. [2026-07-30] Sign reclassification (1630-1659 credit to skatteskuld, 2500-2599 and 2610-2659 debit to fordran) applies to the STATUTORY presentations only: the K2 iXBRL årsredovisning and INK2R. It is deliberately NOT extended to /rapporter Balansräkning (lib/reports/balance-sheet.ts) or Balansrapport (lib/reports/balansrapport.ts). Both are organised BY ACCOUNT NUMBER under BAS-prefix headings, and balansrapport.ts documents an invariant that depends on every row staying debit-positive where it was booked (total_assets_ub + total_equity_liabilities_ub = beraknat_resultat); moving konto 1630 into a liability section would break that add-the-rows-to-verify property and hide the account from anyone looking it up by number. So the answer to "extend it to the remaining balance-sheet surfaces" is that there are none: the two statutory surfaces both have it, and the other two must not. Pinned in BOTH directions by lib/reports/tests/sign-reclassification-scope.test.ts so neither a future sweep re-flags the operational reports nor the reclassification silently vanishes from a statutory one again. [2026-07-30] Reconciliation is delivered in two places rather than as one UI panel. (a) INK2R now self-checks: when the year is closed it compares its own årets resultat against the booked konto 2099 and warns in Swedish if they disagree, mirroring the check k2-mapper has had since 2026-07-23. This is the alarm that was missing when INK2R reported 0 kr against a booked 469 542 kr and nothing warned because the balance sheet still tied out on its own. (b) lib/reports/statement-reconciliation.ts + GET /api/reports/statement-reconciliation return årets resultat from every surface side by side, grouped into families: ledger + statutory MUST agree and a mismatch is reported, while operational legitimately differs by bokslutsdispositioner + skatt until Stage 2 of #1051 lands. The visual panel is deliberately NOT built in this change: it needs a /frontend-design pass against the locked concept conventions plus sv/en strings, and the warning in (a) already puts the alarm where the user actually looks. The endpoint exists so the panel (and the MCP/agent surface) has a data source when it is built. [2026-07-29] OAuth error popup stays open instead of auto-closing: the postMessage is dropped on any popup/opener origin mismatch, and closing anyway made every such failure invisible (Fortnox silent-connect incident). [2026-07-29] FORTNOX_REDIRECT_URI on prod deliberately left on app.gnubok.se for now: flipping it to app.accounted.se before that callback URL is registered in the Fortnox Developer Portal would break connect earlier, at the authorize step.

[2026-07-29] Counterparty quick-review crash: typed the review dialog's template prop as a narrow ReviewTemplate instead of BookingTemplate, rather than filling out a full synthetic BookingTemplate. The crash existed because { id, name_sv } as BookingTemplate made 20 absent fields look present to the compiler; debit_account came back undefined, reached the dialog's required defaultAccount: string, and threw on accountOverride.startsWith('2'). Making the optional fields optional turns that whole class into a compile error. A fabricated full template (picking an arbitrary TemplateGroup, risk_level, fallback_category for something that has no catalog entry) would have kept the type honest-looking and the data dishonest.

[2026-07-29] Counterparty verifikation preview passes templateVatRate but deliberately NOT templateVatTreatment: for reverse charge, JournalEntryPreview emits the basbelopp pair (44xx/4598, momsdeklaration rutor 20-24), while buildMappingResultFromCounterpartyTemplate's legacy path emits only the 2645/2614 fiktiv-moms pair. That is not an engine gap: a counterparty template is learned from the user's own past vouchers, and a voucher that HAD basbelopp lines would have been learned as a multi-line line_pattern instead. So the preview mirrors the legacy path exactly and does not invent lines the booking will not create.

[2026-07-29] Hardened components/ui/toaster.tsx to coerce non-renderable title/description values instead of only fixing the call sites that passed the error envelope object. The Toaster is a sibling of {children} in the ROOT layout, so "Objects are not valid as a React child" there escapes app/error.tsx AND app/(dashboard)/error.tsx and lands on global-error, which blanks the app and (via its one-shot reload) pins the user on the fallback for that path. Call sites still route through getErrorMessage; the coercion is the choke point for the one that forgets.

[2026-07-29] Not fixed in the counterparty crash PR, logged instead: a successful counterparty-template booking sets exitingIds (the row animates out) but shows no "Bokförd" toast and no "Ångra" undo, unlike every other booking path in app/(dashboard)/transactions/page.tsx. Adversarial verification classified it non-crash UX parity; bundling a booking-flow behaviour change into a crash fix would have widened the diff past the report.

[2026-07-29] formatCurrency now coerces a null/empty currency to SEK. Checked prod before writing this: 0 of 28 416 transactions rows have a NULL currency, so this is hardening, not a fixed live crash, and the adversarial verifiers were right to refute the "transactions list blanks" claim on reachability. It is still worth having: the column is nullable with no NOT NULL, Transaction.currency declares it required, the browser Supabase client carries no Database generic so rows arrive as any, and a = 'SEK' default parameter only fires on undefined (currency: null throws RangeError out of Intl). It also covers enable-banking's accounts_data JSONB, where currency genuinely is optional.

[2026-07-29] TicWorkspace ignores a cached profile blob with no statuses key rather than normalising it and rendering. Prod check: 17 of 17 extension_data rows for general/tic + company_profile predate the TIC v2 upgrade (#584) and have no statuses/signatory/board/representatives/payrolls, i.e. the workspace is in the error boundary for every company that ever opened it. Normalising alone would fix the crash but leave those sections permanently blank, because the success render path has no refresh button, and the auto-fetch effect is gated on !profile. Dropping the stale blob lets that effect refetch the current shape and re-save it, so the 17 rows self-heal on next open.

[2026-07-29] Center Node AB support deletion (Anders Orback) run manually service-side, bypassing the product's AAL2/consent gate on anonymize_user_account: his written support request is the consent (documented in the audit_log row 8501e9e0), and support had already replied "du behöver inte göra något mer", so the fix-the-button-and-let-him-click alternative would have contradicted a sent mail. Manual run mirrored the delete route + RPC body byte-for-byte; GoTrue admin logout endpoint 404s on our GoTrue version, so global signout was done by deleting auth.sessions rows (equivalent effect, ban blocks refresh regardless).

[2026-07-29] Booking-feedback parity: extracted runCategorize's success tail into one finishBooking() rather than copying the toast into the counterparty branch. The counterparty path was already a second, thinner implementation of the same tail (the reason it silently lacked confirmation, undo and the count decrement), so a third copy was the wrong shape. Also caught while wiring the parity test: handleTransactionBooked (manual booking dialog / voucher match) never decremented totalUncategorizedCount either, so the header count stayed one high until the next refetch; fixed. Deliberately NOT given an Ångra action: its matched branch links the transaction to a PRE-EXISTING verifikat, and /uncategorize storno-reverses whatever journal_entry_id the transaction points at, so an undo there would reverse a voucher the user never created in that flow.

[2026-07-30] InvoiceMatchDialog classifies stale targets as matchable, settled, or not open instead of calling every invalid status fully paid: paid and zero-balance targets need different copy from cancelled, credited, disputed, reversed, draft, or malformed targets, while valid partially paid invoices keep the existing amount-difference flow. Blocked targets do not fetch or show a voucher preview or a confirm-outcome panel because neither match route has a reachable success path for them.

[2026-07-30] Issue #1289 ruta 05 null-rate fallback is report-local and requires both the 30x1/30x2/30x3 suffix and a matching 25/12/6 % moms account label; explicit configured values win. Declined historical backfill, account-creation derivation, and a DB NOT NULL guard: 3011 is custom rather than BAS 2026, NULL is valid across class 3, and history or mixed vouchers cannot safely set future defaults.

[2026-07-30] Skattekonto look-alike rows show ränteberäkningsdatum inline as muted text rather than as a fourth column: a retroactive omprövningsbeslut arrives as one transaction per re-charged month sharing date, text and amount, and only that field separates them. Kept it an exception marker per design convention 5 (12 % of prod rows qualify) instead of a column that is empty on the rest, and paired the month-differs rule with a same-band twin check so no two rows can ever render identically.

[2026-07-30] Amends design convention 11: .stagger-enter no longer re-runs on client-fetch completion for a list that is already rendered. The verifikat list now keeps its rows mounted and dims them during a refetch instead of collapsing to a skeleton and springing back, because the height change under the pointer was causing real mis-clicks on filter/sort/page changes. Stagger still runs on server render and on first client load. Chose dim-in-place over row-count-matched skeletons: matching heights only equalises the two states, it does not remove the transition.

[2026-07-30] Voucher rows get a hover-revealed "öppna i ny flik" affordance (components/ui/open-in-new-tab.tsx) next to the existing link rather than flipping the link itself to target="_blank". Working a filtered list means leaving and returning, and filter/scroll state is React-only, so a second tab is the only way to keep the list; cmd-click already worked on most rows but nothing advertised it. Kept the primary click navigating in place so existing habits are unaffected, and used HOVER_REVEAL_CLASS so the control is reachable on touch.

[2026-07-30] A makulerad (reversed) verifikat now renders struck through and dimmed in the verifikatlista, matching what Grundbok already did. Applied per data cell, not on the : text-decoration propagates to descendants and a child cannot opt out, so striking the row would have drawn a line through its action controls and status chips. Renamed journal_status.status_cancelled "Makulerad" -> "Avbruten" only: 'cancelled' is what the engine sets instead of DELETE for an aborted entry, never for a storno, and "Avbruten" is already the word used for that status in another namespace. Deliberately did NOT rename status_reversed "Omförd" -> "Makulerad" even though Grundbok already renders that state as "Makulerad": the compliance review flagged that in Swedish practice "makulerad" can imply a voided document with no ongoing audit trail, whereas a storno'd verifikation must stay traceable under BFL 5 kap 5 §, so the two states are legally distinct and the wording is a founder call, not a drive-by. The Omförd/Makulerad split between verifikatlistan and Grundbok therefore survives this PR and is still open.

[2026-07-30] Vouchers-per-account was a naming problem, not a missing report: /reports/huvudbok?account=1930 already lists a single account's verifikat with click-through. The command palette's token-AND matcher dropped the query "verifikat per konto" because the huvudbok entry never carried the word "verifikat", and the report library had no search at all. Added the synonym set (verifikat, kontoanalys, kontokort, stäm av) to both, via a new optional ReportDescriptor.searchTerms so the vocabulary lives with the catalog rather than being duplicated per surface. Kept "Huvudbok" as the visible name; Fortnox's "Kontoanalys" is a searchable synonym only, and "Kontoutdrag" is deliberately NOT one because it already means bank statement here and in Fortnox.

[2026-07-30] HOVER_REVEAL_CLASS gains focus-within:opacity-100. focus-visible only matches the element itself, so using the constant on a non-focusable wrapper (the skattekonto Matcha/Bokför cluster, which previously hand-rolled focus-within) left those buttons transparent while a keyboard user tabbed through them. Fixed in the shared constant rather than per call site, since the constant is documented as the one true row-control helper and every wrapper use has the same trap.

[2026-07-30] Report-vocabulary synonyms live in ReportDescriptor.searchTerms, NOT in the command-palette keywords, when they are words another report already owns. "stäm av"/"avstämning" on the huvudbok palette entry hijacked Enter from Bankavstämning, because the palette auto-selects the first hit and huvudbok is listed above it. The library search shows a list and has no such failure mode, so broad task-vocabulary belongs there. [2026-08-01] Palette is persisted as an independent root data attribute instead of new next-themes names: Light, Dark, and System values remain backward-compatible, and System can still follow the OS while each palette supplies both light and dark tokens. [2026-08-01] Palette refinement increases hue in primary, frame, secondary, muted, and border tokens only: Indigo, Forest, and Sand read more clearly without recoloring success, warning, destructive, or chart semantics. [2026-08-01] Accounted's base self-hosted Compose omits cpus and healthcheck.start_interval: both controls are optional, bundled NAS Compose parsers can reject them, and CPU policy belongs in host tooling or a local override. The version-controlled docker-compose.resources.yml overlay retains the former two-CPU cap and faster startup probing for command-line Compose 2.20.2+ without breaking the portable base. Existing operators are told to reapply the cap through either path. Supabase bind mounts, database ports, and JWKS syntax remain version-matched upstream concerns, with scoped NAS guidance in Accounted's integration docs rather than a forked Supabase Compose file; the guide loopback-binds both upstream Supavisor ports by default because changing 5432 alone resolves a collision but does not prevent LAN exposure.

[2026-07-30] A missing org number on either side of the Bokio connect probe does NOT block the connection; only a confident mismatch does. Accounted allows companies without an org number and a provider response can omit it, so blocking on absence would refuse legitimate connections to prevent a mismatch we have no evidence of. Absence instead falls through to labelling the consent with the company the credentials actually opened, which is what lets the user catch it. Same reasoning applied to keeping 429/5xx from the probe out of the invalid-credentials mapping: a provider outage must not read as "your token is wrong".

[2026-07-30] The sandbox lock on a paid extension workspace is checked BEFORE the capability gate, and gets its own state (ExtensionSandboxLockState), not the billing upsell. Every new company, sandbox included, gets a 30-day trial grant from the seed_trial trigger, so the paywall waves a demo company straight through onto a workspace whose external services lib/sandbox/guard.ts blocks. And an anonymous demo user has no billing to upgrade: the exit is "Skapa konto", which must sign the anonymous session out first (registering on top of it registers into the sandbox).

[2026-07-31] Seeded chart names corrected to BAS 2026 where the label contradicted what the engine books on the account (7210 'Semesterlöner' -> 'Löner till tjänstemän', 7010, 3001/3002), with an exact-literal + is_system_account backfill so user renames survive. The mislabel class fixed here is "name says X, bookings are Y": payroll books salaries to 7210, 12% revenue books to 3002. The 3100 'Momsfri försäljning' deviation from BAS ('Försäljning av varor utanför Sverige') stays: there the label and the ruta 42 mapping agree by design, so renaming to BAS would create the mismatch, not fix one.

[2026-07-31] Assistant product knowledge REVERTED before merge (founder call): the assistant gets no internal knowledge of Accounted's own features yet. The working product-tier implementation (agent_atom_registry CHECK widening, product/bokforingsmallar atom, discovery, MCP + panel wiring, all CI green at bb65224c) lives in fix/mall-line-type-clarity branch history and a follow-up issue for when it is wanted. Only the UI clarification ships for the radtyp confusion: unified "Kostnad/Intäkt" label and an InfoTooltip explaining the three radtyper at the point of choice.

[2026-08-01] Cloud Backup OAuth redirect URIs resolve from NEXT_PUBLIC_APP_URL, with request origin only as a self-hosted fallback: Google and Dropbox require pre-registered callbacks, so deriving them from an old alias or preview host can reject the flow before consent; one resolver keeps authorization, exchange, revoke, and sync origins consistent. [2026-08-01] Do not claim a fixed Supabase Docker revision: Accounted does not maintain a tested Supabase stack pin, so the self-hosting guide requires one complete immutable upstream tag or commit instead of naming an unverified version.

[2026-08-01] gnubok_list_invoices and gnubok_list_recurring_schedules use the same offset, has_more, and next_offset contract as the other paginated MCP tools, with id as a descending tie-break after the existing business-date order. Returning has_more without an offset left rows beyond the first 100 unreachable, while the secondary order prevents equal invoice dates or creation timestamps from reshuffling between page requests.

[2026-08-01] MCP page offsets are declared as non-negative integers and defensively floored before PostgREST range calls: fractional offsets cannot name a stable row boundary and can produce invalid range bounds when execution bypasses schema validation.

[2026-08-01] Paginated MCP invoice tools fetch one lookahead row and use it when Supabase omits the exact count: returning a conservative next_offset avoids falsely declaring the current page terminal and silently truncating callers, while exact-count responses and page sizes remain unchanged. [2026-08-03] Issue #563 automatic bank and skattekonto sync resolves company and firm capability grants in bulk before applying the 50-item run cap: limiting raw connection rows first let 50 expired trials permanently starve paying companies. Kept this in the shared TypeScript entitlement layer instead of adding a scheduling RPC or migration because the existing grant, expiry, and explicit-disable semantics already live there and no schema change is required. [2026-08-01] Out-of-order SIE IB activity is bounded by the target fiscal-period end, not its start: this excludes later-first imports while preserving same-period continuation suppression; successor IB resync checks the current error state plus a real target-period entry because result.success is finalized later, keeping replacement on a new engine voucher plus storno without letting a no-op import succeed through resync alone. [2026-08-01] Successor SIE IB replacement uses a specialized engine RPC instead of loosening the owner-only generic relink RPC: non-viewer members and scoped service-role imports are supported, while one period-row lock and expected-pointer CAS make the replacement voucher, storno, reversal status, pointer swap, and voucher sequence increments commit or roll back together. [2026-08-02] Out-of-order SIE IB resync requires exact date adjacency: the nearest later fiscal period can sit beyond a missing middle year, and replacing its authoritative IB with a non-adjacent UB would make that later period temporarily wrong until the gap was imported. [2026-08-03] SUPERSEDES the 2026-07-24 message-only invoice delivery decision: current Resend delivery webhooks identify the affected address or addresses in data.to, so one email with CC can keep its aggregate outcome and also store independent recipient outcomes. The recipient map is keyed only by immutable To/CC positions (to:1, cc:1), never by addresses; BCC and unmatched recipients remain message-level only. This preserves the WORM evidence row, avoids duplicating recipient PII, and does not require splitting one customer email into several messages. [2026-08-03] Issue #1360 keeps EUR annual reports fail-closed at general eligibility rather than only digital filing: the ledger and annual-report model are SEK-denominated, so allowing a EUR profile to lock a paper version would mislabel SEK amounts; full EUR support requires a company accounting-currency model across the ledger, report builders, and iXBRL. [2026-08-03] Issue #1267 localizes the latest-posted-voucher label in the web views while PDF and spreadsheet exports remain Swedish: translating one export label would create mixed-language files, so full export localization stays a separate surface-wide change. [2026-08-03] Wise imports fail closed on refunded or unknown statuses, unknown directions, and cross-currency transaction-history rows: the available export contract cannot establish their signed balance effect, and v1 must not silently discard a business event. Ordinary balance-statement rows share the canonical wise_ID external ID with transaction history to prevent overlapping cross-format imports; only conversion legs with explicit Exchange From/To metadata add the statement currency because the same Wise ID represents one movement per balance. [2026-08-03] Wise balance-statement "Total fees" stays description-only, guarded by a running-balance continuity warning: Running Balance moves by exactly the signed Amount per row, so the fee is a breakdown of Amount and booking it separately (as wise.ts does for the "(after fees)" history export) would double-count the cost and desync the imported account from the real Wise balance. [2026-08-03] Shared format contracts centralised in lib/invariants/ (org number, BAS account number, ISO date, fiscal year), each with its rationale recorded next to the rule. Trigger: four Skatteverket/Bolagsverket-bound export paths (KU10, AGI, SRU redovisare, iXBRL preflight) each had their own idea of a valid organisationsnummer, so a company stored with a space or in 12-digit form could file AGI all year and fail at the arsredovisning deadline. normalizeOrgNumber moved from lib/company-lookup/ and isSaneDateString from lib/utils.ts; both old paths re-export. The iXBRL check-digit verdict is warn, not error: we do not block a statutory filing on a Luhn assumption unverified against a primary source. KU10 12-digit passthrough pinned by test, not changed (open domain question). ROT/RUT brf_org_number left alone: different documented contract. Ratchet guard 8 holds the remaining 114 inline copies.

[2026-08-03] CI gained a pg-upgrade job: apply the merge-base schema, seed real rows, apply ONLY the PR migrations, assert the data survived. Rationale: pg-real applies all 548 migrations to an EMPTY database, so a NOT NULL / CHECK / unique index / backfill passes against zero rows and can still break prod. Proven locally against supabase/postgres:15.8.1.060 with three bad migrations: a CHECK violating an ore-level row and a NOT NULL on a populated column both exit 0 on empty and exit 3 on seeded. Base migrations are read from the merge-base git tree, not the working tree, so a PR that edits a shipped migration still surfaces here. [2026-08-03] Issue #323 automatic excess depreciation is limited to reconciled IL 18 machinery and equipment with linear book depreciation and posts 8853/2153: buildings, intangible assets, and the 25 percent rest-value method follow separate rules, so calculation fails closed on an incomplete register or unposted planned depreciation. [2026-08-03] Issue #314 zeroes the F-skatt avgifter basis at the calculation boundary as well as the rate: a rate-only exemption would stop the 7510/2731 charge but leave a false contribution basis in salary reports and AGI totals; the separate FK011/FK131 XML rendering defect remains scoped to issue #315. [2026-08-03] Issue #814 ships the custom inbox-domain dialog polish (i18n, role gating, load-error state) while INBOX_CUSTOM_DOMAINS_ENABLED stays off: the 2026-07-02 gate decision holds until Emil flips the flag and restores the workspace entry point, so the feature is ship-ready but dormant. [2026-08-03] Issue #789 keeps ROT/RUT submission manual: Accounted generates, archives, and tracks Skatteverket HUS V6 XML, but the authorized user uploads and signs in the official e-service because no supported direct submission contract is available.

[2026-08-03] Net deductions (issue #316) default to settlement accounts 1613/2794/7385/2799 via LINE_ITEM_ACCOUNTS, not 2890 as the issue sketched: keeps getLineItemAccount the single mapping source so payslip lines, previews and vouchers agree; explicit account_number still overrides. [2026-08-03] net_deduction_benefit_payment keeps a single 7385 default despite covering all benefit kinds: there is one item type with no benefit-kind field, fri bil is the dominant co-payment case, and per-line account_number override handles the rest; a per-benefit split needs a schema change and its own issue. [2026-08-03] Invoice-list sorting loads every paginated row and keeps normal statuses muted while exception statuses use chips, not row tints: sorting must remain complete beyond PostgREST's 1,000-row cap, and semantic colors stay data-only under the locked design conventions. [2026-08-03] MCP model-free upload (#748): payload ceiling bumped 58.5K to 59K instead of search-only visibility: both upload paths must stay listed (signed-URL pair is primary for file-capable harnesses, legacy base64 is the only path for clients without file access); create outputSchema trimmed to 3 fields first. [2026-08-03] Hosted browser sessions use one HMAC-signed, HttpOnly, Supabase-session-bound cookie for idle and absolute timeout state (issue #362). A bounded input heartbeat advances only idle activity; the signed start time never moves, so the 12-hour cap cannot be extended. This stays application-side rather than relying on Supabase refresh-token time-box settings, whose enforcement is refresh-driven and cannot represent real browser inactivity. API-key and MCP bearer surfaces are exempt, self-hosted defaults off, and the sign-in method is copied from a short-lived hint into the signed state instead of user metadata so concurrent sessions cannot overwrite each other's re-auth method. [2026-08-03] Customer-metadata enrichment during provider migration fills only columns that are NULL and treats empty strings and empty arrays as explicit user clears that reruns must not overwrite: NULL is the only "never configured" state, so a migration rerun can never undo an edit a user made after the first import. The enrichment update is spelled as a literal three-column payload (CustomerMetadataEnrichment) rather than a dynamic record so the phantom-column guard checks the columns instead of counting another unresolvable expression. [2026-08-03] Asset disposal (#325) commits through a dedicated commit_asset_disposal RPC that delegates to commit_journal_entry with commit_method 'user_accept' instead of adding a new 'asset_disposal' value to the allowed-list: the disposal dialog is a user-accepted commit, actor provenance fields already record the surface, and the register update plus voucher stay one transaction. [2026-08-03] The disposal RPC validates metadata invariants (non-negative proceeds and VAT, VAT requires a treatment, VAT never above gross proceeds, scrap carries no proceeds) rather than deriving register metadata from the journal lines: duplicating the TS disposal planner in SQL would drift, and once disposed_at is set the register row is frozen by trigger, so corrections go through storno plus a new disposal rather than register edits. [2026-08-03] Tax depreciation (issue #324) is elected per fiscal period on a pooled snapshot chain, not per asset: fiscal_periods carries method, rule, opening, base, deduction and closing values, with DB guards enforcing method continuity and opening equal to the previous closing; assets keep depreciation_method linear for book depreciation, and company_settings.tax_depreciation_method was dropped because a second company-level method meant an admin/member RLS mismatch and a non-atomic second write; the annual snapshot chain is authoritative. [2026-08-03] kompletteringsregel_20 with a positive basis and zero acquisition cohorts is refused rather than computed: reducing over an empty cohort set would claim a full write-off the cohort evidence does not support (IL 18 kap. 17 §), so such periods require manual review instead of an automatic deduction. [2026-08-04] Balance-sheet synthetic result = complement of classes 1-2 (class 0/9/null rows included), not classes 3-8: a resultatavslut posted to 2099 without zeroing class 3-8 then self-cancels inside the residual instead of double-counting equity; mirrors balansrapport's residual definition (#1333) [2026-08-04] v1 settings PATCH writes directly (no staging), following the v1 customers precedent: REST callers are already scope-gated, staging is an MCP segregation-of-duties concept. [2026-08-01] OCR quick-fix "bounded retry on transient Bedrock errors" dropped: @anthropic-ai/bedrock-sdk already retries 429/5xx twice by default (maxRetries=2, not overridden in extract-invoice-fields.ts); duplicating it would triple worst-case latency for no reliability gain. [2026-08-01] Page-count gate (issue #553) changed from skip-extraction to slice-first-3-pages via pdf-lib: invoice data sits on page 1, and a >3-page supplier invoice getting ZERO fields was the worse failure mode. too_many_pages skip remains only for unsliceable (encrypted/malformed) PDFs; truncation recorded in extracted_data.pages. Side effect: client_opt_out now outranks too_many_pages in skip-reason priority (an opted-out caller never extracts regardless of length). [2026-08-01] Extraction classification fields (documentKind/payment/merchantCategory/legibility) validate with .catch(null) instead of strict enums: a hallucinated label must degrade to unknown, not sink the whole document parse; amounts keep strict parsing on purpose. HEIC handling = attempt sharp transcode at runtime, fall through to today's empty-extraction when libvips lacks HEIF (prebuilt binaries exclude it for patent reasons), with a UI hint replacing the silence. [2026-08-02] WhatsApp channel tables: whatsapp_messages/conversations/link_codes are RLS-enabled with NO policies (service-role only): rows hold third-party PII (phone hashes, chat text) with no company scope and no v1 UI reader; whatsapp_phone_links is USER-scoped (auth.uid()), not company-scoped, because a phone binding belongs to a person. whatsapp_conversations triaged into ARCHIVE_EXCLUDED_TABLES (bot state, company_id is only a pin; receipts live in document_attachments). check_and_increment_whatsapp_sender_quota is EXECUTE-granted to service_role only (quota-drain lesson from 20260726090000). [2026-08-02] whatsapp-inbox link codes: a bare 6-char body without the AC prefix must contain a digit to count as a code; the ambiguity-free alphabet makes ordinary words ("hejhej") valid code shapes, and greeting text must earn M1, not a confusing "wrong code" M2. Prefixed codes (what the panel and wa.me prefill always send) are never rejected. [2026-08-02] whatsapp-inbox webhook: truly unknown inbound types (reactions, ephemeral, future Meta additions) get silence + a skipped row instead of the M15 unsupported-content reply; answering a thumbs-up reaction with "I only take images and PDF" is noise. M15 stays for explicit content types (video/sticker/location/contacts). [2026-08-02] Bulk-book notes x channel_context: the shared batch note and the per-item rendered WhatsApp context are JOINED (' · '), not either/or. Bulk booking has no per-item notes review, so a caller-wins rule would silently drop the only copy of the representation documentation; in book-direct/convert the caller DOES see the field (UI prefills the rendered string), so there a supplied value wins and the server default only fills empty/absent notes. [2026-08-02] renderChannelContextNotes caps at 220 chars by dropping WHOLE participant names ("… och N till", first names kept, min 1); free text (syfte/note/caption) is ellipsis-cut only as a last resort. Caption ranks below representation and user_note because it rides along with the photo rather than answering a question. Output is Swedish-only on purpose: it lands in the verifikat description, a regulatory surface. [2026-08-03] Notes on chat-sourced items: PRESENCE of the notes field decides, not truthiness (supersedes the 2026-08-02 "server default fills empty/absent notes" line). BookDirectlyDialog now always submits notes, '' included, and book-direct/convert only default from channel_context when the field is ABSENT. Reason: the dialog prefills the chat note, so a user who reads it, disagrees and deletes it was having it written back onto an immutable verifikat, removable only via rättelse. Kept notes: z.string().max(2000).optional() deliberately: .default('') or a min(1) would collapse "cleared" and "no opinion" into one value again. [2026-08-03] renderChannelContextNotes takes { includeCaption } and leaves the photo caption OUT by default. Representation answers and user_note are replies to a question the bot asked; the caption is unreviewed chat text. Bulk-book and the server-side route defaults run with no per-item review (the MCP approval preview deliberately carries no per-item PII), and what they write lands in the immutable verifikat description, so only Bokför direkt (editable field, user reads it first) opts the caption in. [2026-08-03] invoice_inbox_items moved from ARCHIVE_EXCLUDED_TABLES into the archive dump as a COLUMN PROJECTION (id, created_at, source, status, document_id, matched_transaction_id, created_journal_entry_id, created_supplier_invoice_id, channel_context). Picked over making the verifikat line loss-free: the line caps at 220 chars by design and Skatteverket wants every deltagare, so the full representation answer must survive in the archive a leaving company keeps as its BFL 7-year record. New MasterDataTableSpec.columns keeps the inbox workflow state (email bodies, OCR output, error messages) out; additive only, like denormalize. [2026-08-02] whatsapp-inbox PR4 staging: receipts awaiting a company answer are parked as their own whatsapp_messages rows (processing_status='skipped' + error_message='staged_awaiting_company'), NOT copied into whatsapp_conversations.context as the spec sketched. The rows already carry media_id/wamid/mime/filename, re-opening them is a guarded UPDATE, and a jsonb staging array would lose items under concurrent read-modify-write from parallel webhook invocations. Same intent, race-free representation. [2026-08-02] whatsapp-inbox PR4 re-send (M9): a sharper re-sent file creates a NEW inbox item and marks the old one channel_context.quality.superseded=true instead of swapping the old item's document. Replacing in place has no supported path (attach-document 409s when document_id is set) and would orphan the original WORM document against the anchored-doc invariant (see 2026-07-27 floating-underlag work). [2026-08-02] whatsapp-inbox PR4 schema: ONE new migration (20260802210000) adds whatsapp_messages.acked_at. Burst-ack membership (which ingested rows the single combined M4/M5 covered) must be derivable relationally across webhook invocations; deriving it from "last outbound ack timestamp" breaks the moment one outbound insert fails, and conversation-context accumulation races. [2026-08-02] whatsapp-inbox M6 body omits merchant/amount (spec showed them): for multi-company senders the item is created only after the company answer, so nothing is extracted when the question is asked. Body degrades to "Vilket företag gäller kvittot/kvittona?". [2026-08-02] whatsapp-inbox PR5b retention: revoked links crypto-shred by setting phone_enc='' (column is NOT NULL; empty string is the cleared marker, guarded by neq for one-shot idempotency) while phone_hash and phone_masked survive on purpose (re-link uniqueness history + audit display). Scoped to whatsapp_* tables only: inbox_rate_counters (the per-company sibling) has NO cleanup anywhere and stays that way here; adding one is a separate change, noted in the PR body. [2026-08-03] whatsapp-inbox GDPR erasure: anonymize_user_account REVOKES and crypto-shreds the phone link instead of DELETEing it. The declared auth.users ON DELETE CASCADE never fires (Accounted tombstones the auth row for ~100 years), and revocation-not-deletion is the channel's existing discipline: lookupActiveLink filters revoked_at, so a revoked link makes every further message take the content-free unknown-sender path, while phone_hash survives as non-reversible (HMAC + pepper) re-link history, exactly like the deliberately retained auth.users.email. [2026-08-03] whatsapp-inbox company question: kept the guarded state commit BEFORE the M6 send (a fast tap must find the state) and added a rollback on send failure, rather than sending first. Send-first would let two concurrent burst workers both send M6, losing the single-ask guarantee the .neq('state','awaiting_company') guard exists for. [2026-08-03] whatsapp-inbox company_choice_expired: at the 48h TTL the parked receipts are NO LONGER discarded. The options stay in the conversation context and the rows stay staged, so a late digit/tap still files them (Meta serves the media ~30 days); only rows older than that get the terminal marker. Chosen over "notify at expiry" because the 24h service window is closed by then and v1 sends no templates, so there is no way to tell the user in chat; and over "auto-file into the default company" because there is no default (that is why the question was asked). [2026-08-03] whatsapp-inbox concurrency: conversation writes go through updateConversation(), an optimistic compare-and-set on the existing updated_at column (the update trigger makes it a revision counter), instead of adding a version column or moving pending_question/question_queue/budget into real columns. No migration, works for every writer including the sweep, and the mutation re-runs against fresh state instead of failing. [2026-08-03] whatsapp-inbox inline dispositions (stop/start/byt/company choice): the durable side effect now runs BEFORE the terminal wamid row is written, with a SELECT pre-check for dedupe. Insert-first-then-effect was at-most-once (a crash in between lost the action forever: the redelivery 23505s and the sweep never claims 'done' rows). Accepting a possibly repeated idempotent effect plus a duplicate confirmation is the right trade against silently dropping an opt-out. Not routed through the received/claim lifecycle instead, because the sweep re-dispatches text rows to the answer worker, which would turn a crashed 'stopp' into an M16 fallback. [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] 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. [2026-08-05] Dropped "ML 13 kap 8 §" cites for kontantmetoden VAT timing (comments/docs only): section is the old ML 1994:200 numbering; in ML 2023:200, 13 kap is input-VAT deduction. Rule stated without section cite until the current-law section is verified. [2026-08-05] The in-app assistant now reads invoice_inbox_items.channel_context (the answers a user gave in WhatsApp) as first-class underlag context, marked "uppgivna av användaren" and ranked above OCR output. Found in the field: the assistant asked for representation participants the user had typed into WhatsApp minutes earlier, because the intent's inbox query selected only document_id + extracted_data. Also backfilled by document_id, since a receipt can reach the intent through the document paths without the inbox row being matched to the transaction. [2026-08-05] The WhatsApp representation question now asks ONCE for a missing purpose instead of silently storing participants with purpose=null. Skatteverket wants participants AND purpose; accepting half and saying "Tack!" produced an undocumented deduction. Anti-loop: the follow-up fires only when no representation block exists yet, so a second incomplete answer is accepted as-is rather than nagging. [2026-08-06] Session replay masking narrowed from mask-everything to pattern-based (founder-approved): fully masked replays were wall-to-wall asterisks and useless for support debugging. lib/analytics/replay-masking.ts masks currency-shaped text, person-/organisationsnummer (text and typed input) and password inputs; data-ph-mask still force-masks tagged PII, data-ph-unmask stays honored for chrome, and everything else including typed input is now visible in replays. Privacy policy and RoPA updated in the same change; supersedes the 2026-07-27 maskTextSelector '' decision. [2026-08-06] ROT/RUT payout strings were placed in the invoice_editor namespace while RotRutPayoutDialog and the invoices page read useTranslations('invoices'), so all 44 labels rendered as raw "invoices.rot_rut_" key paths in production since #1380. Moved the keys to invoices rather than repointing the components, since the dialog belongs to the invoice list, not the editor. Message files are edited textually, never via JSON.parse/stringify: they contain duplicate keys a round trip would silently drop. Same bug class fixed in TemplateBookDialog (bookkeeping) and Correction/StrikeLines dialogs (journal_detail) by adding the strings to the namespace each component reads, matching the existing precedent that toast_posted_* is duplicated across journal_list and journal_detail. Added i18n/tests/message-keys.test.ts, which resolves every literal t() key against both locales: next-intl has no build-time check and fails by rendering the key path, so nothing caught this before users did. [2026-08-06] The ROT/RUT payout button is hidden from the invoices header unless the company has an invoice with deduction_total > 0 or rot_rut_enabled is on in tax settings. ROT/RUT concerns only companies selling eligible work to consumers, and a payout can never precede the invoice that created the claim, so the derived signal cannot hide the action from someone who needs it. Read from the company_settings row the page already fetches for ore_rounding (no extra round trip); deliberately not scoped to the fiscal-year filter, since a begäran is claimed the year after payment. ?rot-rut=1 still opens the dialog, so the feature is hidden, not removed. [2026-08-06] Supplier credit notes under kontantmetoden now reverse when the ORIGINAL was already booked (paid), not only under faktureringsmetoden: skipping left the expense and the 2641 ingaende moms deduction overstated with no accounting trace. Mirrors the customer-side creditNoteNeedsJournalEntry(). The v1 route's GDPR-minimised projection had to re-add registration_journal_entry_id/payment_journal_entry_id/paid_at/paid_amount: status alone misses a part-paid-but-booked original. [2026-08-06] Kontantmetoden year-end cut-off (BFL 5 kap 2 §) books moms to the VILANDE accounts (2618/2628/2638 ut, 2648 in), never 2611/2641: vilande accounts are deliberately absent from ACCOUNT_RUTA/ACCOUNT_TO_BOX, so the moms stays out of the momsdeklaration until payment, which is what bokslutsmetoden requires. 2647 was considered and rejected: it is domestic omvand betalningsskyldighet, unrelated. Cut-off posts as two AGGREGATE verifikat reversed on day 1 of the next period, and deliberately does NOT set invoices.journal_entry_id: the payment flows route on that link, so per-invoice linking would send every new-year payment down the accrual clearing path against a receivable the vandning already removed, booking the settlement twice. [2026-08-06] WooCommerce integration (extensions/general/woocommerce) is feed-only, orders + refunds: one positive inbox row per paid order (date_paid, gross total), one negative row per refund, nothing auto-booked, matching the Stripe feed doctrine of 2026-07-24. Gateway fees and payouts are deliberately out of scope: core wc/v3 exposes neither (they belong to the gateway; WooPayments has its own API and is a possible follow-up), so promising fee-level accuracy from the Woo API alone would be a lie. order.transaction_id is stored as the row reference for later gateway-side reconciliation. [2026-08-06] WooCommerce feed clearing account is 1680 Andra kortfristiga fordringar, not 1686: 1686 (Fordringar for kontokort och kuponger) is the economically right BAS account for gateway receivables but the Stripe feed owns it and cash_accounts enforces UNIQUE(company_id, ledger_account); 1680 is the closest correct free account in the 168x group. Consequence accepted: a store using the Stripe gateway plus a connected Stripe feed shows the same money in both feeds on different accounts, and the dedup account guard correctly does not merge them; panel copy positions Woo as the order feed and Stripe as the money feed. [2026-08-06] WooCommerce v1 has no webhooks: delivery runs on WP-Cron (fires only on site traffic), auto-disables after 5 consecutive failures, and has no refund topic, so it cannot be the backbone of an accounting feed. Nightly modified_after polling with a 24h cursor overlap + manual sync instead; webhooks can be added later as a poke-then-refetch accelerator. [2026-08-06] wc-auth callback authenticity: WooCommerce signs nothing on the handshake POST, so possession of the single-use oauth_state is the CSRF defense and activation additionally requires the received keys to pass a live probe against the STORED store_url. Per-tenant consumer key/secret are AES-256-GCM encrypted (WOOCOMMERCE_CREDENTIALS_ENCRYPTION_KEY, skatteverket token-store pattern). external_id scope is the store host, not the connection id, so disconnect/reconnect of the same store never re-imports history. [2026-08-06] WooCommerce sync redesigned after skeptic review refuted the first version: pagination is cursor-based (modified_after advances per page; offset pages only within a same-second date_modified tie, since modified_after is strictly exclusive), termination is an empty page (hosts may cap per_page), the persisted cursor never advances past failed refund fetches, ingest errors, or deadline-skipped work (failure floor = earliest affected order's date_modified - 1s), the deadline is checked between refund fetches inside a page, and rows dated on/before bookkeeping_locked_through are dropped at map time on EVERY run because modified_after selects on the wrong date dimension (a refund bumps date_modified long after date_paid). Cash-account currency falls back to the first order's real currency and a creation failure writes error_message on the connection so the panel cannot show a healthy store that never syncs. normalizeStoreUrl refuses localhost/private-IP/.local/.internal hosts (hostname-level SSRF guard). [2026-08-06] Recurring invoice cadence (user request) is a generic interval_months SMALLINT 1-12 (default 1) rather than a cadence enum: the UI offers only the four presets (1/3/6/12), while API/MCP accept any 1-12 (e.g. every 2 months), and existing rows stay monthly via the default. Missed-run roll-forward and edits/reactivation of interval>1 schedules anchor on the schedule's own next_run_date month grid (rollNextRunDateForward), so a quarterly Jan/Apr/Jul/Oct schedule missed in an outage rolls Jan 15 -> Apr 15, never Feb 15; monthly (interval 1) keeps its pinned today-anchored recompute semantics unchanged. Changing interval alone never touches next_run_date: the new cadence applies from the next run, so an edit can never pull a send earlier (a 3->1 change therefore waits out the current gap; visible via Nasta korning). [2026-08-06] Prompt clarifications render from a structured summary (lib/agent-context/chat-clarifications.ts), never off the raw channel_context blob. A WhatsApp "nej" stores representation with participants:[] and purpose:null and denied:true, so branching on !purpose reads a settled denial as a half answer: the shipped renderer emitted "syfte SAKNAS: fråga bara efter syftet" about a meal the user had just said was not representation. denied and the genuine half answer (participants named, purpose missing, which BFL 5 kap 6-7 § does want completed) are now separate states. Also: the photo caption no longer reaches the prompt, for the reason already written down in channel-context-notes.ts (nobody was asked for it, nobody reviewed it), and free text passes through flattenMemoryContent because promptTemplate output is seeded as a user message and wrapToolResult only wraps tool results. [2026-08-06] Unmatched underlag are PROPOSED to the assistant, never auto-linked. WhatsApp intake writes neither invoice_inbox_items.matched_transaction_id nor transactions.document_id, so a chat-captured receipt is invisible to every lookup and #1425's backfill-by-document_id has nothing to backfill. Scoring unmatched items at read time (lib/agent-context/underlag-candidates.ts, reusing core-receipt-matcher) closes that with no migration and no link written by a machine, preserving the human confirm step; setting matched_transaction_id at intake above a confidence bar remains the open alternative and is a founder call. An uncomparable cross-currency amount disqualifies a candidate outright, because the matcher drops the amount signal there and date + merchant alone score 1.0. [2026-08-06] Sandbox ledger history marked no_doc_required instead of seeding receipt documents: the history represents books kept before the company arrived in Accounted, so its underlag sits in the previous system. Same rationale and same sidecar table the SIE-import opt-in uses. Without it the demo's first screen read "Verifikat utan underlag: 39". [2026-08-06] Sandbox payroll takes skatteavdrag from FALLBACK_TAX_TABLES_2026 rather than a flat schablon: the draft run ships calculated, so its live "Beräkna om" would have jumped ~4 600 kr away from the sibling booked run, and a wrong skatteavdrag would show unlabelled in the payslip, the 2710 line and the AGI figures. [2026-08-06] Added guardSandbox to /api/salary/runs/[id]/payslips/send: it was the only send path without one, and seeding a booked salary run put "Skicka lönebesked" one click from an anonymous visitor with live Resend behind it. [2026-08-06] WINT provider built Tier A only (partner-facing v1 endpoints, SIE rendered by our own sie-builder from /api/Voucher + /api/Account): their native SIE export and IncomingInvoice live in the internal Full spec and are deliberately unused until WINT grants partner access. Ships dark behind WINT_MIGRATION_ENABLED. Auth is credential pass-through (mail+password exchanged once for a JWT pair; password never stored) because WINT has no OAuth or API keys. [2026-08-06] Login credentials error says "Fel e-postadress eller lösenord", not "Fel lösenord": GoTrue returns one invalid_credentials code for unknown-email and wrong-password alike (anti-enumeration), so a "wrong password" claim would be both unknowable and an account-existence leak. Clarity comes from inline placement + reset link instead. [2026-08-06] Empty SIE file (0 parsed vouchers AND no raw #VER declaration) finalizes as completed no-op, not failed: Fortnox exports an empty file for a not-yet-booked fiscal year and failing it aborted the whole migration wizard (CashLeads case). The failed-downgrade now fires only when the file contained vouchers that could not be imported; the raw-content #VER cross-check must stay, since a separator/encoding mismatch can swallow every #VER block with only a warning-severity parse issue and would otherwise masquerade as a legitimate empty year. The balance-only continuation-guard scenario rides along as no-op since re-running the same file can never produce a different outcome. [2026-08-07] Körjournal MCP tools ship search-only (catalogVisibility 'search'): the three tools crossed the tools/list 59K context ceiling, and the payload guard's stated preference is search-only over bumping. Agents reach them via gnubok_search_tools ("körjournal", "milersättning", "mileage"). [2026-08-07] book_mileage_period risk tier is 'medium', not 'high' like create_voucher: the verifikat lines are fixed (7331 debit at the DB-configured schablon + a whitelisted counter account), not caller-supplied arbitrary lines; same tier rationale as post_annual_depreciation. Default counter account is 2820 (skuld till anställda), with 2893/1930 as explicit choices. [2026-08-07] mileage_trips.status has no per-trip storno flow: a booked trip is underlag frozen by trigger; correcting a wrong booked period goes through reversing the verifikat (existing storno paths), not through editing trips. Keeps the trip log append-only like a paper körjournal. [2026-08-06] Bucket A defaults pass commits the /pending Godkänn pill directly for low/medium risk and keeps the ConfirmationDialog only for high risk: the Granskning row already states source, title, risk and offers Detaljer, so the dialog's second Godkänn restated the row (the audit's expert lens called double-Godkänn the thing professionals do not tolerate). The chat-side "Godkänn alla N" was DEFERRED, not built: ApprovalCard owns its whole state machine internally (commit fetch, account-activation retry, typed high-risk confirm) and a bulk commit from AgentChat would leave committed cards rendering as pending; that is assistant-redesign seam 8.8 (approval batching) and needs the state lifted, not a button. [2026-08-06] SIE-export period default left unchanged despite the choice-audit finding: FiscalYearSelector with includeAllOption=false already auto-selects the newest started period once loaded, so the "opens with nothing selected" claim is only fetch latency. FyPicker gained preferLatestEnded for helårsmoms instead, which also skips the shared per-company localStorage scope on that surface: a filing page defaulting to the current (unfilable) year because Balansräkningen was last viewed there is the one wrong default. [2026-08-06] Review-workflow triage on the Bucket A branch (13 confirmed findings): fixed 10, incl. the branch-killing one (setActiveCompany's cookie write throws in Server Component render, so the /select-company auto-forward silently never fired: the cookie set is now best-effort because the gnubok-company-id cookie is write-only compat nothing reads). Batch "Ingen moms" now goes over the wire as 'exempt' instead of collapsing to undefined, which had an explicit no-VAT choice booking the derived 25%; the same pre-existing collapse in QuickReviewDialog/CategoryExpandedDialog is left for a follow-up. Skipped by choice: generalizing AiFilledIndicator for history provenance (the note's copy already names the source) and converting BulkBookInboxDialog's hardcoded-Swedish option lists to i18n (whole-file migration, not this branch's divergence). Monthly momsdeklaration default is deadline-aware (M-2 until the 12th/17th, M-1 after; over-40M always M-1) mirroring deadline-config, not just calendar-ended. [2026-08-07] Sandbox cleanup repaired via a dedicated gnubok.sandbox_cleanup transaction-local flag (respected by write_audit_log, audit_log_immutable, enforce_dimension_registry_guards, enforce_pending_operations_no_delete) instead of tombstoning sandbox users like the real account-deletion flow: sandbox data is synthetic demo content, not rakenskapsinformation, so full deletion is the correct GDPR/BFL posture and the audit-log DELETE bypass re-verifies per row that the company is a sandbox before letting anything through. Also revoked the default PUBLIC EXECUTE both cleanup RPCs had carried on prod since March (anon could call them via PostgREST), and validated the whole delete chain by replaying it against prod inside aborted transactions (21 sampled users across all seed eras) plus a committed staging run. [2026-08-07] QuickReviewDialog's 'none'-collapse closed with a deviation test, not a blanket map: 'none' as the SEEDED default (exempt categories) stays off the wire so untouched bank-fee bookings remain byte-identical, while 'none' as a user deviation goes as explicit 'exempt' (books no VAT line, records the classification the momsdeklaration should see, lands income on 3004). The batch dialog's blanket 'none'→'exempt' map from #1443 is correct THERE because its select has no seeded-per-category default. CategoryExpandedDialog carries the same collapse but has zero references (dead code): left untouched, deletion is hygiene for a separate sweep. [2026-08-08] Provider migration errors surface via a new PROVIDER_API_MODULE_INACTIVE code instead of reusing PROVIDER_LICENSE_MISSING: the existing code's message is Fortnox-specific (re-order the Integration license) while Visma's 4002 "No access to module: api_standard" needs different remediation (activate the API under Appar och tillägg, check standardforetag), and both differ from AUTH_EXPIRED whose "reconnect" advice loops forever since OAuth succeeds against Visma's shared identity server. Orchestrator failures split fatal/non-fatal: connection-level codes (auth expired, license missing, module inactive) rethrow and abort the run because every remaining call is doomed, while per-step upstream/unknown failures keep log-and-continue (one bad step must not discard other steps' persisted rows) but are recorded on results.stepErrors so the result UI stops presenting an all-failed sync as "Allt ar uppdaterat". Root trigger: ElvaSmultron's Visma sync failed five times server-side, reported success with zero rows, and reached us as a bug report for a customer-side subscription setting. [2026-08-08] INK2 huvudblankett field 1.1 (Overskott av naringsverksamhet) is SRU code 7104, not 7113: verified against Skatteverket's official 2025P4 faltkoder Excel (INK2_SKV2002-33-01-24-04.xls) after prod SRU files were rejected with "UPPGIFT 7113 ar inte ett giltigt postnamn" (Miles Scherrer, Burre Nacho AB). 7113 came from our own swedish-sru-filing skill reference, which was wrong and is fixed in the same change; every other emitted INK2/INK2R/INK2S code was cross-checked against the official lists and is valid. Only profitable companies were affected (7114 for underskott was already correct). [2026-08-08] Ruta 41 unblocked with a static ACCOUNT_RUTA mapping of 3231/3232/3233 (incl. 3232/3233, in the SKV 4700 mapping though not in our shipped BAS 2026 catalog) instead of starting the per-account VAT-treatment picker: the ElvaSmultron/Spiris support case needed omvänd-moms sales in the declaration now, and RC sales deliberately stay out of the ruta 05-08 vs 10-12 pairing checks because the buyer accounts for the VAT. The picker (any custom konto to any ruta, incl. purchase-side 4056-style Visma accounts to ruta 20) is #1457. [2026-08-08] Community registry source of truth moved into this repo (registry/ with entries + authors, validator wired into core-build): the site button "Lagg till en egen" pointed at erp-mafia/accounted while the registry MDX lived in the private website repo, so an external contributor had no path to PR (found by the first would-be contributor). The website now syncs FROM registry/ instead of owning the content. Entry bodies are gated to plain Markdown because the site renders them through MDX, which would execute JSX and import/export statements inside the website build. [2026-07-28] Transaction method (structured payment rail): the trailing channel phrase ("Överföring via internet", "Kortköp/uttag") is stripped from transactions.description at INGEST and by a one-shot BACKFILL, not merely hidden at render: description is the mutable working title, original_description keeps the full bank string, and every dedup surface (external_id: date+öre only; content bridge: prefix-containment over original_description ?? description, and a trailing strip leaves a prefix) is provably unaffected. transaction_method is text + CHECK (repo convention, no PG enums) beside verbatim bank_transaction_code / proprietary_bank_transaction_code evidence columns per data_quality_master Appendix B Layer-A; the dead enrichment jsonb was NOT reused (the Gokind lesson: opaque blobs with no readers die). mapping-engine now also matches original_description so user rules written against the full bank text keep firing on stripped rows. [2026-07-29] Transaction-method backfill scope: classification and title-stripping are FEED-ROW concepts (import_source present, not manual/mcp), enforced identically at ingest and in the 20260808090100 backfill, plus an adjective guard so "Egen insättning"/"Eget uttag"/"Intern överföring" keep their full titles even on feed rows (the phrase IS the meaning after a possessive/scope adjective). Chosen over vocabulary tweaks because the failure mode for unknown bank phrasings must be "row unchanged", and user-authored titles must never be rewritten by a channel vocabulary. A read-only prod dry-run script exists for coverage measurement but prod reads were left to the founder (permission-gated). [2026-08-08] Compliance-bot finding on the transaction_method backfill (booked rows' titles rewritten without a rattelse trail) triaged as satisfied-by-design, not a blocker: BFL 5 kap 5 attaches to bokforingsposter, and the backfill touches no journal table; the verifikat description is snapshotted into journal_entries at commit and SIE #VER export reads journal_entries only (both verified in code, no report reads transactions.description lazily); the bank original is preserved byte-identical in original_description by the same UPDATE (enforced since 80ef1ee0, and prod has 0/25,566 feed rows lacking it). The stricter TRANSACTION_TITLE_LOCKED gate on booked rows blocks arbitrary user free-text renames, a different mutation class from a deterministic trailing-vocabulary strip that skips user-edited titles and keeps the original adjacent. Period-lock triggers sit on the journal tables and fiscal periods, not on transactions; the pg-upgrade CI run applied the backfill against seeded booked rows with all enforcement triggers active. [2026-08-08] Assistant panel undock/resize hardcodes Swedish UI strings (title/aria on the new handle and dock/undock toggle) instead of next-intl: every existing string in components/agent (AgentSheet, AgentChat, session list) is hardcoded Swedish with zero next-intl usage, so two translated strings in an otherwise Swedish-only surface would be inconsistent both ways; migrating the whole agent surface to next-intl is its own change. Geometry persists in user_preferences.ui_state.agent_panel (design rule 9, never localStorage); expanded focus mode now reserves page margin like the compact dock instead of overlaying (the reported bug), clamped so the page keeps a 480px readable column; @use-gesture/react stayed unused in favor of a ~30-line pointer-capture helper (three call sites need only delta tracking). [2026-08-08] Receipts checklist step (PR #1461) done-signal = any invoice_inbox_items row for the company, not a document_attachments count: document_attachments also archives outbound invoice PDFs, ROT/RUT payout files and Bolagsverket submissions, so its count would mark the step done when the user merely sends their first invoice; invoice_inbox_items is written by exactly the three intake channels (email/WhatsApp/upload) and carries a company_id index. Same PR: vat_registered with moms_period=null renders an explicit "välj momsperiod"-link in the checklist instead of silence, because that state makes the deadline engine generate zero VAT deadlines without any error (all three moms configs require a concrete period). [2026-08-08] Per-voucher RC basis gaps (findRcBasisGaps) downgrade from filing-blocking ERROR to WARNING only under per-rate evidence: the 44xx/45xx basis accounts grouped by momssats must match ruta30/0.25, ruta31/0.12, ruta32/0.06 two-sided within 0.5 kr, all rate boxes non-negative, and no RC_OUTPUT_MISSING present. A first cross-rate-sum predicate was refuted by /skeptic (wrong-rate fiktiv moms reached parity and unblocked a 7 800 kr under-declaration; a negative rate box made the predicate vacuous), so the certificate is per-rate, which rutor alone cannot express (rutor 20-24 are partitioned by purchase type, not rate); evidence therefore flows from the account totals. Why downgrade at all: a moms-only rattelseverifikat carries fiktiv moms whose basbelopp lives in another (often reversed) verifikat, and no voucher arrangement satisfies both the per-voucher scan and the aggregate identity in that state, so the ERROR was an unfixable dead end (Orto Engineering 3DJake case 2026-08; support vouchers A169/A175/A177 joined the blocklist they were meant to clear). Data side repaired separately with voucher A177 restoring bank parity and the basis/moms identity exactly. [2026-08-08] Fenced-JSON fix uses brace-slice, not fence-regex: also rescues preamble/postamble prose around the object, and degrades to the existing empty-result path when no braces exist. [2026-08-08] extractJsonObject upgraded from brace-slice to depth-aware balanced scan after PR 1460 review: prose containing braces around the JSON no longer poisons the slice; first parseable candidate wins. [2026-08-08] Inline-page UI catch-up applies chips-mark-exceptions to report verdicts uniformly: positive verdicts (Balanserad, Balanserar, Avstämd) render as muted text and only deviations keep a destructive Badge; page-flow instructional copy on Bankavstämning moved behind the FocusedReport "?" HelpPopover (convention 7) instead of being deleted, since the IB/preview semantics are load-bearing for correct reconciliation. [2026-08-08] Journey branch question (PR 2 of the activation concept) renders only in mode='first' and persists initial_setup_path as a fire-and-forget PATCH: in mode='add' a silently-failed setActiveCompany (deliberately non-fatal in createCompanyFromOnboarding) would make the PATCH land on the PREVIOUS company's settings, and experienced multi-company users get the Hem checklist anyway; navigation never blocks on the PATCH because the checklist path is a nicety, not a prerequisite. Provider preselect at /import?mode=migration&provider=X auto-advances only for sieViaApi providers (fortnox/bjornlunden/briox): visma/bokio must land on the provider list where the "SIE krävs först" gate renders with its async connection status. [2026-08-08] SIE export always emits #FORMAT PC8 even when bytes are UTF-8: the record is compulsory in the spec and strict importers (Visma Spiris) reject files without it, while real encoding is detected from bytes (Fortnox ships the same shape). Default bytes stay UTF-8; encoding=cp437 remains opt-in. [2026-08-08] Shopify integration copies the WooCommerce feed pattern (extensions/general/shopify, feed-only, nothing auto-books) rather than the Stripe Connect OAuth pattern: Shopify discontinued admin-created custom apps with revealable shpat_ tokens on 2026-01-01, and a one-click multi-merchant OAuth app requires Shopify App Store review plus protected-customer-data and read_all_orders approvals. MVP auth is therefore a merchant-created Dev Dashboard custom app whose client id/secret the user pastes; the server runs the client credentials grant per sync run (~24h tokens, never stored). Phase 2 (public unlisted OAuth app, Shopify Payments payout/fee reconciliation, bulk-operation backfill, webhooks) is deliberately out of v1. [2026-08-08] Shopify feed clearing account is 1584 (Fordringar Shopify Payments, the e-handel skill's 158x convention): 1686 is owned by the Stripe feed and 1680 by the WooCommerce feed, and cash_accounts enforces one account per ledger per company. Frozen external_id scheme: shopify_{shop_domain}_order|refund_{legacyResourceId}, scoped on the myshopify.com domain (not the connection id) so reconnects never re-import; distinct from the MCP skill's shopify_order_{id} scheme on purpose so the two import paths cannot collide. [2026-08-08] Shopify order feed queries zero PII fields (no customer/email/addresses): keeps the integration entirely outside Shopify's protected-customer-data program and the GDPR RoPA small; BFL verifikat reference needs only the order name/id. Qualification is displayFinancialStatus in PAID/PARTIALLY_REFUNDED/REFUNDED and test=false, row date is processedAt; no MCP tools (same precedent as bank_sync/woocommerce: feeds are cron/HTTP only, the agent path is the shopify-to-gnubok skill). [2026-08-08] extensions.schema.json enum also gained "stripe" while adding "shopify": the enum had drifted (stripe was enabled in extensions.config.json but missing from the schema, failing editor validation); fixed in the same touch since the file had to change anyway. [2026-08-08] Login panel is method-stated (BankID hero default, remembered via accounted-login-method cookie) instead of a stacked method list: matches the Swedish bank/Fortnox convention, gives exactly one primary action per view; errors moved from boxed banner to a field-adjacent single line (NN/g 3/4/10), reset link surfaces from the second failed attempt. [2026-08-09] Upsell FAB dismissal is session-scoped (sessionStorage), not persisted: closing the paywalled sheet or the pill's X hides all floating assistant UI for non-payers until the next browser session; a permanent dismissal would let one click silence the conversion surface forever, and payer/collapsed FAB behavior stays untouched. [2026-08-10] Transactions inbox fetches ALL pending rows merged into the single transactions state array (tracked window boundary via pagedCountRef/pagedThroughDate) instead of a parallel pendingTransactions state: ~20 setTransactions mutation call sites (book/ignore/edit/delete) would each need dual updates and would drift; the merged array keeps mutations one code path, at the cost of a date-boundary filter for the history view. [2026-08-09] /migrate streaming is opt-in via Accept: application/x-ndjson instead of replacing the JSON contract: the wizard is the only caller today but a hard cutover would break open pre-deploy tabs and the route's locked error-status tests; mid-stream failures re-send the structured envelope as a terminal error event because the 200 is already committed once the stream opens. [2026-08-09] Regeluppdat + docs-freshness scans (#1417) built as local loop skills with due-date self-gating, not cloud crons: cloud routines were retired 2026-07-20, and session crons die at 7 days, so weekly/monthly cadence is achieved by loop-ignite running each loop when its run marker says it is due. loop-regeluppdat files tickets only (no auto-fix PRs): regulatory changes touch money math and compliance logic, which .claude/loops.md forbids loops from changing. Docs check diffs the live .md mirror routes against repo-built markdown (exact, canonicalised both sides) instead of diffing the gnubok-website checkout, so it also catches deployed-but-stale and route-404 states. [2026-08-10] RC VAT template fix scoped to template-library.ts (applyTemplate + deriveTemplateLinesFromBooking): counterparty/SIE learned patterns already strip fiktiv-moms legs and regenerate them via generateReverseChargeLines with the gross base, so mapping-engine/counterparty paths were already correct and stay untouched. [2026-08-10] Receipt hunt cron keeps searchMail=false: the mailbox leg stays manual until its time budget is proven. A sweep of one 172-message mailbox took over 600s while the route's maxDuration is 300, so enabling it nightly would time out mid-run. Flip both this flag and RECEIPT_HUNT_COMPANY_IDS together once the per-company budget is measured. [2026-08-10] Account 2012 reverted from BAS_REFERENCE and the EF F-skatt template moved to 2013 (#1409): the primary source (bas.se BAS 2026 v2 PDF) has no 2012 anywhere; the EF equity block is 2010/2011/2013/2017/2018/2019. 2012 "Avräkning för skatter och avgifter" is a Visma/Bokio/BL program convention that the swedish-year-end-closing skill had absorbed as if standard. Kept per-company charts that already have 2012 untouched (their history is legal and the account is a valid free slot); only the reference, the seeded template and the skill guidance change. [2026-08-10] committed_at overrides audited via a dedicated COMMITTED_AT_OVERRIDE action and a SECURITY DEFINER writer (#1444): reusing SECURITY_EVENT would overload attack semantics onto a sanctioned backdate, and a plain INSERT inside set_committed_at() fails under the pg harness where SET ROLE service_role has no BYPASSRLS and audit_log has no INSERT policy. EXECUTE revoked from anon/authenticated so PostgREST cannot expose the writer as an audit-noise RPC. [2026-08-10] Atom seed migrations gained a version-downgrade guard in the generator (#1483): each seed is a full 108-atom upsert built from its branch's tree, so two parallel PRs each carrying a seed meant the last-applied one silently reverted the other's atom bodies while skills:check stayed green (it compares files to the manifest, never the DB). The ON CONFLICT now skips rows whose registry version is newer than the seed's. Dance-event VAT stayed guidance-plus-descriptor only: no rate cutover logic in the engine, since 6% already exists as a treatment and the entry date decides the rate the user picks. [2026-08-10] Supplier betalfil (payment batches) ships pain.001.001.03 ONLY, no Bankgirot LB generator (founder call via scope questions): at ship time LB is dead at Handelsbanken/SEB and dies at Swedbank for supplier files 1 Sep 2026; Danske replaces LB with ISO 20022 during its Apr-Nov 2026 modernisation window without publishing a per-format date (27 Nov 2026 = all file communication must go via the bank; verified against danskebank.se 2026-08-10 after a bot review claimed 12 May, which is actually the bankgiro-alias initiation date). An LB path would have weeks of shelf life at the majors; the DB format CHECK still allows 'bg_lb' so a future LB or pain.001.001.09 addition needs no migration, the API Zod schema gates to 'pain001'. The supplier pain.001 generator (lib/payments/pain001-supplier.ts) reimplements the four tiny XML helpers instead of exporting them from lib/salary/payment/pain001-generator.ts: ~40 duplicated lines beat destabilizing a production-hardened salary dialect (2026-07-12 entry), and the two dialects genuinely differ (supplier files carry RmtInf SCOR/Ustrd and giro creditor addressing SESBA 9900 BGNR / 9960 BBAN; salary forbids RmtInf and marks CtgyPurp SALA). splitDomesticBankAccount IS shared so account routing can never diverge. Batches fix the documented no-regeneration-guard hazard (2026-07-26 entry) by construction: msg_id derives from the batch id at creation and CreDtTm from created_at, so re-download is byte-identical (bank dedup on MsgId works) and only a new batch mints a new MsgId. Generating/downloading a file books nothing: settlement stays in mark-paid/bank-match, matching the help_body direction that payment truth comes from the bank. [2026-08-10] Supplier pain.001 dialect corrected against a real Swedbank Validex run (MIG 1.0, eken.validex.net), which is stricter than the Bankforeningen appendix the generator was built from. Four generator changes: (1) MIG character set enforced by transliteration (a-acute to a, ampersand to plus, Swedish aao survive; leftovers become '?', matching LB padText), because rule 214 rejects e-acute in names outright; (2) InitgPty/Dbtr OrgId is now mandatory and the batch service refuses companies without organisationsnummer (rule 002), reading settings.org_number before the write-once companies.org_number; (3) BGNR creditors debit the company bankgiro when one exists, in their own PmtInf per (date, debit-form) group, because rule 219 demands BGNR-to-BGNR, with Cdtr PstlAdr/Ctry SE always present so IBAN-debited giro payments stay valid under rule 020 (v1 is domestic-only); (4) Strd carries RfrdDocAmt/RmtdAmt = the instructed amount (rule 217). Consequence accepted: the byte-identical re-download contract holds per generator version, not across dialect fixes; MsgId is unchanged so bank-side dedup still keys correctly. Validex account creation has a reCAPTCHA that was deliberately not circumvented; the founder registers and uploads.