Files
accounted/DECISIONS.md
T
Jakob Wennberg cce0de5704 feat(mcp): already-explained voucher guard at stage and commit for match_batch_allocate (#2294) (#2346)
* feat(mcp): already-explained voucher guard at stage and commit for match_batch_allocate

The dashboard match-batch route refused BATCH_TX_POSSIBLE_DUPLICATE when
posted, unlinked vouchers already summed to the bank row (PR #2300), but the
MCP door (gnubok_match_batch_allocate staging + commitMatchBatchAllocate)
called the RPC with no guard, so an agent could book a Bankgirot aggregate a
second time. The detector existed once; the guard lived in one door.

One shared decision helper, lib/invoices/already-explained-guard.ts, now
sits on top of the existing detectors (no fork) and is called by the
dashboard route, the MCP staging tools and the commit executors:

- gnubok_match_batch_allocate refuses to stage, coded
  BATCH_TX_POSSIBLE_DUPLICATE, naming the vouchers, the reconcile_match /
  link_transaction_to_journal_entry call that resolves the row, and the
  exact force + expected_journal_entry_ids binding.
- commitMatchBatchAllocate runs the same guard before the RPC and
  re-validates a staged force binding against the set detected at commit,
  so a stale approval cannot book a duplicate; 409 auto-rejects with the
  vouchers in result_data.
- force + expected_journal_entry_ids on the tool mirror MatchBatchSchema;
  an honoured override stages with a compliance_warning and, after the
  booking succeeds, writes BankTransactionDuplicateDismissed to
  behandlingshistorik (dashboard route included; it only logged before).
- gnubok_match_transaction_to_invoice and commitMatchTransactionInvoice get
  the dashboard's 1:1 soft-duplicate guard (MATCH_INVOICE_POSSIBLE_DUPLICATE
  / MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH) with force +
  expected_journal_entry_id; at commit it runs before the storno.
- Registry: both duplicate codes gain retryable: false and a remediation.

Catalog payload held under the 60K ceiling by trimming the two tools' own
descriptions (59 988 measured, ledger entry in payload-size.bench.test.ts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

* docs(decisions): record the 2026-09-06 ten-issue batch's first-principles choices

Carries the DECISIONS.md lines for PRs #2337 #2339 #2340 #2341 #2342 #2343 #2344 #2345 #2346 #2347 in one place so the ten branches do not conflict on this file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

* fix(mcp): refuse an unverifiable forced override, surface a failed duplicate check, validate the binding (#2294 review)

Review round on PR #2346 (CodeRabbit + compliance):

- guardAlreadyExplained returned 'clear' when the detector threw even with
  force=true, so a forced 1:N override could book without re-validating
  expected_journal_entry_ids and left no behandlingshistorik record. It now
  returns a distinct 'unverifiable' outcome under force (mirrors
  guardDuplicatePaymentVoucher); the dashboard route, the MCP staging tool
  and the commit executor all refuse it with the new registry code
  BATCH_TX_EXPLAINED_CHECK_FAILED (409, retryable, remediation). Regression
  tests on every caller.
- A detector failure without force still fails open at stage time, but no
  longer silently: the tools track onDetectError and stage a
  complianceNote, so preview_data.compliance_warning is set on both
  match_batch_allocate (GenericPreview renders it) and
  match_transaction_invoice (MatchTransactionInvoicePreview now renders
  data.compliance_warning through AttnLine).
- expected_journal_entry_ids / expected_journal_entry_id are validated at
  the MCP boundary (array of 1 to 10 non-empty strings / non-empty string)
  and refused with VALIDATION_ERROR instead of being silently filtered.
  No schema description text added: catalog payload unchanged.
- RoPA: .compliance/ropa.yaml gains bookkeeping.duplicate_dismissal_history
  for the BankTransactionDuplicateDismissed record (Art. 6(1)(c), BFNAR
  2013:2 p. 9.16, retention per BFL 7 kap, stored in processing_history).

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:56:36 +02:00

1.0 MiB
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-30] Link-RPC FX fallback books the residual as its own verifikat, keeps the existing CURRENCY_MISMATCH codes for every fail-closed case, and stays off on kontantmetoden: the linked voucher is posted and immutable so the residual cannot live inside it; reusing the codes avoids new TS error mappings while details.reason disambiguates; on cash method no receivable was ever booked, so there is no residual account to true up (migration 20260830140000). [2026-08-18] Shared-host white-label auth links use an exact NEXT_PUBLIC_WHITELABEL_DOMAINS allowlist and direct per-brand callbacks, with NEXT_PUBLIC_APP_URL as fallback: bouncing recovery through the canonical host would scope the recovery session cookie to that unrelated domain, while exact registered hosts preserve the brand session without trusting arbitrary Host headers or browser origins. [2026-08-18] Bokio connection validation uses the documented GET /v1/companies/{companyId}/company-information contract and treats only 401/403 as credential rejection: the removed bare company path returned 404 for valid credentials, while a 404 from the documented endpoint identifies the company ID and other failures are not evidence that the token is wrong (#1670).

[2026-08-13] E-invoice product-truth correction covers the MCP workflow skills and the MCP-exposed swedish-invoice-compliance atom: the atom's "for Accounted e-invoice generation" heading made the same unsupported product claim as issue #1577, so all active guidance now directs external delivery followed by gnubok_mark_invoice_as_sent; Peppol implementation remains tracked in #546. [2026-08-13] KU10 organisation identities normalize to the 12-digit 16-prefixed form and receive structural XSD validation without a new Luhn gate: Skatteverket KU schema 12.0 requires that shape, names FK201 UppgiftslamnarId, and explicitly leaves check-digit validation outside the schema.

[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-01] Brand sidebar re-tint via CSS selector (html[data-brand] .bg-frame > aside) instead of editing DashboardNav markup: element-level custom properties beat any :root/.dark specificity, DashboardNav stays untouched for the WL-02 deep-chrome light-text override, and default hosts get no selector at all (data-brand only stamped when a brand renders). [2026-08-01] SourceCodeFooter (WL-06) uses t.has('source_code') with a hardcoded sv/en fallback: the key lands in messages/*.json only in the wave-2 i18n merge (dev_docs/white-label/build/i18n-keys-branding.md), and rendering a raw key path on the login page in the interim was unacceptable. [2026-08-01] Mail slices (WL-13/WL-05): auth mail via the Send Email hook stays Swedish-only in lib/email/auth-templates.ts (Supabase's payload carries no recipient locale, and mail templates sit outside next-intl per .claude/rules/i18n.md); the reminder cron's env-less base-URL fallback moved from the literal app.erp-base.se to getBranding().appUrl (canonical NEXT_PUBLIC_APP_URL, identical whenever the env var is set, which prod always does). [2026-08-01] Cockpit landing (WL-14) decided at the login + mfa/verify pages via GET /api/clients/landing, not middleware or '/': a '/' redirect would break the soft switch's enter-client navigation (which targets '/'), middleware cannot afford brand+membership lookups per request, and a server action from /login is intercepted by the middleware's logged-in-user redirect; the fetch helper whitelists '/clients'|'/' and degrades to '/' so non-byrå flows stay byte-identical. [2026-08-01] Home-domain signpost gate (WL-01) lives in the dashboard layout, not middleware: the layout already has memberships + one cached brand query, middleware on Edge would pay brand lookups on every request, and the middleware active-company write-back is host-agnostic by design (navigation rule, not security); /settings/account and /clients stay reachable through the gate. [2026-08-01] Soft switch (WL-09) keeps a full-page load but to the TARGET page (performCompanySwitch destination param): the hard navigation is the deliberate cross-company leak-wipe (React state, router cache, in-flight fetches); soft client routing would need auditing every company-scoped cache and was explicitly allowed to stay a reload by the resolution as long as no start-page bounce remains. [2026-08-01] Tab-guard mutation seam (WL-09): window.fetch wrapped while the dashboard shell is mounted, guarding mutating same-origin /api requests only; there is no shared client fetch helper in the codebase to wrap, server actions POST to page routes (the blocking dialog covers them), and browser-direct Supabase mutations are already required to be server-routed per project rule. [2026-08-01] Byrå creation gate (WL-15) enforced in create_company_with_owner itself (migration 20260804113000) plus a readable-error precheck in createCompanyFromOnboarding: the RPC is SECURITY DEFINER and EXECUTE-granted to authenticated, so any app-level-only gate is bypassable via PostgREST. [2026-08-01] Phantom-columns unresolvable ceiling raised 360 -> 370 (count 361 after the white-label waves): no single wave file introduced an identifiable rewritable dynamic expression (the wave-window files' unresolved entries all predate the build), so the guard's own escape hatch (raise with documented reason) beats speculative refactors of working query code. [2026-08-01] appName sweep (WL-12 A4) left 5 literal classes as "Accounted": arsredovisning direktinlamning copy + DigitalInlamning personnummer note (statutory filing surface, the Bolagsverket agreement holder is legal identity), llms.txt + connect-claude docs + v1 API doc strings (API/MCP identity per the map's out-of-scope list), and Accounted:-prefixed storage keys/event names (wire format). When in doubt the sweep left it and reported, per WL-12. [2026-08-01] Tab-guard server-action seam (gap-fix): verified next@16.2.12 invokes client server actions via the bare global fetch (server-action-reducer.js) so the patched window.fetch DOES see them; the guard now blocks mutating action POSTs on mismatch, with the company-switch action allowed through via a markCompanySwitchInFlight flag (action ids are opaque per-build hashes, so a flag around the one sanctioned call beats id matching); blocking it would deadlock the dialog's own "switch back" exit. [2026-08-01] Browser-direct Supabase writes (gap-fix): exactly 3 sites existed (invoice status, SRU code, calendar deadlines); all routed through guardBrowserWrite() returning boolean instead of throwing, so call sites stay one-line and the blocking dialog (not a toast) is the user feedback. The guardStore moved from CompanyTabSync module scope into lib/company/tab-guard.ts so write sites share the same belief without React context threading. [2026-08-01] team/members/[id] uses withRouteContext without requireWrite: requireWrite gates on the ACTIVE COMPANY role, the wrong dimension for a team-scoped mutation (a byra owner may have any company active); the team owner/admin check in the handler is the authorization. Error bodies stay the team-family plain-string shape ({ error: string }) for consistency with the sibling invite/accept/members routes and their shared client parsing. [2026-08-01] Dark-mode ring lift (gap-fix, WL-02): lift threshold L < 45%, target L 62% (inside the prototype's 60-65% band); dark-mode --primary-foreground flips to near-black when white loses 4.5:1 on the lifted tone (the onboarding gate only guarantees white on the STORED color). Light-enough brands keep byte-identical dark blocks. [2026-08-01] Curated font menu (gap-fix, WL-03): lora/fraunces/playfair with Source Sans 3/Work Sans/Public Sans bodies, all Google Fonts OFL, no new npm deps (next/font/google is built-in); registered preload:false so menu fonts cost nothing on default hosts. [2026-08-04] SIE timeout fix via pgrst.db_pre_request hook, not ALTER FUNCTION SET statement_timeout: proved empirically that function-scoped statement_timeout never re-arms the already-running statement's timer (the 20260629160100/20260721144311 fixes were no-ops); pre-request runs as its own statement before the main query so set_config there works. Chose path-scoped hook over chunked import (smaller, fixes all three SIE RPCs at once, keeps 8s for everything else). [2026-08-04] Byra Nyckeltal v1 (WL-16) fetches per-client KPI via N parallel get_kpi_report_aggregates calls, not a new uuid[] RPC: zero migrations on the white-label branch and fine at the ~10-client byra scale; an array RPC is the scale path past ~15 clients. Cross-client comparison axis is CALENDAR months (clients have different fiscal years); cash/VAT are as-of the current fiscal period, never range-filtered. [2026-08-04] Byra brand self-service (WL-17) limited to the LOGO and APP NAME: domain and colors stay ops-managed (white-glove decision stands; a broken byra-set color/domain could brick their cockpit). brands has no write RLS by design, so writes go through /api/byra/brand/logo with service client + explicit owner/admin check, files under logos/byra/{teamId}/. Sidebar label beside the logo = app_name, byra-editable via PATCH /api/byra/brand (Emil re-decided from domain-prefix same day). [2026-08-04] Byra landing uses a session cookie (gnubok-company-picked), not a DB flag: middleware writes the fallback company back to user_preferences on first request, so the DB cannot distinguish picked from auto-picked. [2026-08-04] Brand COLOR theming removed from the request path (founder call): white-label = logo + app name + domain only, every host keeps the standard monochrome theme. buildBrandVarsCss, the WCAG gate and brands.brand_color/chrome_color stay in the codebase dormant for a future opt-in; the layout no longer stamps data-brand or renders brand-vars CSS. [2026-08-05] User-level home-domain affinity = middleware redirect (not signpost): byrå members bounce to their brand domain, brandless users bounce off brand domains to the platform URL; founder chose redirect knowing sessions are per-domain (one extra login at the right branded login). WL-01 signpost stays for per-company homing inside a domain.

[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] gnubok_link_documents_to_vouchers is search-only, shares the single tool's WORM precheck, and rejects an all-skipped batch. Search-only because a one-off bulk migration tool does not belong in the catalog every session pays for in context, and keeping it default pushed the tools/list projection past the 58.5K ceiling, whose own guidance prefers opt-in search over raising the budget. The WORM guard and the document lookup moved into a shared precheckDocumentLink rather than being copied into the bulk executor: two copies of a BFL 5 kap 6 § guard drift, and a bulk call must be exactly N single calls. Staging now also previews already_linked and duplicate_in_batch instead of resolving them as matched and skipping them silently after approval. A batch that links nothing returns 409 rather than a committed no-op: partial skips stay committed, but an approval-gated operation on räkenskapsinformation must not leave an audit record asserting a run that changed nothing. [2026-08-05] Trap-closing batch (G2/G3/G4/G10/G12/G14 from the aktivering/K3/projekt analysis) shipped as five separate PRs, so each entry below names the PR that carries it; nothing here describes code that lives only in this diff. Three non-obvious calls: (a) legal wording about WHEN switching frameworks is allowed was omitted from the K3-to-K2 dialog (this PR) because BFNAR 2012:4 is not covered by any repo skill, so the dialog states system consequences only; an interim K3_REQUIRED_FOR_UPPSKJUTEN_SKATT registry error was written and then deleted within this same PR when the founder decision removed the feature it gated, so it never shipped. (b) PR "assets K2 guard": account 1081 flagged k2_excluded in the TS BAS reference + data migration 20260805112000 for existing chart rows: BFNAR 2016:10 punkt 10.4 substance (skill k2-vs-k3.md line 67) outranks the incomplete 20260225103139 backfill list; 1260/1269 stay unflagged in this pass because no asset-register category range reaches 1260-1279, a full k2_excluded-list reconciliation against official BAS marks is a separate follow-up. (c) PR "year-end preflight blocker links": validateYearEndReadiness gained a REQUIRED blockers: {code,message}[] field with errors: string[] kept as a derived mirror, so the public v1 compliance route stays byte-identical while PreflightStep gets stable codes; the voucher-gap and trial-balance link branches in BlockerRow were deleted as dead code rather than given a new page. [2026-08-05] K3 uppskjuten skatt on obeskattade reserver ESCALATED, not changed: the dispositions builder books Dr 8940 / Cr 2240 = 20.6 % of gross 21xx in juridisk person, but swedish-asset-accounting (depreciation.md:88) states K3 29.37 keeps obeskattade reserver at gross in juridisk person with the 79.4/20.6 split reserved for koncernredovisning, so the entry double-counts the tax portion (result charged twice, 2240 overstated). Prod blast radius verified read-only 2026-08-05: 0 companies on K3, 2 companies with any posted 2240/8940 lines (3 entries, SIE-import shaped), and INK2 field 7651 adds the whole 8900-8989 range back so the error is tax-neutral. Per CLAUDE.md "stop and ask" on Swedish tax law this goes to the founder before any engine change; the K3 principle note was meanwhile reworded to describe what the engine actually books. [2026-08-05] K3 29.37 escalation RESOLVED same day, founder chose removal: the uppskjuten_skatt disposition (Dr 8940 / Cr 2240 = 20.6 % of gross 21xx) is deleted end to end in juridisk person: proposal step, POST kind, DispositionsStep case, K2-to-K3 account seeding, and the K3_REQUIRED_FOR_UPPSKJUTEN_SKATT error registered earlier the same day (the gate died with the feature it gated). latent-tax-calculator.ts keeps only LATENT_TAX_DEFAULT_RATE for analytical presentation (soliditet/justerat EK in build-data.ts); the 2240/8940 chart rows of already-switched companies are deliberately left in place (audit history precedent from the original seeding comment). Old clients sending the kind now get schema 400, chosen over a bespoke 410-style error because the kind no longer exists in the contract and the only known clients are our own UI and MCP, neither of which sends it. Refinement after the review round: 29.37 makes gross vs split an ELECTION for the legal entity rather than a prohibition, so what shipped is a product default (gross), not a claim that the split is unlawful; the deleted code implemented NEITHER election, since it credited 2240 while the BR still carried 21xx gross, and no company can be on the split method today (0 K3 companies on prod). Offering the split as a disclosed policy option is a possible future feature, and would have to ship together with the matching redovisningsprincip note and a BR that presents the reserves net. [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-11] Webshop Orders replaces the WooCommerce transactions feed with a core webshop_orders table (platform column, Shopify plugs in later) + core page/API; the extension keeps fetch/mapping and writes through upsertWebshopOrders() (same direction as ingestTransactions). Refunds are separate bookable rows with parent self-FK; external_id keeps the frozen woo_{scope}_order|refund_{id} scheme so the overlap with already-imported transactions rows is a string join (legacy_transaction_id cross-mark; booking route 409s both directions). Booked/invoiced rows get a DB financial-freeze trigger; remote drift sets remote_changed_after_freeze instead of mutating (storno is the correction path). [2026-08-11] Per-store payment-method -> account mapping lives in webshop_store_settings keyed by (company, platform, store_scope), not as JSONB on woocommerce_connections: survives disconnect/reconnect, readable by core without touching extension state, reused by Shopify unchanged. Mapping only prefills the booking dialog (manual base); persistence of a dialog override is opt-in ("Kom ihåg"). Invoice-paid orders convert MANUALLY to unnumbered draft kundfakturor (no auto-invoicing: webshop may be invoice-of-record; scraped orgnr never lands on legal fields unreviewed). [2026-08-11] default_voucher_series_per_source_type JSONB deliberately NOT reseeded for 'webshop_order' (resolver falls back to 'A'; same precedent as stripe_payout et al). webshop_orders deliberately has NO write_audit_log trigger: nightly sync upserts would flood audit_log; booking/invoicing are audited on journal_entries/invoices. Migration files carry apply-time versions 20260811073315-073422 (applied to staging via MCP; renamed per the orphan-fix recipe). [2026-08-11] Skeptic pass on webshop orders BLOCKED the first build; fixes shipped in the same worktree: (1) refunds now carry a VAT reversal (refund line_items fetched, else prorated from the parent's breakdown; a zero-tax refund of a taxed sale was the worst finding, silently over-declaring ruta 10); (2) booking is draft-claim-commit with a conditional link-back (.is journal_entry_id null), closing the concurrent double-book race, and drafts cancel voucher-free on a lost claim; (3) jsonb comparisons in the ingest are field-wise (Postgres does not preserve object key order; JSON.stringify falsely flagged every booked row as remote-changed); (4) VAT buckets are SIGNED (discount/gift-card lines book as revenue reductions, never abs-flipped) and fee_lines are part of breakdown + line snapshot; (5) legacy-feed guard honors transactions.is_ignored and also covers create-invoice; buttons stay visible and the server 409 guides (soft-guard rule); (6) cursor reset migration (20260811211418) makes the switch-over backfill 90 days so existing feed rows actually get cross-marked twins; (7) 0%-sale-to-non-SE and foreign-VAT advisories in the booking dialog, driven by new customer_country (20260811211335); (8) Shopify connections do NOT gate the Order nav until the Shopify sync writes webshop_orders. [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. [2026-08-10] Validex round 2 (1 fatal left): a present PstlAdr must carry TwnNm from November 2026 (rule 222), and the round-1 fix had added Ctry-only creditor addresses everywhere. Resolution follows Swedbank's own conditionality instead of stuffing TwnNm in unconditionally: BGNR-to-BGNR payments carry NO creditor address (rule 020 does not require one there), IBAN-debited payments carry the supplier's town (new nullable supplier_payment_batch_items.payee_city snapshot) plus Ctry SE, and the debtor gains PstlAdr TwnNm/Ctry from company_settings.city, which also clears the info-level rule 236. Rule 183 (BIC must be SWEDSESS) fires only because the sandbox debtor is a fake SEB account; real Swedbank customers resolve SWEDSESS from their own settings. [2026-08-10] Validex round 3 falsified the round-2 reading: rule 237 (creditor PstlAdr mandatory) fires on the BGNR-to-BGNR path too, so rule 020's "required unless DbtrAcct is BGNR" only governs the Ctry element, not the address as a whole. Creditor PstlAdr is now emitted on every payment, TwnNm from the payee_city snapshot when known, and the preview warns (payee_city_missing) when the supplier register lacks a city, since 237 + 222 together make a town effectively mandatory at Swedbank and a Ctry-only address is rejected today, not from November. [2026-08-10] Tax table percent rows (>80 000 kr/month) drop ore via Math.floor: whole-krona rule (oretal bortfaller) per SFF 2011:1261 22 kap. 1 § as applied by Skatteverket's tabellavdrag guidance (the statute governs stated amounts; Skatteverket's tables and guidance apply the same truncation to computed skatteavdrag); an API response missing either table section (30B or 30%) is treated as API failure so the bundled fallback serves complete data instead of clamping. Incomplete bracket data (gaps, failed pagination pages, malformed kolumn values) fails loudly rather than withholding 0. [2026-08-10] Staging DB reconcile (metjnjrhvujscngnpzdv): the tracker had skipped everything from 20260721101500 to 2026-08-10 (105 local-only versions) while 25 rows existed only remotely. Renamed 10 remote rows to their repo versions (same name, MCP apply-time version drift: sandbox-cleanup consolidation, shopify, tax-depreciation, JEL index), deleted 7 superseded sandbox-iteration rows with no local file, and left 8 rows from unmerged branches (white-label brands/teams, vacation columns, agent-atom product tier) untouched since their content is deliberately live for the byra rigs. Older seed_agent_atom_bodies files register version-only: each seed is a full idempotent upsert with a version guard, so only the newest seed's content needs to run. [2026-08-11] Verifikat header sort (belopp): PostgREST computed column total_amount(journal_entries) + the direct-query path (RPC bypass, same strict-period trade-off voucher sort already documents) instead of extending list_fiscal_period_entries_with_related: one 15-line SQL function versus a new RPC signature + regenerating every caller, and "Alla rakenskapsaren" needs the direct path anyway. Header toggle copies the invoices SortableHeader semantics (first click asc, second flips) over per-column natural direction: cross-page consistency beats the marginally better first click. [2026-08-11] Verifikat header sort is a tri-state cycle (asc -> desc -> default date_desc) after Emil's first click-test: with a persisted sort there was no way back to the resting order from the header. Deliberate divergence from the invoices header (two-state), which starts unsorted and has no default to return to. Multi-column stacked sorting declined: single active sort is the intended model; the second click replacing the first is standard table behavior, not a bug. (Superseded later the same day: shift-click stacked sorting shipped, see the stacked verifikat sorting entry below; the plain-click tri-state stays.) [2026-08-11] HTML mail invoices (invoice inbox): body-only and .html mails become text/html documents stored in the received form instead of a server-side PDF conversion; BFL wants the original form preserved, PDF rendering would need a headless-chromium dependency (declined), and the browser print dialog covers save-as-PDF. Rendering is fully sandboxed (iframe sandbox in the workspace, CSP sandbox header on /api/documents/:id/inline) because mail HTML is attacker-controlled; a new-tab/open-raw affordance was deliberately NOT added. text/html is accepted only via the email pipeline (EMAIL_ALLOWED_MIME_TYPES), not manual upload. [2026-08-11] Stacked verifikat sorting: shift-click appends a key (asc -> desc -> remove), plain click stays single-key tri-state, capped at 3 keys. Wire format sort_by=token,token keeps single tokens valid so old clients and previously persisted values need no migration. Voucher tiebreak follows the LAST key's direction to generalize the #972 same-direction rule; RPC path stays reserved for the single-date-key shape it can express. [2026-08-11] Transactions "Alla" tab source filter: un-gated the shared toolbar picker for both modes and removed TransactionHistoryList internal coarse picker instead of extending it: design convention 8 (one context picker per page); history filters client-side over the paged window, so "Ladda fler" pages unfiltered dates (same behavior as search). [2026-08-11] suggest-booking derives the proposed kontering on demand rather than storing it on the inbox row or computing it in the receipt hunt: a stored proposal goes stale against a corrected amount, a re-matched transaction or a template the company taught itself since, and the nightly hunt is already at its 300 s ceiling for a proposal most rows never open. It composes the existing evaluateMappingRules -> buildTransactionEntryLines chain rather than a second one, so the shown lines cannot drift from the posted lines. It withholds the proposal entirely on a foreign-currency row that matched via the mapping_rules branch: mapping-engine.ts buildResult computes VAT from the transaction's own currency while every other line is SEK (its own NOTE tracks this), which understates ingaende moms by the exchange rate and still balances, so nothing downstream catches it. Guarding the surface was chosen over fixing buildResult in this PR because that changes posted VAT amounts across every caller; the counterparty and static-template paths already convert correctly and are not withheld. [2026-08-11] Agent skills for the API ship as generated artifacts, not authored docs: skills/accounted-api/ is CI-checked output (apiskill:check) of scripts/api-skill/generate.ts, rendered from the same lib/api/v1 registry that serves the API and its OpenAPI spec, so the installable skill cannot drift from the server. Edit scripts/api-skill/overlays/ or the registry, never the output. The per-operation renderer is the portable tool inside skills/openapi-to-skill/ (the generic spec-to-skill generator): our own skill dogfoods it. Skills live in top-level skills/ because that is the directory npx skills add erp-mafia/accounted scans; the OpenAPI generator was extended to emit requestBody + path parameters (previously response-only) rather than teaching the skill generator to read Zod directly, so every spec consumer benefits, not just the skill. [2026-08-11] Gmail search fans out with a bounded pool (5 per connection) instead of Promise.all over every message id: Gmail enforces a per-user CONCURRENCY ceiling, not just a daily quota, and answers 429 "Too many concurrent requests for user" well below this app's volume. A real two-connection run returned mails=25 documents=0 purely from 429s. The catch in searchOne turned every refusal into an empty result, which is indistinguishable from an empty mailbox, and the manual hunt loop stops on fetched===0 as its "nothing left to find" signal, so the user was told their receipts do not exist by a search that never ran. searchFailureCount() now separates "could not look" from "nothing there", the run route returns it, and the loop treats a pass with failures as failed rather than finished. [2026-08-11] docs/DOCKER.md documented the Resend delivery webhook secret as RESEND_WEBHOOK_SECRET, a name no code path has ever read; the email extension reads RESEND_DELIVERY_WEBHOOK_SECRET (extensions/general/email/lib/delivery-webhook.ts). Fixed the doc rather than accepting the wrong name as an alias in code: an alias would keep both spellings alive forever for a variable that is already easy to typo, and the wrong one only ever reached operators through that one guide. docs/WHITELABEL.md already documented it correctly. [2026-08-11] Anthropic, Vercel and Supabase removed from the portal directory: all three email their invoices to European customers, so listing them told the user to go and log in for a document already in their inbox. The directory's bar is "does not send the invoice", not "also has a portal", and the poll it was seeded from asked which portals people log into, which people answered with where an invoice can ALSO be found. The same objection may reach further down the list; an entry is a claim that the invoice cannot be had any other way and is worth checking per vendor. [2026-08-11] Portal URLs are swept by scripts/check-portal-urls.mts rather than trusted: the directory shipped with 18 hand-written paths, none opened, the file said so and shipped anyway, and a founder then hit a 404 on Google Workspace (/ac/billing/history). A sweep found GitHub's /settings/billing 404 too. Rule now is the shallowest URL that certainly resolves: landing one click short of the invoice costs little, landing on an error page spends the trust the feature runs on. Google, OpenAI and Hetzner refuse automated requests, so they cannot be swept and are kept shallow deliberately; only a genuine 404 fails the script, since failing on an unreachable host would train people to ignore it. Trygg Hansa removed: neither candidate URL could be reached at all. [2026-08-11] Credit-note deduction fields (deduction_total, per-item deduction_amount) stay POSITIVE magnitudes, unlike every other amount on a credit note: both columns carry CHECK (>= 0) in the DB, and negating them made every ROT/RUT credit fail at insert (prod support case 2026-08-11). Verified inert: the reversing verifikat recomputes the ROT/RUT split from quantity/unit_price (generateRotRutLines), the PDF hides the deduction section for credit notes, getAmountToPay skips deductions when credited_invoice_id is set, and ROT payout candidates require status='paid', which invoices_credit_note_not_paid makes impossible for credit notes. Any future reader summing these fields across invoice + credit note must special-case credit notes. [2026-08-12] Company mark in user menu = initial monogram, not another lucide glyph: any stock icon is equally generic; square-vs-circle distinguishes company from person. [2026-08-12] overscroll-behavior-y: contain (not none) on html/body: contain kills Android pull-to-refresh and scroll chaining but preserves iOS rubber-banding; none would deaden document scroll. [2026-08-12] Fortnox and Bokio receipt imports share one private attachment adapter: keeping period resolution, WORM upload, per-verifikat hash deduplication, content sniffing, and best-effort counters in one loop prevents provider branches from drifting on compliance and idempotency. [2026-08-12] Provider receipt imports derive a document UUID from company, verifikat, and content hash: the existing document primary key becomes an atomic cross-request claim without a migration, while each attempt uses a unique storage object and a losing insert verifies the retained winner before removing only its own unreferenced object. [2026-08-12] The Fortnox migration completion prompt reports dry-run scanned attachments as "found", not would-link counts: provider availability is known before download, while content duplicates and final importability are only honest after the user starts the idempotent import. [2026-08-12] The Fortnox document follow-up resolves the provider from the completed preview and stays visible after a zero-result scan: preview consent data is authoritative across wizard state transitions, and a visible empty state prevents a successful migration from silently losing the promised document step. [2026-08-12] Fortnox document import stays opt-in after the SIE migration: the automatic follow-up performs only a dry-run count, and document downloads and WORM writes begin only when the user clicks the separate import action. [2026-08-12] Skattekonto booking claim race: the loser leaves its just-created draft unlinked instead of deleting it. lib/bookkeeping/engine.ts exposes no draft-discard function and journal tables are never raw-deleted; the delete_last_voucher RPC exists but is owner/admin-gated and lives outside the engine, so best-effort cleanup could fail on role and add phantom-delete audit noise. An orphan draft is legally deletable by the user in /bookkeeping; the request that lost the claim returns ALREADY_BOOKED and never commits. [2026-08-12] SLP on supplier invoices mirrors the reverse-charge zero-net pair mechanism (apply_slp item flag) instead of free-form credit rows: keeps the 2440 balance guarantee and the item model intact; year-end calculator nets off posted 7533 to avoid double provision. [2026-08-12] Enable Banking auth-method pin narrowed to hidden_method=true + psu_types match: PR #854's blanket decoupled pin broke Lunar-class banks; hidden-only restores its stated intent. [2026-08-12] Skattekonto bulk booking commits server-side per row (draft+commit in one request): avoids orphan drafts and 2N round trips; upcoming/duplicate-suspect/no-rule rows excluded. [2026-08-12] Kundfaktura status quick-filters stayed inside ContextPicker (counts + URL write-back), no chip row: founder lock "status views live behind a filter chip, not a seg". [2026-08-12] Detail pager uses sessionStorage list-context + router.replace, not server neighbor queries: the kundfaktura list order is client-computed and not expressible in PostgREST. [2026-08-12] Automatic logout is opt-in per user (user_preferences.auto_logout, default OFF), reversing the 2026-07 always-on session timeouts: founder decision after a user complaint about multiple daily re-logins. The objection that an opt-in security control is effectively a removed one was raised and overruled; the compromise levers kept are NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true (re-enables enforcement for everyone without a code change) and the opt-in snapshot living inside the signed timeout cookie (no per-request DB read; the preference is read only at mint, and the preferences API clears the cookie on change so a toggle takes effect on the next request). No backstop absolute cap was added: "off" means the Supabase refresh-token lifetime governs, exactly the pre-hardening behavior. Pre-toggle cookies (no autoLogout field) are authentic-but-stale and re-minted preserving their timers, never routed down the tamper path, so the rollout logs nobody out. [2026-08-12] Content dedupe on document ingest is an opt-in uploadDocument flag wired into the intake funnel (uploadAndExtract + mail-hunt ingest), NOT a unique index on (company_id, sha256_hash): archival callers (sent invoices, filings, bank exports) legitimately store repeating bytes and a blanket constraint would break them; the SELECT-then-insert race is accepted exactly as in the WhatsApp precedent. On a hit the funnel adopts the existing inbox item (callers always get a real inbox_item_id) or files an item against the existing document; the mail hunt skips outright since a second item would only duplicate work in Underlag. [2026-08-12] "One pending-op-owned component" (flows prereq, seam 8.3) is read as one preview + vocabulary + name-mapping (components/pending-operations/OperationPreview + vocabulary, lib/pending-operations/tool-name), NOT one chrome: the duplicated seam was the preview dispatch (three renderings, and ApprovalCard's 4-tool-name switch silently dropped every other hydrated type to raw JSON), while the chromes (list row on /pending vs inline chat card) are deliberately different surfaces, and forcing one over both would be a visual redesign of /pending requiring separate sign-off. [2026-08-12] Inbox "booked" state is derived server-side from the matched transaction (GET /items enrichment) in ADDITION to write-side created_journal_entry_id stamps, not stamps alone: created_journal_entry_id is UNIQUE (migration 20260515090000), so on a bulk-book samlingsverifikat only one of N matched items can ever carry the stamp; a stamp-only fix could not clear the reported flood of matched items on bulk-booked transactions. The constraint stays (it guards the book-direct double-fire race); the stamp becomes a fast path and the derivation the source of truth. [2026-08-13] Pulled archive/connectfile out of Fortnox DEFAULT_SCOPES (reverting the #1541 addition) instead of enabling them in the Fortnox Developer Portal: requesting a scope the registered app lacks makes Fortnox reject authorize with invalid_scope before login, which broke EVERY Fortnox connect in prod within minutes of the #1541 deploy (verified in Vercel logs, user willemduplessis999/TETTET). The attachment-import feature itself stays: it already degrades via 403 -> PROVIDER_DOCUMENT_SCOPES_REQUIRED with a reconnect follow-up. Re-add the scopes only after the portal registration has them approved. [2026-08-13] ChannelQuestionAsked/Answered/Expired registered in processing_event_types rather than dropping the appends: appendQuestionHistory swallows the FK violation by design so the WhatsApp reply still goes out, which turned a missing catalog row into a per-question production error nobody saw, and the question exchange is part of how the underlag was obtained (BFNAR 2013:2 kap 8). [2026-08-13] Underlag failures resolve the response where it fails (getResponseErrorMessage + status) and an expired session announces itself on the existing session-timeout BroadcastChannel, instead of a global authenticated-fetch wrapper: throw new Error(json.error) printed "[object Object]" for the structured envelope, so the middleware 401 on a backgrounded mobile tab surfaced as the generic "Något gick fel" with no way back; a wrapper would have to be threaded through every call site to buy the same thing here. Upload failures also post metadata (status, size, mime, resolved reason) to /api/log, the one API path exempt from the timeout gate, because a request answered before the route runs leaves nothing in the function logs and the user-reported failures were invisible there. [2026-08-13] Oversized phone photos are re-encoded in the browser (2400px long edge, JPEG q0.85 stepping down) rather than raising a platform limit or streaming straight to storage: hosted rejects any request body over 4.5 MB itself (measured against prod: 4.4 MB reaches the route, 4.6 MB returns a plain-text FUNCTION_PAYLOAD_TOO_LARGE), before the route runs and therefore invisibly in the function logs, while the route advertises 10 MB it can never receive. A downscaled photo is still a faithful, durably readable reproduction (BFL 7 kap), which a refusal is not. What cannot be shrunk (PDF, or HEIC where the browser will not decode it) is refused client-side with its actual size named, and 413 was added to the HTTP status map so a rejection in transit still says what happened. Direct-to-storage upload, which would remove the ceiling for PDFs too, is the follow-up, not this fix: it moves sha256/WORM integrity off the server. [2026-08-13] The book-route underlag fix landed as a pinned-document leg inside propagateUnderlagForBookedTransaction rather than the planned "extract categorize-core's propagation block into a shared helper": PR #1547 had already done that extraction overnight and wired /book and bulk-book to the shared helper, but the helper only walked matched inbox items, so a document pinned via transactions.document_id with no unconsumed inbox item (direct upload, or item consumed elsewhere) still booked to "Underlag saknas". Anchoring the pin inside the helper fixes /book, categorize, bulk-book and attach-after-book in one place; the pin is read fresh (not from the caller's pre-booking snapshot) so a concurrent attach still anchors, and the bulk-book RPC's own atomic doc-linking makes the leg a no-op there. [2026-08-13] Bank↔SIE match suggestions stored as three columns on transactions (potential_journal_entry_id/method/confidence, mirroring potential_invoice_id) with DB-trigger invalidation, not a suggestions table: one candidate per row matches the greedy matcher's contract, the enrichment/consume/clear paths are the exact shape the invoice hints already use, and triggers (self-clear on link/ignore, sibling-clear on consumption, clear on storno reversal) cover every write path including MCP and RPCs where app-side clearing cannot. [2026-08-13] /api/reconciliation/bank/run apply runs WITHOUT selected_matches now apply at the 0.9 unattended floor and persist the 0.75-0.89 band as suggestions, a deliberate behavior change from apply-everything-including-fuzzy: nobody reviewed those pairs, and auto-committing 0.75-fuzzy matches on an unreviewed run is exactly what the unattended threshold exists to prevent. Reviewed applies (selected_matches) keep the legacy no-floor behavior. [2026-08-13] "Granska migrerad historik" is a conditional third tab on the existing Transactions page (renders only while suggestion rows exist), not a new page: the plan called for a filtered view, the rows are transactions, and a permanent surface would advertise a migrator-only flow to every company. Its attn line yields to the SKV-reconnect line (max one per page, convention 6). [2026-08-13] The pre-migration row marker keys on sie_imports.fiscal_year_end, deliberately period-based: it labels which period a row belongs to. This is NOT the #917 trap (suggesting a sync-skip date from fiscal_year_end); the marker suggests nothing, and the account picker's migrator nudge uses fiscal_year_start to pull MORE history, never less. [2026-08-13] Staging migrations (20260813120000 [file since renamed to 20260813210000, see the rename entry below], 20260813121000) applied via execute_sql with explicit supabase_migrations.schema_migrations inserts under the exact file versions, instead of MCP apply_migration: apply_migration stamps its own apply-time version, which would leave staging's history diverging from the repo files mid-reconcile (the drift Emil is currently draining). Prod untouched; it gets both files at merge. [2026-08-13] /skeptic on bank-and-sie-match returned BLOCK (correctness + compliance skeptics refuted; regression could not). All four blocking defects fixed in the same worktree: bulk confirm now chunks at the schema's 500-id cap; all_accounts rejects (400) any combination with dry_run/account_number/selected_matches instead of silently applying on a requested preview; the sweep summary JSONB carries errors and the checklist note stays silent on an incomplete sweep; confirm revalidates the voucher's bank-leg amount AND direction at click time (closes the inline-rattelse staleness hole, since strike-and-replace keeps status posted and fires no invalidation trigger) and resolves NULL-cash_account_id rows via the PRIMARY cash account instead of hard-coded 1930. [2026-08-13] Two pre-existing holes the skeptic surfaced were deliberately NOT fixed here (scope containment): (a) the >= 0.9 auto-apply in runReconciliation has never written payment_match_log ('matched' is only logged by the invoice-match routes), which fails the log's own BFL 7:1 charter and predates this feature; (b) the match-invoice routes' storno-conflict branch reverses ANY linked journal entry without checking reconciliation_method, so a reconciliation link to a multi-event SIE verifikat can trigger a whole-entry reversal. Both need their own issues/PRs; this feature widens their exposed population, so they should land before or with the migrator rollout. [2026-08-13] Correction-chain depth guard measures depth by walking correction_of_id/reverses_id links (new lib/core/bookkeeping/correction-chain.ts), never by matching "Rättelse:" prefixes in descriptions: custom verifikationstext (allowed since #1031) would dodge any text-based check. Threshold 3, advisory with an explicit allow_deep_chain bypass on every surface (service option, REST body, MCP tool arg, UI confirm) per the standing soft-guard rule; Christoffer's agent chains hit 10 deep. The guard also fires at MCP staging time, not only at commit, so the agent reconsiders in the same turn instead of after approval. [2026-08-13] The reverse (storno) UI on the journal detail page got the same catch-and-confirm bypass as the correction dialog even though the plan scoped UI work to the correction dialog: reverseEntry carries the same guard, and without a working "Återför ändå" the guard would dead-end in a toast, which the soft-guard rule forbids. [2026-08-13] tools/list payload ceiling bumped 59K to 59.5K for the two allow_deep_chain schema properties (trimmed to one sentence first): the bypass is wire contract agents must discover to act on CORRECTION_CHAIN_TOO_DEEP, not trimmable prose. [2026-08-13] Bedrock stream retry is once per TURN, not per iteration, and only for classified transient failures (429/5xx/transport cuts/the two known stream-corruption signatures; any other 4xx is permanent). The failed attempt persists nothing (persist happens after finalMessage()), so the retry re-sends identical messages; the client discards the partial bubble via the new stream_restart event carrying the pre-attempt text snapshot. [2026-08-13] Kontantmetoden cut-off is a year-end readiness blocker with a staged MCP remedy, not a warning or a lock-time gate: BFL 5 kap 2 § requires all unpaid receivables and liabilities at fiscal year end, and a lock-time failure would occur after executeYearEndClosing has already posted its immutable closing entry. The staged preview freezes all cut-off and day-one reversal lines; approval re-collects the reskontra and refuses drift or any existing full or partial marker before posting through the bookkeeping engine. [2026-08-13] v1 categorize/batch-categorize wire the shared underlag propagation after the CAS write rather than inlining anchoring logic, and the route tests mock the helper to assert wiring only (called once per booking the request owns; skipped on partial success and lost CAS races): the helper's own semantics (pin anchoring, never-steal, failure isolation) are unit-tested where they live, and duplicating them at route level is what let the v1 surface drift out of the #1560 fix in the first place. Salvaged from the closed duplicate PR #1559: the attach-after-bulk-book samlingsverifikat test. [2026-08-13] Whole-krona skatteavdrag fix covers all four percentage paths, not just the plan's two exported functions: the inline flat-30% branches in calculation-engine.ts (fSkattStatus not_verified + no-table fallback) computed öre through r() under the same SFF 22 kap. 1 § rule, so leaving them would keep the defect alive in two live paths. Both now route through calculateSidoinkomstTax. [2026-08-13] Bank import: an explicit format choice that parses 0 rows falls back to auto-detect (info issue names both formats) instead of failing: an explicit bank pick must never underperform Automatisk identifiering, and the fallback result carries the detected format so external_ids equal the auto path. generic_csv is exempt: it is the manual column-mapping escape hatch whose default mapping legitimately parses 0 rows. [2026-08-13] BOM'd bank files decode with utf-8-sig semantics (BOM stripped at byte level, never re-included in the windows-1252 fallback), plus UTF-16 BOM support: kills the mojibake-prefix class that broke exact-match header detectors. [2026-08-13] franvaro audit trigger functions made SECURITY DEFINER (search_path pinned) instead of adding RLS policies on salary_absence_franvaro_audit: the audit table must stay client-unwritable, and an INSERT policy for authenticated would let clients forge audit rows. New DB_PERMISSION_DENIED (500) instead of reusing FORBIDDEN for 42501 in the absence path: an RLS denial of a trigger write is a server misconfig, not a user-permission failure. [2026-08-13] Bank import duplicate warning is advisory only: execute still sends all rows and ingest skips server-side; the preview deliberately runs without settlement account and mirrors, so copy never promises an exact number. [2026-08-13] Transaction cash-account move shipped as a dedicated PATCH endpoint plus UI action; manualLink's cross-account guards stay intact (users move the row rather than cross-linking a verifikat). [2026-08-13] Recon bulk matching = promotion of the existing preview/apply flow (attn line, autorun=1 deep link, strong-relabel, confidence_threshold on the app route) rather than a new engine; the review step is never bypassed and autorun fires only via the query param. [2026-08-13] MCP list tools fetch everything via fetchAllRows (bounded registries) instead of limit/offset pagination: no outputSchema growth against the 59K tools/list ceiling, which now has roughly 1 token of headroom after the audit_package egress note. [2026-08-13] Booking duplicate guard widened (sibling window +-3 days with exact-date-first total order, is_transaction_booked semantics, ledger twin de-exclusion) and the dialog offers Matcha/Ignorera for sibling candidates; renaming the "Att bokfora" tab is deferred to a founder call. [2026-08-13] SIE mojibake: prevention only in code (dead gateway SIE path deleted, CP1252-artifact tripwire that warns and never blocks); repairing affected companies' posted entries is a founder decision (rattelse RPC vs re-import with replace), not automated. [2026-08-13] Agent float panel: contain-on-open/resize via containFloatRect; clampFloatRect and the mid-session hang-off-the-edge allowance deliberately untouched. [2026-08-13] Issue #1408 audit aggregates 5820, 6072, and 2641 lines per journal entry and returns line-id arrays: individual line pairing creates false 25 percent matches on multi-line vouchers, while journal lines have no vat_rate field and tax_code is only non-operative metadata. [2026-08-13] Issue #1408 audit aggregates 5820, 6072, and 2641 lines per journal entry and returns line-id arrays: individual line pairing creates false 25 percent matches on multi-line vouchers, while dropping non-exact 6072 plus 2641 ratios misses defective representation hidden in mixed vouchers, so those remain as lower-priority manual review. Journal lines have no vat_rate field and tax_code is only non-operative metadata. [2026-08-11] AI credentials: added lib/ai/provider.ts, which resolves the backend from the environment (Tier 1 of issue #1406), instead of swapping Bedrock out for the direct Anthropic API. The swap was the smaller change but would have been impossible to contribute upstream: #1406 requires that hosted behaviour stay unchanged. Precedence when both credential sets are present: Bedrock wins, so that adding an ANTHROPIC_API_KEY cannot silently move production inference out of eu-north-1 (EU residency is a deliberate BFL/GDPR posture, not a default). AI_PROVIDER=bedrock|anthropic is the escape hatch. Model ids are written bare in code and prefixed to eu.anthropic.* only for Bedrock, because on-demand throughput requires the cross-region inference profile; an operator override that already carries a prefix passes through untouched. @anthropic-ai/sdk was declared explicitly at 0.95.0, the version bedrock-sdk 0.29.1 already pulled in transitively, so the lockfile dedupes to a single copy with no new download. Call sites converted: agent composer, invoice-inbox extraction, document-extraction model label, and both receipt-hunt clients (the last two are not named in the issue, which predates receipt-hunt landing in main). [2026-08-11] AI credentials: added lib/ai/provider.ts, which resolves the backend from the environment (Tier 1 of issue #1406), instead of swapping Bedrock out for the direct Anthropic API. The swap was the smaller change but would have been impossible to contribute upstream: #1406 requires that hosted behaviour stay unchanged. Precedence when both credential sets are present: Bedrock wins, so that adding an ANTHROPIC_API_KEY cannot silently move production inference out of eu-north-1 (EU residency is a deliberate BFL/GDPR posture, not a default). AI_PROVIDER=bedrock|anthropic is the escape hatch. Model ids are written bare in code and prefixed to eu.anthropic.* only for Bedrock, because on-demand throughput requires the cross-region inference profile; an operator override that already carries a prefix passes through untouched. @anthropic-ai/sdk was declared explicitly at 0.95.0, the version bedrock-sdk 0.29.1 already pulled in transitively, so the lockfile dedupes to a single copy with no new download. Call sites converted: agent composer, invoice-inbox extraction, document-extraction model label, and both receipt-hunt clients (the last two are not named in the issue, which predates receipt-hunt landing in main). [2026-08-11] Self-hosted: the 30 s poll in lib/hooks/use-document-extraction.ts left alone even though #1406 names it. It is a symptom of missing credentials rather than a bug in itself: with AI configured, extraction finishes in 2-8 s and the wait disappears. The real fix is for the extraction-status endpoint to report "not configured" instead of leaving the column NULL forever, which is a separate change. [2026-08-13] Tier 1 self-hosting packages the document-extraction extension in the stock image: a plain ANTHROPIC_API_KEY must cover documents uploaded in the app as well as emailed invoices and the assistant to satisfy issue #1406; this does not add provider abstraction or other Tier 2 work. [2026-08-13] document-extraction's manifest no longer lists AWS_REGION under requiredEnvVars: the extension accepts either AWS static keys or ANTHROPIC_API_KEY, which the manifest schema cannot express as alternatives, and requiredEnvVars only drives a build-time warning rather than gating execution.

[2026-08-13] dependabot.yml added as security-updates-only (open-pull-requests-limit: 0) with an ignore on @anthropic-ai/bedrock-sdk >=0.30.0: enabling scheduled version bumps repo-wide just to pin one package would create review noise, and the exact 0.29.1 pin alone does not stop an automated bump PR from reintroducing the 0.32.0 empty-stream outage class; the ignore covers both paths.

[2026-08-13] Renewal-flood guard reuses runReconciliation in the PATCH /accounts backfill instead of adding an import-time matcher: the manual /sync route and the cron already run the identical SIE-overlap gate plus unattended-threshold sweep, and a third matching implementation would drift. Unlike those two sweeps, the new call resolves a per-ledger-account scope via resolveCashAccountScope (the documented #1290/#1298 call-site fix) because the backfill knows each enabled account; a scope resolution failure skips that account's sweep rather than widening to the pooled unscoped form.

[2026-08-13] Gap-fill renewal default anchors on the connection's newest imported transaction (feed coverage), not ledger coverage: the flood is a feed-level re-request problem, and ledger coverage already has its own suggestion box in the picker. The gap-fill start subtracts 7 days because external_id dedup makes overlap free and banks book some rows late. The async default only flips the radio to gap-fill if the user has not touched the group before the probe lands.

[2026-08-13] Skeptic refutation fix on the renewal guard: the picker's gap-fill probe keys its state by connectionId and lists accounts in its deps, so the pre-existing reset effect (re-runs on every accounts identity change, e.g. the panel's visibility refetch after a BankID app switch) can never wipe the renewal default without a matching re-probe. Backfill sweep hardening from the same pass: accounts whose cash_accounts row did not resolve (found: false) are skipped instead of degrading to the pooled currency-only form, and the sweep window opens at the oldest returned booking date when the bank over-returns history. resolveGapFillStart clamps to the backend's 365-day floor so the shown date always matches the actual backfill start. [2026-08-13] WhatsApp decline observability (#1552) reuses whatsapp_messages with content-free rows for unknown-sender declines instead of a new table or aggregate RPC: no migration (no orphan risk), the wamid unique index gives redelivery dedupe for free (a redelivered bad-code or greeted message no longer earns a second reply), and the existing 30-day unknown-sender retention pass already deletes the rows. Write amplification from an over-quota flood is bounded by a 20-rows-per-hash-per-day trace cap, not by dropping the trail entirely. The settings panel gets a closed event enum derived server-side (lib/last-event.ts), never raw error_message text, so internal errors cannot leak to the client. [2026-08-13] Issue #546 ships a provider-agnostic Peppol BIS Billing 3 XML export with strict Swedish preflight, not a fake send path: certified access-point delivery, SMP lookup, receipts, inbound handling, credentials, and commercial terms depend on Emil selecting and contracting a multitenant provider, and the existing email delivery state cannot truthfully model those guarantees. [2026-08-13] Issue #546 Peppol transport foundation uses separate immutable staging, event, and evidence records with a disabled send gate: Corner 3 transport is not buyer acceptance, raw out-of-order events must remain auditable, and Storecove or Qvalia credentials plus a verified webhook contract are still external product inputs. [2026-08-13] Kontantmetoden year-end VAT supersedes the 2026-08-06 VAT-reporting premise: BAS 2618/2628/2638 and 2648 feed the final declaration, reverse-charge purchases include both VAT sides and their basis, and only the mechanical day-one reversal is excluded from later VAT periods. Skatteverket requires unpaid invoice VAT in the final period and warns against reporting it twice after year end. [2026-08-13] Per-account VAT treatment is class-aware and explicit values override the static BAS mapping; SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. [2026-08-13] Per-account VAT treatment is class-aware; explicit values extend custom accounts while canonical accounts keep their static BAS momsdeklaration mapping. SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. VMB carries no default account rate because its VAT base is the margin, not gross sales. [2026-08-13] AGI needs two SKV scopes, not one: added agdredovisningperiod (hanteraredovisningsperiod: kvittenser/las) alongside agd (inlamning) in the per-flow DEFAULT_SCOPES, after a real prod filing signed fine and then 403'd on "Hamta kvittens". Pinned with a scope-set regression test since this is the third scope-cleanup casualty; the AGIPanel missing-scope banner now checks both, because a token with agd alone fails only at the last step. [2026-08-14] Server-side Supabase clients now go through createServiceRoleClient() (lib/supabase/service-client.ts) instead of importing supabase-js's createClient directly. autoRefreshToken defaults to true and auth-js starts the 30 s refresh ticker unconditionally off-browser; the ticker calls unref(), so the process still exits and nothing fails in CI or on Vercel, where the process is short-lived. But unref() does not make a timer collectable: it stays a GC root for its callback and retains the GoTrueClient, the SupabaseClient and the entire request scope captured around it (socket, IncomingMessage, ServerResponse, headers, route context; roughly 100 kB per client). A self-hosted instance died of heap exhaustion after 42 h, the last 24 completely idle, with a heap snapshot showing 445 retained request graphs and ~1050 Timeouts in the 30 000 ms bucket retained via autoRefreshTicker; the rate matched traffic exactly (healthcheck every 30 s plus the per-minute webhook dispatch cron = 3 clients/min x 148 min = 444). 22 call sites were migrated; only app/api/settings/booking-templates/sync/cron/route.ts had ever passed the options. SERVER_AUTH_OPTIONS is spread LAST inside the helper so a caller passing its own auth block cannot re-enable the ticker, and check 9 in scripts/checks/no-new-antipatterns.mjs fails CI on any new value import of createClient (type-only imports are fine). Browser clients are deliberately untouched: a signed-in tab genuinely needs the refresh, and lib/supabase/client.ts uses @supabase/ssr's createBrowserClient anyway. [2026-08-13] Correct the shipped VAT-treatment constraint in a new migration: the predecessor PR reached main while the replacement review was active, so the immutable original migration stays byte-identical and the class-aware vocabulary is enforced additively with NOT VALID plus validation. [2026-08-13] Issue #1457 supersedes the stale canonical-account exception above: an explicit per-account VAT treatment always overrides BAS fallback, while a reverse-charge treatment derives the editable 25 percent standard rate only when no booking default exists; class 5-6 SIE accounts require review when a treatment is actually suggested, not for every ordinary expense account. [2026-08-13] AGI needs two SKV scopes, not one: added agdredovisningperiod (hanteraredovisningsperiod: kvittenser/las) alongside agd (inlamning) in the per-flow DEFAULT_SCOPES, after a real prod filing signed fine and then 403'd on "Hamta kvittens". Pinned with a scope-set regression test since this is the third scope-cleanup casualty; the AGIPanel missing-scope banner now checks both, because a token with agd alone fails only at the last step. [2026-08-13] Reversed the #1250 rule that the APIGW "required scopes are not authorized" body must name only the subscription: production proved the same body also fires when the TOKEN lacks the scope (AGI kvittens needed agdredovisningperiod), so naming one cause and hiding the other presents a coin flip as a diagnosis. The message now names both knobs plus the refused API path, and system mode points at SKATTEVERKET_SYSTEM_SCOPES again. Classification is unchanged (ACCESS_DENIED, still not a reconsent code): a reconnect only helps once the scope exists, so it can never be automatic. [2026-08-13] Issue #1408 separates evidence classification from lock and VAT overlays, and treats import and correction sources as false positives for live-template provenance: 1,316 of 1,361 production signature matches came from those sources, so a broad account signature must not become a correction queue. [2026-08-13] Issue #1408 does not route locked candidates through the current correctEntry service or an unlock: the service creates its storno in the original period, so a compliant locked-period correction needs a separately tested open-period storno and replacement path that preserves the original and full rättelse trail. [2026-08-13] The AGIPanel kvittens-scope notice is dismissible (persisted per granted scope string), unlike the hard agd banner: all existing hosted tokens lack agdredovisningperiod, and until Skatteverket's application registration carries that scope a reconnect mints the same grant again (SKV silently drops unregistered scope names), so a hard reconnect demand would be the #1010 un-clearable banner, each futile attempt costing a BankID round-trip via disconnect. Keying dismissal by the exact grant keeps a futile reconnect quiet while a widened grant re-evaluates from scratch. [2026-08-13] Web picker no longer advertises HEIC/HEIF (accept lists in InvoiceInboxWorkspace): supersedes the 2026-08-01 HEIC decision (runtime sharp transcode + hint). The transcode+hint stays as fallback for drag-drop/API/channel HEIC, but without heic in accept iOS transcodes photo-library picks to JPEG, which makes AI extraction work and shrinks 413 risk; magic-byte validation now covers real HEIC/HEIF via ftyp brands instead of a blanket exemption. [2026-08-13] Skipped the planned lib/browser/downscale-image.ts module: lib/documents/shrink-image.ts + upload-size.ts already shipped the client-side downscale wired into the inbox uploadFile (hosted 4 MB ceiling, 2400px long edge, honest refusal message); a second overlapping compressor with different thresholds would create contradictory policies. [2026-08-13] WhatsApp M1 greeting: media from unlinked senders bypasses the 1-hour throttle behind a 10-minute burst window (3/day cap kept): a photo is a receipt someone expects to be handled, and pure hour-long silence taught senders their receipts were being filed; one burst still earns exactly one reply. In-channel silence after stopp stays absolute (only the literal start is read): stopp is an opt-out and any other reply would violate it, so the escape hatch is app-side instead (POST /link/unmute + panel button), where the user is already authenticated and asking. [2026-08-13] Staged inbox upload re-adds 'processing' to invoice_inbox_items.status (migration 20260813213000, supersedes 20260504180000's tightening, which assumed extraction stays synchronous): web uploads insert the row before Bedrock runs with extracted_data NULL, so the existing MCP create-from-inbox null-guard blocks booking in-flight rows for free; the gnubok_list_inbox_items status filter enum was deliberately NOT widened (tools/list payload sits ~25 tokens under the 57 500-token cap per 2026-07-26; unfiltered listings already return 'processing' verbatim); the sweep cron flips stale processing rows to 'received' with the empty skeleton instead of re-extracting (the UI retry button covers re-runs, and a cron silently re-spending Bedrock tokens on every crash would hide the crashes). [2026-08-13] payment_match_log CHECK migration renamed 20260813120000 -> 20260813210000 at merge time: main gained its own 20260813120000 (fix_franvaro_audit_trigger_definer) while this branch was in flight, and identical versions abort the Supabase apply with a schema_migrations_pkey duplicate. Staging's tracking row was updated to the new version in the same step, freeing the 20260813120000 slot for main's migration when staging reconciles. [2026-08-13] PR #1598, compliance findings closed with the rollout after the Swedish accounting review escalated them from follow-up to fix-with-rollout: (a) runReconciliation's >= 0.9 auto-apply now writes 'matched' to payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); (b) the three match-route storno-conflict branches no longer storno-reverse a reconciliation-linked verifikat: a reconciliation link points at an independent verifikat that may evidence other affarshandelser, and a wholesale reversal is an over-broad rattelse (BFL 5 kap 5 §). The detach is DEFERRED (round 2, CodeRabbit): nothing is persisted up front; the final transaction update overwrites the pointer and clears reconciliation_method in the same write, so a failure anywhere in the match flow leaves the existing link intact, and the release is logged as 'unmatched' after the commit. [2026-08-13] PR #1598, CodeRabbit findings: confirm-suggestions maxDuration 300; lookbackTouched on the migrator nudge buttons; persistSuggestions on main's post-backfill sweep; sie_sweep stamp errors logged; sandbox keeps the CSV CTA (file import works there); payment_match_log CHECK swap now NOT VALID + VALIDATE (no table-scan under ACCESS EXCLUSIVE); every logMatchEvent call awaited (serverless can freeze unawaited work). [2026-08-13] Historical audit gap quantified on prod (read-only): 762 manual-method links across 52 companies since 2026-03-23 have no payment_match_log row (upper bound: includes linked_to_existing_voucher drops AND older unlogged manual paths). Not backfillable (the inserts never landed); the links themselves are intact on transactions. Recorded here as the explicit ops note the compliance review asked for. [2026-08-14] AGI whole-krona handling is truncation (oretal bortfaller, SFF 22 kap. 1 par.), never rounding, with the remainder credited to 3740 in the avgifter voucher, Visma-style: rounding inside the salary run (Dooer-style) was declined because it distorts the exact 7510 cost and per-employee amounts. [2026-08-14] FK487/2731/payment amount follows Skatteverket's own computation (IK587): per avgiftssats on the whole-krona underlag sums, with youth/vaxa cap splits, in lib/salary/declared-avgifter.ts. First attempt truncated the engine's ore-exact sum; the skeptic pass refuted it (diverges kronor from SKV's draw on multi-employee ore rosters). Two accepted 1-kr-scale approximations documented in the module header: trunc(basis) instead of per-FIELD truncation, and per-(category,sats)-cell truncation so the category breakdown cross-foots exactly. Amount overrides (FoU-avdrag) fall back to per-category truncation of the overridden amounts. [2026-08-14] agi_declarations.total_tax/total_avgifter store the DECLARED whole-krona amounts (was: ore-exact run totals). The payment file pays those for whole-krona rows but keeps paying ore-exact for legacy ore rows: their salary bookings credited 2731 with ore, and truncating the payment would strand the ore on 2731 (skeptic regression finding R3). [2026-08-14] tax_withheld_override requires whole kronor (schema .int()): an ore override would book 2710 with ore the whole-krona skattekonto draw never clears. [2026-08-14] SUPERSEDED (same day, by the booked-verifikat entry below): journal preview hidden for booked/corrected salary runs: a preview built by today's booking rules would contradict immutable verifikat booked under earlier rules (skeptic regression finding R2). [2026-08-14] Amount overrides route all three legs (booked 2731, stored/filed FK487, payment) through the SAME hybrid computation (overridden rows contribute their manual amounts per category; colleagues keep the SKV-exact underlag computation); basis-only overrides are inert on money totals: a basis override never reaches the filed IU fields, so letting it steer FK487/payment would file an FK487 contradicting the declaration's own IUs and underpay the skattekonto (skeptic round-2 finding). The booking detects amount overrides via an explicit flag, never the magnitude band, so a small deliberate adjustment cannot book as fake oresutjamning. [2026-08-14] No .int() guard on avgifter_amount_override/avgifter_basis_override (unlike tax_withheld_override): the override editors prefill ore-exact effective values (feedback: prefill override editors), so rejecting ore would 400 a saved prefill; the per-category truncation absorbs ore consistently on all legs instead. [2026-08-14] Regenerating a legacy-booked period's AGI post-deploy flips its stored totals to declared integers, so the payment then leaves the old booking's ore residual visible on 2731 (skeptic finding F3): ACCEPTED. The alternative preserved a false equilibrium (overpaying skattekontot by the ore forever); the residual is real, one-time, and manually clearable against 3740. [2026-08-14] Booked/corrected salary runs show the ACTUAL posted verifikat (via the preview route) instead of a recomputed projection: today's booking rules would contradict immutable vouchers booked under earlier rules (skeptic finding R2/F2: gating the preview away entirely removed the page's only voucher view). [2026-08-14] v1 REST book route gets full override parity with book-run (tax/net/avgifter coalescing + the override flag): the skeptic showed a run with review overrides booked via v1 would diverge from the AGI by the full override delta. F-skatt rows ignore avgifter overrides on BOTH booking surfaces, mirroring the AGI's isFSkattRow invariant (an override on an F-skatt row must never book social charges the declaration excludes). [2026-08-13] MCP account_override (custom accounts via categorize) implemented as a new shared helper lib/bookkeeping/account-override.ts used by the MCP staging preview and categorize-core commit path, mirroring v1 REST semantics (business-side replacement, class-2 VAT drop with 2610-2649 exception) plus a same-account degenerate guard v1 lacks; the v1/internal REST routes keep their inline copies untouched: refactoring them into the helper would widen a scoped fix into a three-surface regression risk. Divergence from REST: MCP rejects account_override + category 'private' explicitly instead of silently ignoring it (agent surfaces get deterministic errors, not silent drops). [2026-08-13] gnubok_create_voucher staging gate softened to engine semantics inline (already-fetched chart rows + getBASReference) instead of calling findUnresolvableAccounts: identical verdicts, zero extra queries, and the preview gains will_activate_accounts + BAS-name fallback so the approver sees the auto-activation side-effect. [2026-08-13] suggest_categories rules on accounts outside the fixed maps now surface as expense_other/income_other with the rule's own account and a neutral Swedish match_reason (no MCP jargon: the same suggestion renders in the web UI); previously such rules were silently dropped (agent-reported as "4020 not available"). [2026-08-13] Öresavrundning av nettolön: round-UP-only to whole kronor, booked on 3740 as a derived 'oresavrundning' line item (semesterersattning pattern), no per-employee carry-forward: rounding down would underpay wages, 3740 is the codebase's established rounding account, and a carry-forward ledger is not worth the complexity for max 99 öre/employee/month. [2026-08-13] totalEmployerCost excludes the öresavrundning amount (skeptic finding): payslip summary, KPI cards and lönejournal recompute employer cost from stored columns, so an engine-only inclusion printed two different totals on the same payslip; the öre cost lives in the ledger as the 3740 debit instead. Manual 'oresavrundning' line items are schema-blocked: they are the only type the booking excludes from the gross reconciliation, so a hand-created one structurally unbalances the verifikat. [2026-08-13] Startkort empty states use inline gradient scrims over their strata images: this is imagery treatment inside a hero surface, not card chrome, so the "no bg-gradient on cards" rule deliberately does not apply there (and nowhere else). [2026-08-13] Startkort webp assets (public/startkort/) are rendered outputs from the strata-engine in the CRM workspace, with per-file sources and flags recorded in components/dashboard/startkort-assets.ts; regenerate there, never edit the webp files by hand.

[2026-08-13] Renaming a migration after the Supabase preview applied it orphans the PREVIEW tracker, not prod: the preview branch for PR 1605 had recorded 20260813180000, the rename to 20260813213000 removed that file, and the next Migrations task aborted with "Remote migration versions not found in local migrations directory". Repair = delete the orphaned version row from supabase_migrations.schema_migrations on the preview branch project only (prod never applied it, so merge-time apply is unaffected), then re-run tasks with a fresh commit. The migration itself is drop-if-exists idempotent so the replay under the new version is safe. [2026-08-14] PR #1608 round-2 compliance finding (VMB class-3/4 VAT hole): account_override now books GROSS with no auto-VAT line unless the caller stated explicit VAT intent (vat_treatment or vat_amount). Deliberate divergence from v1 REST, which keeps the category-default standard_25 on overrides: MCP callers are agents, and a forgotten flag must under-deduct (lawful), never fabricate an ingående-moms deduction on a margin-scheme account. The review's class-appropriateness suggestion (block e.g. expense onto 27xx) was noted but not implemented: the approval gate is the guard, and a class matrix would block legitimate balance-sheet bookings v1 REST supports today. [2026-08-14] Skatteverket connections are per (user, company): the stacked UNIQUE(user_id) + UNIQUE(company_id) pair is replaced by UNIQUE(user_id, company_id), every token read/write is company-scoped, and /skattekonto/saldo returns 401 NOT_CONNECTED when the active company has no row. A multi-company operator connects each company separately; legacy NULL-company rows are ignored by scoped reads and just require a reconnect. [2026-08-14] Pristine empty-state gates key on all-years emptiness (a count probe), never on the scoped list result: a default fiscal-year selection is a scope, not a filter, and it made the bookkeeping start card unreachable on brand-new companies. [2026-08-13] Radius ladder locked (convention 16): 4 tiers by role (pill toolbar controls / rounded-xl overlays / rounded-lg surfaces / rounded-sm leaves); rounded-md, bare rounded, rounded-2xl and rounded-[Npx] retired app-wide, hard-failed by check:guards off-ladder-radius. Before: 7 radii in circulation with no rule; one toolbar row on /transactions mixed 4 shape languages. [2026-08-13] Toolbar shape language: pills won over "inputs stay rectangles". Search in a page toolbar is a pill (ToolbarSearch, h-8) matching chips/pickers/buttons beside it; the same search inside a dialog or form keeps rounded-lg Input. Rationale: convention 8 already made pickers pill chips and convention 3 made buttons pills, so the rectangle search/segmented were the odd ones out; one row = one shape reads as trust. [2026-08-13] Dialogs promoted 8px -> 12px (rounded-xl): SettingsModal, slide-over and CommandPalette were already 12px, so dialog.tsx was the overlay-tier outlier, not the rule. [2026-08-14] SKV manual-verifikat deep link passes row data in URL params instead of a new single-row GET: the list endpoint is enrichment-heavy, core cannot import the extension, and the match endpoint re-validates everything server-side (tampered params can only prefill an editable form). [2026-08-14] Manually created verifikat from the SKV deep link keeps source_type 'manual' (not 'system' like the extension's own drafts): the user typed it; the coupling is the journal_entry_id backlink set via the existing match route. [2026-08-14] SEB Transaktioner layout gets native seb-profile support (#1616), not a new format or generic_csv mapping: detect keys on the Insättningar/Uttag column pair (unique among supported formats, cannot steal files), and an unsigned Uttag magnitude is normalized to negative since the column is withdrawals by definition. User-provided sample rows are the verbatim fixture; #1565's explicit-choice fallback had nothing to fall back to because no profile parsed this layout at all. [2026-08-14] QuickReviewDialog inbox picker links picked docs with inbox_item_id but deliberately NOT transaction_id: the dialog's existing upload path never pinned transactions.document_id either, and adding the pin would be a drive-by behaviour change to uploads. Known asymmetry with TransactionBookingDialog, which pins both; revisit as its own change if the underlag indicator matters on rows booked from att-göra. Verified safe on the compliance side: since migration 20260703160000 every "saknar underlag" predicate (verifikat_without_documents, transactions_without_documents, the MCP tools, full-archive-export, the worklist badge) keys on document_attachments.journal_entry_id, never on transactions.document_id, so an unpinned row is never reported as missing underlag. [2026-08-14] The onboarding branch question ("Var fanns bokföringen innan?") became its own journey step at the existing KLART station (done -> source, same station grammar as momsyn/moms under MOMSEN) instead of a sixth rail station: a 6-point rail crowds the 680px band's 150px label slots, "BOKFÖRINGEN INNAN" would sit next to the existing "BOKFÖRINGEN" station, and mode='add' (which never sees the branch question) would need an asymmetric rail. The done screen ends in a revealed Fortsätt that dispatches DONE_CONTINUE (mode='first' only, reducer-guarded). [2026-08-14] Migration /preview fetches ALL allowed fiscal years (dropped latestOnly): the connect step's "Hittade X konton och Y verifikationer" renders from /preview's sieStats, not /sie-data's generateImportPreview, so fixing only /sie-data would have left the founder-reported "0 verifikationer" (actual: 4153) on screen. Costs one SIE export per extra year at connect time, the same work /sie-data repeats right after; honest counts won over latency. [2026-08-14] /sie-data validation stays newest-file-only (not per-file, not on the merged parse): preserves exactly which datasets are accepted today, and validateSIEFile assumes single-file invariants (balance yearIndexes relative to ONE current year) that mergeParsedSIEFiles deliberately does not preserve. Older files' problems still surface per-file at import time. [2026-08-15] Register detail de-box (customers/suppliers/articles): added new def_* label keys next to the old colon-suffixed and inline-format keys instead of renaming or deleting them: message files are only ever edited line-wise (duplicate-key hazard forbids JSON round-trips), so dead keys are cheaper than a risky rewrite. Also converted the customer invoice list's per-row status Badge to muted text with a chip only for Förfallen, per the locked "chips mark exceptions" convention: paid on every row was the same-chip-on-every-row bug the convention names. [2026-08-15] Employee form save-scope fix: moved the self-saving sections (Förmåner, Ingående saldon) OUTSIDE the page

under a "Sparas separat" kicker with a one-line scope hint, kept them as Cards (locked section pattern; the task-suggested hairline no-box region would deviate from every sibling section) and rejected the tab/sub-page shape (page has no tab structure, would be more invasive). This also fixes a real double-fire: shadcn Button has no default type, so the panels' save/add/delete buttons inside the form implicitly submitted the whole employee PATCH on every click; opening-balances now has its own whose save enables only when dirty. [2026-08-14] SKV manual-verifikat deep link payload moved from URL params to single-use sessionStorage (supersedes same-day URL-params decision): compliance swarm flagged financial data in query strings landing in history/access logs/Referer (GDPR Art.5(1)(f), ISO A.8.12); URL now carries only the opaque row id. [2026-08-14] SKV prefill sessionStorage XSS window accepted as residual risk (ISO A.8.12 low, swarm PR #1621): script execution already implies full ledger read via authenticated APIs; a server-issued staging token adds a roundtrip, not protection. Documented in manual-verifikat-prefill.ts header. [2026-08-15] BankID mobile stranded-tab fix: the session id never reaches the browser; it lives in a signed __Host- HttpOnly cookie (extensions/general/tic/lib/bankid-flow-cookie.ts) that /poll, /complete, /link and /cancel read. Reported bug: BankID does not reliably return the user to the tab that started the flow (outside plain Safari the iOS https redirect goes to the OS, which opens a NEW tab), the session lived in per-tab sessionStorage, so the new tab rendered the start button and the completed signup was stranded. Login hid it because its completion is self-finishing and the Supabase session is a cookie every tab shares. Cookies being origin-wide is exactly what the handoff needed, so there is no client storage, no cross-tab lock and no heartbeat. TWO REJECTED ATTEMPTS, both killed by adversarial review before merge, both worth not re-deriving. (1) localStorage plus an owner/heartbeat lock: a TIC sessionId is an unauthenticated bearer credential (/bankid/poll is skipAuth and returned user.personalNumber verbatim; /bankid/complete with mode 'login' returns a tokenHash that verifyOtp turns into a session, MFA skipped via bankid_linked), so origin-wide persistent storage turned ANY same-origin XSS into a login-fixation primitive, and the design also deadlocked in the very flow it fixed (the hidden tab stopped polling, the new tab cleared the record, and the storage event never reaches a tab iOS has frozen). (2) The first cookie version: it pinned mode but not the USER, so on a shared browser an abandoned link flow bound the first person's personnummer to the second person's account, which is the same class of flaw that killed (1); its cookie also used a narrow Path, which a script can shadow by setting the same name at a LONGER path (RFC 6265 sends longer paths first, and a Max-Age=0 at the shorter path cannot delete it); and its "single-use" was a Set-Cookie, which does not serialise two requests that already carried the cookie. Consequences now, all deliberate: __Host- with Path=/ and unconditional Secure, because the prefix forbids Domain and forces Path=/, leaving exactly one possible (name, domain, path) and making both the longer-path shadow and a subdomain toss unsettable (Secure costs nothing since isBankIdEnabled() is false when NEXT_PUBLIC_SELF_HOSTED is set, so the earlier conditional-Secure only risked shipping unprotected behind a proxy that omits x-forwarded-proto); readBankIdFlow fails closed on two cookies of the name rather than picking a winner; SameSite=Lax not Strict, because the BankID return is a top-level navigation from outside the site; a link flow requires auth at /start and pins userId, and /link rejects a flow belonging to anyone else; single-use is a unique index (bankid_consumed_sessions, migration 20260815120000) claimed BEFORE generateLink, since serverless has no shared memory and a second magic link invalidates the first; the claim fails closed on any error other than 23505; account_exists deliberately does NOT consume, so a mistyped e-mail is correctable; the window is 300s for the order and re-issued at 900s once /poll observes completion, because the signup e-mail step is a person typing and on the old design it was bounded only by TIC's retention; /poll whitelists its response fields, so the personnummer is no longer returned to anyone, and clears the flow when TIC 404/410s or answers without a status, so a dead session cannot make BankID look unavailable for the rest of the window. The client resumes from ONE probe against /poll, never a readable hint cookie: a hint goes stale, cannot tell a live order from a dead one, and made the component poll on mount in states where nothing was in flight. link never auto-resumes at all, since picking one up without a click is how an abandoned flow binds to the next person. Android stays on redirect=null; no Android report motivates changing it and #194 closed that path deliberately. [2026-08-15] BankID flow cookie, final security invariants after a third skeptic round refuted the user-pinned-cookie version: a signed HttpOnly cookie is NOT a browser binding. It proves the server minted the value, not that this browser started the flow, so an attacker can mint one with curl, authenticate it with their own BankID, and leave it in a shared browser; and for login/signup there is no user to pin (unlike link). The auto-resume that fixes the stranded-tab bug is therefore the thing that made a completed identification transferable between PEOPLE: the next person to open /login (BankID is the default panel) would be signed straight into the planter's tenant with no prompt, and on /register would be invited to finish a signup binding the planter's personnummer to an account made with their own e-mail. Fix: a completed flow is resumed AUTOMATICALLY only when a per-tab sessionStorage marker (lib/auth/bankid-flow-ownership.ts) says the person here started it; otherwise BankIdAuth shows a "fortsätt bara om det var du" confirm card (status 'resumable') that never names the holder (naming would leak a stranger's identity). The marker is per-tab and cleared at every terminal state and on cancel, so a stale marker cannot authorise resuming a same-mode flow another tab started; the BankID app returning to a NEW tab lands in a tab without the marker, so it too must confirm, which is correct. link still never auto-resumes at all. Second, the re-issue-on-completion window (900s so the signup e-mail step does not expire mid-typing) was unbounded: /poll pushes expiresAt out, and with no fixed origin a caller could poll in a loop to keep a usable identification alive for TIC's whole retention. Added startedAt to the signed state and a MAX_TOTAL_LIFE cap (order window + verified window) enforced in verifyBankIdFlow. Third, /poll and the dead-session branch no longer CLEAR the cookie: a clearing Set-Cookie is untargeted, so a slow response about a dead session would delete whatever flow is in the jar by the time it lands, including one just started in another tab; the client settles on the status instead and the cookie expires on its own. Only /cancel (the user pressing Avbryt, client-awaited before starting anything new) and the terminal /complete + /link exits clear. Also: /poll rejects a body mode that does not match the cookie's mode (a login session must not be finishable through the signup panel); every /complete and /link exit that has minted or consumed settles; readBankIdFlow catches decodeURIComponent so a malformed cookie is "no flow" not a 500; the raw session id is logged only as an 8-char prefix. Single-use is bankid_consumed_sessions (migration 20260815120000) claimed before generateLink, fail-closed on any non-23505 error, so a deploy MUST apply the migration before the code or every BankID auth returns session_invalid. [2026-08-15] BankID flow, corrections after a fourth skeptic round. (1) sessionStorage is NOT "gone when the tab closes": it is restored by reopen-closed-tab (Ctrl+Shift+T), browser session restore, and tab duplication, so the per-tab ownership marker can be inherited by a different person, which reopened the exact silent-auto-login-as-a-stranger vuln the marker was meant to close. The marker is therefore no longer trusted on its own: BankIdAuth auto-resumes (no confirm) ONLY a flow that is still PENDING on the mount probe's first observation AND flowStartedHere(mode). An already-complete flow finished before this mount existed and always routes to the confirm card. The pending check is the real boundary (you can only auto-consume a flow you watched complete live); the marker just distinguishes same-tab-reload from arrived-from-elsewhere among pending flows. (2) The component's own Avbryt raced the untargeted /cancel cookie clear: it set status 'idle' synchronously (start button reachable) then awaited /cancel, whose late Set-Cookie Max-Age=0 could delete a freshly started flow's cookie. Added a 'cancelling' status that renders a spinner and no start button until /cancel resolves, so no /start can race the clear. (The register Tillbaka already had the isCancelling disable.) (3) /poll returned the holder's givenName/surname on complete, readable by a probe before the confirm gesture, contradicting the card's no-name guarantee. The name is now withheld from a probe (request carries { mode }) and returned only to the active poll loop (no mode), which the signup e-mail step needs and which runs only after ownership/confirm. Confirmed sound and not changed: the MAX_TOTAL_LIFE cap (startedAt in the signed payload, re-issue preserves it, hard cap 1200s from /start); /poll not clearing the cookie; single-use consume before every mint; the mode-match check skipping the active poll (empty body → mode undefined). Known residual, documented in the PR: a person at the EXACT same abandoned tab another left mid-flow is out of any client marker's reach and degrades to ordinary unattended-authenticated-tab risk. [2026-08-15] BankID resume: the per-tab sessionStorage ownership marker (bankid-flow-ownership.ts) was REMOVED, superseding the two prior entries that built on it. It could not close the residual it was meant to: sessionStorage is restored by reopen-closed-tab/session-restore/tab-duplication, so the marker can be inherited by a different person, and there is no client signal that survives an iOS same-tab reload (a navigation) yet dies on a restore (also a fresh load) - both look identical. Since no client-side token can prove "the person here is the one who started this flow" across any fresh document load, the only correct rule is to never auto-consume a resumed flow: the mount probe now routes ANY found live flow to the confirm card ("Fortsätt bara om det var du"), and only that click starts polling and consumes. Auto-consume happens ONLY inside the live component instance that called startSession (desktop QR click; the pre-navigation mobile launch), which by construction is the person who started it. Cost: one "Fortsätt" tap after returning from the BankID app on iOS (new tab or same-tab reload) - exactly the population with the reported stranded-tab bug, for whom a labelled one-tap continue is clearer than a silent resume anyway. Desktop QR (no reload, live component) and Android (returns to the live tab without reload) never hit the resume path and are unchanged. This fully closes both the complete-flow and pending-flow restore vectors and deletes the module plus its unsound premise. [2026-08-15] Underlag import (attaching a folder of receipts to SIE-migrated verifikat) matches on journal_entries.source_voucher_series/number, never on our own voucher_number: the importer renumbers per target series, so a file named after our number would land on the wrong verifikat exactly when the import skipped an empty/unbalanced voucher. The (period, series, number) resolver was lifted out of extensions/general/arcim-migration into lib/documents/voucher-ref-resolver.ts and is now shared by the provider sweep and the filename flow, so both have one resolution truth. [2026-08-15] Underlag import is a SEPARATE optional import mode (/import?mode=underlag), not a step inside the SIE wizard (founder call 2026-08-15): the receipts normally arrive later and from a different export, so a migration must never be blocked on having them ready. [2026-08-15] A filename that parses to a number with no series (31.pdf) is resolved but NEVER auto-selected, even when the lookup returns exactly one candidate, and a date-shaped name is refused outright rather than read as a voucher number. Linking a document to a posted verifikat is irreversible räkenskapsinformation (BFL 7 kap), so the cost of a wrong parse is permanent and the cost of asking is one click. The date guard is deliberately looser than the parser (unpadded components, two-digit years, space and slash separators, and any bare four-digit year-shaped number): a false positive costs one manual assignment, a false negative costs a permanent wrong link. [2026-08-15] Every underlag-import batch is scoped to a fiscal year the USER declares, and resolution filters candidates to that year before the index is built. Rejected the original design, which resolved company-wide and treated "exactly one candidate exists" as proof of identity: source systems restart voucher numbering annually and a filename carries no year, so with a partial migration (or with that year's A31 among the vouchers the importer routinely skips as empty/single-line/unbalanced) a 2023 receipt was silently and permanently attached to a 2025 verifikat. Cardinality is not identity. Scoping cannot make the year inferable, so it makes it asserted; the year is read back in the confirm dialog because it is the one input the files cannot corroborate. Found by the /skeptic pass on PR #1627, not by the tests. [2026-08-15] SUPERSEDED same day, see the entry below: the attach route took the fiscal year for its re-resolution from the TARGET entry, never from the client. Picking among candidates the server itself proposed is NOT an override: only a filename the user resolved by hand sets that flag. Flagging candidate picks would have switched the consistency check off on exactly the ambiguous rows it exists to protect. [2026-08-15] Column lists for the two voucher-ref reads are written inline at the call site instead of a shared VOUCHER_SELECT constant: tests/schema/no-phantom-columns.test.ts resolves .select() literals by AST scan, so a constant is opaque to it and hoisting dropped all eight journal_entries columns out of the phantom-column net (and pushed the unresolved ceiling from 377 to 379, failing npm test). The provider sweep and the plan read now select different lists anyway: the sweep resolves thousands of entries and needs none of the display columns. [2026-08-15] SUPERSEDES the entry above: the attach route takes the declared fiscal year from the REQUEST (echoed back from the plan the user reviewed) and asserts it equals the target entry's own fiscal_period_id, before any other check and including overrides. Deriving the year from the target was tautological: an entry is by construction inside its own period, so the filter could never drop it and the year axis was unfalsifiable. Server-side year enforcement was zero; it existed only as React state. The regression test that "proved" otherwise passed only because the mock let one journal_entries row report two different fiscal_period_id values to two different reads, a state Postgres cannot produce. Found by the second /skeptic pass on PR #1627. [2026-08-15] The wizard snapshots the plan's fiscal year and every downstream read (summary, confirm dialog, manual re-resolution, each attach request) uses that snapshot, never the live picker. The picker stays interactive while a preview of up to 2000 filenames is in flight, so a confirm dialog reading live state could read back a different year than the plan was built from, which is worse than no confirm dialog at all. [2026-08-15] ver is excluded from being a voucher series in filename parsing. Without it ver 31.pdf parsed as series VER and came back auto-selectable, while the spelled-out Verifikat 31.pdf correctly yielded a series-less reference needing confirmation: the same filename got two trust levels decided by an abbreviation, and the loose one was the abbreviation. The date guard also covers day-first and US order (31.01.2024, 12-24-2024), where the day would otherwise become a voucher number that always exists in the year. [2026-08-15] FyPicker gained an opt-in requireExplicitChoice prop and the underlag import uses it. Default behaviour (auto-select the newest period when nothing is stored) is right for a filter, where a sensible default beats an empty page, and wrong where the year is an ASSERTION the user is making: the third /skeptic pass showed the wizard silently pre-filled the newest year, so a user migrating 2023 receipts who never opened the picker would resolve them against 2026, get matched rows pre-selected, and have only the confirm dialog between that and permanent links. The whole "the user declares the year" guarantee was fiction until this. A previously stored explicit choice for the same surface is still restored; no other caller changes behaviour. [2026-08-15] requireExplicitChoice gates FyPicker's ENTIRE auto-selection block with one outer condition, not individual branches, and also suppresses the localStorage write. The fourth /skeptic pass showed the per-branch gate had missed the restore branch, so the guarantee held only for a user's first-ever batch: from the second on, the year was silently pre-filled from an earlier unrelated batch, and in a multi-year migration last-used is the worst possible default since the user is by definition moving to a different year. The write suppression matters because FyPicker persisted BEFORE onChange, so a pick the wizard rejected mid-preview would still have been recorded. Within one sitting, the wizard's reset() carries the year across batches; nothing else does, deliberately. [2026-08-15] Filename parsing demotes (never refuses) collision-famous refs from auto-selection: A0-A6 (paper sizes; every scanner emits A4.pdf), K2-K13/N1-N9/T1-T2 (Skatteverket blanketter), Q1-Q4 (quarters), and any three-letter series (IMG/DSC/DOC/SCN are cameras; real SIE series are 1-2 chars). Two independent /skeptic passes flagged the same inconsistency: 31.pdf required confirmation while A4 scan.pdf, which carries LESS voucher evidence, was pre-ticked. Verifikat A4 exists in every migrated ledger, so refusing would orphan the genuine A4 receipt; demotion costs it one click. Residual accepted and documented: an existing 1-2 letter series plus a small number in an ad-hoc name (B2 hyra.pdf) is indistinguishable from a real ref by filename alone. [2026-08-15] The attach route runs the filename resolver on EVERY request; override is honored only when the filename is unresolvable in the declared year (no parse, or zero candidates), never for a filename the resolver can place elsewhere (CodeRabbit major on PR #1627). The shipped UI only overrides rows whose filenames resolved to nothing, so the tightening costs it no capability; it closes the API path where a lying client set override=true and scattered cleanly-named underlag across arbitrary same-year verifikat. planAcceptsTarget was renamed planPermitsAttach to carry the override semantics in one place. [2026-08-15] Declined (recorded per /resolve-pr triage): classifying validateDocumentFile failures by regex on the localized message in the attach route stays as-is; it mirrors the established pattern in app/api/documents/route.ts, and moving the validator to coded returns is a core-service contract change outside this PR. The Swedish-review notes on idempotency (content hash IS in deterministicDocumentId) and the period-lock error regex (DB trigger is the real guard) require no change. [2026-08-15] Underlag may attach only to posted or reversed verifikat, enforced in the attach route AND in the resolver reads (Swedish-review round on PR #1627). The SIE import RPC posts every entry inside its own transaction, so a draft with a source ref should be unobservable; the enforcement exists because the link is irreversible and an invariant living in another file is not one this surface may lean on. Reversed stays attachable: a storno'd original remains räkenskapsinformation and its underlag belongs on it. [2026-08-15] Confirmed intentional (Swedish-review note): with override=true and an unresolvable filename, the attach endpoint links a document to any same-company, same-declared-year, posted verifikat, migrated or not. This mirrors /api/documents/[id]/link, which imposes no filename check at all, so it introduces no new capability class; tenant, year and period-lock enforcement always apply. [2026-08-15] BankID tabs bind to a random non-secret flowId signed into the shared flow cookie and sent as a request header after start or explicit resume: mode pinning alone cannot distinguish two same-mode tabs, so an older tab could otherwise silently follow, cancel, or complete a newer person's identification after /start replaced the origin-wide cookie. This supersedes the 2026-08-15 decision that deliberately skipped mode matching on active polls. [2026-08-15] Did not apply BankID migration 20260815120000 to Supabase staging during PR #1625 follow-through: read-only reconciliation found 14 staging-only and 99 branch-only migration versions, so applying on top of that divergent ledger would violate the no-orphan rule. Production is reconciled with zero remote-only versions and exactly this PR migration local-only; hosted pg-real validates the migration until staging is reconciled. [2026-08-17] Klarmarkera (closed_externally) ships without a pg-real test: the migration is one additive boolean column, no trigger/RPC/RLS change; the close-path guards are unit-tested in period-service.test.ts. [2026-08-17] MCP article refs on create_invoice only, not update_invoice: the update tool's full-replace item semantics need their own design pass; also refuse cross-currency price prefill instead of converting, the agent must pick the currency explicitly. [2026-08-17] Session replay masking inverted to deny-by-default (founder-directed after user pushback on session recording): ALL input values are masked (maskAllInputs with no maskInputFn, so rrweb masks wholesale; placeholders are attributes and stay visible) and ALL text is masked unless it sits under data-ph-unmask chrome or a th (dry-table column headers are raw th per page, so the mask function treats th as chrome rather than tagging hundreds of sites). Chrome tags live on the shared primitives (PageHeader, Label, Button except role=combobox triggers which render selected values, TabsTrigger, Badge, Card/Dialog/Sheet titles and descriptions, tooltips, help popovers, empty states, settings labels); tagged chrome is still pattern-scrubbed for amounts and identity numbers, and data-ph-mask beats data-ph-unmask so call sites that interpolate user data into chrome stay masked. Toasts (title AND description) deliberately NOT tagged: they interpolate user data at too many call sites to audit, and an audit found live leaks (deadline titles, bank account names) in titles alone. Confirm-dialog wrappers (ConfirmDialog, ConfirmationDialog, DestructiveConfirmDialog) force data-ph-mask on their titles/descriptions centrally: convention 10 makes confirm copy describe the object being acted on, so it is user data by design; that one change closed 14+ audited leak sites. A very-thorough audit of user data flowing into unmasked primitives ran in the same change and every found site got a call-site data-ph-mask. Failure mode for untagged new UI is over-masking, never leakage. Supersedes the 2026-08-06 pattern-based default; privacy policy and RoPA updated in the same change. [2026-08-17] Betalfil missing-bankgiro UX: advisory warning in PaymentFilePanel (download stays enabled, route stays the authority) + click-to-prefill from tic_snapshot instead of auto-seeding company_settings.bankgiro: sender payment data must be user-confirmed, and the snapshot is unvalidated registry JSON. [2026-08-16] /transactions FyPicker double-fetch fixed by gating the initial fetch on FyPicker's existing onReady (fires after its restore onChange) instead of the analysis doc's literal "read the persisted period synchronously in initial state": localStorage only holds the period ID, not the FiscalPeriod bounds, so a synchronous read would suppress FyPicker's restore (value !== null) and leave the fetch permanently unscoped while the chip claimed a year. Same outcome (one scoped fetch per mount, background refetch on period change) without a stale-bounds cache or new FyPicker API. [2026-08-16] Row exit animation for dry-table rows collapses via td padding/line-height/font-size transitions plus a numeric max-height (.row-collapsible) on the fixed-height cell spans, not grid-template-rows 0fr (the AttGoraSection pattern): table cells cannot host the grid wrapper without restructuring every td, and max-height needs a numeric rest value because auto/none does not interpolate. prefers-reduced-motion hides the exiting row instantly (display: none) while the 350ms timer does the state cleanup. [2026-08-16] Restyled QuickReviewDialog's inbox-picker trigger to the same full-width dropzone-footer row as TransactionBookingDialog even though it did not share the orphan-button layout: both surfaces come from #1620 and should present the same underlag affordance; the alternative (leaving a small outline button in one dialog and a footer row in the other) would split the visual language of one control. Presentation only, disabled-while-booking kept (PR #1628). [2026-08-17] Supplier auto-match now keys on vat_number between org_number and name, in one shared lib/suppliers/match-supplier.ts instead of five inlined copies: extraction deliberately leaves orgNumber null for non-Swedish entities, so momsregistreringsnumret is the only exact key a foreign supplier has and an exact name match was the sole remaining fallback. Normalisation is done in JS over the suppliers that carry a vat_number at all, because PostgREST cannot normalise in SQL; a prefix-vs-no-prefix pair (SE556012579001 vs 556012579001) matches, two different country prefixes never do. [2026-08-17] retry-extraction re-runs the supplier match but writes matched_supplier_id only on a positive match, unlike the other extraction paths which write null too: "Tolka om" is the affordance a user reaches for precisely when auto-linking failed, and clobbering a supplier they then picked by hand would be a worse regression than leaving a stale link. [2026-08-17] Batch "Bokför valda" ships ONE aggregate toast with an "Ångra alla" action instead of dropping undo from batch toasts: per-row undo is feasible today (each booked row storno-reverses via POST /uncategorize, the same endpoint as the per-row Ångra), so the aggregate action just pools it over every booked row. Silent mode suppresses the per-row success toast and the generic failure toast only; interactive escalations (SI/CI match suggestions, duplicate warning, activate-account) keep their dialogs because they are the only way forward for those rows. [2026-08-17] /pending: a FAILED fetch for a tab whose rows are not on screen HOLDS the loading state (spinner + error toast) rather than clearing rows to the empty state: "Inget att granska" after a failed load would be indistinguishable from a genuinely empty list, and BFL-relevant pending work must not look done when it is unknown. [2026-08-17] Full-archive direct download resurfaced as an ImportRow + small centered dialog on /import's Exportera tab (row "Komplett arkiv", hash #full-archive), not by re-mounting the orphaned components/settings/BackupDownloadForm.tsx: the form was pre-frame card styling with a duplicate cloud-backup section, while the export tab's existing SIE dialog sets the house pattern (ImportRow -> sm:max-w-md dialog). Its logic (estimate, 413 handling, last-download stamp) ported into components/import/FullArchiveDialog.tsx; the orphan and its dead settings_backup_download i18n namespace deleted. The dialog reuses FiscalYearSelector despite design.md's "legacy, no new uses" line: FyPicker is a toolbar context chip, and the SIE dialog in the same file already uses FiscalYearSelector for the identical dialog-form slot, so matching it beats introducing a third pattern. Row gated to owner/admin because GET /api/reports/full-archive enforces that role server-side; showing members a download that can only 403 helps nobody. [2026-08-17] Article picker now overwrites the line's ROT/RUT (deduction_type + work_type) from the article's housework_type, INCLUDING clearing it when the article has none: article-defines-the-row is the established applyArticle semantic (description/price/unit already overwrite), and keeping a RUT flag when switching a row to a material article would silently claim a deduction on material (HUSFL labor-only rule). Kundkort personnummer prefill is a server-side fallback in buildInvoiceWriteData (typed > stored draft > kundkort), never a client prefill: customers.personal_number reaches the browser only as ciphertext/mask by design, so the editor just relaxes the required-mark and says where the number will come from. [2026-08-17] Arcim's "610 bilagor i Hela historiken men 100 i räkenskapsåret" in the full-archive dialog is NOT a pagination bug: verified against prod, exactly 100 documents are linked to posted vouchers in the single (extended) fiscal year and 510 are unlinked inbox/receipt docs, which scope=all includes by design (same split as cloud backup's year-ZIPs vs Grunddata.zip). Kept the semantics, fixed two things instead: estimateArchiveSize's period branch ran one unpaginated read with one flat IN() over every entry id (undercounts past the PostgREST row cap, URL blowup past ~a few hundred ids) -> now CHILD_FK_CHUNK-chunked and fetchAllRows-paginated like writeDocuments already was; and the dialog now states per scope which document set is counted, so the gap reads as intent, not as a bug. [2026-08-17] Supplier standardkonto empty-string fix lives in the API schemas, split by verb: '' normalizes to undefined on create (key dropped, column NULL) but to null on update, because update routes pass validated fields straight into .update() where undefined means "leave unchanged"; without the null mapping a cleared standardkonto/e-post would silently never clear. Client keeps sending '' as-is (the old email-strip hack removed), since stripping client-side would break exactly that clear path. The field itself became an AccountCombobox filtered to cost classes 4-7 (matches the agent-path expenseAccountField rule); other 4-digit numbers stay typeable, and the API still enforces format only. Standardkonto stays optional: it only prefills supplier-invoice lines, and the ledger-context suggestion covers the empty case, so requiring it (what the bug accidentally did) is wrong for the target user. [2026-08-17] Replay-masking skeptic round (PR #1639): explicit data-ph tags now resolve BEFORE the th chrome fallback in replayMaskText (a single closest over tags-plus-th let a th nested in a masked container win on DOM proximity, CodeRabbit); seven missed text-leak sites got call-site masks (delete-invoice number, credit-page number, IB voucher ref, TIC orgnr since TIC serves it unnormalized so the separator scrub cannot be relied on, articles search term, dimension segment labels, activate-account buttons); the attribute channel (placeholders prefilled with effective values, title tooltips) is handled with rrweb's blockClass: user-data placeholders carry ph-no-capture, which removes the element from the recording while app UX keeps the founder-approved prefill-override pattern intact. Chose ph-no-capture over stripping the placeholders because the prefilled effective value IS the UX. [2026-08-17] Skattekontoutdrag file import (Sebastian's request) writes into skattekonto_transactions, not into transactions as a pseudo-bank with 1630 unlocked in BankFileConfirmStep: rows inherit the skattekonto_rules 1630 booking engine, matching, drift and both UIs for free, while the literal ask would bypass the rules and double against the SKV inbox for connected companies. Dedup pairs file hash-keys with API id-keys by CONTENT in both directions (import-time skip/promote against existing rows, sync-time takeover that rewrites an imported row's key in place so journal links survive connecting the API later). Import is free for everyone per the requireSkvCapability doctrine (manual paths never blocked); only sync/saldo stay capability-gated. The parse route hard-rejects files that fail detectSkattekontoFile and statements whose opening+sum!=closing, and warns on orgnr mismatch against company_settings: wrong-company imports are a known support-incident class. [2026-08-18] Webshop order booking defaults to BAS 1686 (Fordringar för kontokort och kuponger), not 1680: 1680 is the generic "Andra kortfristiga fordringar" parent, while 1686 is the account BAS defines for a claim on a payment provider (bas.se moved this receivable off 1580 onto 1686 for exactly that reason), and the Stripe extension already settles against 1686, so a store running both surfaces now shares one clearing account. Separately, booking a webshop order no longer dies on AccountsNotInChartError: seed_chart_of_accounts() seeds a minimal chart that omits 3004, 3740 and any clearing account, all of which an ordinary order reaches (0%-rate line, öre residual, unmapped payment method), so the book route now ensures the closed WEBSHOP_PREFILL_ACCOUNTS set exists first. Deliberately narrow and non-fatal: only accounts our own prefill emits are ever auto-created (a user-typed account still errors), a deactivated row is reactivated rather than duplicated, and any failure is swallowed so the engine's typed error still wins. Inserts go one literal-payload row at a time because the no-phantom-columns guard cannot resolve a .map()-built array, and verifying the 13 columns beats saving at most eight first-use round trips. [2026-08-17] articles.housework_type keeps two vocabularies (Skatteverket arbetstypskod, or bare ROT/RUT) instead of migrating legacy ROT/RUT rows: a kind-only row cannot be upgraded to a code without knowing the work, so the article form preserves the legacy choice as an explicit option and the invoice prefill treats it as kind-only; everything else normalizes to null and is rejected at the API. [2026-08-17] ROT/RUT claim completeness (arbetstyp + arbetstimmar) is enforced at invoice creation (validateInvoice + CreateInvoiceItemSchema + editor), not only at begäran-file time: the file blocker fired when the invoice was already numbered/booked/paid with no repair path short of a credit note; schablontjänster (TRANSPORT/TVATT) stay hours-exempt. Yearly ceiling accumulation is per CUSTOMER in the editor (personnummer is ciphertext client-side) and warning-only; server warnings are still dropped on success, so the editor computes its own via the shared deductionCapWarnings helper. [2026-08-17] invoices.remaining_amount gets a BEFORE INSERT trigger deriving total - paid_amount - deduction_total when a fresh unpaid real invoice arrives with NULL/0, instead of only fixing the writers: four writers had drifted (proforma conversion x2, MCP create_invoice, sandbox seed) and 337 open invoices on prod sat at 0, so the column's DEFAULT 0 must never be able to mean 'settled' again; UPDATE is left to the settlement code, which legitimately writes 0 on full payment. Writers fixed too (defense in depth).

[2026-08-17] 77xx nedskrivningar split per official BAS kopplingstabell in BOTH k2-mapper and ink2-engine (fältkod 7515: 7700-7739, 7750-7789, 7800-7899; 7516: 774x, 779x): agent feedback 2026-07-07 reported the K2 side; the INK2R side and the swedish-sru-filing reference table had the same whole-77xx-to-7516 error, verified against bas.se INK2_P1_intervall-240118.pdf before overriding the skill reference. NE-bilaga mappings deliberately untouched (NE has no separate omsättningstillgångar line). [2026-08-17] MCP feedback loop = local /loop-feedback-triage appending dev_docs/mcp_feedback_digest.md + small PRs, NOT a GitHub-issue digest or Resend email: closes the loops.md backlog item blocked since 07-09 on a "channel decision". Issues stay founder-authorised; the digest is the read surface. gnubok_feedback reply copy no longer promises weekly aggregation (it was never true); tool advertised in server instructions + agent briefing (feedback_channel), where it was previously discoverable only by scanning tools/list. [2026-08-17] Non-IBAN foreign payment accounts (USD/GBP): added generic bank_code + foreign_account_number to InvoicePaymentAccount (JSONB, no migration) instead of per-country fields (routing_number, sort_code, bsb); rule = IBAN OR (bank_code + foreign_account_number + BIC), only for NON_IBAN_CURRENCIES, label per currency. Chosen over a field per country: the Currency union only carries USD/GBP among non-IBAN systems, and one generic pair keeps the PDF/settings/schema surface small; extend NON_IBAN_CURRENCIES + bankCodeLabelKey when AUD/CAD land. Agent feedback 2026-08-03. [2026-08-18] NEXT_PUBLIC_* boolean flags are read as VALUES via lib/env/public-flags (flagEnabled/isSelfHosted), never compared in place: process.env.NEXT_PUBLIC_SELF_HOSTED === 'true' is constant-folded against the Docker sentinel at build time and dead-code-eliminated, erasing the sentinel docker-entrypoint.sh needs, so every Docker self-host silently ran with the entitlement paywall live (ai/bank_sync/skatteverket/email_send dying 30 days after company creation). flagEnabled uses a Set lookup rather than === because a minifier can fold the latter. Guarded twice: check:guards folded-public-flag (source, every PR) and a docker-publish step asserting the sentinels survive the built image (artifact, the only place the failure is observable: dev and Vercel both have real env values).

[2026-08-18] Swish QR fix (RUT/ROT) scoped to buildSwishQrDataUrl only, preview-pdf left as-is: previewInvoice carries no deduction fields at all, so the editor preview is internally consistent (its QR matches its own totals block) but ignores ROT/RUT entirely; that is a separate display gap to file as its own issue rather than widening this money-math diff. [2026-08-18] Shopify webshop_orders port: vat_breakdown is reconstructed from the ORDER-LEVEL taxLines (net = tax / rate, remainder as a 0%-bucket, refuse on missing rates or overshoot) instead of summing line items like the WooCommerce sync: Shopify's discountedTotalSet excludes cart-level discount allocations and lineItems is a paginated connection, so part-summing can silently produce a wrong per-rate net, while tax-per-rate and the charged total are authoritative order-level facts. Refund VAT is always prorated from the parent's mix (Shopify's Refund object exposes no per-rate tax without paging refundLineItems per refund). [2026-08-18] Shopify order feed keeps its paid-only qualification (PAID/PARTIALLY_REFUNDED/REFUNDED) after the webshop_orders port, unlike WooCommerce which also imports unpaid orders for the invoice flow: widening qualification is a product decision, out of scope for the port; unpaid orders re-surface via updatedAt when payment captures. The line-item snapshot is stored only when the parts reconstruct the charged total to the ore (else [] and the invoice conversion falls back to one aggregate line), and the bookkeeping-lock row filter was dropped: an Orders-page row behind the lock is an overview row, not permanent inbox noise, and booking is still blocked by the lock triggers (parity with WooCommerce). [2026-08-18] Skattekontoutdrag sum mismatch (opening + events != closing) demoted from a hard 400 to a preview confirm gate showing ingående/händelser/utgående/differens, mirroring the orgnr-mismatch gate: Sebastian's real export was refused on it (2026-08-18) with no way forward and no figures to diagnose; nothing is booked at import and dedup makes a later complete re-import safe, so refusing the file only blocked the rows that WERE readable. Parser also takes the earliest opening / latest closing across several marker pairs, reads a marker saldo from a trailing running-saldo column, and accepts U+2212 / plus-sign amounts; the route logs the figures (amounts and counts, never row text) so the next report is diagnosable from Vercel logs. Kept the hard reject only for zero readable rows. [2026-08-18] Issue #1666 uses an atomic archive-and-replace migration reset instead of hard deletion: provider-imported rows cannot be proven disposable, external filings cannot be fully observed, and BFL retention requires the original documents, vouchers, treatment history, and audit trail to remain intact. [2026-08-18] Company migration reset eligibility stops before the first journal entry or voucher-sequence row, regardless of status or import provenance: the archived source and replacement are the same legal entity, so a fresh replacement sequence is safe only when no prior voucher namespace or stranded draft exists; imported transactions, documents, periods, and import history may still be retained and restarted. [2026-08-18] Company migration reset also stops before any customer or supplier invoice and gives the replacement owner a read-only retained-source archive download: invoices can carry issued-document and credit-note continuity before a voucher exists, while BFL accessibility is met without exposing an archived company in write-capable selection. [2026-08-18] Categorizing an ignored transaction atomically clears is_ignored in the batch route, single-transaction routes, and shared categorization core: categorization is explicit intent to book the row, and one update preserves transactions_is_ignored_no_journal_entry without forcing a separate unignore-and-retry action; the constraint name maps to TX_CATEGORIZE_IGNORED_CONFLICT as defense in depth. [2026-08-18] Issue #1668 keeps VAT confirmation in a dedicated sticky end column and makes truncated source names reveal on hover, focus, and activation: column truncation alone would still strand the hard-blocking action at responsive widths, while activation gives touch users the same full-text affordance. [2026-08-18] Issue #1659 exposes one canonical per-period VAT deadline resolver from deadline-config and makes both the MCP close check and VAT period default consume it: monthly, quarterly, annual, over-40M, and January/August rules must not drift across parallel formulas again; the MCP adapter alone applies the same banking-day adjustment as generated tax deadlines. [2026-08-18] PR #1679 reports deadline_unavailable instead of guessing when VAT settings are missing, and annual AB deadlines require the configured fiscal year to match the resolved report period: a missing or stale filing profile must not produce a plausible but legally wrong date. [2026-08-18] PR #1679 uses company_settings.entity_type as the sole annual VAT deadline source in MCP close checks: falling back to companies could mask missing or inconsistent filing settings, so annual deadlines now fail closed instead.

[2026-08-18] Defect record (compliance-swarm PI1.3, no risk_register.csv exists in repo so this log is the equivalent): Swish QR on invoice PDFs encoded the pre-deduction total on ROT/RUT invoices with the amount locked (editmask 0), risking customer overpayment by the full skattereduktion; impact window = since Swish QR + ROT/RUT coexisted; remediation PR #1685 = QR now encodes getAmountToPay().toPay on every send/download surface (dashboard send/preview-of-send/pdf, v1 send/pdf, MCP pending-operations commit, recurring, issue-and-book; the editor preview is tracked separately in #1686), v1 pdf/send projections gained the amount-path columns (deduction_total, ore_rounding), pinned by lib/api/v1/tests/invoice-columns.test.ts. [2026-08-18] Generic CSV mapping (#1671): description guess now excludes clock-time columns (Time/Tid/Klockslag by label, HH:MM by values) and knows Lunar's Title/Titel label; Lunar detect() sniffs comma/semicolon/tab and matches header CELLS exactly (date, title|text, amount, balance) instead of substrings, aligned with what parse() resolves on. NOT changed: the 2026-08-13 generic_csv exemption from the parsed-0-rows auto-detect fallback stays; lifting it would route an explicit "Annan CSV" pick into a dedicated parser and remove the manual escape hatch. Not verified against the customer's actual file (Gmail thread not readable in-session): the semicolon/tab widening is the plausible detection miss, a Swedish-localized Lunar header is not confirmed to exist and was not special-cased in the Lunar detector (the generic path now maps it correctly anyway). [2026-08-18] IBAN prefill reads only enabled, still-connected SEK cash_accounts: disconnect keeps rows (bank_connection_id nulled) and the connect picker mirrors deselected accounts, so an unfiltered read could offer a closed or third-party IBAN as invoice payee (skeptic refutation on PR #1695). [2026-08-18] Editor PDF preview (#1686) recomputes ROT/RUT server-side from the posted lines with the same helpers as build-invoice-write.ts (computeDeduction / computeInvoiceDeductionTotal, base inkl. moms at the rendered rate, invoice-doc only) and resolves the masked personnummer the same way (typed value, else an individual customer's kundkort personnummer): the client is not trusted with the deduction math, and the preview must state the same avdrag row, info box and "Att betala" as the invoice that gets created. The editor now also posts deduction_personnummer / deduction_housing_designation to the preview route, only when a line claims a deduction (same privacy rule as buildInvoiceWritePayload). Swish QR amount in the preview follows buildSwishQrDataUrl, fixed separately in #1685. [2026-08-18] Skatteverket read data is visible to every company member, no new role gate (#1673): token rows are per (user, company) but the fetched skattekonto/declaration data belongs to the company, and viewers already read skattekonto_transactions and the local snapshot with no role check; membership (dispatcher-resolved ctx.companyId + company-scoped SELECT policy on skatteverket_tokens) is the gate. Reads resolve the caller's own token first, then the most recently issued active token of any member (all rows ordered, never .maybeSingle(), which errored once two members had connected). Writes (moms utkast/las/submit, AGI submit/spara/las, connect/disconnect, /status) stay on the caller's own token: BankID signing is personal. [2026-08-18] Abonnemang sell view rebuilt as a Fönster order summary (I abonnemanget: four outcome lines, one per PAID capability, plus one freeze-and-retain sentence; Pris och villkor: price with the interval toggle in the row, first charge date, cancellation as flat rows; one CTA; one Stripe line). Founder found the 07-11/07-27 version cluttered and not upgrade-inspiring: the skyline hero (h-40, decorative) ate a quarter of the 680px settings window and pushed the offer below the fold, and the same reassurance was repeated four times (attn line, under CTA, 'Så funkar det' rows). BillingHero removed (asset stays registered for onboarding); benefits rewritten from feature names to what happens for the user; every money term stated exactly once, where the decision is made. Copy moved from hardcoded Swedish into settings_billing (sv+en). Alternative considered and not built: a landing-page style offer stack (serif headline + bullets + CTA), punchier but breaks the Fönster grammar and hides the money terms in one line. [2026-08-18] Abonnemang sell view, second pass the same day: the Fönster row version (Pris / Första debitering / Uppsägning rows + eyebrows) still read as cluttered to the founder (five pieces of small grey text, an uppercase label per row, three price facts in one row). Replaced with the 'one number' layout: the price in the display serif with the interval toggle beside it and one exkl./inkl. line, four benefits as noun + short gloss in a 2x2 grid, one CTA, one terms sentence (first charge date, cancel any time, Stripe), one 'ingenting raderas' sentence with the legal behind '?'. Pane 633px -> 609px; word count roughly halved again. Rows and eyebrows stay for the paying/demo views where they describe state, not an offer. [2026-08-18] AGI receipt fallback (#1597): GET /agi/status serves the signed record from agi_declarations (kvittensnummer, response_data.signeradAv/signeradTid, submitted_at) only when the agi_submission_{period} cache is absent, and the declaration-sourced record deliberately carries NO salaryRunId: the period row is UNIQUE per company+period and regenerating a correction repoints its salary_run_id at the correction run while the stored kvittens still belongs to the original, so trusting the column would render the correction as filed with a superseded receipt. Ownership rests on signeradTid/submittedAt vs the run's agi_submitted_at stamp (same value) plus updatedAt = submitted_at, which predates any later correction's XML. Cache present still wins because it is the only place the in-flight states live. Rejected: a second client fetch in AGIPanel (two sources of truth for one card) and merging both records in the route (mixes another declaration's fields into an in-flight state). [2026-08-18] Trial seeding trigger widened to every PAID capability (20260818170000): the 2026-06-29 trigger hardcoded the four launch keys while PAID_CAPABILITIES grew to seven (stripe_payments, woocommerce_sync, shopify_sync); the Stripe webhook seeds from the constant, so payers had all seven and trialers four (prod 2026-08-18: 226/230 active trialers lacked stripe_payments). Fixed by redefining the function with all seven and mirroring existing trial bank_sync grants for the three keys; the pg test now compares the seeded set against PAID_CAPABILITIES itself so the two cannot drift silently again. Rejected: generating the VALUES list from the TS constant at build time (no codegen path into migrations exists; a test that pins them together is the cheaper guard). [2026-08-18] Invoice ROT/RUT personnummer surfaces (detail page, invoice PDF, preview PDF, editor kept-hint) switch to the payroll mask convention YYYYMMDD-XXXX (birth date visible, last four hidden), computed on read from deduction_personnummer_encrypted via lib/invoices/deduction-personnummer.ts: no schema change, nothing stored, never throws (bad ciphertext logs and renders no personnummer). The browser gets the mask from GET /api/invoices/[id]/rot-rut and never both the mask and the last four (that is the full number); v1 REST and MCP keep deduction_personnummer_last4 for compatibility (an additive deduction_personnummer_masked is a possible follow-up). InvoicePDF derives the mask itself when the caller passes the stored row, so none of the 11 render call sites can silently drop the personnummer; the preview route passes an already-masked value since it only has plaintext. The separate Skattereduktion card on the invoice detail page is folded into Detaljer as plain rows (Personnummer, Fastighet, Skattereduktion status with the begäran lifecycle) per founder decision 2026-08-18: it duplicated the totals block. [2026-08-19] Betald-stämpel / betalningsbekräftelse (#1693): the paid copy is always a fresh re-render of the faktura (GET /api/invoices/[id]/pdf?variant=paid, refused unless status = paid, filename Betalningsbekraftelse-.pdf, invoice-pdf-source reason 'payment_confirmation' so the UI caveats it like any re-render), and the archived original in invoice_deliveries is never read, replaced or relinked: it is the invoice as sent (unpaid) and a different document. The send action (POST /send-payment-confirmation) reuses the email service, recipient routing and branding of the invoice send but none of its side effects (no status flip, no sent_at, no delivery row, no journal write) and logs the send via the normal logger: invoice_deliveries has no kind column and an 'email'/'sent' row there would become the archived original to resolveInvoicePdfSource; a typed history entry needs its own migration and is a follow-up. The two actions live inside the Betald card on the detail page, not in the header row (founder decluttered it in #1699); partially_paid gets only the Betalt / Att betala rows in the PDF and no banner or button, because the customer still owes money. The template keys the stamp off the stored status, so every re-render of a paid invoice carries it; credit notes and proformas are unchanged. [2026-08-18] Migration-reset archive access follows the immutable reset link from a currently owned replacement instead of requiring mutable membership on the retained source: freezing archived company_members would break legitimate team removal and account anonymization, while replacement ownership plus an archived linked source keeps retained accounting information reachable without reactivating it. [2026-08-18] Migration-reset replacements preserve next_invoice_number and next_arrival_number from the retained source: the replacement is the same legal entity, and a non-default counter can represent imported or previously allocated numbering even when no invoice row remains; restarting at 1 risks reuse or an unexplained break, while pg-real pins continuity at values above 1. [2026-08-18] Migration-reset eligibility treats every persisted Skatteverket VAT submission_* workflow row as authority interaction evidence, not only successful audit-log rows: historical direct locks stored the signing state without auditing it, and Accounted cannot observe whether a user completed BankID signing outside the app. Unsigned drafts must be removed through the product; locked or uncertain state fails closed and is escalated. [2026-08-18] Migration-reset eligibility also blocks AGI pending_signature and agi_submission_* state plus every ROT/RUT payout request: both flows hand work to Skatteverket for external upload or BankID signing before Accounted can observe the filing outcome, so a missing receipt or locally generated/cancelled status cannot prove that the data is disposable. [2026-08-19] Keep reversal allocation metadata limited to failures before any reversal header exists: later cleanup preserves a cancelled header with the allocated voucher number, so documenting it as an unused voucher gap would be false. [2026-08-19] Bank reconciliation: ignored transactions are excluded from bank_transaction_total/difference (surfaced as separate count+sum) rather than keeping the old "what the bank moved" semantics: the ignore flag's dominant real-world use is feed duplicates (which never moved money and never get a ledger leg), so including them made is_reconciled unreachable after a correct dupe cleanup (observed: permanent 78 867 kr differens on a fully booked EF account). descriptionsBridge now strips ALL whitespace before the prefix compare (collapse-only still misses a dropped space); safe because char-filtering preserves prefix relations and the compare stays inside a (date,ore) bucket. [2026-08-19] Inline rättelse bank guard anchors to the linked bank amount, per account, not to the pre-state and not to the 19xx group net: a non-zero change on a 19xx/cash-ledger account is allowed iff the post-state net on that account equals the signed sum of the linked transactions resolved to it (once per transaction, split links by allocated_amount; NULL cash_account_id resolves to the primary cash account, then 1930). Per account rather than group so a wrong-bank-account booking (1930 vs 1940) stays a storno job: a group check would let the net drift between accounts and break per-account bank reconciliation. When no anchor resolves the old strict refusal stands. Reskontra sides (15xx/24xx) stay strictly net-preserving because their anchor is the payment row, not a bank amount. [2026-08-19] Import mapping step gets a bulk "Bekräfta alla föreslagna" for the VAT-treatment review gate, batching the per-row confirm semantics unchanged (defaults kept, rows marked reviewed): a Fortnox chart routinely puts 70+ class 3/4 accounts behind the gate and the one-click-per-row flow across 50-row pages was an observed live migration dead end (Boltonshield 2026-08-18, stuck at "50 kvar"). Rejected: auto-skipping review for accounts unused by the imported vouchers, because the chart rows are still created with the suggested treatment and a silently wrong default on a soon-used account is exactly what the review gate exists to catch. [2026-08-19] Dialog overflow hardening: Dialog/Sheet titles and descriptions get break-words at the primitive; AccountCombobox's non-flat dropdown is portaled to document.body with viewport-clamped geometry (same rationale as info-tooltip's TooltipContent portal, since DialogContent's overflow-y-auto otherwise grows a horizontal scrollbar around the 34rem panel); the four rattelse-family dialog explainers are unified behind one RattelseExplainer HelpPopover (convention 7, MatchVoucherDialog precedent) instead of four near-duplicate inline paragraphs; a dialog-overflow-risk ratchet in no-new-antipatterns.mjs keeps bare-1fr tracks, dialog whitespace-nowrap and unportaled wide overlays from coming back. [2026-08-19] Trial-expired visibility: the 2026-07-11 "no trial-expired nag" call is narrowed at the founder's direction (user-reported discoverability failure 2026-08-18): one on-entry dialog with persisted acknowledgement (user_preferences.ui_state.trial_expired_ack, per user AND company, never localStorage) plus a non-dismissable quiet chrome link (SubscriptionTouchpoint replaces the vanishing countdown pill, stays visible when the rail is collapsed, and adds the first mobile touchpoint). Free tier stays a legitimate resting state: no recurring nag, muted chrome tone, no ochre. getCompanyEntitlements also reads company_subscriptions.status inside its existing Promise.all (zero extra round-trips) so churned payers get "abonnemang" copy instead of "provperiod". Hem AttnLine variant skipped: the page already carries the otherAccountHint AttnLine and design.md allows max one per page. [2026-08-19] Removed PeriodiseringAutoDetectToggle (settings > Automatik) instead of wiring it: the localStorage key it wrote (periodisering_autodetect_enabled) had no reader anywhere, so it advertised "automatisk periodiseringsdetektering" while changing nothing; auto-detect is already best-effort and review-gated in the wizard, so the row is now a plain link to the periodisering wizard. Deleting the key is safe: it was write-only. [2026-08-19] Periodisering auto-detect materiality floor uses entity_type as a K1 proxy: no stored flag distinguishes förenklat årsbokslut (K1, BFNAR 2006:1) from full årsbokslut (BFNAR 2017:3) for enskild firma, so every EF gets K1 wording and every AB gets K2, always advisory ("behöver normalt inte"), never prohibitive. Suggestions under 5 000 kr are tagged low-confidence (unticked) rather than dropped because the relief is a MAY, not a MUST; personnel-cost lines (7xxx) are exempt from the floor since K1/K2 require personnel costs to always be accrued. [2026-08-19] Hem build-assistant hero downgraded to the quiet-sentence pattern (AgentPromo, matches SkatteverketPromoCard; founder direction 2026-08-18 'redesign first, maybe remove later'): dismissal is per-company localStorage (erp_agent_promo_dismissed:) like the SKV promo, gate and hasAi/billing routing unchanged. [2026-08-19] Banking settings UI state derives from a pure helper (extensions/general/enable-banking/lib/connection-state.ts), not inline JSX conditions: sort precedence, the single page-level .attn sentence, and each row's one primary action must agree on which state a connection is in, and only a pure module can unit-test that. The same-bank connect intercept excludes 'pending' rows (an in-flight authorization is not a renewable connection) and the fresh-connect body sends force_new: true after the intercept so the parallel 409 server guard can distinguish deliberate second connections; 'pending' rows now render as a spinner row ("Väntar på banken") for their whole lifetime instead of only locking the connect button for 30 s, since an invisible in-flight row was the confusion. [2026-08-19] Bank reconnect supersede reuses status 'revoked' plus a new superseded_by column instead of a new status value, and re-points transactions.bank_connection_id to the superseding row: every existing filter, ledger-claim release, and cron skip already handles 'revoked' correctly (no CHECK-constraint migration on a live table), superseded_by disambiguates a supersede from a user disconnect, and re-pointing the feed rows (plain FK metadata, never journal tables) is what makes the picker's gap-fill probe and per-connection scoping survive a renewal. [2026-08-19] /transactions source filter persists in per-company localStorage (v2 key) rather than user_preferences.ui_state, matching the FyPicker/JournalEntryList page-scoped filter idiom (design rule 9 reserves ui_state for split-button modes/nav), and a derived effectiveSourceFilter memo replaces the stale-filter reset-guard effect: the guard raced the async cashAccounts/skvRows/transactions loads on every mount and wiped the restored choice back to 'Alla källor'. [2026-08-19] Skattekonto sync datumFrom bound applied uniformly to EF and AB (earliest fiscal_periods.period_start, no bound when none exists): an AB's skattekonto is the company's own so the bound is a no-op there, and one code path beats an entity_type branch; the EF case (owner's personal account, ~555 days of private pre-company history) is the one that needed it. [2026-08-19] company_settings.employer_registered chosen as the "does this EF employ anyone" signal for the avdragen-skatt gate (not an employees-table query): it is the existing AGI gate signal (20260717151000, attested pays_salaries OR any salary_run), already maintained for exactly this question, and one boolean on the settings row the rule context already fetches costs no extra round trip. [2026-08-19] requires_employer added as a data-driven skattekonto_rules column (set on the 'avdragen skatt,personalskatt,a-skatt' seed) instead of a hardcoded pattern match in TypeScript: the table exists precisely so rule semantics ship without redeploys, and a string match in code would silently miss per-company clones of the seed pattern. [2026-08-19] Notices Option A (contextual AttnLine slot fed by lib/notices) over a global layout notice strip: reuses convention 6 instead of adding a second piece of persistent chrome that could itself stack; Option B stays a founder call. [2026-08-19] notice_dismissals is server-side per (company, user, notice_id): dismissals must work cross-device and stay personal (a colleague still sees the notice), unlike the localStorage pattern SkatteverketPromoCard uses (left as-is here, follow-up). [2026-08-19] Notice ids embed a state discriminator (connection id + status/expiry/error timestamp) instead of a snooze timestamp: a dismissal hides exactly the state the user saw, and a NEW failure mints a new id that surfaces without any clock logic.

[2026-08-19] Personnummer-shaped org_number is REJECTED (400) for business customer_types instead of silently accepted or soft-warned: masking only exists on individual rows, so accepting it stores an unmasked personal identifier (GDPR art. 5.1 c); the month-position rule (orgnr always >= 20) makes the shape check false-positive-free. Enskild firma customers must be created as customer_type=individual. [2026-08-19] New-customer payment terms fall back to company_settings.invoice_default_days (then 30) in every create path (UI, internal API, v1, bulk, MCP staged op) via lib/customers/resolveDefaultPaymentTerms, rather than adding a separate customer-terms setting: one setting, one meaning, and the invoice flow already reads the same column.

[2026-08-19] The CI build OOM is the TYPE-CHECK pass, not bundle growth: measured with tsc --extendedDiagnostics the repo needs ~4.19 GB at 506d030b and ~4.19 GB on a branch on top of it, i.e. a steady-state ceiling against Node 20 default old-space (~4 GB), not any one PR's regression. Fixed on main independently by raising the build heap to 8192, which this branch keeps; recording the measurement so the next person does not go hunting in a diff. Vercel builds already run with a larger heap and were never affected. [2026-08-20] Bokio getCompany accepts both the spec envelope and the live flat body: the published v1 spec (bokio/bokio-api company-api.yaml) wraps company-information in companyInformation, but api.bokio.se/v1 returned the company object flat on a 200 in prod (BokioResponseError in logs, customer script showed the same). Tolerating both instead of picking one means a spec/live drift in either direction can no longer turn a valid integration token into a connection failure. [2026-08-20] Moderföretagets org.nr in the årsredovisning note accepts foreign registration identifiers (CHE-123.456.789, HRB 12345, 923 609 016) and only enforces the NNNNNN-NNNN third-digit-2-9 rule on 10/12-digit all-numeric values: ÅRL 5:21 asks for the parent's identifier as its home register writes it, and a Swiss holding was refused as "ej personnummer". Personnummer shapes stay rejected; a foreign 10/12-digit all-numeric id with third digit 0-1 is the accepted false negative. [2026-08-20] Fortnox document discovery treats a 400 whose body mentions behörighet/scope/licens as "scopes required" (not only 403) and retries the voucherfileconnections list unfiltered when the financialyear filter 400s: the first live Fortnox underlag run (Boltonshield) failed three times with a bare "400 Bad Request" that no log line explained, so status, body and Fortnox's own message are now logged and shown in the UI instead of guessing which of the two causes it was.

[2026-08-20] Detail pages (kundfaktura first, then the stale card-pile siblings: leverantörsfaktura, verifikat, kreditfaktura, avyttring, lön) adopt the register-detail document grammar from #1624 instead of card stacks: DetailSection/DefRow groups, one status element per the list pages' chips-mark-exceptions rule, one primary next step plus "Förhandsgranska" visible and everything else behind a ⋯ overflow menu, the line table on the dry-table idiom with the headline total in the serif. Considered keeping a two-column card sidebar with fewer cards; rejected because the card border carried no hierarchy the hairline kicker does not already carry, and a second column of stacked boxes is exactly what the founder called clutter. [2026-08-20] The bank-reconciliation card leads with unexplained_difference (difference minus the two unmatched-list totals) instead of the raw difference. Every krona of the difference is, by construction, (unmatched bank rows) - (unmatched vouchers), so the raw figure alarms about ordinary mid-year backlog while saying nothing; the residual is the only part that can mean something is wrong. Verified on prod: Arcim 1930 2025-07-17..2026-08-20 shows 70 884,49 difference and exactly 0,00 residual. [2026-08-20] unmatched_gl_line_total is null (not 0) on a foreign cash account: get_account_gl_lines_for_matching projects neither currency nor amount_in_currency, so its rows carry no amount in the account's currency. The card falls back to the flat figures there rather than showing a bridge whose middle row would silently be a SEK number in a EUR column. [2026-08-20] getReconciliationStatus now passes effectiveFrom (the IB floor) to fetchGLLinesForMatching instead of the caller's raw dateFrom. countedLines and countedTx were already clamped to that floor, so a window opening before the account's opening balance (the v1 endpoint's default, or any multi-year range) counted vouchers from a period whose movements the reconciliation deliberately drops. [2026-08-20] A non-zero unexplained difference is stated factually, never in destructive red. Measured on prod over the 206 single-1930-account companies with >=10 transactions: 136 are exactly 0,00 and 63 are >=100 kr out, and the dominant cause is ledger lines the candidate RPC hides (posted/storno on 127 companies, reversed/bank_transaction on 66, posted/correction on 49) rather than user error. Surfacing those hidden lines is tracked as follow-up work, not fixed here. [2026-08-20] Bank reconciliation moved to the shared ReportDateRange (catalog params 'fiscal-range') instead of teaching FyPicker a custom range: the view's own "Datum fran/till" inputs plus a Filtrera button were a second period control competing with the header's rakenskapsar picker (convention 8), and FyPicker's value model is a period id, so a range does not fit its contract. ReportDateRange gained defaultPreset and storageKeyPrefix for this. [2026-08-20] Reconciliation opens on the FULL year and keeps its own range-preset memory, separate from the shared report-family key. Inheriting a "Denna manad" preset last used on Resultatrapport would show an alarming difference for a window the user never chose on this page, and a part-year reconciliation window answers a question nobody asked. [2026-08-20] The matcher now runs automatically once per window+account when there is unmatched work, instead of waiting for a button many users never found. It is a dry run: nothing is written, and Tillampa still requires an explicit click. ?autorun=1 keeps a distinct meaning (run even on a clean window) so the transactions-inbox deep link still produces a result rather than silence. [2026-08-20] Unmatched bank rows that no voucher on the account could settle (direction-compatible and equal to the ore) get "Bokfor" linking to /transactions?highlight= instead of a match picker. They are unbooked affarshandelser, not reconciliation work, and the picker held nothing for them. The rule is deliberately strict: a false negative offers booking on a pairable row (a legitimate outcome), a false positive sends the user into an empty picker. [2026-08-20] Entitlement partition for the sovereign self-host (plan WS3 PR3, ships dark): isPaywallBypassed() became isBypassedFor(key). Hosted behaviour is byte-identical (dev/DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, otherwise the grant lookup). On a self-host every LOCAL capability stays always-on, FORCE_PAYWALL included (an AGPL operator's own instance is never gated on what it runs itself, which is also why the existing "FORCE_PAYWALL never overrides self-hosted" test still holds), and only the four CONNECTOR_CAPABILITIES (bank_sync, skatteverket, org_lookup, migration: services Accounted operates) fall through to the grant lookup, where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host reports state 'paid' when a connector grant is active and 'none' otherwise, never 'trial_expired' (that copy talks about a hosted trial). CONNECTOR_CAPABILITIES is deliberately separate from PAID_CAPABILITIES and outside the trial-seed trigger, so a hosted company can never hold a connector grant. The capability_grants.source CHECK gains 'connector' by looking the inline auto-named constraint up through pg_constraint. Adding the connector extensions to the self-host Docker preset was deferred to the instance-wiring PR: until a connector key can actually be issued, shipping those extensions in the image would only show dead-end upsells. [2026-08-20] Connector-key infrastructure (plan WS3 PR4) ships the hosted registry + the instance sync, nothing a customer can buy yet: connector_keys / connector_usage_events are service-role-only tables (RLS on, no policies) with an atomic validate_and_increment_connector_key RPC that copies the api_keys pattern (SHA-256 at rest, FOR UPDATE row lock, per-minute window) and is REVOKEd from PUBLIC, anon and authenticated before anyone can call it (the SECURITY DEFINER exposure lesson applied up front); /api/connect/entitlements behind withConnectorAuth (Bearer or X-Connector-Key, 401/403/429, one usage row per request); keys issued by scripts/issue-connector-key.ts (dry run unless --confirm, prints the key once). The instance side writes source='connector' grants expiring at min(now+72h, period_end+3d) on every hourly sync, deletes them on 401/403 or a non-active status, and leaves them alone on network/5xx errors: the grant rows ARE the offline cache, no new cache code. The hourly job lives only in docker/crontab.self-hosted through a new EXTRA_JOBS table in the crontab generator (with its own drift tests), because vercel.json is the hosted schedule and hosted has no connector key. connector_usage_events is a separate table because metered_events.company_id references hosted companies and a connector key belongs to an instance, not a company here. Deferred: the proxy routes (bank/skv/org/migration: founder legal check with Enable Banking/SKV/TIC is the launch blocker), a connect.gnubok.se host rewrite (the instance calls app.gnubok.se/api/connect directly; a dedicated host is a later DNS decision), the self-host Docker preset change and the settings row. [2026-08-20] Connector bank proxy (plan WS3 PR5a): app/api/connect/bank/[...path] brokers Enable Banking for self-hosted instances with tokens staying on the instance (founder decision) and the proxy stateless apart from a secret-free connection ledger. Design that keeps EB Annex 1 §3/§7 satisfied: the instance never holds the EB JWT (minting moved to lib/connect/upstreams/enable-banking-jwt.ts so core does not import @/extensions/; the extension re-exports it); the consent redirect goes to OUR already-registered EB callback, which detects an HMAC-signed connector state (lib/connect/hosted/state.ts, 15-min TTL, CONNECTOR_STATE_SECRET or a one-way derivation of the service-role key) and 302s the browser back to the instance, so no per-instance redirect URI is registered at EB. Ownership: connector_connections ledger stores sha256(session_id) and the account uids, never the session; GET/DELETE /sessions and /accounts/{uid} calls verify the handle/account belongs to the presenting key. Quotas: per-company bank connection limit (connector_keys.limits, sold in the package, checked at POST /auth), per-key RPM (validate RPC), and a GLOBAL budget (connector_reserve_upstream RPC, connector_upstream_counters, ~30% of EB's 300/min so hosted is never starved; fail-open on a counter error). validate_and_increment_connector_key gained a limits column (v2). issue-connector-key.ts scopes default to bank_sync,skatteverket (TIC/org_lookup out of v1 per founder) and take --bank-connections-per-company etc. Path allowlist only, never an open passthrough. Deferred: the SKV broker (PR5b, same pattern, callback branch on the SKV extension) and the instance-side wiring (PR6: EB client connector-mode branch, self-host preset, settings row). [2026-08-20] Connector Skatteverket broker (plan WS3 PR5b, stacked on PR5a): the SKV OAuth/data path is core-side (lib/connect/upstreams/skatteverket-oauth.ts) so app/api/connect/skv/* does not import @/extensions/; it duplicates the extension's endpoints/scope set (one integrator = Arcim's registered SKV client) rather than sharing, matching the EB JWT relocation pattern. Tokens stay on the instance: POST /api/connect/skv/oauth/token exchanges the code/refresh with Arcim's client secret and RETURNS the tokens to the instance (which encrypts+stores them), the ledger keeps only sha256(access_token) and sha256(refresh_token). authorize-url uses OUR registered redirect_uri + a signed connector state; the SKV extension /callback gained the same connector branch as EB (isConnectorState → 302 back to the instance, code never exchanged there). The data proxy app/api/connect/skv/api// is an allowlist over the four backing APIs (moms, skattekonto, agd-inlamning, agd-period): the instance sends the user's SKV Bearer (as X-Connector-Upstream-Authorization) + X-Connector-Key, the proxy verifies the token hash against the ledger and adds Arcim's Client_Id/Client_Secret gateway headers (never exposed to the instance). Same per-key/global budget as bank. SKATTEVERKET_ENABLED + SKATTEVERKET_TOKEN_ENCRYPTION_KEY stay operator-set on the instance since the tokens live there. Refresh update written as two literal .update() branches to keep the no-phantom-columns ceiling at 380. [2026-08-20] Connector instance enablement (plan WS3 PR6): the self-host Docker preset (docker/extensions.self-hosted.json) now includes enable-banking + skatteverket, so a connector-key self-host has the bank/SKV extensions enabled instead of hidden (grants are written; carrying actual traffic waited on PR6b's client wiring: the bank half landed via #2094, the SKV half still pending); without a key they show the existing capability_blocked upsell (unconfigured extensions no-op). lib/connect/instance/upstreams.ts is the connector-mode seam: an upstream is in connector mode only when GNUBOK_CONNECTOR_KEY is set AND the instance has no own credentials for it (hasOwnEnableBankingCredentials / hasOwnSkatteverketCredentials), which is what keeps hosted byte-identical (hosted always has own creds). GET /api/connector/status is the operator's wiring view (self_hosted flag, per-upstream own_credentials|connector|unconfigured, key prefix never the key, granted connector capabilities). DEFERRED to PR6b (needs a live connector key + a real bank/SKV to verify end-to-end, and touches the live EB/SKV consent path): wiring the EB api-client/consent-callback and the SKV oauth/api-client to actually call the proxy in connector mode (base-URL + header swap for data reads; redirect_url=instance callback + X-Connector-Company + connector_state round-trip for consent), plus the "Synka nu" settings row (UI, needs visual sign-off). The seam, preset and status route make PR6b a contained follow-up. [2026-08-20] Connector Skatteverket broker (plan WS3 PR5b, stacked on PR5a): the SKV OAuth/data path is core-side (lib/connect/upstreams/skatteverket-oauth.ts) so app/api/connect/skv/* does not import @/extensions/; it duplicates the extension's endpoints/scope set (one integrator = Arcim's registered SKV client) rather than sharing, matching the EB JWT relocation pattern. Tokens stay on the instance: POST /api/connect/skv/oauth/token exchanges the code/refresh with Arcim's client secret and RETURNS the tokens to the instance (which encrypts+stores them), the ledger keeps only sha256(access_token) and sha256(refresh_token). authorize-url uses OUR registered redirect_uri + a signed connector state; the SKV extension /callback gained the same connector branch as EB (isConnectorState → 302 back to the instance, code never exchanged there). The data proxy app/api/connect/skv/api// is an allowlist over the four backing APIs (moms, skattekonto, agd-inlamning, agd-period): the instance sends the user's SKV Bearer (as X-Connector-Upstream-Authorization) + X-Connector-Key, the proxy verifies the token hash against the ledger and adds Arcim's Client_Id/Client_Secret gateway headers (never exposed to the instance). Same per-key/global budget as bank. SKATTEVERKET_ENABLED + SKATTEVERKET_TOKEN_ENCRYPTION_KEY stay operator-set on the instance since the tokens live there. Refresh update written as two literal .update() branches to keep the no-phantom-columns ceiling at 380. [2026-08-20] Entitlement partition for the sovereign self-host (plan WS3 PR3, ships dark): isPaywallBypassed() became isBypassedFor(key). Hosted behaviour is byte-identical (dev/DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, otherwise the grant lookup). On a self-host every LOCAL capability stays always-on, FORCE_PAYWALL included (an AGPL operator's own instance is never gated on what it runs itself, which is also why the existing "FORCE_PAYWALL never overrides self-hosted" test still holds), and only the four CONNECTOR_CAPABILITIES (bank_sync, skatteverket, org_lookup, migration: services Accounted operates) fall through to the grant lookup, where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host reports state 'paid' when a connector grant is active and 'none' otherwise, never 'trial_expired' (that copy talks about a hosted trial). CONNECTOR_CAPABILITIES is deliberately separate from PAID_CAPABILITIES; the trial-seed trigger does seed 30-day source='trial' rows for bank_sync/skatteverket (they are PAID keys) but never writes source='connector' and never seeds the connector-only keys (org_lookup, migration), and on a self-host only source='connector' rows unlock a connector capability, so a hosted company can never hold a connector grant. The capability_grants.source CHECK gains 'connector' by looking the inline auto-named constraint up through pg_constraint. Adding the connector extensions to the self-host Docker preset was deferred to the instance-wiring PR: until a connector key can actually be issued, shipping those extensions in the image would only show dead-end upsells. [2026-08-20] Sovereign package docs (plan WS2 PR1): docs/SOVEREIGN.md is written as regulatory-risk elimination with a per-provider fact sheet checked on the vendors' own pages (Elastx CaaS/DBaaS/3 Stockholm AZs/ISO 27001:2022; GleSYS VPS + S3, no managed k8s, EU-owned not Swedish-owned; Safespring S3 with Object Lock COMPLIANCE/GOVERNANCE; Berget api.berget.ai/v1 with gemma-4-31B-it vision and an SLA that excludes serverless; evroc Think Models EU-only), never as "US cloud is illegal", and it leads with the MCP server as the agent surface that needs no AI provider at all (alignment rule R5). The connector subscription is described as planned and not yet available rather than documented as if it shipped. Vercel Speed Insights is now gated behind !isSelfHosted() in app/layout.tsx (the last ungated hosted-only telemetry; read via lib/env/public-flags per the folded-flag rule). Backup/restore ship as scripts/self-host/{backup,restore}.sh (pg_dump custom format + storage volume tar + optional db-config volume for the pgsodium root key, SHA-256 manifest, AWS CLI v2 against any S3-compatible endpoint, optional COMPLIANCE-mode Object Lock) with a bash -n + refusal-path test, because self-hosted Supabase has no managed backups and BFL 7 kap needs a credible 7-year archive. Stale self-host docs fixed: the 4-of-23 cron table replaced by a pointer to the generated crontab and the pgvector line corrected (nothing stores embeddings). [2026-08-20] Vercel build heap is raised through vercel.json buildCommand (NODE_OPTIONS=--max-old-space-size=6144 npm run build), not a project env var and not build.env: a project-level NODE_OPTIONS also reaches function runtime (V8 sizes the heap against a limit the function does not have), and build.env is marked deprecated in the vercel.json schema; buildCommand scopes the flag to the build exactly like core-build.yml's 8192 does for CI. 6144 fits the standard 4-core/8 GB build machine next to the main next process; the type-check needs ~4.5 GB and was hanging at V8's ~4 GB default ceiling (4 production timeouts 2026-08-14..20). [2026-08-20] The production build type-checks tsconfig.build.json (tsconfig.json minus tests and mocks) via typescript.tsconfigPath; tsconfig.json stays the editor/ESLint view of the whole repo. Why: Next 16.3 runs the project-local tsc CLI by default (prerequisite for TypeScript 7's native checker, which has no JS API) and that checker checks the complete project it is given, whereas the old API checker silently dropped diagnostics from tests/.test. files. ~490 real type errors live in test files today (mostly route handlers called without the ctx argument); vitest never type-checks them, so nothing caught them. Excluding tests from the build keeps that debt where it was (invisible) instead of turning it into a red deploy; a separate tsc job for tests is the follow-up that makes it visible. Measured: tests are ~10% of the check's memory, so this is correctness, not the memory fix (that is the vercel.json heap bump). [2026-08-20] AI provider abstraction (#1406 Tier 2, Sovereign plan WS1 PR1) ships extraction-first: a job-shaped service in lib/ai (generateText / generateStructured / extractFromDocument, NO streaming members) with an Anthropic-family adapter that delegates to the existing createAiClient() factory and sends the exact request literals the call sites sent before (request-shape tests deep-equal them, so hosted Bedrock stays byte-identical), plus an OpenAI-compatible adapter for BYO Swedish endpoints. Only document extraction moved onto it in this PR; the chat loop, composer, receipt hunt and WhatsApp interpreter stay on the direct SDK behind a shrink-only allowlist in the new direct-ai-client antipattern check, because the AI surface audit (2026-08-18) recommends ripping the chat runtime and making the composer deterministic, and porting streaming + translation for code with a delete recommendation against it would be wasted work. The streaming port is gated on that founder call (plan rule R3). [2026-08-20] The OpenAI-compatible adapter uses the Vercel AI SDK 6.x (ai 6.0.259 + @ai-sdk/openai-compatible 2.0.69), exact-pinned and guarded, and NOT 7.x: 7.0.0 shipped 2026-06-25 (eight weeks old at pin time) while 6.x still receives releases, the plan approved "v6" on 2026-08-18, and the SDK is confined to lib/ai/services/openai-compatible.ts so a later major bump is one reviewed file. Lockfile regenerated with npm@10 (npm 11 drops next-intl's nested @swc/helpers entry, per the dependabot-lockfile memory). [2026-08-20] Inbox double extraction fixed by ownership, not by reordering: uploadDocument() awaits the document.uploaded handlers inline, so the document-extraction extension ran BEFORE the invoice-inbox row existed and its copied-from-invoice-inbox branch had fired 0 times ever (every inbox document, web/email/WhatsApp/MCP, was extracted twice: 3 707 + 1 666 calls in 30 d). The inbox now declares extractionOwner: 'invoice-inbox' on the upload (event payload), the extension yields, and the inbox mirrors its single outcome onto document_attachments (extracted_data / extracted_at / extraction_model) from every writer (sync, deferred, attach, retry, MCP) via one helper, so the status poll and the agent reads keep working unchanged. A DB trigger was considered and rejected: it would mirror user edits (PUT extracted-data) as if they were AI output and needs a migration + pg test for what is a TS-level ordering problem. [2026-08-20] Every "no extraction will ever happen" outcome is now stamped on document_attachments as skipped: (no_ai_entitlement, ai_unconfigured, system_generated, sandbox, client_opt_out, ai_no_vision, pdf_rasterizer_missing) and the extraction-status route maps the quiet ones to 'disabled' on the first poll. Prod check (30 d, read-only): 309 of the audit's 327 never-extracted uploads belonged to 29 companies with zero successful extractions, i.e. the paywall working silently and the UI spinning 30 s to find out; the remaining 18 are download failures, now stamped failed:storage_download. Self-generated documents (upload_source 'system': our own invoice PDFs, payout files, filings; 59 paid model calls in 30 d) are skipped as system_generated. [2026-08-20] Strict JSON on OpenAI-compatible endpoints uses a hand-maintained JSON-schema mirror of the extraction Zod schema, opt-in via AI_STRICT_JSON, never an automatic Zod-to-JSON-schema conversion: the Zod schema carries .catch()/.transform() that have no schema equivalent and a generated schema would drift silently. Zod stays the validator either way; JSON-in-prose + extractJsonObject remains the default everywhere because it works on every model and is what hosted runs. [2026-08-20] AI_API_KEY made optional for the OpenAI-compatible backend: a base URL alone now counts as configured (hasAiCredentials / resolveAiProvider), so a local model server (llama.cpp/Ollama/LM Studio/vLLM), which usually has no auth, works with just AI_BASE_URL + AI_MODEL. The openai-compatible service only sends an Authorization: Bearer when AI_API_KEY is set, so a keyless local server is never handed an empty bearer. Hosted providers that require a key still set AI_API_KEY. Bedrock/Anthropic credential logic unchanged. [2026-08-20] poppler-utils is the one system package added to the self-host runner image (Sovereign plan WS1 PR2): pdftoppm renders the first pages of a PDF for AI backends without native PDF input (an OpenAI-compatible Swedish endpoint), measured at ~4 MB plus shared libs on node:22-alpine (pdftoppm 25.12), written to /tmp which docker-compose.yml already mounts as tmpfs under the read-only root. Hosted never calls it (Bedrock reads PDFs natively) and the cron image is untouched. pdfjs-dist + @napi-rs/canvas were rejected earlier (two npm deps, memory spikes, dead weight on hosted). scripts/smoke-ai-provider.ts is the backend-agnostic "is AI wired up" check; verified live against hosted Bedrock and against a local OpenAI-compatible mock (the mock received Bearer auth, per-tier model ids and one image_url part per rasterized page). [2026-08-20] Issue #1674 audit: did NOT touch the middleware to make /privacy and /dpa reachable without login (app.accounted.se 307s both to /login?next=...), even though prospects therefore only ever see the stale marketing-site legal pages: the task was scoped to text edits only, no code behavior changes. The published contradiction (www.accounted.se/dpa listing Anthropic PBC and OpenAI Inc in USA) lives in the marketing site, outside this repo; handled via external-edit list to Emil, not repo changes. [2026-08-20] #1663 filed-declaration read API (v1 GET /skatteverket/vat-declarations) reaches the Skatteverket extension through a registry-resolved read service (lib/skatteverket/declaration-status.ts, mirroring the skatteverket-commit boundary), not by importing extension code into core (CI guard forbids it) and not by dispatching to the cookie-session extension routes (wrong auth surface, deadline-completion side effects): the service uses resolveReadAuth so any company member's API key can read what another member connected (#1673 model); the existing MCP gnubok_vat_declaration_status stays on caller-token auth (changing it is a separate concern); AGI got no v1 endpoint because gnubok_agi_status covers MCP reads and the /agi/kvittenser read path carries reconciliation writes that need their own design. [2026-08-20] RIP-3 chat cutover is scoped to general.help only: the free-form Q&A /chat panel now runs on a page-scoped single-call console (AskConsole → POST /api/agent/ask, persist:true), so it works on any configured backend incl. a local OpenAI-compatible model. The tool-loop intents (transaction.categorization, invoice.draft, supplier_invoice.review) and the docked AgentSheet still use AgentChat + run-turn.ts because they stage operations and need the tool loop, so run-turn.ts is NOT deleted here (the plan gates its deletion on "once nothing calls them"; RIP-4 migrates the rest). Persistence is an opt-in branch on the existing /api/agent/ask route rather than a new endpoint, so page-scoped one-off asks (a report page) stay stateless; the console writes both turns to agent_conversations/agent_messages as canonical Anthropic text blocks so the /chat sidebar and resume keep working across old streaming threads and new single-call ones. [2026-08-19] Provider re-sync replace mode resolves EVERY overlapping completed sie_imports row and treats one it cannot resolve (not_found/not_completed) as a stale watermark to skip, importing the year fresh, but still aborts on a locked or closed period: an unresolvable row has nothing left to delete, while importing over entries that could not be deleted would duplicate verifikationer. [2026-08-20] The single-call /chat assistant answers over a bounded READ-ONLY tool loop (audit Option A: "single-call actions over the existing MCP tool functions"), plus an always-on company snapshot as the backstop: #1759 shipped a version that read only the company name/entity, so it answered "jag har ingen bokföringsdata" to every figures question. Rather than re-introduce the ripped streaming Anthropic runtime, the provider-agnostic lib/ai generateText gained optional tools/maxSteps: the OpenAI-compatible service forwards them to the Vercel AI SDK (stopWhen: stepCountIs) which drives the loop, and the Anthropic-family service hand-rolls a small loop against messages.create (kept on the raw Anthropic SDK so no new deps and hosted stays byte-identical for every non-tool caller). ask-service attaches ONLY the read slice of general.help's tool whitelist via agentToolRegistry (write/staging + memory-write tools excluded; readOnlyHint/destructiveHint re-checked), dispatched with the same agent_chat actor run-turn uses. Works on Bedrock and on any local model with function-calling (Qwen); a text-only model still answers status questions from the snapshot (company_settings + deadlines, never figures). Not chosen: deterministic-context-only (bounded coverage) and unifying both providers on the AI SDK (would need @ai-sdk/anthropic + @ai-sdk/amazon-bedrock deps and change the hosted path). [2026-08-19] undo_bank_file_import (#1672) skips unbooked rows carrying payment_match_log history instead of weakening the audit_log_immutable delete guard: the log is append-only räkenskapsinformation (BFL 7 kap) per 20260323120000 ("Do NOT add cleanup/DELETE jobs") and the single-row DELETE route already refuses those rows (TRANSACTION_DELETE_HAS_AUDIT_TRAIL); the undo reports skipped_match_history so the user can ignore the stragglers. Rejected: a scoped trigger bypass like the GDPR account-delete RPC uses (erasure is a legal right overriding retention; an import undo is not). [2026-08-19] transactions.bank_file_import_id has NO retroactive backfill: attribution by (format, date window) can mislink rows to the wrong batch when a company has several same-format imports, and "undo this import" must never delete rows from a different one. Imports executed before 20260820071500 are simply not undoable through this action. [2026-08-19] Sidebar company switch (#1664): reinstated the existing CompanySwitcher at the top of the expanded desktop sidebar instead of turning the logo slot into the switcher (the issue offered both); the logo keeps its home link plus a native title tooltip, the user-menu flyout stays as the secondary path, and the collapsed rail relies on the UserMenu avatar since 64px has no room for a labeled switcher. [2026-08-19] #1674 audit fixed the in-repo disclosures only (privacy sub-processor Bedrock row now names Anthropic as model vendor, replay paragraph states the deny-by-default guarantee, both locked by app/(public)/privacy/tests): Anthropic was deliberately NOT added as its own sub-processor row because no code path sends data to Anthropic (hosted uses Bedrock in eu-north-1, lib/ai/provider.ts); the artifact the prospect actually read (published DPA PDF or marketing security page listing Anthropic/OpenAI in the US) lives outside this repo and needs founder/legal action, as does any underbitraede-list wording. [2026-08-19] #1674 audit fixed the in-repo disclosures only (privacy sub-processor Bedrock row now says AI requests go to Amazon Bedrock and the models are Anthropics Claude running inside Bedrock, replay paragraph states the deny-by-default guarantee, both locked by app/(public)/privacy/tests): Anthropic was not added as its own sub-processor row because the HOSTED posture is Bedrock by credential precedence (lib/ai/provider.ts: static AWS keys select Bedrock, region is AWS_REGION with eu-north-1 as the default, not a guarantee); a direct Anthropic API path DOES exist in code for self-hosted deployments (AI_PROVIDER=anthropic, or ANTHROPIC_API_KEY without static AWS keys), so "no code path sends data to Anthropic" would be false and the page asserts nothing about Anthropics underbitraede status either way; whether Anthropic is an underbitraede, plus aligning the published DPA PDF / marketing security page (which listed Anthropic/OpenAI in the US), is founder/legal action outside this repo. [2026-08-20] Reverted #1765: the company switcher is NOT mounted at the top of the desktop sidebar. Founder call after seeing it live: switching belongs in the bottom user block only (the UserMenu flyout), so the sidebar top stays brand + collapse and the nav starts immediately below. #1664's "one-click from the top" framing is therefore declined, not merely unimplemented; the logo title tooltip went back with the revert since it shipped inside the same commit. Do not re-add a top-of-sidebar switcher from #1664 without a new founder decision. [2026-08-20] PR #1770 dropped its own privacy/DPA rewording (Anthropic-korda-av-AWS phrasing, enda-underbitraede claim, date bumps) in favor of the wording #1766 already merged and pinned with app/(public)/privacy/tests/ai-and-replay-disclosures.test.ts: the test forbids the DPA naming Anthropic and forbids the Bedrock row asserting underbitraede status either way, so this PR keeps only what #1766 did not cover, the systemdokumentation-mall.md integration row (said Accounted -> Anthropic -> Accounted; now Amazon Bedrock, EU) and the middleware-scope decision line. [2026-08-20] Fortnox voucher-attachment scopes (Arkivplats + Koppla filer) are requested per authorize call from the underlag follow-up only, never from an ordinary connect, and gated on FORTNOX_DOCUMENT_SCOPES_APPROVED in lib/providers/fortnox/oauth.ts (the portal-registration switch). Two reasons: Fortnox derives customer licence requirements from what the integration requests, so an all-connects request would put an Arkivplats licence in front of customers who never import a receipt (the portal says so in as many words); and a scope the registered app lacks makes authorize reject with invalid_scope before login, so keeping it off the default connect caps the blast radius at the underlag flow instead of every Fortnox connection (incident 2026-08-13). A document consent is always a superset of an ordinary one, because the callback overwrites the consent's tokens in place and a narrower grant would revoke the migration's own ledger access. While the flag is false the attachment 403 reports PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE with no action offered, instead of reconnect advice for a permission we never ask for: that advice sent Klura AB around the OAuth loop four times and to buy the Fortnox Arkiv module for nothing (support case 2026-08-20). Portal registration alone changes nothing observable, which is why turning the scopes on and back off that day neither caused nor fixed the error. [2026-08-21] Flipped FORTNOX_DOCUMENT_SCOPES_APPROVED to true: Arkivplats and Koppla filer are now enabled for integration 39254 in the Fortnox Developer Portal (founder confirmed). Only the opt-in underlag reconnect requests them, so the ordinary connect is unchanged and no customer is asked for an Arkivplats licence to connect. Set it back to false if the portal ever loses the scopes, since authorize then rejects with invalid_scope before login. [2026-08-21] Qvalia is the Peppol Access Point (contract signed 2026-08-21); the adapter lives in lib/invoices/transports/qvalia.ts behind the provider-neutral PeppolTransport seam, switched on per environment by PEPPOL_TRANSPORT_PROVIDER=qvalia plus QVALIA_* credentials: Storecove was the doc's technical preference, Qvalia won on the commercial track (Swedish AP+SMP, partner model, existing relationship); v1 is consolidated-account send-only (recipient lookup, submit, shared-secret webhook, evidence), a draft is issued with mark-sent semantics only after the network accepts it, and a sync rejection freezes that exact XML as terminal failed so the identical document is never re-sent; inbound, credit notes, 0088 GLN for enskild firma and per-company registration are separate PRs. [2026-08-21] Korjournal distance suggestions via OSM (Nominatim+OSRM public endpoints) instead of Google Maps API: no key, no cost, AGPL-friendly, works self-hosted; addresses proxied server-side without identifiers and OSMF disclosed on the privacy page. [2026-08-21] Skeptic pass on #1778: routing endpoint switched from router.project-osrm.org (demo server, non-commercial only) to FOSSGIS routing.openstreetmap.de (fair use, attribution shown in UI); lookup click-gated instead of as-you-type per Nominatim's no-autocomplete policy; OSMF/FOSSGIS disclosed as independent recipients, not underbiträden. [2026-08-21] SCHABLONINTAKT_RATE_BY_CLOSING_YEAR backfilled 2020-2024 (SLR 30 Nov per Riksgalden: -0.09/-0.10/0.23 floored to 0.5 %, 1.94 %, 2.62 %) and the rate now resolves lazily (resolveSchablonintaktRate: 0 when no 212X account carried an opening balance): the table only covered 2025/2026 and the builder consulted it unconditionally, so every AB closing a pre-2025 year got a generic 500 at bokslut step 3 (126 open FY2024 periods on prod, incl. a byra trial). 2019 and earlier stay unmapped on purpose: the 100 %-of-SLR rule keys on beskattningsar STARTING 2019-01-01+ (prop. 2017/18:245), so a 2019 closing can be a brutet ar under the old 72 % factor. Unmapped-year-with-fonder now raises SCHABLONINTAKT_RATE_NOT_CONFIGURED (typed, 500 so runtime-error clustering still flags the missed December update) instead of INTERNAL_ERROR. [2026-08-21] RIP-4 "optimal" auto-booking cascade, Tier 2 = the provider-agnostic account SELECTOR (lib/agent/categorize/select-account.ts), built per the 2026 research (artifact dc0c2760): the model does NOT free-form a categorizer; it CHOOSES from a closed set — the deterministic candidate accounts (Tier 1) + the 19 standard business categories (each maps deterministically to a BAS account via getDefaultAccountForCategory) + "needs_review". So the model can't invent an account, account/VAT stays deterministic and validated, and it runs on any provider (Bedrock or a local model) via getAiService().generateStructured. Founder chose the optimal path (model selects on EVERY transaction, LLM calls are fine), so confidence uses SELF-CONSISTENCY (default 3 samples, majority vote, agreement fraction) combined with the model's stated confidence and floored by the winning candidate's deterministic confidence — never the model's verbalized confidence alone (research: systematically overconfident). reasoning field precedes choice in the schema (reason-before-choice). needs_review is never auto-applied. Calibration of the combined score → the auto-book/suggest/review gate is a later tier. Not yet wired: Tier 1 candidate gathering (counterparty templates + getSuggestedCategories) + a route + the ApprovalCard UI (next PRs). [2026-08-21] RIP-4 cascade Tier 1 (candidate gathering) + the proposal route. lib/agent/categorize/candidates.ts assembles the deterministic candidate slate for a transaction exactly like the gnubok_suggest_categories MCP tool (mapping_rules + per-merchant history via buildMerchantHistory/getSuggestedCategories + the learned counterparty template), NO model call, deduped by account (highest confidence wins) and capped. Suggestions carry no VAT so it derives the category default (getDefaultVatTreatmentForCategory); the counterparty template carries its own. POST /api/agent/categorize runs Tier 1 → Tier 2 selectAccount and returns the proposal + the candidate slate; it NEVER posts (the caller renders an approval card). Gated on getAiStatus().configured (any provider incl. local), same gates as /api/agent/ask (auth via requireAuth, rate, membership, sandbox, capability). Next: wire the transaction row to this route + the ApprovalCard (UI, visual sign-off), then calibration + the auto-book gate. [2026-08-21] Provider underlag import (arcim-migration /import-documents) is now time-budgeted + resumable (200 s slice, stable provider-id order, cursor/nextCursor/partial, UI loops with "x av y") and opts out of AI extraction (extractionOwner: 'none', stamped skipped:opted_out): Fabian's 113-file Fortnox import ran every file's Sonnet extraction inline inside one request, hit the hosted 300 s function limit after ~17 files twice (09:02 and 10:49 UTC) and the UI showed the generic "underlagen kunde inte importeras". The files are linked to posted verifikat on arrival, so the extraction bought nothing and cost a model call per file; chunking rather than raising maxDuration because a 2,000-file archive would still not fit. Opt-out is per-call (not "skip whenever journal_entry_id is set") to keep the diff scoped. [2026-08-21] MCP signed Storage URLs (gnubok_create_document_upload upload_url, gnubok_get_document_content signed_url, audit-package download_url) are served through a same-origin proxy, /api/storage/[...path] → .supabase.co/storage/v1/object/{sign,upload/sign}/documents/..., instead of a Next rewrite: Claude Desktop's sandbox only reaches the MCP host (app.accounted.se) and blocked the PUT to supabase.co (Fabian, 2026-08-21). A route handler reads NEXT_PUBLIC_SUPABASE_URL at runtime (rewrites bake at build, which breaks the Docker image), is deliberately NOT withRouteContext (the signed token is the only credential, validated by Storage per object path; the proxy forwards only signed documents-bucket paths to our own host) and is a no-op rewrite when NEXT_PUBLIC_APP_URL is unset so a self-host never gets a localhost link. [2026-08-21] RIP-4 cascade step 3 (UI): the AI booking proposal is surfaced INSIDE the existing QuickReviewDialog rather than a new inline-row card, so it reuses that dialog's proven, deterministic, balanced commit path (POST /api/transactions/[id]/categorize) instead of a parallel one. components/transactions/AiCategorizeProposal.tsx fetches POST /api/agent/categorize on dialog open (keyed on tx.id so it remounts per transaction), pre-fills accountOverride + vatTreatment via handleAccountChange (class-2 VAT clearing preserved), and shows the confidence band (säker/trolig/välj konto) + "Varför" + the candidate alternatives (click to re-apply). Falls back SILENTLY to the deterministic defaults on error, and shows a soft note on 503 (ai_unconfigured) — the dialog always works without AI. NO silent auto-posting (founder call, avoids the storno-on-undo mess): "säker" = pre-filled, one-tap Bokför via the dialog's existing button; true hands-off auto-book waits for calibration. i18n: strings inline Swedish for now (assistant surface), lift to messages/{sv,en}.json before final merge. Confidence bands (0.8/0.5) are placeholders until calibration. Needs founder visual sign-off before merge (project_nav_ia_redesign). [2026-08-21] RIP-4 step 4 = calibration. lib/agent/categorize/calibration.ts is the engine: isotonic regression via pool-adjacent-violators (distribution-free, monotonic) over (confidence, was_correct) samples → a calibrator that turns raw selector confidence into a probability that actually means what it says; plus reliabilityByBucket/ECE and bandFor(). bandFor NEVER returns 'auto' without a fitted calibrator (no silent booking on an unproven score) and never auto-books above an amount cap (default 2000 kr) — so "säker" stays honest until proven. Measurement loop: migration 20260821100000 categorize_calibration_samples (append-only, company-scoped RLS, confidence CHECK [0,1]); POST /api/agent/categorize/outcome logs one sample (proposed vs booked account → was_correct) fire-and-forget from QuickReviewDialog on a successful book (sandbox skipped to keep the corpus clean); AiCategorizeProposal surfaces the proposal metadata via onProposal. scripts/fit-categorize-calibration.ts (READ-ONLY) prints the reliability diagram + ECE + fitted calibrator once data exists — run it in a few weeks, then store the calibrator/thresholds where bandFor reads them and only THEN consider enabling auto-book. Fitting needs >=200 real samples so nothing calibrates today; the loop just starts collecting. Migration applies on merge (auto-apply-on-merge active) — not applied manually. [2026-08-21] The categorize selector now reads the underlag, not just the bank line — the highest-leverage quality lever for the cold-start majority (prod: 365 companies with 32.7k unbooked tx, median 0 templates, so the LLM carries them). lib/agent/categorize/underlag.ts gathers the matched receipt/invoice text (receipts.matched_transaction_id + invoice_inbox_items.matched_transaction_id + the transaction's own document_attachments), rendered as bounded Swedish text (supplier, date, total, moms, line items). Core reads these tables directly via supabase (table names, not @/extensions imports — the tables live in the shared DB). POST /api/agent/categorize gathers it server-side when the caller didn't pass underlag, so the model sees the actual supplier + line items. Best-effort ('' on any failure); server-side only, no client change (so no conflict with the calibration PR #1784 which also touches the dialog). Prod read (project pwxtzglxptnnvjrpixpg) also confirmed: 3342 active counterparty templates / 26k occurrences → established users get strong instant candidates; NO backfill needed (templates already reflect historical bookings). [2026-08-21] A personnummer-shaped org_number on customer_type=individual is MOVED into personal_number (encrypted, masked on read) and org_number cleared, on every write path (CreateCustomerSchema transform for web/v1/bulk, both PATCH routes, MCP gnubok_create_customer at staging and commitCreateCustomer for in-flight ops), rather than rejected like the business-type case (#1724): the value is unambiguously the person's own personnummer, the MCP tool had no personal_number input until now so agents had nowhere else to put it (134 such rows across 10 companies on prod), and the v1 docs promised org_number was accepted for individuals, so a 400 would break live clients for no privacy gain. Only an org_number that is a DIFFERENT personnummer than a submitted personal_number is refused (CUSTOMER_PERSONAL_NUMBER_CONFLICT). MCP stages the personnummer encrypted (personal_number_encrypted + personal_number_masked, personal_number is now a forbidden staging key) and hashes the masked preview for idempotency, since the random-IV ciphertext would make identical retries look like payload changes. Read surfaces (web customer list, MCP list_customers) mask a legacy individual row's org_number personnummer instead of showing it raw; scripts/repair-customer-personal-number-in-org-number.ts moves the existing rows and is run by hand with --confirm after deploy. MCP create_customer payment_terms are resolved from company_settings at staging: the staging-side || 30 is why #1708's fix never reached MCP customers. [2026-08-21] Peppol receiving (PR2) keeps Qvalia's consolidated partner account: every company's 0007:orgnr is registered on OUR account (PUT /partner/{p}/account/{p}/peppol/{id}) and inbound documents are routed by the AccountingCustomerParty EndpointID through peppol_registrations, because Qvalia confirmed sending needs no per-company account and child accounts would only add a 100 kr/mån tenant fee per customer; the exact received UBL XML is archived as a WORM document (upload_source e_invoice, extractionOwner none) and the inbox row is filled from the structured UBL with confidence 1 (no model pass), following the mail-hunt precedent of core inserting invoice_inbox_items directly; personnummer-based companies are refused registration until 0088 GLN support exists (publishing them would put personal data in the Peppol Directory); the poll is a 10-minute cron (GET .../readinvoices marks documents read at Qvalia, so every fetched document is archived before anything else can fail). [2026-08-21] Confidence honesty fix, driven by a real backtest (scripts/backtest-categorize.ts, read-only: runs the real cascade on already-booked prod transactions and scores the model's pick vs the human's actual account). Backtest exposed the selector reporting 0.95 on pure category guesses → "säker" was a lie (high-conf picks only 52% accurate). Fix: confidence is now driven by DETERMINISTIC BACKING (the confidence of a candidate that independently points at the chosen account), not the model's verbalized confidence (which the backtest showed is ~always "high"). A backed pick takes the candidate confidence, reduced only when the model is unsure (BACKED_MODEL_FACTOR); an UNBACKED pick (category guess no candidate agreed with) is capped at 0.7 — below the säker band (0.8) — so a guess is never "säker". Re-backtest: säker accuracy 52% → 73%, and far fewer picks claim säker (only template-backed ones). Still not auto-book-grade (~73%, want ~95%); auto-book stays off until isotonic calibration on real approvals. Backtest caveats: exact-account match is strict (penalizes reasonable-but-different picks + companies' idiosyncratic charts), sample is established users (cold-start majority has no ground truth yet), backtest ran samples=1 (no self-consistency). Some confident-wrong cases are POISONED templates (a past mis-booking → wrong candidate the model correctly follows), a data-quality issue not fixable in the confidence math. [2026-08-21] Behandlingshistorik ships as a report over existing stores (journal_entries.committed_at + audit_log + rattelse log + import tables) rather than on processing_history: that table only carries Document/BankTransaction/System events in prod, while audit_log is complete, immutable and already the archive's revision/behandlingshistorik.json. Event labels stay Swedish in both locales (räkenskapsinformation, archived 7 years, same rule as SIE/grundbok); only the view chrome is translated. Bokföringsposter come from journal_entries (not audit COMMIT rows) so entries predating the audit log or from the July SIE-import window are never missing. [2026-08-21] Peppol access is granted per company by the operators, never self-served (peppol_access table, locked by default): every transmission is billed per document by Qvalia and every receiving identifier consumes a contracted tenant slot, so the company asks from Settings > Fakturering (request row + e-mail to support) and we enable it with scripts/peppol/access.ts, setting max_sends (null = no cap) and separately receive_enabled; the send route refuses PEPPOL_ACCESS_REQUIRED / PEPPOL_SEND_LIMIT_REACHED before touching the invoice, and registration refuses PEPPOL_RECEIVING_NOT_ENABLED. Founder call 2026-08-21 after the first open-for-all hour in prod. [2026-08-22] BoXon feedback fix: leftover assistant proposals no longer need manual cleanup in Granskning. lib/agent/pending/reject-conversation-pending.ts rejects a conversation's still-pending pending_operations in one update (guarded on status='pending' so it never stamps 'rejected' over a committing/committed verifikat; company-scoped; filtered by agent_metadata->>conversation_id). Surfaced two ways: POST /api/agent/conversations/[id]/reject-pending (the chat's "Rensa förslag som inte godkänts" button) + auto-clear when the conversation is ARCHIVED (best-effort in the PATCH; archive = "I'm done with this thread"). Kept durable-by-default (proposals still resume) — the button is explicit user intent, not an auto-reject on every panel close, so resume still works. Chat button drops all staged cards from view on click (committed ones are already booked; the card was only a confirmation). UI PR: needs founder visual sign-off on the button. [2026-08-22] OSS support = one oss revenue momskod (off every ruta) + SIE-import label suggestion, not per-country invoice rates or an EUR/ECB OSS report: Skatteverket requires OSS sales out of the ordinary momsdeklaration, and the missing piece a Fortnox OSS user hits first is the mapping step (3106-style labels were pushed to ruta 35, OSS-named accounts had no option). Per-country VAT rates on invoices and the quarterly EUR underlag are a separate feature; the recommended interim workflow is one revenue account per country/rate (as Fortnox suggests) + Huvudbok per kvartal + manual ECB conversion. [2026-08-20] Provider invoice mappers report an absent VAT/net field as undefined, never as 0 or as "net equals gross". Fortnox's list payload (InvoiceShort) carries no Net, TotalVAT or InvoiceRows (confirmed against the go-fortnox client's struct split), so the old ?? total fallback derived 0 kr VAT on every migrated Fortnox invoice; measured on prod, 8 712 sales invoices across 43 companies assert a rate with 0 kr of VAT, plus 1 240 supplier invoices. LegalMonetaryTotalDto.lineExtensionAmount is now optional to express it. [2026-08-20] The migration writes vat_rate: null rather than 25 when no evidence established a rate, and keeps vat_treatment at the schema default instead of widening Invoice['vat_treatment'] to nullable: the column is read at 90 call sites across 38 files including the booking paths, and a nullable treatment there is a far larger change than the bug warrants. The null rate plus the reported vatUnresolved count is the signal. [2026-08-20] An observed 0 % rate on a SEK invoice maps to exempt, not standard_25 (the old fallback). A momsfri sale labelled 25 % routes to revenue account 3001 and into ruta 05 of the momsdeklaration. A 0 % rate in a foreign currency stays export. The source cannot distinguish momsfritt from omvänd skattskyldighet, so currency is the only signal used. [2026-08-20] A rate divided out of the totals is snapped to a Swedish statutory rate only within 0,5 percentage points; anything else (19 % DE, 24 % FI) leaves vat_rate null rather than being coerced. A rate stated on a line always beats one divided out of the header, because a mixed-rate invoice divides out to a blended figure matching no real rate. [2026-08-20] Invoice detail hydration is bounded by a 90 s budget and fetches OPEN invoices first, rather than hydrating every invoice. The largest migrated company holds 1 911 invoices and Fortnox allows 4 req/s, so full hydration would need ~8 minutes against the route's maxDuration = 300; open invoices are both the few (<=71 per company) and the only ones a later payment match or credit note can still book off the wrong numbers. What the budget does not reach is counted into the migration result, never silently dropped. [2026-08-20] resolveVatTriple guarantees net + vat === gross even when the provider states all three and they disagree, absorbing the gap into the net rather than the VAT. Fortnox's Total is post-öresavrundning while Net + TotalVAT is the unrounded Gross, so they differ by up to 50 öre; passing both through would leave subtotal + vat_amount != total on the invoice row, and the header booking path in invoice-entries.ts derives the 1510 debit from the sum of its credits, so the receivable would sit a few öre off what the customer owes with the verifikat still balancing. The VAT is the figure that must survive intact because it reaches the momsdeklaration. [2026-08-20] Invoice detail hydration addresses the endpoint by the resource config's own idField read off the raw payload, not by dto.id. Björn Lundén's sales config names invoiceNumber while its mapper builds dto.id from entityId, so dto.id would have requested a different invoice or none; every other provider/resource pair happens to agree, which is exactly why the mismatch was easy to miss. [2026-08-21] A migrated mixed-rate invoice stores vat_rate: null while keeping a treatment, matching what buildInvoiceWriteData already does for a natively created one (isMixedRate ? null : theRate). Labelling the header with the first line's rate would assert 25 % on an invoice that is 25 % and 6 %, and dividing the rate out of the totals gives a blended figure matching no statutory rate. The money is unaffected either way: generatePerRateLines groups per ITEM rate, which is why the per-line vat_rate/vat_amount are the part that has to be right. [2026-08-21] Invoice detail hydration stops the whole pass on a 401/403 and bounds every in-flight call against the budget deadline. The provider clients retry 429s and 5xx with backoff (Fortnox: 6 attempts, up to 60 s apart), so a call starting one millisecond inside the budget can still be retrying minutes later, and three concurrent ones could hold the migration past its 300 s ceiling; racing each against the deadline returns control even though the socket is not cancelled. A rejected token fails identically for every remaining invoice, so continuing would spend the Fortnox rate-limit budget for nothing: note that limiter keys on the literal string 'global', making 4 req/s a PLATFORM-WIDE budget shared by every company and every concurrent migration, not a per-token one. [2026-08-23] Reversed the 2026-07-26 call and moved the two free-text ilike legs of MCP query_journal off the journal_entries!inner embed onto fetchEntryLines: the embed's correlated LATERAL was the query behind the daily statement timeouts (57014) in prod, and leg B (line_description) is bounded by the same entry-side scope the plain query already fetches in full, so the two-step pass is never more expensive than the non-text query with the same filters. Cost: legLimit/legCapHit and totals_scope='returned_slice' are gone (text totals/truncated are now exact; the field stays, always 'full_match'). No trigram index this round; if the ilike scan itself proves slow on staging that becomes a separate RPC PR. [2026-08-22] Per-company invoice sending domains are gated by a manually granted capability (custom_sender_domain), deliberately NOT in PAID_CAPABILITIES: the opt-in must not be trial-seeded or written by the Stripe subscription sync, and non-grantees must see an unchanged invoicing settings page (the section hides on the 403 capability_blocked envelope). The sending-domain module has no Resend orphan-adoption path (a name that already exists is a 409), because the same Resend account holds the platform's own outbound domain. The delivery log was left untouched (no from_address column): adding it would re-open the hardened invoice_deliveries evidence triggers/redaction paths for a nice-to-have, and the log already measures delivered/bounced per send. [2026-08-22] company_sending_domains verification state (domain, status, resend_domain_id, dns_records, verified_at, last_checked_at) is service-role only via a BEFORE trigger keyed on the JWT role claim; tenant JWTs may only open a pending claim and edit sender_local_part/sender_name/enabled. Skeptic refutation: RLS alone let a granted admin insert {domain: platform sender domain, status: verified} through PostgREST and send invoice mail as the platform. The claim/verify helpers therefore take a separate service-role writer for those columns. Second refutation: a domain Resend later flips to failed made every invoice send for that company fail; the Resend adapter now retries once as the platform sender when an explicit company From is rejected (nothing was sent on the rejected attempt, so the retry cannot double-send). [2026-08-22] Sending-domain verification writes bind by (id, company_id, domain, resend_domain_id IS NULL) and verify/webhook compare Resend's domain name with the row before writing verified; resolveInvoiceSender additionally refuses reserved platform domains and non-hostnames at send time. Skeptic re-check: a tenant could delete and re-insert its pending row under the same id with a reserved domain during the claim's Resend round-trip (TOCTOU), and the service-role writer updated by id alone. Defense in depth over a single gate. [2026-08-24] Pre-FY bank transactions (issue #1825, aktiekapital deposit dated before registration) auto-book into the first OPEN unlocked fiscal period with entry_date = period_start and the real bank date in the verifikationstext, instead of asking per booking or offering "Skapa rakenskapsar" (a pre-registration year is legally wrong). The clamp lives ONLY in createTransactionJournalEntry and fires strictly on date < earliest period_start, so interior period gaps and future dates keep the old null return; a closed/locked first year also keeps it (storno-safe). ensureFiscalPeriod copies got a matching guard so no calendar-year period is ever minted for pre-FY dates. [2026-08-24] #1717 öre settlement in match_batch_allocate replicates the ORE_ROUNDING_SETTLEMENT_MAX = 1.00 kr policy band in SQL (reject overshoot only at >= 1 kr; absorb sub-krona diffs to 3740 with full 1510/2440 clear) instead of routing the batch through planInvoicePayment: the RPC is one atomic transaction and must stay self-contained; the band constants are documented as mirrors of lib/money.ts. The mark-paid route and invoice-detail button now accept partially_paid (settleInvoicePayment's CAS guard always did) so a stuck invoice can be closed; the remaining-aware dialog proposal is deliberately scoped to SEK + accrual + no ROT/RUT deduction: cash partial completion is server-blocked (cashPartialBlockReason), FX needs a payment-day conversion the pure proposal lacks, and a ROT/RUT remainder is Skatteverket's 1513 share settled by the payout flow. [2026-08-24] Assistant empty answer is a typed failure (EmptyModelAnswerError, 502 'Assistenten gav inget svar. Försök igen.'), one manual retry, NO auto-retry: the silent 200 with an empty answer was the 'Tänker then nothing' bug after the RIP-3 cutover (1500-token cap on /api/agent/ask), and auto-retry would double model spend while hiding the regression. Related deliberate request-shape change: anthropic-family's step-exhausted fallback now keeps tools with tool_choice none, because replaying a transcript containing tool_use/tool_result without declaring tools is rejected by the Messages API (the old shape 400ed every step-exhausted answer). general.help stays on the single-call runtime (founder decision); the fix raises headroom to 5400 tokens and surfaces the failure instead. [2026-08-24] Issue #1820 self-billed credit fix: creditConfirmNumber()/originalRef fall back invoice_number -> external_invoice_number (typed 400 INVOICE_CREDIT_NO_NUMBER if both null) instead of relaxing the DB numbering constraint or dropping the type-the-number confirm step; the confirm step stays (dropping it is a founder call). The invoice-date Forval chip surfaces in ALL editor modes, not only self-billed: the silent today-default exists in every mode and the chip line already carries the due date. In self-billed mode fakturadatum + mottagningsdatum render uncollapsed next to the external number (transcription fields, not defaults); the panel rows are hidden there because registering the same RHF field twice desyncs the inputs. The v1 credit route's existing id-slice fallback was left unchanged (public API behavior). [2026-08-24] No-IBAN reconnect pairing (issue #1709) uses only per-currency exactly-one-each-side elimination, deliberately WITHOUT name equality: ASPSPs reformat product names between consents, so requiring it would silently disable the fix for the banks that need it, while the one-per-currency guard already bounds a mis-pair to skipping rows whose account+date+amount+occurrence all collide. upsertFromPsd2 needed no change: its explicit reuse_cash_account_id promote path already covers a same-connection holder, so the fix only names the paired row from the callback. [2026-08-24] Dashboard "Verifikat utan underlag" deep link filters server-side via a TS resolver (lib/bookkeeping/missing-underlag.ts, extracted from the bulk-missing route) with a two-step ids-then-rows fetch, NOT by extending list_fiscal_period_entries_with_related or the verifikat_without_documents RPC: no migration, and the predicate stays byte-identical to the badge count's bulk-remedy path. Three deliberate sub-choices: collapse_corrections is ignored while the filter is on (the badge has no collapse notion, hiding corrected originals would desync list from count); the deep link scopes the visit to all fiscal years in memory only (badge counts all years; the saved per-company year preference is not overwritten); client-side re-filtering against attachmentCounts was removed when the filter is on (late-arriving counts could hide server-included rows and desync rows from count). [2026-08-23] Reconciliation engine (PR 1): the skattekonto status engine lives in core lib/reconciliation (reads the core table + the extension snapshot row in extension_data directly) instead of in the skatteverket extension: core must never import @/extensions/, and the reconciliation facade must work with zero extensions; the matcher stays in the extension and writes its proposals to the row at sync time. Proposals are propose-only (suggested_journal_entry_id is never a link); the same per-entry one-to-one assignment replaces per-row "exactly one candidate". Ledger balances everywhere in reconciliation use the trial-balance predicate status IN (posted, reversed): the drift check summed posted only, which misstated 1630 for every company with a storno on the account. [2026-08-23] Reconciliation doors (PR 2): dashboard routes, the v1 API and the MCP tools all call lib/reconciliation/{service,items,actions}.ts; no door re-implements a link. Policy lives in the door: page + REST apply directly, MCP stages (reconciliation_match / reconciliation_unmatch pending operations, executors in commit.ts). The MCP write tools are catalogVisibility search (and gnubok_link_transaction_to_journal_entry moved to search) because the tools/list payload ceiling (59 900 tokens) left no room for them in the default catalog; the reads (status with account_key, items) stay default and the items description points at the write. The skattekonto link now has its canonical implementation in core lib/skatteverket/skattekonto-link.ts (needed by core doors; core must not import the extension); the extension route still uses its own matchSkattekontoToEntry until its queued-mock tests are ported, then it delegates. New scopes reconciliation:read/write; gnubok_get_reconciliation_status keeps reports:read and the legacy bank routes keep transactions: so no existing key is cut off. [2026-08-23] Avstämning page (PR 3) ships without the period picker, the manual two-pane match mode and the sign-off button: the page renders the approved 'Vald riktning' layout (rail + tiles + bridge + actions + banded table) over the PR 2 dashboard routes only, so that it is verifiable on its own; period + sign-off arrive together in PR 4 (both are period-bound), manual N:M matching with residual booking in PR 5. Bank accounts get the same generic body plus links to the existing bank view for the matcher run rather than embedding the 1900-line BankReconciliationView: one body for every account kind is the point of the page, and embedding would have doubled the header. [2026-08-23] Reconciliation sign-off (PR 4) is an append-only attestation table (account_reconciliations) with a reopen stamp, not a flag on the account: who signed what through which date, with the numbers as they stood, is the thing an auditor and the Hem row read, so it must survive a later change of mind. Sign-off is refused with an unexplained difference unless forced with a note (the note is what the next reader sees). Separate scope reconciliation:signoff (write is not enough): an integration that links rows should not be able to attest. The worklist category reconciliation_due is gated on adoption (zero until the company has signed anything off) so the nudge reaches the people who reconcile monthly without becoming a new chore for everyone. Webhook events added additively without bumping API_V1_VERSION: the dated version is reserved for breaking changes; a new event type breaks no existing subscriber. [2026-08-23] Reconciliation agent surfaces (PR 5): the Hem notice for a skattekonto that disagrees with the ledger reads a summary the sync persists (extension_data skattekonto_reconciliation_latest) instead of recomputing the bridge on every render; the same persisted summary feeds nothing else yet. The attention resource's reconciliation_due category and the Hem row share lib/worklist countReconciliationDue (one predicate). Manual N:M matching with residual booking, dropping the local MatchDialog on /skattekonto, restyling the bank view and eval scenarios are deferred to PR 6: they need the two-pane UI and a visual pass, and none of the agent surfaces depend on them. The skattekonto sync cron now orders eligible companies by stalest sync before its 50-per-run cap (never-synced first) instead of raising the cap: a fixed order plus a cap starved the tail. [2026-08-24] MCP underlag warnings are advisory text, not staging blocks: soft guards must stay bypassable and IB/migration vouchers legitimately lack a kvitto; approve-scope fallback copy is static because stagePendingOperation has no access to key scopes and plumbing them through every staging tool was not worth the diff. [2026-08-24] Inline matcher (PR 6a) applies through the existing /api/reconciliation/bank/run intersection guard rather than new endpoints: the page renders the dry-run pairs and applies per row or strong-only (floor 0.85, re-enforced server-side), so a stale preview can never link a pair the fresh run would not. The old bank view keeps living until 6b (manual N:M + residual booking) reaches parity; only its matcher trips became unnecessary today. [2026-08-24] vat_amount (categorize/bulk_book) is transaction-currency, converted to SEK at booking: the validation bound already read it in transaction currency (the underlag's denomination), and the gross line already converts through resolveSekAmount, so converting the VAT the same way was the only coherent option. Documenting it as SEK instead (the reporter's first suggestion, feedback seq 254607) would force agents to pre-convert with a settlement rate they cannot see. [2026-08-24] Declared currency/voucher_series nullable in three MCP listing schemas on column-nullability alone (no traced null producer): loosening an output schema can only stop false validation failures, never cause one, and legacy rows predate the columns' defaults. Declined (for now) a full Ajv execute-vs-schema round-trip harness in output-schema.test.ts: right long-term answer to this bug class, but a session-sized project of its own; the audit's seven confirmed sites are pinned by a targeted declaration test instead. [2026-08-24] Manual matching (PR 6b) ships N:1 only (many outside rows -> one verifikat): bank links are independent per transaction (the engine allows it by design), skattekonto groups are all-or-nothing with the sum settling the verifikat (one guarded UPDATE, partial hit rolled back). 1:M (one row over several verifikat) and residual booking wait for a link table in 6c: the single journal_entry_id pointer on both row kinds cannot express them, and faking it (pointing the row at the residual verifikat) would break the bridge. The worksheet therefore enables Koppla only when the selection nets to zero and says so otherwise. [2026-08-24] Skattekonto payment file gets pain.001 through the supplier-payment generator (generateSupplierPain001), not the salary pain001 generator: the payment is a plain BG+OCR giro transfer (no SALA CtgyPurp), and the supplier dialect is the Validex-validated shape for exactly that; the LB path stays the default so nothing changes for banks still on LB. [2026-08-24] Residual booking (6c) reuses transaction_voucher_links instead of a new link table: the junction already models one transaction against several verifikat (bulk-book), and both GL RPCs plus the TS bridge now treat it as a link, which also stops samlingsverifikat from polluting the open buckets. Residuals are bank-only (Skatteverket posts ränta/avgifter as their own rows), capped at 5 000 kr (above that it is a missing booking, not a fee), direction-checked against the kind, and the links are made before the booking and undone if the booking is refused. [2026-08-24] query_journal status default 'posted' -> 'all' (posted+reversed, the trial-balance inclusion rule) after a customer's agent summed posted-only lines over a storno-heavy Q2, found phantom VAT residuals on 2614/2641/2645/2647 and demanded a revert of correct books: one-leg sums are never balances. Funded the new status_filter_warning field and richer status docs inside the tools/list token budget by trimming sibling descriptions in the same tool rather than bumping the 59.95K ceiling (the payload guard's own guidance); warning fires off an entry-level opposite-status head count, exact for what the sentence claims and cheap, instead of re-running the line fetch. [2026-08-24] Detach-duplicate underlag ships as a SECURITY DEFINER RPC (detach_underlag_duplicate) instead of loosening the document triggers: the WORM guards stay intact for every other path, the carve-out is transaction-local (gnubok.allow_delete) and audit-logged first, and detach is refused unless another anchored underlag remains on the verifikat (BFL 5 kap 7 par) AND a remaining sibling has an identical sha256_hash (only byte-identical duplicates detach; skeptic-hardened 2026-08-24, along with an enforced posted-status guard and company_id on the audit row). Pinned docs (transactions.document_id / supplier_invoices.document_id) stay replace-only. [2026-08-24] Single-call chat console (general.help, AskConsole → /api/agent/ask) now carries the thread's earlier turns into every model call, via a new optional history on the provider-agnostic GenerateTextRequest (real message turns before the prompt in BOTH adapters: Anthropic-family messages array, OpenAI-compatible via AI SDK messages; an absent/empty history leaves the request byte-identical to the single-turn call, so hosted extraction and every other caller are untouched). The 08-20 RIP-3 cutover made each turn stateless (conversationId was only the tool actor id), so a follow-up in a resumed thread was answered blind (user report: "frågar vad jag refererar till"). History is loaded server-side from agent_messages (loadChatHistory: text only, hidden + tool rows dropped, alternation repaired, newest 16 rows / 10k chars) rather than sent by the client, so the client cannot forge earlier turns and old streaming threads replay cleanly. Rejected: inlining a transcript into the prompt (works everywhere but weaker turn semantics and blurs data vs instructions) and loading history in AskConsole (client-trusted history). Separately: the docked assistant panel now remembers its open thread per tab in sessionStorage (lib/agent-panel/session-restore) and reopens it after a full reload (the deploy prompt's "Ladda om" wiped it); sessionStorage, not user_preferences, because this is this-tab-this-session state that must not follow the user to other devices or tabs. And DeployReloadPrompt's full-width wrapper gets pointer-events-none: at z-[60] after the panel in DOM order it swallowed clicks on the panel's composer ("går ej att skriva"). [2026-08-25] /reports/bank-reconciliation retired behind a redirect to /reconciliation instead of kept as a "power" page: everything it did (matcher, manual N:1 matching, residual booking, IB tag, move-to-account) lives on the account-keyed page, and two reconciliation surfaces meant two truths. The catalog slug stays so old links, the report library and ?autorun=1 deep links keep working. [2026-08-25] reconciliation_residual staged op tiered 'medium', not create_voucher's 'high': it books one typed verifikat (6570/8410/8310/3740 vs bank) bounded by RESIDUAL_MAX_AMOUNT and is undone by storno + unmatch, i.e. the same blast radius as categorize_transaction. Scope is transactions:write (same as the v1 route) because it writes the ledger. [2026-08-25] gnubok_update_customer keeps catalogVisibility 'search' even though #1876 suggested reconsidering it: the tools/list payload guard sits at the 59.95K ceiling with zero headroom, and listing the tool would inline its full inputSchema + STAGED_OPERATION_SCHEMA + meta. The tool stays reachable via gnubok_search_tools; revisit only together with a payload-budget pass. personal_number on update reuses create's staging crypto (personal_number_encrypted in params, masked-only preview) and mirrors the REST PATCH semantics: masked echo = unchanged, null = clear; idempotency hashing switches to the masked preview only for personnummer-bearing updates so every other update keeps its previous hash identity. [2026-08-25] Rot/rut candidate list (#1884) treats a partially_paid invoice whose customer share is settled as claimable, instead of only surfacing it as blocked: the share outstanding is DERIVED as total - paid_amount - deduction_total (the buildInvoiceWriteData / migration 20260817191708 formula) rather than read off remaining_amount, because payment-sync's storno path recomputes remaining_amount without subtracting the deduction (skeptic-proven divergence), so the stored column is not a deterministic signal while the three header fields are maintained by every settlement path. Current settlement code flips such invoices to paid at exactly derived-share 0, and a legacy row stuck at partially_paid has NO user repair path (a 0-kr payment is rejected as overpayment), so blocked-with-reason would explain the dead end without opening it. The gate lives in evaluateInvoiceForFile so the list and file generation can never disagree. Decided (paid/partially_paid) begaran items are omitted from BOTH lists before any other classification, wrong-type included (skeptic-caught ordering hole): finished business, visible in the request history, and surfacing them would flood the list forever. DEDUCTION_TOTAL_MISSING (lines claim a deduction the header never recorded) blocks file generation too, not just the list: the 1513 receivable was never booked, so requesting the line amounts would claim money the ledger does not carry. [2026-08-25] Notification recipient lookup is a two-step query (lib/notifications/member-email), not the company_members -> profiles!inner(email) embed and not a new FK: company_members.user_id references auth.users, so PostgREST has no relationship to traverse and the embed 400'd, silently killing all four notification emails (kvittens, drift, backup, connection-expired) since they shipped. Adding an FK to profiles would be a migration on a core tenancy table for zero functional gain. Second lesson recorded: the drift path DID log the failure and nobody read it, so the guard is post-deploy delivery verification, not more logging. [2026-08-25] The skattekonto connection-expired email is deleted, not fixed: SKV's per-flow refresh tokens live 65 minutes, so per-consent-episode dedup means one "your connection expired" mail per connect, arriving an hour after every successful BankID login; that trains users to ignore mail. Rejected alternative (kept for revisit): fix + throttle to one mail per user per 7 days, fired only when a scheduled sync actually failed. Residual accepted knowingly: web-only users now have NO proactive channel for a dead SKV connection (banner needs a visit, briefing needs an agent, drift email needs a working sync); the agent briefing's skatteverket_connection block and the rewritten SKATTEVERKET_NOT_CONNECTED copy are the compensating surfaces. The skattekonto.connection.expired event and needs_reconsent flagging stay. [2026-08-25] SKATTEVERKET_NOT_CONNECTED stays one code for both never-connected and expired: splitting would ripple through every consumer (error-map, v1 routes, MCP dispatch, UI), and the declaration-status path already differentiates in its message. The copy is agent-directive on purpose (only a person can run BankID; do not retry until the user confirms) because the old English copy "Reconnect with BankID before retrying" invited agents to retry something only a human can fix. [2026-08-24] Manual reconciliation adapter (Reko bilagor, PR 1) computes the ledger side per fiscal period via generateTrialBalance (IB + movement through the balansdag), never as an all-history sumAccountBalance: year-end posts an opening_balance verifikat that re-books every balance account in the new year, so an all-history sum counts a closed year twice. Reskontra/semesterskuld specifications are "per idag" (open items now), labeled so in the bridge; a per-date reskontra is a follow-up. A typed external_balance is accepted only on manual accounts without a system specification (EXTERNAL_BALANCE_NOT_ALLOWED elsewhere): letting a stated number override the bank, Skatteverket or the reskontra would hide the very difference the sign-off exists to record. [2026-08-24] Reconciliation underlag (Reko bilagor, PR 2) is its own table (account_reconciliation_attachments) scoped by (company, account_key, through_date), not extra columns on document_attachments: that table's link is a verifikat and its WORM version chain is about digitized receipts, while a bilaga belongs to a balansdag and may be attached before the sign-off exists. Files stay in the documents bucket under documents/<company>/reconciliation/... so the bucket's company-scoped RLS applies unchanged; removal is a stamp (never a delete, BFL 7 kap.) enforced by trigger; the full archive copies the files into bilagor/ with a hash manifest. No v1 API endpoints in this PR on purpose: the concurrent reconciliation-residual work edits the v1 route loader, spec snapshot and scopes, and files cannot be uploaded by an agent anyway. [2026-08-24] Bokslut checklist (Reko bilagor, PR 3) keeps the item catalogue in code and only the per-period state in bokslut_checklist_items: steps the system can judge (drafts, voucher gaps, trial balance, sign-offs through balansdagen, reskontra tie-outs) are computed live every time and a stored row only overrides them, so the checklist never claims a state the ledger contradicts; manual steps (inventering, osäkra fordringar, dispositioner) are what the konsult ticks. Mutable on purpose (a late verifikat reopens a step), no DELETE policy. The missing-fiscal-year check is a pure helper reused by the readiness warnings and the SIE import result; the non-adjacent previous_period_id fix is #1849 and is not duplicated here. [2026-08-24] Bokslutsbilagor pärm (Reko bilagor, PR 4) is generated from the sign-off rows, the trial balance through balansdagen and the attachment rows, never by recomputing each account's live status: the bilaga documents what was attested (numbers as they stood at sign-off, who, when, note) plus the files with their SHA-256, which is what a kvalitetskontroll reads. Whole period only (a bilaga is per balansdag), PDF-only export, written into every period folder of the full archive as JSON + PDF; an archive run has no acting user, so the checklist's readiness-derived items are left as stored there. [2026-08-25] A period klarmarkerad as closed in a previous system (closed_externally) no longer trips the trial balance's "closed without closing_entry_id" guard for statutory pre-closing balances: its closing verifikat never existed in these books, so the booked balances are the pre-closing balances and there is nothing to strip. The guard stays for periods our own engine closed, where a missing link is a real inconsistency. Found by Väla Redovisning: Klarmarkera + Årsredovisning = 500. [2026-08-24] fiscal_periods.previous_period_id is adjacency-only: findNextPeriod ignores a chained period that does not start the day after the current one, and SIE import only wires predecessor/successor links between date-adjacent periods (before: nearest period across any gap). A non-adjacent link is what sent a company's opening balances two years forward (feedback seq 249297); 40 such links exist on prod across 39 companies and are neutralized by the read-side guard, not repaired in this change. A gap in the chain means a missing räkenskapsår (BFL 3 kap), which reports should show as missing rather than bridge silently. [2026-08-24] Pending-operation authorization refusals (401/403 from an executor) release the claim back to 'pending' instead of consuming the op as 'rejected': the refusal happens before any side-effect and reflects the credential, not the booking, so the same op must survive for an authorized approver (/pending UI or a scoped key). Every CommitResult now carries operation_status so agents stop inferring "consumed" from status 'failed'. Deterministic content errors (400) still consume the op: re-staging is the only fix for those. [2026-08-24] tools/call rejects unknown top-level parameters (VALIDATION_ERROR naming the valid keys) instead of ignoring them: hosts do not reliably enforce inputSchema, and a misspelled key silently widened gnubok_query_journal to the whole journal (feedback seq 261545). company_id stays tolerated on every tool because the routing layer owns it. Chose server-side enforcement over per-tool presence guards: every schema already declares additionalProperties:false, so the contract exists, it just was not enforced. [2026-08-25] Skattekonto-OCR is the twelve-digit redovisare identity plus a Luhn check digit (13 digits), built with the shared toRedovisare12 rather than a second local rule: the previous ten-digit form (11 digits total) was never a reference Skatteverket accepts, and the payment file must not disagree with the AGI it pays about who the taxpayer is. The payment route prefers saldo.ocrNummer from the cached skattekonto snapshot over the derived value: it is Skatteverket's own answer for the account we actually sync, and it covers identities the converter has no rule for (samordningsnummer, GD-nummer) plus the 90 prod companies whose companies.org_number has drifted from company_settings.org_number. [2026-08-25] The AGI panel stops claiming "väntar på BankID-signatur i Mina Sidor" when the Skatteverket session is dead: with a personal token that lives ~65 minutes and no ombud grant on prod (skatteverket_company_connections is empty), the kvittens cron can never reconcile, and 0 of 7 pending_signature declarations have ever reached submitted. Chose UI honesty plus the existing reconnect path (runPostConnectRefresh reconciles on consent) over building a new polling mechanism: background reconciliation needs the läsombud rollout, which is a registration decision, not a code change. [2026-08-25] resolveSkattekontoOcr uses the cached saldo.ocrNummer without a freshness gate (review bot asked for one): a skattekontoreferens is a function of the taxpayer identity, not of a balance, so it cannot go stale the way a saldo does. In the one scenario where cached and computed disagree (org number corrected after the last sync), the cached value is the correct reference for the skattekonto the company is actually connected to, and recomputing would be the guess. [2026-08-24] Agent-first onboarding (issue #1814) ships as shape B+ (signup inside the MCP OAuth popup, lazy auth, setup tools token-agnostic) and NOT as pre-identity provisional tenants first: bank and Skatteverket connects need a human browser anyway, so the claim-link shape buys little for a lot of TTL/abuse/RLS work; it stays a later auth swap. [2026-08-24] Keys minted from the OAuth popup before the first company exists get company_id NULL and are bound lazily in validateApiKey (first validation after a company exists) instead of at company creation: creation happens in a Server Action that knows nothing about keys, and one chokepoint covers every creation path. [2026-08-24] /api/mcp-oauth/authorize now forces TOTP enrollment (not just verification) for password accounts with no factor: the middleware skips enrollment for zero-company users, so a popup signup would otherwise mint an MFA-exempt key for an account with no second factor. BankID-linked accounts stay exempt. [2026-08-24] /auth/callback honours next only when it targets /api/mcp-oauth/authorize (via safeReturnTo): consent handles the zero-company state, an arbitrary deep link would not. [2026-08-25] WhatsApp instant received-signal = emoji reaction (U+2705) sent from the webhook, not an extra text message: reactions add no chat bubble so the one-combined-ack-per-burst design survives; best-effort and not persisted as an outbound row (cosmetic, like mark-read), gated on the CHAT_ALLOWED_MIME_TYPES allowlist so junk never earns a checkmark. [2026-08-24] MCP lazy authentication (#1814 PR 2) lists the FULL default tool catalog to anonymous clients and only gates tools/call: the agent has to be able to name a protected tool to trigger the 401 challenge that opens the Connect (and signup) prompt; listing only public tools would hide the trigger. Descriptions are public documentation anyway. [2026-08-24] Public (pre-auth) MCP tools are the three documentation tools only (search_tools, list_skills, load_skill); org-number lookup stays behind the challenge for now because the TIC lookup lives in another extension and cross-extension imports are forbidden. [2026-08-25] SIE IB voucher series (issue #1882): default is 'M' (first candidate of M,O,P,Q,R,S,T,V,W,X,Y,Z not used by the file's #VER), matching the migration-adjustment series and avoiding conventional series letters; user-overridable in the wizard, openingBalanceSeries in APIs, opening_balance_series in MCP. MCP import_opening_balances stays default false (web true) deliberately: agent imports are often continuation imports where an IB entry would duplicate a prior year's UB, and the divergence is now documented in the tool schema. [2026-08-25] IB accumulation root cause (issue #1882): replace_sie_import deletes only source_type='import' rows and clears the OB pointer, so the IB voucher (source_type='opening_balance') survives every replace cycle and each re-import created another one. Fixed app-side with an orphan-IB guard in executeSIEImport (skip + warning when a posted opening_balance entry already exists in the period) instead of amending the RPC: the RPC change needs a migration + pg tests on a legally sensitive delete path and is left as a follow-up. [2026-08-25] Orphan-IB guard relinks a single survivor and diffs its amounts (skeptic pass on PR #1896): skipping alone left the period pointer NULL, so reports showed IB 0 (compute_prior_opening_balances excludes entries dated on period_start), year-end's duplicate-IB blocker never armed, and the manual IB flow could double-book; relink is permitted by enforce_opening_balance_immutability while the pointer is NULL, reverseEntry clears it again for the storno-then-reimport path, and a stale orphan whose amounts differ from the file is called out in the warning instead of silently kept. Auto-storno of the stale orphan was deliberately NOT done: reversing posted entries inside an import needs founder sign-off. [2026-08-25] reset_fiscal_year (#1883) catches foreign_key_violation and aborts instead of enumerating every table that references journal_entries: RESTRICT/NO ACTION FKs (assets, accruals, salary runs) fail the whole reset with FISCAL_YEAR_RESET_LINKED_ENTRIES, SET NULL FKs (transactions, invoice payments) unlink as their designers intended. Future FKs stay covered without touching the RPC. [2026-08-25] Fiscal-year reset treats a posted vat_settlement verifikat, SKV declaration lock/submit audit rows and skatteverket-extension submission* keys as "VAT declared" evidence and refuses: SKV's actual declaration state is not observable from the DB, so every local trace fails closed (same posture as company_migration_reset 20260818224000). [2026-08-25] Proposal prefill/preview (lib/bookkeeping/proposal-lines.ts) mirrors the ENGINE's formulas, not the historical preview: net leg = gross minus single-rounded VAT (independently rounded extractNet/extractVat unbalances 12% grosses at 14 mod 28 ore), plain Math.round ore parity via a local engineRound (roundOre's EPSILON nudge diverges from booked verifikat at exact-half floats like 8.620.25), legacy counterparty proposals now render the 2645/2614 fiktiv-moms pair the legacy booking path actually emits (the 2026-07-29 decision claimed preview/engine parity but the preview omitted the pair; once 'Andra rader' made the preview bookable, the omission would book RC expenses without fiktiv moms), sign-mismatched counterparty matches are mirrored like the server, static template accounts are entity-resolved (_ab), the 'none' VAT sentinel is resolved via resolveExplicitVat before line computation, and the settlement swap applies only to a literal 1930 leg (applySettlementAccount parity). Chosen over sourcing the prefill from MappingResult builders directly to keep the client dialog free of server-only inputs; skeptic counterexamples are locked in proposal-lines.test.ts. [2026-08-24] gnubok_create_company (#1814 PR 3) is a direct write with an explicit two-phase confirm (preview, then confirm=true) instead of a staged pending_operation: pending_operations rows are company-scoped and there is no company to attach the staging to before it exists. [2026-08-24] gnubok_connect_bank / gnubok_connect_skatteverket hand the user a browser link plus connection status instead of driving the PSD2 or Skatteverket flow from the MCP server: both extension handlers need a cookie session and BankID in a browser, and cross-extension imports are forbidden. The web app's /import?mode=psd2 and the skatteverket authorize route are the links. [2026-08-24] The MCP consent page pre-ticks companies:write for an account that has no company yet: that account is connecting in order to create a company, and a default that dead-ends on insufficient scope right after signup would be the worse default. Still an untickable checkbox, still bounded by the client's scope ceiling. [2026-08-25] gnubok_create_company / POST /api/v1/companies require f_skatt explicitly and org_number whenever vat_registered (review findings on #1864): a defaulted F-skatt approval or a VAT-registered company with no momsregistreringsnummer would flow straight into invoices (ML 17 kap 24 §, SE-R-005). Explicit beats convenient on a legal fact. [2026-08-25] An enskild firma's first fiscal year must end on 31 December in the programmatic setup paths, mirroring the wizard's own rule text: the calendar-year mandate (BFL 3 kap. 1 §) is not lifted by the first-year extension. [2026-08-25] reset_fiscal_year skeptic hardening (#1897): filed ROT/RUT begaran and cross-year rattelse/storno chains BLOCK the reset (new snapshot guards) instead of being silently unlinked or crashing on the RI UPDATE; other SET NULL links (invoices, payments, transactions) unlink by design with dialog disclosure, since they are re-bookable and the year's external reliance states all refuse. Full verifikat content is archived in company-scoped RESET_SNAPSHOT audit rows before deletion: the trigger's line-level audit rows carry no company_id and header rows carry no amounts, which would otherwise destroy rakenskapsinformation (BFL 7 kap). [2026-08-24] The OAuth AS metadata advertises client_id_metadata_document_supported (CIMD, #1814 PR 4) without fetching or validating the client's metadata document: authorize/token never keyed anything on client_id, the redirect_uri allowlist is the trust boundary, and CIMD only changes what Claude/Codex send as client_id (an HTTPS URL instead of a DCR-minted UUID). Fetching the document would add a network dependency to every consent for no gain in this design. DCR stays for ChatGPT. [2026-08-25] CIMD is NOT advertised after all (reverses the 2026-08-24 entry; CodeRabbit on #1866): the spec expects an AS that advertises client_id_metadata_document_supported to fetch the document and match redirect_uri exactly against it, and our authorize endpoint only checks the global allowlist. Advertising would claim a check we skip. Add the flag together with an SSRF-safe cached CIMD fetch + exact redirect matching (localhost port-agnostic for Claude Code/Codex); DCR is free for us (stateless register), so nothing is lost meanwhile. [2026-08-25] Webshop orderunderlag (#1881) is a generated PDF via the existing @react-pdf/renderer + uploadDocument path (same mechanism as archiveIssuedInvoicePdf), not an HTML document: no new dependency, WORM-archive viewers already render PDFs, and magic-byte validation has no HTML arm. Only the verifikat_without_documents RPC gains 'webshop_order' in its needs-doc list; transactions_without_documents stays unchanged because webshop_order entries never hang on a transactions row (the legacy-feed cross-lock guarantees it), so the strict-subset invariant holds without touching it. [2026-08-25] Proposal line-pattern settlement leg now takes the counterparty template's learned legacy pair (credit for expense, debit for income, mirror-swapped, || 1930), passed raw from QuickReviewDialog: two skeptics refuted the 1930 default (engine books e.g. 2440 from SIE-learned patterns; preview/prefill showed 1930). Declined CodeRabbit's two suggestions on #1894 deliberately: the 3740 rounding line keeps the engine's business-side placement for BOTH diff signs (parity contract; the engine's negative-diff imbalance cannot reach the ledger, commit_journal_entry rejects it; engine-side sign fix is a separate issue) and the naiveOreRound baseline stays raised to 622 (engineRound is a documented parity exception, not drift). [2026-08-25] The production-only _backfill_remaining_20260817 invoice repair snapshot is privilege-contained, not deleted or relocated: PR #1655 identifies it as the safety snapshot for the 337-row 2026-08-17 remaining_amount repair, but the repository establishes neither its retention classification nor approval to destroy financial evidence, so the migration enables RLS, revokes PUBLIC/anon/authenticated access, and limits service_role to read-only for an authorized follow-up review while postgres retains owner control. Neither PR #1655 nor repository history establishes the original repair's behandlingshistorik/rattelse traceability or whether any repaired invoice was linked to a posted voucher; verifying the repair's who/when/what trail and its relation to booked entries is an explicit compliance follow-up, not inferred or altered by this access fix. Default privileges stay unchanged in this scoped fix because Supabase's platform transition and the application's many existing implicit grants require a separate compatibility audit. [2026-08-25] Risk ID RISK-2026-08-25-INVOICE-BACKFILL-SNAPSHOT treatment record (PR #1901): Risk Owner and follow-up owner Emil; classification restricted financial remediation evidence pending BFL review; treatment preserves all 337 rows and merges only the anonymous-access containment; BFL retention/rattelse review deadline 2026-09-25; residual risk after containment Low, explicitly including postgres-owner bypass until that review. Retention or deletion requires the separate reviewed follow-up, and this PR must not delete or alter snapshot rows. This entry and PR #1901 are the repository-native Risk Treatment Plan reference because the repository has no risk register. [2026-08-25] acount, arbore, elma, m360, redovisningskompaniet, willem, and ziffr under accounted.se are customer-facing production hosts and fail closed with an empty 503 before session handling when NEXT_PUBLIC_SUPABASE_URL targets staging project metjnjrhvujscngnpzdv: Emil classified the hosts explicitly; exact-host callback allowlisting and the canonical app remain unchanged, while project ownership, Auth settings, DNS, keys, data, and migration are separate operational work. [2026-08-25] Supabase Auth bot protection uses dependency-free Cloudflare Turnstile with a two-step rollout: a missing public site key keeps existing Auth flows available, while a configured client fails closed until it has a token; provider enforcement is enabled only after the client deploy is verified. The generic Docker image always permits Cloudflare's script and frame origins because its optional site key is substituted at runtime, after the CSP has been built. [2026-08-25] Plugin distribution goes through the Claude plugin directory (public GitHub link, claude plugin validate, submit from claude.ai admin-settings or Console), not an organisation marketplace: org marketplaces accept private/internal repos only and require the Claude GitHub App, so a public monorepo can never pass that dialog (the 'Repository not accessible' error is misleading). Install-time guidance is a /accounted:setup slash command, the convention Anthropic's own plugins use (commands/-setup.md); no SETUP.md mechanism exists in the plugin spec. [2026-08-25] Report date-range API (Deepgrid request): unknown-query-param rejection is scoped to the income-statement/balance-sheet report routes and the two matching MCP tools, not a global v1/MCP behavior change: a blanket strict mode across 100+ tools and every route risks breaking existing integrations for zero user benefit; the report surfaces are where silent ignoring corrupts meaning (a full-year report mistaken for a partial one). REST balance sheet accepts as_of as an alias for to_date (natural balance-position vocabulary, mutually exclusive with to_date); the MCP balance-sheet tool got only as_of_date (matches existing gnubok as_of_date naming, keeps tools/list payload under the bench ceiling). No MCP PDF tool: binary payloads over MCP are a separate design question; agents fetch the new v1 REST /pdf endpoints instead. [2026-08-25] Ranged income statement sums period movements, not closing balances (skeptic refutation on PR #1909): with from_date > period_start the trial balance rolls pre-range P&L activity into opening columns, so closing-column sums are year-to-date mislabeled as the range (July revenue reported as Jan-Jul). generateIncomeStatement now passes periodMovements to buildIncomeStatementFromRows whenever fromDate is set, matching the resultatrapport convention; full-period behavior is byte-identical. from_date was also dropped from the v1 balance-sheet routes (a balansräkning is a cumulative position; ÅRL 3 kap): as_of/to_date only, matching the MCP tool. [2026-08-25] Issue #1870: skattekonto AGI seed reverted 2730 -> 2731 (salary side kept on 2731), not the alternative of moving SALARY_ACCOUNTS.AVGIFTER_LIABILITY to 2730: BAS 2026 defines 2731 as exactly the reported-but-unpaid arbetsgivaravgift liability (the accrual account is 2940), and the salary module's whole-krona/ore-residual logic (PR #1609, 2026-08-14 decisions) is built around 2731. The 20260519160000 migration's rationale mislabeled 2731 as the accrual account; a one-sided flip either way reintroduces the split. Historical 2730 debits since 2026-05-19 are left for per-company reclass verifikat, not repaired in-migration. [2026-08-25] The marketplace entry for the Accounted plugin is a git-subdir source (public repo URL + path claude-plugin), not the relative path ./claude-plugin: relative sources only resolve when the whole marketplace repo is cloned (Claude Code), while Claude.ai's Add-marketplace backend fetches the manifest and resolves each plugin source as a repository, which surfaced as 'Repository not accessible' on a public repo. git-subdir is also the form the plugin-directory catalog uses for monorepos. [2026-08-25] Payslip YTD ("Ackumulerat") stays a stored snapshot on salary_run_employees, refreshed at approve + book, rather than being recomputed at PDF-render time: an employee who re-downloads a lonebesked must see the figures it had when it was issued, and a render-time sum would silently restate delivered payslips after any backdated correction. The same change widens the counted prior-run statuses from booked-only to approved/paid/booked (corrected stays excluded: its correction run replaces the whole month), because the original snapshot-at-calculate-time rule froze a YTD that was missing every month not yet booked when next month's run was prepared. [2026-08-25] ROT/RUT BegartBelopp truncates to whole kronor (truncateToWholeKronor), not half-up: the deduction is capped at 50%/30% of arbetskostnaden. At the RUT cap, half-up manufactured begart > betalt and blocked correct invoices; for ROT below the cap it over-requested past the cap and the 1513 fordran, so those files now ask 1 kr less (skeptic-verified on PR #1910). [2026-08-25] Woo bulk revenue template = per-rate account choice, no hardcoded varor/tjanster preset: BAS 2026 has no standard 30xx goods/services subdivision (3040-series is company-specific), so presets would invent accounts; chosen accounts are validated against the company chart instead, and only diffs from the 3001-series default are sent. [2026-08-26] Support-dialog attachments use the existing email delivery path without storage or schema changes: this keeps the feature scoped to the contact form. The budget is 5 files / 4 MB total under the 4.5 MB hosted request-body ceiling, with client-side image shrinking when needed. [2026-08-26] RFC 9728 protected-resource metadata is served at THREE locations (root, path-based /.well-known/oauth-protected-resource/, and /.well-known/oauth-protected-resource): Claude.ai's connector setup derives the metadata URL from the server URL and fetches it before any 401, so the root document our WWW-Authenticate header points at was not enough ('Authorization with Accounted failed' with only 404s in the logs). One builder, three routes; the path-based route answers 404 for any path other than the MCP endpoint so no phantom resource is advertised. [2026-08-26] Self-host connector gate honors only capability_grants rows with source='connector' (hasCapability, getCompanyIdsWithCapability, getCompanyEntitlements): the trial-seed trigger grants bank_sync/skatteverket to every new company for 30 days, self-hosts included, which must not unlock connectors without a hosted key. Hosted reads every source as before. [2026-08-26] npm publishing of packages/accounted-mcp and packages/gnubok-mcp is gated on "package.json version not on the registry" (push to main touching packages//package.json), not on git tags: the repo's v.. tags belong to the Docker image, the bridges version independently, and a version gate makes re-runs and unrelated package.json edits no-ops. Auth is an NPM_TOKEN secret rather than npm trusted publishing (OIDC) because accounted-mcp has never been published and npm cannot bind a trusted publisher to a package that does not exist yet; --provenance still attaches the Sigstore attestation, which is why gnubok-mcp's repository.url had to move from erp-mafia/gnubok to erp-mafia/accounted (the registry rejects a mismatch). [2026-08-26] Archived customers/suppliers hidden via archived_at IS NULL on every non-v1 list/picker (not is_active): customers have no is_active column and v1 already treats archived_at as canonical; is_active on suppliers stays a legacy mirror. MCP list tools got a bare include_archived boolean and the tools/list ceiling moved 60.7K to 60.8K instead of trimming unrelated tool prose: main had ~6 tokens of headroom, so any contract at all crossed. [2026-08-26] MCP serverInfo.version, extension version and /api/health version reuse currentAppVersion() (12-char SHA, '1.0.0' fallback) instead of a new 7-char slice: one identifier across behandlingshistorik, health and MCP so a support thread can match a deploy by a single string; gnubok_get_vacation_balance got a real estimated_liability_sek by exporting semesterberedning's dayValueSek rather than dropping the description's promise, with descriptions trimmed to stay under the tools/list ceiling. [2026-08-26] raw-route-auth guard judges each top-level export segment of a route file, not the whole file: transactions/[id] (wrapped PATCH + hand-rolled DELETE) and transactions (wrapped POST + hand-rolled GET) passed the file-level check for months because one withRouteContext call exempted every sibling handler. Baseline unchanged (mcp-oauth/authorize is the one grandfathered file). [2026-08-26] No ratchet on direct requireAuth() calls in app/api: requireAuth() is the MFA (AAL2) guard withRouteContext itself calls, and .claude/rules/api-routes.md sanctions it for routes without a company context (onboarding, account, user prefs). The 20 remaining direct callers skip request ids and the canonical envelope, not MFA; migrating them is a consistency campaign, not a security fix, so it was not folded into the bypass PR. [2026-08-26] defer_invoice_booking (#967) now gates booking on every door, not just the dashboard: MCP send_invoice / mark_invoice_sent / create_supplier_invoice_from_inbox, v1 invoices send / mark-sent and supplier-invoices create, and the inbox convert route all checked accounting_method === 'accrual' and posted a verifikat at issue for deferred companies. All six now call booksInvoicesOnIssue() (lib/bookkeeping/booking-mode.ts), the same helper the dashboard routes use, so the setting has one meaning. No data repair attempted: vouchers already posted for deferred companies through these doors are legitimate entries and stay. [2026-08-20] The swedish-e-invoicing skill now names Upphandlingsmyndigheten as Sweden Peppol Authority across all eight files, not just the one that was flagged: the handover completed 1 July 2026 (regeringsbeslut Fi2025/01826) and the skill was written in future tense, so a partial fix would have left the atom internally contradictory and still pointed agents at peppol@digg.se. Four digg.se URLs were repointed to their verified 301 targets on upphandlingsmyndigheten.se; the fifth, DIGG Peppol testbadd, is a hard 404 with no redirect and no successor page at the new authority, so it was replaced with the SFTI Validex verification service (https://sfti.validex.net/) rather than left dead or guessed at. Historical attributions (Q4 2025 traffic statistics, the 0007:2021006883 Peppol-ID example) deliberately still say DIGG because they were accurate when published. [2026-08-20] Outbound mail gets a second provider behind the existing EmailService seam (Sovereign plan WS2 PR2): SmtpEmailService over nodemailer 9.0.5 (MIT-0, zero dependencies, exact-pinned and guarded), selected by EMAIL_PROVIDER=smtp or auto-detected from SMTP_HOST only when no RESEND_API_KEY is set, so hosted stays on Resend byte-identical. The From header and its header-injection defence are shared verbatim with the Resend service so customers see the same sender shape. Resend's delivery-status webhook stays Resend-only (SMTP has no equivalent). The email extension manifest now lists every provider variable as optional: requiredEnvVars cannot express "one of two sets" (the same limitation document-extraction hit in 2026-08-13) and it only drives a build-time warning. [2026-08-26] SmtpEmailService honors options.from (a company's own verified sending domain, #1802) exactly like ResendEmailService: explicit "

" when the address passes the same guard, platform default otherwise, and one retry as the platform sender when the relay refuses the company sender. The builder is mirrored in smtp-service.ts rather than imported because the platform fallback address differs per provider (RESEND_FROM_EMAIL vs SMTP_FROM_EMAIL); the injection defence and RFC 5322 quoting are identical. [2026-08-26] A dead Skatteverket connection gets ONE ochre line, on /skattekonto (page level, no longer nested in the saldo branch where a company with no snapshot never saw it) and on /transactions (all source filters, reversing the 2026-08-14 filter gate): deliberately a banner and nothing else: no page takeover, no removed sync button, nothing hidden. Prod on this date has 151 of 162 connected companies with every token dead (97 of the 101 tokens touched in 30 days are needs_reconsent), because SKV personal sessions live 65 minutes and the nightly cron flags the row the next morning. At that rate a flag that HIDES anything hides it from nearly everyone, so the first cut of this change (reconnect StartCard replacing the page, sync button swapped for a reconnect link, shortfall and empty-state suppressed) was rejected by all three skeptics: it would have removed the saldo, the Kronofogden line and the page's only route to the bankgiro and OCR at exactly the moment the account is short. The banner is additive and stays honest at 93%. Known and accepted: /status is caller-scoped while sync is company-scoped (resolve-auth prefers a live row), so in a multi-member company a member whose own token died sees the line while the company can still sync. Left alone because prod has 3 such companies and the banner hides nothing; fix it by adding a company-scoped field to /status if that ever changes. [2026-08-26] Reference data (fiscal periods, cash accounts, settings, accounts, dimensions, templates, customers, suppliers, articles) moves behind SWR hooks in lib/reference-data with company-scoped keys, a server seed from the dashboard layout (periods, cash accounts, settings only; the chart of accounts can be hundreds of KB and is warmed lazily instead), explicit invalidateReferenceData() after writes, and a raw-reference-fetch ratchet in check:guards. Chosen over Cache-Control on the API routes (a browser HTTP cache would keep serving stale bodies after a mutate) and over Next 16 cacheComponents/partialPrefetching (38 of 81 dashboard pages are client components whose data lives in client fetches, so an app shell prefetch cannot carry it). Expected side effect: period.list and settings.get volume in the op-completed logs drops toward zero because those reads become browser-side Supabase selects; that is by design, not a broken route. [2026-08-26] AGENTS.md now defers to CLAUDE.md for every shared rule instead of duplicating it: the copy had drifted within weeks (no inline-rättelse path, cookie-first tenancy order, 100+ MCP tools). The four Codex-only constraints Emil added 2026-07-21 (erp-base staging-only migrations, prod writes and main pushes need his explicit approval, no local Docker) were kept in a labelled section rather than removed, because the erp-base Supabase project exists (ref pwxtzglxptnnvjrpixpg) and they describe his environment, not stale product facts; only the project-name spelling was corrected from erpbase. [2026-08-26] MCP tool counts in docs say "150+" (connect-claude, READMEs, rules, registry entry) instead of deriving the number from the tools array: lib/docs/content/connect-claude.ts is core code and core must never import from @/extensions/ (CI builds core with zero extensions), and an exact hand-written number (90+, 100+, 120) had drifted three times already. Regenerate by counting name: 'gnubok_ in server.ts when the order of magnitude changes. [2026-08-26] API-key scope pickers (settings panel + OAuth consent) render from one SCOPE_GROUPS list in lib/auth/scope-catalog.ts, with MCP tool counts derived from TOOL_SCOPE_MAP at module load: the panel's hand-copied group list had drifted to 24 of 30 scopes (no articles, companies:write or reconciliation, so dashboard-minted keys could not call those tools) and every per-scope count was stale. The catalogue is a separate pure module rather than api-keys.ts itself because the panel is a client component and api-keys.ts imports crypto and the service-role client; a unit test enforces one-group-per-scope so the next scope cannot silently vanish from the pickers. [2026-08-26] Webhook event catalogue lives in lib/webhooks/public-events.ts (grouped, with docs prose) and the fan-out handler set, the v1 create enum (so the OpenAPI spec and skills/accounted-api), and the docs page all derive from it: the enum and the docs had drifted to 24 of the 28 events the handler delivered, so the four reconciliation.* events were rejected at subscribe time. No API_V1_VERSION bump: the changelog already lists them as additive. [2026-08-26] Removed the phantom V1_ENDPOINT_SCOPES entries GET /api/v1/openapi.yaml, GET /api/v1/companies/:companyId and GET /api/v1/companies/:companyId/events instead of building the routes: no route file, registry entry, docs, skill or test referenced them, and the new scope-registry-parity test needs the map to describe only what exists. A company-detail GET can be added later with its entry in the same PR. [2026-08-26] gnubok_connect_bank / gnubok_connect_skatteverket moved from catalogVisibility 'search' to the default catalog: Claude.ai can only invoke tools present in tools/list, so search-only tools are discover-only there and the onboarding skill's steps 3-4 dead-ended on client-side tool-not-found (verified via event_log: the server never received the calls). Search-only visibility remains fine for tools an agent reads about before asking the user, but anything a skill instructs the agent to CALL must be in the default catalog. [2026-08-26] Migration reset eligibility no longer blocks on existing journal entries, voucher sequences, or invoices (20260826150000, reverses the 2026-08-18 first-record rule): the reset never deletes, the retained source stays write-closed and downloadable from the replacement, and the unchecked "Radera företag" path already produced the same archived-source-plus-fresh-company outcome with no protection at all, so the blockers only routed owners into a dead end (Carrierstories, 2026-08-26, two fiscal years imported in the wrong order). External-state blockers (locked or closed periods, filings and staging state, live bank sync, running imports, integrations, workers, 30-day window, sandbox) are unchanged. [2026-08-26] Dashboard layout diet (responsiveness plan B4): the four nav-visibility probes (WooCommerce/Shopify connections, webshop orders, mileage trips) became one SECURITY INVOKER RPC get_dashboard_nav_flags (20260826120000) rather than a client-side badge hook, because the flags gate whole nav rows and loading them after mount pops rows into the sidebar; RLS still applies inside the function. The layout's getDashboardAuthContext now verifies the JWT locally (lib/auth/claims.ts, shared with requireAuth) with getUser() as the fallback: the proxy already did the per-request revocation check, so the layout's own getUser() was a second Supabase Auth round trip on every hard load. The user-keyed reads (profile, preferences, memberships) moved into the company-resolution wave and the memberships join now supplies the active company row and role, so the layout went from ~20 network calls in 4 waves to ~12 in 2. The memberships-with-embedded-settings single query was NOT attempted: the PostgREST embed shape was unverified and the settings-names query is parallel anyway. [2026-08-26] OAuth consent pre-checks ALL scopes (one-click, list collapsed in details): founder call after the read-only default dead-ended agent flows; defensible because every write is staged for approval, rows stay untickable, grant revocable. [2026-08-26] accounting_method optional with form default (AB=accrual, EF=cash) in CompanySetupSchema/planCompanySetup: founder call to cut agent onboarding input to orgnr + moms period; the default is flagged (accounting_method_defaulted) and must be read back in the preview, never silent. [2026-08-26] Removed the 1580 entry from ACCOUNT_DESCRIPTIONS and flipped AccountNumber name precedence to DB-name-first: the entry falsely labeled 1580 'Fordran for skatt' (tax receivables are 1640/1650; 1580 was traditionally card/coupon receivables, moved by BAS to 1686), and the hardcoded name silently overrode users' own kontoplan names. No replacement entry: 1580 is deliberately off-catalog, so companies with a legacy 1580 now see their own account name. [2026-08-27] generateOpenApiSpec now emits the registry's example as an OpenAPI media-type example on JSON request bodies and JSON 200 responses, and the portable renderOperationMd (skills/openapi-to-skill) renders it as a fenced json block. EndpointDefinition.example has been REQUIRED since Phase 1 and all 125 endpoints populate example.response, but the generator never touched it, so the worked examples reached only the docs markdown builder (lib/docs/content/reference.ts): /api/v1/openapi.json carried zero examples and the generated skills/accounted-api had 0 json blocks across all 12 reference files. Chosen over adding a new registry field (the data already existed, only the delivery was missing) and over hand-writing examples into the overlays (they would drift from the schemas). Examples attach to application/json only: a multipart body's example is a file part JSON cannot express, and a binary (application/pdf) response's registry example describes the JSON envelope that endpoint does not send, so attaching it there would be a lie (pinned by openapi-examples.test.ts). SKILL.md is byte-identical: the 3304 added lines land in the on-demand references/.md, not the always-loaded entry file. Rationale is Anthropic's tool-use-examples finding (72% to 90% on complex parameter handling): a condensed schema states a field's shape, an example states the conventions the shape cannot express. POST fiscal-periods/:id/currency-revaluation gained the one missing example.request so the exhaustive coverage assertions hold. [2026-08-27] /api/v1 now emits Retry-After on a 429 (RATE_LIMITED), sourced from a new exported RATE_LIMIT_RETRY_AFTER_SECONDS in lib/auth/api-keys.ts that the MCP server's previously hardcoded '60' also consumes. skills/accounted-api and its overlays (quickstart.md, conventions.md) have told agents to "honor Retry-After" on a 429 since the skill shipped, but no v1 route ever sent one: the wrapper's auth failure path early-returns through v1ErrorResponseFromCode, whose finalize() set only X-Request-Id and Gnubok-Version. 60 is an exact upper bound, not a guess: validate_and_increment_api_key uses a FIXED one-minute tumbling window and the limited branch deliberately does not slide it, so the current window can never have more than 60 s left. Chosen over emitting the IETF RateLimit / RateLimit-Policy fields (draft-ietf-httpapi-ratelimit-headers-11, 2026-05-23), which is the correct destination but needs a migration first: the RPC computes remaining quota and the exact reset instant internally yet its RETURNS TABLE carries no count/limit/window column, so TypeScript cannot see them. Also corrected the withApiV1 doc comment, which claimed step 8 stamps X-RateLimit-Limit; stampHeaders never did, and error paths bypass stampHeaders entirely. NOT fixed here and worth its own change: those same error paths also skip WRAPPED_RESPONSE_HEADERS, so v1 401/403/404/429/500 bodies ship without nosniff/CSP/HSTS. [2026-08-27] The tools/list payload guard now measures BOTH tool namespaces and asserts on the larger, and the ceiling moves 63.4K to 63.6K to cover it. ?tool_namespace=accounted rewrites every gnubok_ reference to accounted_* (+3 chars each), costing ~209 tokens across the default catalog, so the accounted projection measured 63 491 tokens against a 63 400 ceiling: already ~90 tokens over, and untested, because the guard only ever serialized the gnubok projection (63 282, which passed). CLAUDE.md points new MCP installs at accounted-mcp and the accounted_* aliases, so the untested projection is the one a new user's client actually receives. The bump buys no new catalog surface: it re-points an existing ceiling at the real worst case. Verified the guard bites by temporarily asserting 63 450 and watching it fail on 63 491. A second test pins the DIRECTION of the delta (accounted > gnubok) so Math.max cannot silently stop describing reality if a future rename flips it. Not chosen: raising the ceiling without measuring both (leaves the same blind spot) or shrinking the catalog to fit 63.4K (a real option, but it is the Code Mode conversation, not a test fix). [2026-08-27] gnubok_call_tool is implemented as a REWRITE in the tools/call dispatcher, not as a tool whose execute() forwards to another tool. Server-side every tool was always callable (the dispatcher resolves the name against the whole tools array; isDefaultCatalogTool gates only what tools/list SHOWS), so the failure was purely client-side and is what forced gnubok_reconcile_match back into the default catalog on 2026-08-26 ("a search-only tool is uncallable on Claude.ai"). A forwarding wrapper would have run the inner tool's execute() directly and thereby skipped the scope check, the unknown-argument guard, company routing + membership check, the test-key write block, the staging meta and telemetry, all of which live between resolution and execute. Rewriting {tool, arguments} into a direct call BEFORE resolution makes every one of them apply to the real target for free; the wrapper's own execute() throws, and a test asserts that, so the rewrite cannot be silently removed. Restricted to annotations.readOnlyHint === true: a write must be named directly so the client sees its own annotations and its approval contract rather than a generic wrapper's (this refuses 13 of the 18 search-only tools and unlocks the 5 read ones). Doubly closed to anonymous callers: the pre-auth gate keys on the OUTER name and gnubok_call_tool is deliberately absent from PUBLIC_TOOLS, and the in-dispatcher re-check then keys on the INNER name; nothing is lost because all three public tools are in the default catalog. Telemetry gained errorKind 'bridge_refused' in BOTH lib/events/types.ts and the server's local payload type: widening only the local one type-checks under vitest (which does not typecheck) and fails npm run build, which is how it was caught. [2026-08-27] gnubok_get_agent_briefing's outputSchema condensed 7 743 -> 4 565 chars by replacing four sub-schema interiors (ledger_context, dimensions, skatteverket_connection, recommended_tools) with a permissive {type:'object'} plus a fuller description, keeping the property declared so the top-level additionalProperties: false still holds. Deleting the properties was not an option for that reason, and adding additionalProperties:false to the condensed forms would have rejected the real payload. Safe because agent-briefing.test.ts pins the RUNTIME shape of all four blocks, so the contract stays guarded while the schema stops carrying 3 178 chars of documentation into every tools/list. Kept intact: company (its accounting_method prose drives the settlement posting), atoms and memory (they tell the agent to fetch bodies via gnubok_load_skill). Net with the new tool: guarded (accounted) projection 63 491 -> 62 942 tokens, and the payload ceiling TIGHTENS 63.6K -> 63.1K, the first downward move in that ledger. [2026-08-27] Cockpit auto-landing gated to byrå owner/admin (isCockpitLandingRole; landing route + '/' bounce), superseding the 2026-08-05 all-members widening: plain members land like regular users and open the cockpit from the nav; the middleware zero-company steer stays ungated because a member with no client companies has nowhere else to land. Allowlist over role!=='member' so future roles default to the regular landing. [2026-08-27] New unlinked_documents category on the Accounted://attention resource, backed by lib/documents/unlinked-documents.ts. The whole design is the mime ALLOW-LIST, and the naive predicate is a trap: "current version, no journal_entry_id, referenced by none of the eight linking tables" returns 15 806 rows on prod, of which 11 309 are application/json and every single one is named psd2-responsepN.json, the archived PSD2 bank-API responses the integration stores as evidence of each fetch. Those are unlinked BY DESIGN; surfacing them would hand an agent 11 309 items of work it must not action, which is worse than showing nothing. Measured 2026-08-27: application/json was 11 309 of 11 309 psd2, and pdf/png/jpeg/heic were 0 of 4 495, so the split is clean. Chose an allow-list of underlag-shaped mime types over excluding known-bad filenames, so a future machine-payload format (XML, CSV, an audit bundle) stays out by default instead of leaking until someone notices. Real remaining surface: 4 497 documents across 210 companies, median 3 per company, 481 in the preceding week, and NOT agent-specific (2 374 upload_source=api vs 1 623 file_upload from the web UI). Two-pass fetch mirroring fetchPurchasesWithoutUnderlag: indexed column filter, then eight reference lookups that run only when candidates exist, so the common case costs one query. Scan cap is 300 and is set by URL LENGTH, not table size: each candidate id is echoed through eight .in(column, ids) lookups at ~38 bytes per UUID, and a cap in the thousands would exceed the gateway limit, fail the lookups, and the "claims nothing" fallback would turn every candidate into a false positive. A failing lookup is deliberately treated as "claims nothing" (can only ADD a row) rather than dropping the category, so one misbehaving table cannot hide real work. UnlinkedDocument is a type alias not an interface: the resource assigns it into samples: Record<string, unknown>[] and an interface has no implicit index signature; vitest does not typecheck so this only fails in npm run build. [2026-08-27] NOT fixed, and recorded so the next person does not act on an inflated number: the agent-facing readers (resources/attention.ts, resources/recent-activity.ts) still test booked-ness with a raw journal_entry_id null check instead of the canonical isTransactionBooked, which misses the bulk-book (transaction_voucher_links) and multi-allocation (invoice_payments / supplier_invoice_payments) cases. Real scale measured on prod 2026-08-27: 4 transactions, in 1 company, out of 567 column-filtered unbooked, all 4 via transaction_voucher_links and 0 via either payments table. Worth fixing as hygiene, but it is a 4-row problem and doing it properly in attention.ts needs the same two-pass treatment plus a decision about count semantics for a tenant with thousands of unbooked rows, so it does not belong bolted onto this change. [2026-08-27] Klarmarkera (markPeriodClosedExternally) gets an undo, reopenExternallyClosedPeriod, allowed only while the closed state still comes from klarmarkera (closed_externally set, no closing entry): that close was a person's control decision without a bokslutsverifikat, so reversing it strands nothing, whereas a closePeriod close keeps its closing entry and stays irreversible here. The reopen clears the lock too, because the reason to reopen is to change the period's contents (Forsslund Systems 2026-08-27: five imported years klarmarkerade, then the prior-year SIE turned out wrong; replace refused the closed year, unlock refused the closed state, no way back). Audit_log row plus period.unlocked event; the MCP staged-op surface (lock/unlock) does not get a reopen op yet, follow-up. [2026-08-27] PDF minus sign: added lib/pdf/number-text.ts (pdfNumberText for locale-formatted amounts, pdfText for free text) instead of embedding a Unicode font: sv-SE Intl writes U+2212, which bundled Helvetica/Courier cannot draw, so losses printed unsigned (#1982). Routed through it: financial-statement (RR/BR), kassaflodesanalys, reskontra, momsdeklaration, operational-report and the payslip (fmt, the literal Preliminar skatt prefix, and the calculation-engine formula strings via pdfText; the engine strings themselves keep U+2212 because they are shown on screen too). The K2/K3 arsredovisning templates and note builders use formatPdfKronor from #2013 instead (whole kronor, NBSP grouping), so they do not import pdfNumberText. Font embedding would change every report's look and size for a one-glyph problem; the ASCII hyphen is what the invoice and webshop PDFs already use. Negative zero is printed unsigned on purpose, matching the screen. [2026-08-27] gnubok_update_customer promoted to the default catalog (tools/list), superseding the 2026-08-25 entry that kept it search-only: that entry assumed gnubok_search_tools reachability was enough, but Claude.ai only calls tools present in tools/list (SilverPark E2E 2026-08-26; same reason as the #1936/#1967 promotions), so the tool was uncallable on the primary connector and the reporter of #1706/#1876 read it as missing twice. Bench ceiling 63.4K to 64.1K (+761 measured). Not chosen: a description hint on gnubok_create_customer pointing at the update tool: it would not make the tool callable, and it made gnubok_create_customer rank for "update customer" in gnubok_search_tools. [2026-08-27] MCP update_invoice keeps FULL REPLACE item semantics (no preserve-on-omit) but its lines now accept article_id with the same prefill and default-set VAT adoption guard as create_invoice, plus the permitted-set VAT gate at staging; gnubok_get_invoice is the round-trip read surface (search-only: payload-size.bench.test.ts has no headroom and the update tool it serves is search-only too); the staged preview snapshots the lines being replaced and the effective vat_rate/revenue_account per new line so an approver sees a rebooking; the commit executor scope-checks staged article ids like create does (the FK proves existence only, and arg-guard never sees nested keys). Supersedes the 2026-08-17 'create_invoice only' entry (issue #1642). The web PATCH route's missing article scope check is a separate follow-up. [2026-08-27] MCP invoice round trip closed for non-article lines (skeptic review of #1993): gnubok_get_invoice now returns the ROT property columns (housing_designation/apartment_number/brf_org_number: property identifiers, never the personnummer ciphertext) and gnubok_update_invoice declares line_type/revenue_account/deduction/accrual fields on its item schema, exempts text rows from the quantity gates like CreateInvoiceItemSchema, and gates ROT/RUT completeness (arbetstyp/timmar, fastighetsbeteckning, personnummer availability) at staging so the failure reaches the agent, not the approver. gnubok_create_invoice only gained line_type + revenue_account: commitCreateInvoice drops deduction/accrual fields, so declaring them there would stage silent loss, and the tools/list ceiling (63.4K) has ~60 tokens of headroom left. [2026-08-27] WhatsApp company question (#1589): the root cause was Meta rejecting the reply-button payload synchronously with HTTP 400 #131009 "Duplicate button title" because the sender belonged to two same-named companies, one archived, not a client that refuses interactive messages; fix = archived-membership filter on every channel membership lookup (mirrors lib/supabase/middleware.ts), unique interactive titles (position suffix), a synchronous numbered-text fallback under the same M6 template id, and a drain of rows parked behind the now-dead question only when the sender resolves as 'single'. The async delivery-status fallback leg was deliberately not built (every observed failure was a synchronous 400), and the drain is not extended to default/pin resolution (those choices are still changeable, so an open question there is not dead). [2026-08-27] WhatsApp single-company drain (#1589) also clears the dead company question on the conversation (awaiting_company -> idle, company_options and the company pending_question deleted), guarded via updateConversation and checked against fresh state: with the question left in place every typed word became a company_retry re-offering the archived company, 'byt' was swallowed, and finalizeBurst could not ask about the drained receipts until the 48h TTL. Cleared whenever the sender resolves as 'single' and a company question exists, not only when rows were re-opened (the TTL sweep keeps company_options past the rows); other pending_question types stay untouched. [2026-08-27] WhatsApp unknown-sender quota RPC (check_and_increment_whatsapp_sender_quota) now fails OPEN to the throttled greeting path when the RPC errors or throws (#1599): the greeting throttle (1/h text, 10-min media burst, 3/day, itself fail-closed on read error) and the single-use link-code claim already bound outbound volume, whereas fail-closed silenced the first-touch linking moment on any transient DB hiccup. M2 (bad code) keeps its own small fail-closed throttle in that mode (badCodeThrottled: 1 per 10 min, 3/day per phone hash) since the quota no longer bounds it (review finding on #1991: withholding M2 left a bad code inside the M1 hour completely silent); when that throttle declines, the sender falls through to the throttled M1, which also says how to fetch a fresh code. Over-quota (ok:false) stays silent by design. [2026-08-27] Issue #1947: categorize fails closed (typed 409 TX_CATEGORIZE_JOURNAL_ENTRY_FAILED, nothing written) instead of adding a fourth worklist state for categorised-but-unbooked rows: the verifikat is the booking, and a new state would touch the load-bearing is_business IS NULL predicate, lockPeriod guard and badges. The MCP/bulk door (categorizeMatchedTransaction) was fail-closed only for thrown engine errors, not the engine's null return (closed year or missing period), so the same null guard now refuses there too, before any transactions write, with errorCode PERIOD_LOCKED or NO_OPEN_PERIOD_FOR_DATE via checkPeriodLock. Existing stranded prod rows left for a separate founder-approved repair. [2026-08-27] reverseEntry resets is_business, category and reconciliation_method together with journal_entry_id when it unlinks bank transactions (#1950): the worklist predicate is is_business IS NULL (lib/worklist/types.ts), so clearing only the link hid stornoed rows from Att bokföra and the nav badge while the reverse_warning dialog promised the opposite. The predicate was not switched to journal_entry_id IS NULL because bulk-booked and multi-allocated rows keep it NULL while booked (lib/transactions/is-booked.ts). Fixed in the engine, not per reverse route, so dashboard, v1 and MCP stornos all agree. [2026-08-27] reverseEntry also deletes the reversed entry's transaction_voucher_links rows and releases (is_business, category, reconciliation_method to null) only the rows left with no anchor: bulk-booked samlingsverifikat anchor N>1 bank rows through the junction alone, so the journal_entry_id-scoped reset (#1950) matched nothing there and the rows stayed out of Att bokföra against a reversed entry. The release is guarded by a remaining-links read plus journal_entry_id IS NULL because a residual booking (lib/reconciliation/residual.ts) keeps the main verifikat in journal_entry_id while its junction row points at the residual verifikat; stornoing the residual must not put a still-booked row back in the list. [2026-08-27] fix-cash-mismatch restores the full booked triple (is_business, category, reconciliation_method) in its transaction relink instead of reverseEntry growing a skip-reset option: the remediation re-books the same economic event immediately, so the caller mirrors the match-invoice route's final update; an engine opt-out flag would invite every reverse caller to bypass the #1950 return-to-Att-bokfora guarantee. [2026-08-27] Supplier payment batch creation is atomic via create_supplier_payment_batch (SECURITY DEFINER, #1503): TS keeps the eligibility evaluation and msg_id minting (branding lives in TS), the RPC locks the invoices FOR UPDATE in id order, rechecks active batches inside the transaction, and writes header + items in one statement; domain refusals return jsonb (match_batch_allocate shape) because no write precedes them, the tenant guard raises 42501 (detach_underlag_duplicate shape), and the best-effort cancel of an empty header is gone because the header can no longer outlive its items. [2026-08-27] Jämkning UI (EmployeeTaxCard) requires both jamkning_valid_from and jamkning_valid_to while the API keeps valid_to optional: isJamkningValid (calculation-engine.ts) never applies a beslut without an end date, so the form enforces what the engine needs; the API/MCP contract is left untouched for compatibility (follow-up: align engine or API, founder call). The legacy PATCH /api/salary/employees/[id] gets the same merged start-date/ordering check as v1 so the UI gets a 400 instead of a silently inert beslut. [2026-08-27] Jämkning keys reach the employee PATCH body only when the inputs were visible (A-skatt, not sidoinkomst) and edited this session (jamkningPatch + EmployeeTaxValue.jamkning_touched), otherwise omitted like every other sparse field; native required on the two dates is likewise gated on touched: the card reports null for hidden inputs and the route spreads the body, so forwarding those nulls wiped a stored beslut on a sidoinkomst toggle or an FA-skatt phone-number edit (the engine still applies it for FA-skatt), and an API/MCP row without valid_to blocked native form validation on unrelated edits. Untouched-omit over touched-null: the route's own touched-gate already assumes absent keys leave the row alone. [2026-08-27] #1898 line-pattern 3740 rounding leg now lands opposite the business side when the ratios over-allocate (negative diff) and stays on the business side when they under-allocate (positive diff), in buildMultiLineMappingResult AND computeProposalLines in the same change, superseding the 2026-08-25 note that kept business-side placement for both signs: that note assumed the imbalance never reached a user, but since #1894 the prefilled form carries it and the confirm fails with JournalEntryNotBalancedError. Reachable with normalized ratios too (50/50 on 100.03 kr rounds to 50.02 + 50.02), not only with un-normalized patterns. The flip is applied AFTER the mirror on both sides (the engine's businessSide is already mirrored via side(), the proposal's raw side is mirrored first), no new Math.round(x*100)/100 was written (the existing rounded diff is reused, naiveOreRound baseline unchanged), positive-diff bookings are byte-identical to before, and parity is executable: proposal-lines.test.ts sweeps 0.01..50.00 kr against the engine. [2026-08-27] /mfa/enroll leave() always hard-navigates (window.location.assign) instead of router.push+router.refresh: enrolment raises the session to aal2 which middleware only re-evaluates on a document request, and the push/refresh pair raced leaving the user on the QR screen with 2FA already on (#1948); same pattern the file already used for /api/ destinations and /mfa/verify uses for its invite and route-handler paths. The E2E regression cover (spectest/tests/mfa.ts) lands with the spectest suite separately. [2026-08-27] Brand domains gate signup server-side (brands.signup_mode + brand_signup_allowlist), not in the register page: the browser used to call supabase.auth.signUp directly, so any client-side host check would be cosmetic. Email signup moved to POST /api/auth/signup on ALL hosts (byte-identical GoTrue call for open hosts); BankID gates in /bankid/complete; Google gates via the dashboard layout's brand-domain bounce (the account exists after OAuth, but gets no branded experience). Company invites bypass the allowlist because the invite is the authorization. Allowlisted signups' companies attach to the brand's byrå team via create_company_for_brand_signup (allowlist entry = the byrå's standing WL-15 authorization, recorded by an owner/admin); without the attach, WL-01 would home the company on the canonical domain, invisible on the very domain the user signed up on. Rejected a Supabase before-user-created hook: it does not reliably see the originating host and adds dashboard config coupling. [2026-08-27] New tool-pg vitest project: MCP tools driven through a REAL supabase-js client against a REAL PostgREST (tests/tool-pg/, scripts/tool-pg/reset.sh, npm run tools:pg:reset + npm run test:tools, plus a tool-pg CI job). NOT a duplicate of pg-real: that project holds a pg Pool and writes SQL, which structurally cannot see the half of a tool that PostgREST resolves at request time (the .select() column strings, the resource embeds, the or=(...) grammar, .contains() operand types). Before this, all 100 files in extensions/general/mcp-server/tests faked supabase and query-journal.test.ts deferred its query chain to "the live MCP smoke test", which does not exist in CI: the PostgREST grammar of 157 tools was gated by nothing. Three findings worth keeping. (1) supabase-js hard-codes a /rest/v1 prefix that a bare PostgREST does not serve, so the first version of the harness 404'd all 55 sweep queries, the tools reported the empty response as "Database error: undefined", and the suite passed GREEN while exercising nothing; fixed with a URL-rewriting global.fetch in createToolPgClient, and a permanent self-test now injects a bad column and asserts the harness detects 42703, so a green sweep means something. (2) Errors are captured at the TRANSPORT, not from the thrown Error: the tools wrap failures in their own prose and lose the payload, so a real 42703 arrives as an unclassifiable string. (3) The reset recreates the CONTAINER rather than dropping schemas: storage is owned by supabase_storage_admin so DROP SCHEMA storage fails as postgres, and dropping only public leaves the storage RLS policies migration 20240101000024 creates unconditionally, aborting the next replay partway and leaving a half-migrated database that looks like a migration bug. CI runs PostgREST via docker run --network host rather than a service container, because service containers on a non-containerized job are reachable from the runner but not from each other by name. Current coverage is honest and partial: 74 read tools, 87 real requests, 0 malformed queries, 4 failures all 22P02 from the empty argument set. Per-tool argument fixtures are what deepen it, and the harness is the thing that makes writing them worthwhile. [2026-08-27] Added npm run check:types, a typecheck ratchet (scripts/checks/no-new-type-errors.mjs + typecheck-baseline.json), wired into the core-build checks job next to check:lint. Reason: npm test does NOT typecheck. Vitest transpiles and discards types, so a type error passes all 18 000 tests and only surfaces in npm run build minutes later; that happened TWICE on 2026-08-27 (a widened errorKind union in the MCP server that lib/events/types.ts still contradicted, and an interface that would not assign into Record<string, unknown>[] because interfaces have no implicit index signature). It is not merely a faster copy of the build job: tsc --noEmit also covers __tests__ files, which the Next.js build never compiles, and that is where all 539 baseline errors live. Baseline is keyed per FILE, deliberately unlike the per-RULE lint ratchet: the legacy errors are concentrated in a handful of old test files and TS2322 is common enough that a code-keyed budget would silently absorb a real regression somewhere else, whereas per-file trips the moment a previously-clean file gains an error. Verified the gate actually fires by introducing a deliberate const x: number = 'str' and watching it fail with the exact location, then restoring. Cost measured: 36 s cold (what CI pays, since tsconfig.tsbuildinfo is gitignored) and 4.4 s warm locally via the existing incremental: true. The script sets NODE_OPTIONS=--max-old-space-size=8192 because a bare tsc dies with "Ineffective mark-compacts near heap limit" on this graph after about two minutes, which reads like a hang rather than a misconfiguration; it also detects that OOM string and exits 2 with a "raise HEAP_MB" message rather than silently reporting zero errors. NOT changed: Definition of Done item 1 still says only lint + test. CI enforcement is the stronger mechanism and does not need the policy edit; adding it to DoD is a founder call. [2026-08-27] Dropped VAT cadence localStorage persistence from PR #1998 (kept the settings-row gate): skeptic pass refuted it twice (SSR hydration mismatch from render-phase localStorage read; persisting a cadence that deviates from moms_period keeps the filing pipeline open on the wrong period type with no downstream period-type validation). The mount-time re-seed from moms_period is the self-healing control; FyPicker already persists the rakenskapsar pick. [2026-08-27] Invite-only brand signup ships accepting a low-severity allowlist enumeration residual: POST /api/auth/signup returns 403 for a non-allowlisted email vs 200/400 for an allowlisted one, and the 403 short-circuits before GoTrue, so it is captcha-free and unthrottled: someone with candidate emails can test which are on a brand's allowlist. Not closed because (a) the app deliberately never holds the Turnstile secret (it lives in Supabase/GoTrue; a repo test forbids TURNSTILE_SECRET_KEY in app env), and (b) the clear "you're not invited, go to Accounted" redirect UX inherently reveals the verdict. It leaks membership of guessed emails, not the list, and no ledger/credential data. Follow-up option if it matters later: add signup-endpoint rate limiting. The related fail-OPEN (a brands-table error was read as unbranded, opening invite-only signup during a DB blip) WAS fixed: the gate now returns lookupFailed and both signup routes answer 503. [2026-08-27] Fortnox asset register import ships behind FORTNOX_ASSET_SCOPES_APPROVED=false: the portal registration does not carry the assets scope yet, so consents stay unchanged and the migration reports assets as skipped (scopesMissing) instead of failing, mirroring the document-scopes pattern. [2026-08-27] Byrå-team invite acceptance was implemented only in POST /api/team/accept, which the email+password signup flow never reaches before the dashboard (hosted requires email confirmation, so the register page gets no session to run its client-side accept, and the auth callback + onboarding recovery only knew company_invitations). A new byrå admin therefore landed on /onboarding instead of /clients. Fix: one shared server helper acceptPendingTeamInviteByToken (lib/company/pending-invites.ts), called by the route (unchanged HTTP contract), the auth callback (accepts BEFORE landing resolves, so resolveLandingDestination sees the membership and sends admins to /clients; cookie cleared on success), and acceptPendingInviteByToken (onboarding/select-company recovery, tries company then team). hasPendingInviteForEmail now checks both invite tables. No migration. [2026-08-28] Per-company hiding of standardmallar via new booking_template_hidden table (insert=hide, delete=unhide), not is_active or a library column: system template rows are shared globally, so per-company state must live beside them; hiding is opt-in per company and restorable in settings (user request). [2026-08-28] AR-PDF minus fix uses ASCII hyphen formatting, not font embedding: registering a Unicode TTF for react-pdf would change the whole document's typography and bundle size to fix one glyph; formatPdfKronor keeps built-in Helvetica and sidesteps WinAnsi's missing U+2212. [2026-08-28] Same-bank warning limited to observed one-session banks (SEB only): prod shows Handelsbanken tolerates 4 concurrent sessions, and the generic warning made a user abandon a legitimate renewal. Planned sync-death visibility work was dropped: already shipped via #1271 (health probe), #1727 (stale state), #1969 (cron unstarve). [2026-08-28] Same-bank warning revised to three tiers after skeptic refutation: hard warn SEB, silent/calm only for verified multi-session banks (Handelsbanken, 4 distinct session_ids observed), legacy hedged warning for unknown banks (fail closed), shared-session siblings exempt (fan-out carries them). [2026-08-28] Issue #1643 (orphaned cash_accounts after a broken reconnect): an "orphaned" ledger is defined as a non-active cash_accounts row (demoted to manual, disabled, held by a REVOKED or expired/error connection) whose normalized IBAN also belongs to a row on an ACTIVE connection (the #916 demote-to-manual path keeps the IBAN, which is what makes the twin detectable); a row without a live twin is never orphaned, whether manual/CSV or still pointing at a revoked connection (round 3 dropped the unconditional revoked rule: prod holds 16 revoked-held rows with no twin, mostly the company's only 1930 with up to 425 transactions, and #916 already treats revoked holders like manual ones), so transfers between two of the user's own accounts keep booking. Orphans are only refused in the COUNTER position (commit guards in the categorize route and categorizeMatchedTransaction, plus withheld counterparty suggestions); the settlement leg of a stranded row still settles on the orphan. manualLink accepts a voucher line on a sibling ledger (same IBAN) and RE-POINTS transactions.cash_account_id to the live sibling in the same locked UPDATE, gated on matching currency, and REFUSES the link when no sibling qualifies as a destination (round 4): a cross-account link is exactly what the account-keyed reconciliation counts as an imbalance on both ledgers, and the REST/MCP link callers reach manualLink without the unmatched-entries filter. allocatePsd2LedgerAccount names the chart account BAS-style (BAS reference name for a standard slot, else "Bankkonto "), never the ASPSP holder name. Deferred: migrating stranded rows on reconnect / sync dedup across cash_accounts (problem 2), garbage-collecting empty auto-created chart accounts (needs a deletion-vs-deactivation call), and the rättelse-chain match candidates (problem 5, separate root cause). [2026-08-28] Issue #1643 review follow-up: "same physical account" is keyed on (normalized IBAN, currency), not IBAN alone, because multi-currency accounts (Revolut, Wise) copy one IBAN onto every currency pocket; a same-IBAN other-currency row is a different account. The transfer detector treats the transaction's OWN IBAN as "not a transfer" (interest/fees stamped with the own IBAN never pair with a same-currency twin, live or not) and returns null instead of picking by ledger number when several candidates survive, which is what the pre-PR single-row lookup did. One topology loader defines "live" (enabled + status 'active'; expired/error twins of a live row count as stale) and "orphaned" (a non-live row whose (IBAN, currency) belongs to a live row; a same-IBAN other-currency pocket is never orphaned) for the detector, the match/link flows, the suggest-categories withholding and every commit guard (dashboard, categorizeMatchedTransaction, the v1 REST categorize route, and the free-form /book route at line level). A learned template whose stale 19xx leg is a twin of the settlement row is rewritten to the settlement account (it is the bank leg, not the counter) rather than refused, both at commit and in the suggestion the transactions page is offered; only a twin in the counter position is refused. manualLink re-points a row onto the sibling ledger the voucher was booked on when that sibling is live, or when the own row's holder is definitively gone (bank_connection_id null or revoked) and no sibling is live (same IBAN + same currency = same physical account, and the voucher is the source of truth for where the money went); a row on a live OR merely expired/error/pending connection whose voucher sits only on a dead sibling is REFUSED the link (round 3 left it linking without moving; round 4 turned that into a refusal, since "not live" is not "dead", re-auth renews the same row, moving it would strand the transaction on the orphan, and writing the link anyway is the cross-account link the line check exists to refuse), and unmatched-entries applies the same shouldRepointToSibling predicate so dead-sibling vouchers are not offered to such a row. A voucher touching several sibling ledgers is judged on the best of them (a live sibling, else the first the row may move to), never on the first line PostgREST happens to return. Same-connection re-registration twins (two enabled rows on one active connection sharing IBAN and currency) are deliberately left BOTH live and out of scope: two liveness signals (balance_updated_at, accounts_data uid) were each contradicted by prod data (ingest routes by the entry's ledger_account), so the shape needs a founder decision, see PR #2010 review. The free-form /book guard now covers the single-19xx-line shape too: an ordinary booking pays one PK read of the own row, and when the single bank line is a sibling ledger the row should move to (shouldRepointToSibling) the route re-points cash_account_id in the same locked UPDATE that links the voucher, mirroring manualLink; a twin the row may NOT move to (a dead or disabled twin of a live or still-held row) is refused (round 5: round 4 posted it as typed, which stranded the only bank leg on the dead ledger, problem 4's failure mode), while an unrelated 19xx ledger posts as typed. A disabled (deselected) row is never a sibling, so no automatic re-point lands on a row the transactions page hides; a voucher booked only there is refused like any other cross-account link. [2026-08-28] PDF auto-extract page budget: raised to 8 on pdf-native backends and slice now keeps the last page: the 3-page cap was Bedrock-latency legacy (issue #553), but kept a cap at all so 100-page statements do not burn tokens; last page kept because totals/OCR sit there. Truncation retry doubles AI_EXTRACTION_MAX_TOKENS once instead of raising the default: keeps steady-state cost flat. [2026-08-28] Per-run salary agent gap: new gnubok_set_run_salary staged tool + v1 PATCH instead of extending gnubok_update_payslip_line: the base-salary value lives on salary_run_employees (draft-gated), not on the payslip line (review-agnostic display copy); overloading the line tool would hide the status semantics and keep the recalc-overwrite trap. [2026-08-28] Inbox underlag divergence (#1548) is a separate underlag_status field, not a nulled matched_transaction_journal_entry_id: the book-direct and bulk-book routes 409 on an already-booked transaction, so hiding the verifikat would make the rail re-offer a booking that always fails; the UI keeps divergent items in Att göra with an explanation and a link instead. Anchored-elsewhere conflicts are counted and logged by the daily reconcile cron, never auto-resolved (moving a document between verifikat is a human decision; never-steal is the 2026-08-13 invariant), and not escalated to processing_history from the inline booking path (no dedupe key; it would fire on every booking). The reconcile's 'InboxUnderlagReconciled' event needed a processing_event_types row (FK): the script's old 'InboxUnderlagBackfilled' type was never registered, so its appends had always failed silently. [2026-08-28] Inbox underlag reconcile (#1548) bounds link work, not the read: the candidate set (matched, unconsumed) holds permanent residents (samlingsverifikat siblings, anchored-elsewhere items) that never leave it, so a read cap ordered by uuid would revisit the same window nightly and starve the tail. The full scan is four columns per row; maxItems now caps unlinked items linked per run, the rest are counted as deferred; transactions whose items already read anchored are still propagated outside that budget, because only the propagation anchors the transaction's pinned document and stamps settled items out of the scan (idempotent, self-shrinking). A verifikat in a locked/closed period is its own status (unlinked_locked): the period-lock trigger rejects the link every time, so it is neither retried nor promised to the user as automatic. An unreadable document row is reported as 'unknown' on the wire and kept out of the booked bucket, matching the helper's absence-is-never-anchored contract. [2026-08-28] data_analysis_opt_in (#1346) defaults false with no grandfathering and gates only bookkeeping-content analysis (categorize_calibration_samples via POST /api/agent/categorize/outcome, the calibration-fit script, and the backtest script, whose wider inputs (transaction descriptions, merchant names, matched underlag re-run through the model) are named in the consent copy as "evaluation runs" after review found the outcomes-only wording false for that path; the toggle is admin/owner only to match the company_settings RLS update policy), not PostHog product analytics or MCP reliability telemetry: those carry no bookkeeping content and stay under Art. 6.1f per ropa.yaml, so widening the flag is a founder call; consequence: the calibration corpus collects nothing until a company opts in, and the flag is UI-only (absent from v1 REST / MCP settings pick lists) because consent is a human action. [2026-08-28] Bulk revenue rate-0 slot (#1912): item 1 (require a positive momsfri/export/EU classification) stays deferred because most real momsfri/export accounts are unconfigured today and refusing them needs a configure path first (the issue's own trade-off); resolveRevenueVatBox makes it a one-line switch later. The order-context check refuses only the export/EU-account direction (ruta 36 vs SE/EU country, ruta 40 vs SE, ruta 35/38/39 vs SE/non-EU), because the domestic-account + foreign-country direction is already advisory in the dialog (zero_rate_foreign) and hard-blocking it would regress every untemplated sweep; ruta 40 is 'omsatta utom landet', so an EU country does not contradict it. EXPORT_BOXES from moms-box-mapping was not reused because it merges EU and export boxes. [2026-08-28] Verifikat page promotes "Stryk rader i verifikatet" to a visible outline button only when the period-status endpoint answers open (anything else or unknown keeps it in the ⋯ menu only), and the ⋯ item stays: inline rättelse is the normal path in an open period (#1554), the promoted button must never invite an action the RPC will refuse, and the menu remains the complete action list per the 2026-08-20 detail-page grammar. Struck-line actor labels are resolved server-side via resolveUserLabelsFromProfiles (profiles RLS is self-only) rather than a new column on the log. [2026-08-28] Employee-save failure reported inline (role=alert in the dialog footer, carrying the requestId) in addition to the single destructive toast, and a missing PERSONNUMMER_ENCRYPTION_KEY typed as 503 PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED rather than INTERNAL_ERROR (#1996): the Radix modal aria-hides the root-layout Toaster while the dialog is open, so the toast is invisible to assistive tech and E2E drivers; TOAST_LIMIT is 1, so a second toast is not an option; and the missing key is a permanent configuration gap where "try again later" is wrong and "contact support" is right (same reasoning as CUSTOMER_PERSONAL_NUMBER_UNREADABLE and INVOICE_SEND_EMAIL_NOT_CONFIGURED). The shared postAction helper was not extended (it takes no body and exposes no requestId): keeping the change local to the dialog avoids widening a helper other panels rely on. [2026-08-28] /migrate SIE guard extended to every provider (Fortnox exemption removed) as "a completed SIE import must exist for the company", not "must be part of this run", plus a wizard hint that disables Start when SIE is unchecked and never imported; chose this over forcing the checkbox on because the route is the only seam a direct API call or a stale client cannot bypass, and "must exist" keeps entities-only re-runs after a full migration working (#2000). [2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review). [2026-08-28] get_vat_ruta_source_lines (the VAT ruta drill-down) now applies the same four exclusions as get_vat_declaration_totals (the filed figure): posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and settlement-SHAPED entries (a line on a ruta account plus a line on 2650/1650). It previously filtered on company, status and date only, so expanding a ruta listed verifikat that are not in the number it claims to explain, with no total on the panel to reveal the mismatch. Measured on prod 2026-08-28: 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down is what substantiates a filed figure, so the two must agree exactly. The exclusion CTEs are lifted VERBATIM from the figure rather than re-derived: any divergence reintroduces exactly this bug, and an identical copy is easy to diff when the figure changes. Settlement-shape is detected against journal_entry_lines directly instead of through the figure's vat_lines CTE, which is EQUIVALENT not a shortcut (p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = ['2650','1650'] are both strict subsets of the figure's p_accounts, so restricting to vat_lines first cannot change which entries match); that keeps p_accounts meaning "the accounts of the ruta being expanded" without a fourth account parameter. opening_balance entries are deliberately NOT excluded: the figure exempts them from shaped, which keeps their lines IN the totals, so dropping them here would break the equality in the other direction (pinned by its own test). VAT_ACCOUNTS is now exported from lib/reports/vat-declaration.ts so the route detects shape from the same list the figure uses; a second copy is what let the two disagree. DROP + CREATE OR REPLACE, not CREATE OR REPLACE alone: the signature gains p_ruta_accounts/p_net_accounts and adding parameters registers a second overload PostgREST cannot choose between (trap documented in 20260421140000); OR REPLACE on the new arity keeps the file re-runnable. Verified the new pg test actually catches the bug by reinstalling the old body and watching 3 of 4 tests fail with the real misreporting (2611: drill-down 250/240 vs figure 0/200), then restoring. [2026-08-28] Bankavstamning NULL-link fix scoped to transfer legs with contradicting sign (20260828220000): the naive rule (NULL counts only for the primary account) and the formula-only variant (drop far-leg-settled vouchers from unexplained) were both simulated against prod and rejected; the naive rule worsened 4 of 11 affected cards (worst -37 000 kr false alarm on single-leg vouchers with no user action available), the formula variant blew up healthy cards by up to 474 550 kr. The shipped three-condition rule changes 24 vouchers on 7 cards in 6 companies, all verified per-card. [2026-08-29] Merge of main (#1946 readGrants/earlyGrants) into feat/connector-entitlements resolved by giving readGrants() a keys parameter and applying the source='connector' filter inside it, rather than re-inlining the grants query: the early-grants wave (teamId known) and the fallback read must narrow to the same key list or the self-host partition silently reads every paid key on the layout path. The in-loop source check in getCompanyEntitlements stays as a second line of defence (the unit mocks return rows regardless of filter, so it is also what the tests pin). Hosted query shape is unchanged: connectorGrantsOnly() is false there. [2026-08-29] components/extensions/general/InvoiceInboxWorkspace.tsx set to the #1753 inline-proxy version in the feat/connector-entitlements merge (blob bdd7668c1, which origin/main carried at push time as 7e76961da; the merged parent 338ac4e91 still had the pre-#1753