DECISIONS.md
Rama main · réplica de lectura del repositorio original.
# Decision Log
One line per decision, at most 300 characters: `[YYYY-MM-DD] <decision>: <why>`. Details belong in the PR body. Appended by agents and humans when a non-obvious choice is made. Read before re-litigating a past decision.
Keep the current calendar month here. Archive completed months verbatim under `docs/decisions/`, preserving their original order and wording.
- [August 2026](docs/decisions/2026-08.md)
- Before August 2026: `git show c36bc125a:DECISIONS.md` (archived on 2026-09-13). Code comments citing a July date refer to that archive.
[2026-09-22] Provider migration jobs: Fortnox/Visma consents get their source identity (provider_consents.org_number) from the provider's own company information, at OAuth exchange and lazily before create_provider_migration_job, rather than a fallback to the Accounted company's org number or the consent id inside the RPC. The identity is the idempotency key that lets a reconnected consent resume the same job without re-importing; copying the Accounted org number would assert something the credentials never proved, and the consent id changes on every reconnect. The RPC guard stays unchanged, so no migration.
[2026-09-01] multi_user seat gate enforced app-side via a NEW gated RPC (resolve_active_company_gated) instead of editing resolve_active_company/current_active_company_id: the zero-arg RPC and the RLS twin also run on self-hosted DBs where the paywall must never bite, and the app picks the gated overload only when isMultiUserEnforced(). RLS convergence rides the existing used_fallback write-back. company_capability_config deliberately does not apply to multi_user (no expiry to hang the 20-day grace on).
[2026-09-01] EU reverse-charge packs book directly on 4515/4535 instead of adding a 45xx D / 4598 K basbelopp pair (Anders' literal suggestion): same ruta 20/21 outcome, standard BAS practice for a template that owns the cost account anyway, and a 3-business-line pack would return null from convertLibraryToBookingTemplate and silently vanish from the transaction picker. The 4598 motkonto pattern remains the right tool only where the user's own cost account must be preserved (engine-generated bookings, supplier invoices).
[2026-09-01] Floating supplier-invoice underlag gets a standing daily reanchor cron (/api/documents/reanchor/cron) instead of another one-off repair migration: prod case 2026-08-28 (kontantmetod payment verifikat, doc eligible on every static condition, inline anchor silently did nothing, no log line recorded why) is the second time a hand-written sweep (20260727180000, 20260824150000) was needed; the inline anchor is best-effort by design, so the retry belongs in infrastructure. anchorSupplierInvoiceDocument also stops claiming success on a zero-row guarded update and logs its silent bail branches.
[2026-09-01] app_releases is written by the runtime from /api/version inside after(), not by a deploy hook or a floating promise: Vercel exposes no build hook we can trust to write a row, the handler returns synchronously so a bare void could be frozen before the insert lands (a silently empty version log is worse than none), and the service client is now constructed lazily so the constantly-polled public probe pays nothing once the module guard is set.
[2026-09-01] MCP worked examples ship in the inputSchema for five tools only, not the top twenty (#2066): the tools/list ceiling is the binding constraint, not the writing. After #2089 reclaimed 3 763 tokens the policy in payload-size.bench.test.ts required ratcheting the ceiling down with it, so real headroom was 317 tokens, not the 4 300 the #2089 PR body implied. Ten examples cost 199, leaving ~118. Tools were picked by 30 days of mcp.tool_called crossed with the combinations the descriptions already warn about (account_override without vat_treatment, representation without deltagare, confirmed on a high-risk approval, and get_kpi_report where a caller sent `metric` 604 times to a tool whose only parameter is period_id). Examples are also surfaced in the unknown-parameter error, which costs zero catalog budget and reaches the caller that already failed; a test validates every example against its own schema with the same guard the server runs, because an example our boundary would reject teaches exactly the mistake it then punishes.
[2026-09-01] SKV connector instance wiring (PR6b-2): system (CCG/ombud) auth is deliberately NOT brokered; it stays on the direct path and fails SYSTEM_AUTH_FAILED on a credential-less self-host, because the org certificate and ombud grants are hosted-only. getSkatteverketEnvironment() hard-reports 'prod' in connector mode: the upstream env is resolved from HOSTED's config, and the instance's unset base URLs would otherwise show a Testmiljo badge on real filings (reporting hosted's actual env via /api/connector/status is a #2090 follow-up). buildAuthorizeUrl stays direct-only rather than going async: the connector authorize needs to return the broker's redirect_uri + connector_state for persistence, so index.ts branches to startConnectorAuthorization instead of overloading one function. Connector-layer 4xx bodies (code CONNECTOR_*) are classified BEFORE the SKV-shaped 401/403 sniffing so a broker refusal never tells a self-host operator to check SKATTEVERKET_APIGW_CLIENT_ID; broker refresh 404 CONNECTOR_NOT_OWNED maps to SESSION_EXPIRED (terminal, reconnect fixes) while broker 502 stays a raw error (transient SKV outage must not re-arm the reconnect banner, #1155).
[2026-09-01] multi_user skeptic fixes: Stripe cancel EXPIRES the multi_user stripe grant instead of deleting it (grace anchor; other grants still deleted per freeze-and-retain); app-side state checks go RPC-first via SECURITY DEFINER company_multi_user_state (capability_grants RLS hides team rows from non-team users, byrå clients would misread as frozen); byra-kind teams get a standing team-scoped multi_user grant via backfill + teams trigger (WL-10 assumption made real; partner billing is out-of-band); PGRST202 on resolution fails OPEN (pre-migration DB has no multi_user rows: gated fallback would freeze all non-owners); /api/v1 got the same dormancy gate as MCP. RLS-level enforcement and the mid-session API fallback write-back window stay v2 follow-ups (documented, same class as pre-existing stale-preference fallback).
[2026-09-01] Declined CodeRabbit's UpgradeNote suggestion (PR #1758 follow-up) to append the self-host connector sentence to children instead of replacing them: every caller's children is hosted subscription copy ("... kräver ett abonnemang"), so appending would show subscription wording on a self-host, the exact thing the branch exists to avoid; the "CSV/SIE import stays free" text it cited is a code comment in BankSyncNowButton, not children. Replace-on-self-host stays; a dedicated selfHosted children prop can come when a caller actually needs per-panel reassurance there.
[2026-09-01] getConnectorConfig() rebuilds baseUrl as origin + path (userinfo/query/fragment stripped, warn-logged without the raw value): /api/connector/status echoes baseUrl to the operator and the sync/proxy URLs get paths appended, so nothing secret-shaped pasted into GNUBOK_CONNECT_URL may survive; the stripped parts were never meaningful in a base URL. The status route is also Cache-Control: no-store (key prefix + wiring layout out of shared browser caches).
[2026-09-01] Settings -> API renamed "API & MCP" and its MCP URLs now carry tool_namespace=accounted: resolveMcpToolNamespace() falls back to the legacy gnubok_ prefix when the param is absent, so every connection made from the in-product surface got `gnubok_*` tool names while the docs, the accounted-api skill, and claude-plugin/.mcp.json all reference `accounted_*`. The Claude Desktop block also moves from npx gnubok-mcp/GNUBOK_API_KEY to npx -y accounted-mcp/ACCOUNTED_API_KEY and emits ACCOUNTED_URL so self-hosted and white-label instances get a config pointing at their own host instead of app.accounted.se. The gnubok_sk_ key prefix itself is unchanged (wire format, per CLAUDE.md).
[2026-09-01] Connect-with-Claude ships in two languages as two URLs (/docs/api/connect-claude, /docs/api/anslut-claude) rather than adding locale routing to the docs site: the docs site has no i18n layer, the audience for the Swedish page is end users rather than integrators, and two hand-synced pages is a smaller standing cost than a locale system nobody else needs. Both are exported by scripts/export-docs-to-website.mts and both are asserted by scripts/check-docs-freshness.mts, so drift between repos is caught by the docs loop.
[2026-09-01] Corrects the 2026-07-27 line further down "Claude Code plugin homepage points at the GitHub README": the connect-claude page WAS ported to gnubok-website and docs.gnubok.se/connect-claude has returned 200 since. The plugin homepage can point at the docs page whenever it is next touched; no separate change opened for it.
[2026-09-01] Claude Code plugin install (/plugin marketplace add erp-mafia/accounted) surfaced in-product and in the docs. It is the only path that configures the connection AND the seven workflow commands in one step, and the marketplace manifest is public (verified: raw.githubusercontent 200, repo visibility PUBLIC). Directory listing (an Anthropic review that gets the connector a native Connect button and in-product discovery) is a separate founder task and still open; the one-click install link for custom connectors is unrelated to it and is covered below.
[2026-09-01] Settings -> API & MCP leads with a one-click "Connect to Claude" install link (https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=&connectorUrl=, Anthropic's documented format) instead of four equal-weight copy-blocks. Earlier in the same session I asserted no such deeplink existed; that was wrong, it is documented at claude.com/docs/connectors/building/directory-vs-custom. The link only prefills the dialog (user still reviews and confirms) and requires the instance to be reachable from Anthropic's cloud, so the manual URL stays as a fallback for localhost and firewalled self-hosts. Everything that needs a config file or a terminal (Claude Code, plugin, Cursor, Claude Desktop, API keys) moved behind two disclosures: the default panel went from four code blocks to one button, 1057px to 719px.
[2026-09-01] The connect-MCP group now renders above the API-keys group on /settings/api, and each entry inside the disclosures shows its instruction as visible text rather than a HelpPopover. Connecting a client is why users open the tab (the tab intro says so); key management is the secondary job, and four stacked `?` popovers meant the panel read as opaque code blobs with no instructions on screen.
[2026-09-01] EB callback cross-company guard: an account the connection row itself already carried keeps its own enabled state even when a sibling company claims the IBAN (own-prior outranks claim): flipping a standing feed off on renewal would break legacy pre-session-sharing overlaps, while the F1 exposure only involves accounts NEW to the row. Claim-lookup failure fails closed to enabled:false but still mirrors (unknown claim state is not a proven foreign claim; skipping the mirror on a transient error would strand normal single-company connects).
[2026-09-01] Provider-migration kreditfakturor import as ordinary invoice rows with reversed amounts and status 'credited', always unlinked. No provider DTO carries a reference to the credited invoice, so exact-match linking has no input to match on; guessing would corrupt the AR ledger. The wizard now says so rather than burying them in `skipped`.
[2026-09-01] OAuth callback replay is fixed with no-store plus history replacement only, with no "Anslutningen ar redan klar" page. Keeps the anti-oracle property that consumed-vs-unknown state stays unobservable to an unauthenticated caller.
[2026-09-01] PostgREST embeds between a table pair with more than one foreign key must name the relationship. Enforced by scripts/checks/ambiguous-embed.mjs in the ratchet guard, because neither test layer can see this class: a mocked Supabase client never resolves a relationship, and pg-real bypasses PostgREST entirely. Third appearance, second shipped instance.
[2026-09-01] WORM integrity check moved to a new document_integrity_checks table instead of narrowing enforce_period_lock_documents(). CLAUDE.md declares migration 017's enforcement triggers legally required and never-touch, and the nightly stamp does not need to write document_attachments at all. Trades one extra table for leaving the BFL enforcement surface untouched.
[2026-09-01] processing_history PII: the sender address and mail subject are dropped outright from RateLimitedDropped and AttachmentsTruncated rather than hashed, and the strip ships in the same commit as, and ahead of, the migration registering those event types. Registering first would start persisting PII into an append-only table the archive's erasure path excludes.
[2026-09-01] White-label backend guard inverted from an allowlist of protected hosts to an assertion that any customer-facing production host is served by the production Supabase project. This reverses the earlier explicit-allowlist decision: that model failed open for a customer host under accounted.se, which was serving a byra login page wired to the staging project with no alert, because it was never added to the list.
[2026-09-01] Anon-callable SECURITY DEFINER writes: the guard shape `IF auth.uid() IS NOT NULL AND NOT EXISTS (membership)` is unsafe on its own. The anon JWT carries no `sub` claim, so auth.uid() is NULL for role anon too and the guard short-circuits into the trusted branch. It is defense in depth behind a REVOKE FROM PUBLIC, anon, never a substitute for one. Every new SECURITY DEFINER function ships with that REVOKE; tests/pg/definer-function-grants.pg.test.ts enforces it from a sweep rather than a hand list, because hand-listing is exactly how three guarded numbering RPCs were wrongly declared safe.
[2026-09-01] public.create_invoice_with_items(jsonb,jsonb) is revoked, not dropped. It is prod-only, uncalled and already non-functional, so removing it is cleanup rather than security, and the revoke closes the hole in full. An irreversible schema deletion against production belongs in its own reviewable migration.
[2026-09-01] SKV connector refresh classification fixed broker-side (skeptic refutation on PR #2103, found independently by two skeptics): the broker's /oauth/token catch-all had collapsed SKV's terminal dead-refresh-token dialects (404 id_not_found, 400 invalid_grant, "Refresh Token status is expired": the DOMINANT refresh outcome, per-flow tokens live 65 min) into the generic 502, so a connector instance could never classify ordinary session expiry: raw English 500s instead of the reconnect flow, staged filing ops consumed as non-recoverable, crons retrying raw forever. The broker now re-codes those dialects (refresh grant only, never the code exchange where invalid_grant means an expired one-shot code) as 401 CONNECTOR_SKV_REFRESH_DEAD, which the instance maps to SESSION_EXPIRED; the generic 502 remains raw so a transient SKV outage still never re-arms the reconnect banner (#1155). Same pass: the data proxy now forwards WWW-Authenticate + x-skv-*/x-amzn-*/x-api-* response headers (the instance's MISSING_SCOPE classification reads them; nothing secret rides in them), and the instance's gateway-refusal guidance is connector-aware (a self-host has no SKATTEVERKET_APIGW_CLIENT_ID or Utvecklarportalen access: point at /api/connector/status + support instead).
[2026-09-01] Bank balance exposure (F7): external_balance on the bank reconciliation kind now carries the bank-reported booked balance but difference/unexplained_difference stay movement-based: ledger_balance for bank is a period movement, so comparing it against a point-in-time balance would produce a nonsense difference.
[2026-09-01] Bank balance exposure (F7): the enable-banking callback still stores accounts WITHOUT balances (GDPR data-minimization comment in callback/route.ts: deselected accounts must never have their balance pulled), so a reconnect nulls cash_accounts.balance until the first sync repopulates it. Left as-is on purpose.
[2026-09-01] Bank balance exposure (F7), post-skeptic revision: external_balance stays NULL for the bank reconciliation kind after all. Sign-off persists external_balance into account_reconciliations and bokslutsbilagor computes closing - external from that row, so a today-balance stored on a balansdag sign-off printed a phantom warning-red differens in the year-end appendix. The bank-reported figure is exposed only as the timestamped bank_reported_* pair in the bank block.
[2026-09-01] Bank balance exposure (F7): getAccountBalance returns null on an empty BALANCES response instead of fabricating amount 0; a fabricated zero with a fresh timestamp would pin "banken rapporterar 0 kr" for 12h on payment-decision surfaces. Callers keep the previous stored value.
[2026-09-01] WooCommerce failed orders: excluded 'failed' from order sync + remove-on-transition; kept 'cancelled' importing and 'trash' skip-only: cancelled is a real order some users want visible (asked in user reply), trash can be restored in wp-admin and may mirror a paid event. Removal deletes app-side with freeze guards incl. frozen-refund-child veto (parent_order_id cascades, table has no delete trigger).
[2026-09-01] Skeptic BLOCK on woo failed-order removal fixed by: freeze guards repeated on the DELETE statement (TOCTOU), is_paid=false + legacy_transaction_id null guards, orderRemoves gated on !orderIsPaid. No BEFORE DELETE trigger/RPC: the is_paid guard makes the cascade race unreachable (refund children only exist under paid parents).
[2026-09-01] EB claim guard, skeptic round (PR #2116): active-company standing state (enabled cash_accounts + enabled accounts on its live-ish rows) outranks sibling claims COMPANY-wide, not row-wide: a bank-list renewal arrives on a fresh row and must not switch a working feed off. pending_selection rows neither claim nor remember deselections (unconfirmed callback output; also stops fail-closed writes from poisoning later connects). Guard-disabled accounts are never mirrored from the callback (mirroring enabled:false can promote the seeded primary 1930 manual row and disable it under a foreign identity) and the selection save skips allocation+mirror for disabled never-mirrored accounts, so the no-slot-burned invariant holds end to end. Deselection carry got a picker note; enabling an account clears the guard flags. Legacy both-companies-enabled overlaps stay untouched (Swedish review advisory: prod sweep is a follow-up, not this PR).
[2026-09-01] EB claim guard round 2 (skeptic re-verify): pending_selection rows are asymmetric, not excluded: their ENABLED accounts still claim (attach-created rows hold offered accounts with no cash rows until saved; excluding them reopened the attach-window double-booking), while their disabled flags stay out of deselection memory (unconfirmed callback output). Both fetchAllRows claim queries order('id'): unordered .range() pagination can silently skip rows at page boundaries, and a skipped row is a missed claim (fail-open).
[2026-09-01] Invoice-register coverage detection keys on pre-boundary AR lines (1510/1513), not source_type='import': the reporter's 2026 invoice history was backfilled as source_type='manual' verifikat, which an import-keyed marker (fetchMigrationCoverageEnd) would miss; widening to class-3 revenue would false-flag cash/webshop sales.
[2026-09-01] F2 bank-data staleness: ship freshness reads only (last_synced_at/consent_expires/error_message on gnubok_connect_bank + new GET /api/v1/.../bank-connections, scope companies:read mirroring the MCP mapping): the daily cron already syncs server-side, so visibility is what the incident lacked; an agent-triggerable sync is a product bet (EB call cost, runaway agents) and was deferred by Emil.
[2026-09-01] Verifikationsserie in the Ny verifikation modal is a closed dropdown instead of a one-letter free-text field: a typo there silently opens a brand-new series with its own number sequence, and the letters only mean anything if everyone uses the same ones. The letters are NOT prescribed by law (BFL 5 kap. 7 § requires only unbroken systematic numbering within each series), and the incumbents disagree: Björn Lundén uses A Huvudserie, F Kundfakturor, I Inbetalningar, L Leverantörsfakturor, N Löner, U Utbetalningar, J Bokslut. We ship FORTNOX's table verbatim (A Redovisning, B Kundfakturor, C Inbetalningar från kunder, D Leverantörsfakturor, E Utbetalningar till leverantörer, F Kassa, G Avskrivning, H Periodisering, I Bokslut, J Revisor, K Lön, L Kontantfaktura, M Momsrapport), from their own Systemdokumentation, because Fortnox is the system most companies migrate here from and an imported ledger should keep its meaning. REJECTED an earlier draft that labelled A as Kundfakturor: A is the general series manual entries land in (the one point Fortnox and BL agree on, and Fortnox allows manuell kontering ONLY in A), and migration 20260526120700 ships every source_type defaulting to 'A', so every existing company's A series already holds everything. Calling it Kundfakturor would mislabel their entire history and the modal's own default. The list is closed but any letter the company already configured, or that a draft was saved with, is appended so no existing value can fall out of the picker. Also: tabbing or clicking into an untouched amount field now proposes the outstanding difference (pre-selected, so typing replaces it) when the row already has an account and the difference belongs on that side. This deliberately reverses part of the note in updateLine that said a balancing amount must never auto-fill: that note was about filling on ACCOUNT selection, which stole the amount before the user had a chance to split it. Filling on focus keeps the split case intact because the proposal is selected text, and it fixes the common moms case where the last line is just the remainder.
[2026-09-01] Settings PUT cross-field VAT validations scoped to touched field groups (vat-completeness, 40m-monthly, periodisk sammanstallning), not fixed at onboarding: partial saves from surfaces without VAT fields (invoice bank-details dialog) were hard-blocked by pre-existing vat_registered-without-number state (Marketio Lab case). The invariant still holds on every save that touches its group; explicit null now counts as a clear instead of falling back to the stored value during validation. Onboarding-side VAT number collection left as follow-up.
[2026-09-01] SKV ombud grant verification asks the ombudsregister (Ombudshantering v2, scope obr) first and keeps the read-service 403 probes only as fallback: the register is the authoritative source (role + validity per huvudman) and the 403 heuristics were assumptions never validated against real bodies. Role codes are env-pinned, otherwise matched on rollbeskrivning text; a deep link never mints with a guessed code (OBR_ROLE_UNRESOLVED). The daily ombud sync cron records grant state from shadow mode on but changes no credential policy (resolveReadAuth owns that), and an empty register with local rows downgrades nothing (mass-revocation guard). Separately, SKV session refreshability is now computed from the 65-minute refresh-token life (lib/skatteverket/session-lifetime.ts): /status and the skv_disconnected notice used to call a days-dead refresh token refreshable, which is why users saw the connection vanish with no banner.
[2026-09-01] Skeptic BLOCK on PR #2130 (SKV ombud phase 0) fixed by: the ombud sync cron only touches EXISTING connection rows (a row = the tenant's own Verifiera or deep-link opt-in; the deeplink route now records a pending row) so an org-number twin never gets auto-verified; rows the tenant revoked locally are skipped (a standing grant at SKV is not permission to switch the row back on); a register 404 throws by default (spec: wrong URI) and is empty only for the cron, which guards it; a mass-downgrade guard (>=3 rows and >50% of granted rows losing everything) applies no downgrade that run; grants that classify as neither behörighet are 'error' not 'denied' (unpinned or renamed role codes must never downgrade a verified company); decisions are planned before any upsert so a failed write cannot leak into a downgrade. window.open('', '_blank', 'noopener') returns null, so the pre-opened tab is opened without the feature and opener is nulled by hand.
[2026-09-01] #2125 mapping table: kept the 7 columns and shrank the fixed layout from 1216px to ~990px (Källkonto 144->80, Källnamn 256->160 with the existing truncate+tooltip, 13px text, px-3 cells, icon-only Bekräfta with the label in the header + InfoTooltip) instead of folding Källnamn under Källkonto: the reporters asked for narrower columns, closer column 2, an icon confirm and smaller text, and the #1684 sticky-scroll fallback still covers narrower panels (agent dock open). Also dropped the VAT cell's min-w-72, which overflowed 32px into Konfidens under table-fixed.
[2026-09-01] #2127 skattekonto bulk: every unbooked, non-ignored skattekonto row in the inbox is selectable (isSkvSelectable); bulk Bokför keeps re-filtering through isSkvBulkEligible (button count, summary and submit read one skvBookableSelectedRows list) and bulk Ignorera spans bank + skattekonto selections in one confirmation, calling the per-row PATCH .../ignore 5-wide since no batch endpoint exists. Bullet 2 of the issue (unbooked skattekonto rows "not in att göra after migration") is scoped out: neither Hem's Att göra (lib/worklist book_transaction) nor the nav badge counts skattekonto_transactions, by construction of the canonical predicate, while the inbox itself lists them regardless of date. Whether skattekonto rows join the Att bokföra count is a founder call; reply asks the reporter where they looked.
[2026-09-01] #2128 (row checkbox always visible) left open, no PR: #2093 (merged the same morning, after the Discord report) already made CHECKBOX_REVEAL_CLASS rest at opacity-50 with border-foreground and go solid on hover/focus/checked/coarse pointer, on all 8 list surfaces. The literal ask (fully solid at rest) is a one-token change to that constant but a design change across every list page, so it stays with the founder rather than being bumped in a bug-fix batch.
[2026-09-01] #2126 payment-account-missing wording: made the message currency-aware on the client (getErrorMessage branch on details.currency via describeMissingInvoicePaymentAccount) plus a currency-neutral rewrite of the registry entry, instead of threading messageSv/messageEn through the nine server emit sites: every dashboard caller already sends details.currency, API/MCP consumers get the clarified registry text (names bankgiro/plusgiro/Swish for SEK, IBAN otherwise), and the staged-operation commit path (bare string) calls the helper directly. The reporter saw the pre-#2088 text; #2088 added the Inställningar -> Fakturering pointer the same evening, this removes the "valutakonto" reading.
[2026-09-01] mcp.tool_called gets errorCause = errorCauseTag(err) on the two execution catch paths only (#2051): SQLSTATE or coded-error code, else the error class name, capped at 64 chars; a plain Error deliberately tags null because the class name 'Error' is noise, and pre-execution denials pass nothing since their errorCode already IS the vocabulary. Raw driver messages stay out of event_log on purpose: a constraint-violation message can quote row values.
[2026-09-01] counterparty_aliases joins the categorization_templates audit-trigger strip list (20260901200000) instead of staying logged: prod falsified the original exclusion list within 30 minutes of 20260901103000 going live (15 of the first 16 UPDATE audit rows were alias+learning noise, ~800/day projected vs ~50/day of real rule changes), because the learning path merges aliases in the same write that bumps occurrence_count. Explicit trade-off: a human editing ONLY aliases is no longer logged; accepted since alias growth is overwhelmingly automatic and any change also touching accounts/VAT/pattern/active still logs (first real one, 19:02:17Z same day, captured correctly). Pre-fix noise rows stay in audit_log (append-only) and the read model stops labelling the column so they render as no-ops.
[2026-09-01] MCP catalog budget attacked at the duplicated staged envelope rather than by demoting more reads: measuring the payload by segment showed outputSchema is 38 % of the whole catalog (23 290 tokens) and STAGED_OPERATION_SCHEMA alone 14 736 of it, the same envelope transmitted 58 times, while descriptions (what the three previous rounds trimmed) are only 10 %. period_status now carries its shape in one sentence instead of declared JSON Schema, matching actor/approve/preview which were always bare objects; 2 552 tokens reclaimed with no tool demoted and no field removed. Every edit is in the LOOSER direction because the server emits structuredContent for every tool and the documented failure mode is a declaration too tight making a strict client reject a successful call. next kept additionalProperties: false: staging.test.ts pins it closed and a guard whose reason is not in front of you is not one to loosen for 420 tokens. Ceiling ratcheted to 60 000 rather than the usual ~300 margin, leaving ~1 070 deliberate working margin: server.ts took 70 commits in 14 days and the previous 116-token margin is what starts the ratchet-block-bump-demote cycle visible in the bench log.
[2026-09-01] SIE preview "IB Summa" card relabelled "IB, summa debet" with a visible helper, kept hard-coded Swedish: SIEPreviewStep has no translations at all (SIE-spec terminology), so localising one of four stat cards would produce a mixed-language grid; a user (Jonas Forsslund) read the old label as a net ingående balans and could not reconcile it.
[2026-09-01] Other companies' accounts in the EB account picker are collapsed behind a disclosure, not removed: a sibling claim (claimed_by_company_id from PR #2116) is a strong hint, not proof of ownership, and hiding the rows outright would leave a legacy or mis-booked account unreachable without a database edit. The main list, 'Markera alla' and the 'x av y' counter cover own accounts only; the settings row shows one muted summary line instead of the foreign rows. Brand-new never-claimed accounts still list (unchecked): Enable Banking's account resource carries no owner org number, so nothing in the data attributes them.
[2026-09-01] EB callback re-stamps a sibling claim across an in-place renewal (PR #2141 skeptic finding): accountsMetadata is rebuilt from the bank's list on every callback, so the claimed_by_company_* label from PR #2116 was lost the first time the consent was renewed and the sibling's accounts came back as plain unchecked own accounts. The label is re-derived from a fresh cross-company lookup on accounts that stay disabled here, never copied from the prior row, so a released claim clears itself; an enabled account is the active company's standing state and is never labelled. partitionByClaim also requires enabled === false alongside the flag: a syncing account must never be tucked out of sight, whatever wrote the flag.
[2026-09-01] Inbox +lev/+ver plus-addressing (#2129) stores the sender's tag in a new nullable invoice_inbox_items.kind_hint column rather than inside extracted_data.documentKind: retry-extraction overwrites that JSONB container wholesale, and the sender's statement must outlive the AI's guess. Unknown tags route with kind_hint NULL (a typo never loses a document); the type filter's two narrow entries exclude unclassified rows on purpose (a narrow filter promises a known kind; 'Alla typer' is where the rest live), and the whole type menu stays hidden until at least one row is classified so an unclassified inbox gets no dead control.
[2026-09-01] Ta bort underlag (#2132): detach gate and 409 mapping live in a pure helper (components/transactions/detach-underlag.ts) with file-level assertions, not a rendered component test, because Vitest runs in node and never renders. The skeptic pass refuted the frontend-only plan: the existing DELETE attach-document route left invoice_inbox_items.matched_transaction_id set, and propagateUnderlagForBookedTransaction selects on exactly that column at booking time, so the detached receipt would have been re-anchored onto the new verifikation as immutable underlag (BFL 5 kap 7 §). The route now clears the back-link for the detached doc (scoped to items not yet consumed by a verifikat), mirroring the invoice-inbox unmatch. Accepted limitation: the history list cannot see transaction_voucher_links, so bulk-booked rows (journal_entry_id null) still show the item and get the route 409 with the storno message; the same row-classification gap already applies to Matcha mot befintlig verifikation and Ta bort on those rows, and fixing it means new list-side data plumbing, filed as follow-up with the MCP detach tool.
[2026-09-01] Checklist "Anslut till Claude" done-signal = unrevoked api_keys row named MCP-klient (OAuth) for the USER, not per company: the OAuth token route is the only writer of that name and the key company_id is whatever was active at sign-in (null for companyless signups), so a company filter would miss real connections; the AI-profile flag it replaced never meant "connected to Claude" (#2133). Counted through the service client with an explicit user_id filter, not the user client: api_keys' SELECT policy is company-scoped (20260330130000), which hides companyless and archived-company keys and left the step open for exactly the user who had just connected (skeptic refutation on PR #2147). The manual create route reserves the name (400) rather than adding a source column: a migration for a cosmetic tick is not worth it, and the name is already the only marker every reader of that row uses. The consent-page default (all scopes pre-selected, founder decision 2026-08-26) is described, not changed; the compliance swarm's GDPR Art.25(2) finding on this PR targets that decision and is Emil's call, not this docs fix.
[2026-09-01] PR #2130 CodeRabbit P1 (org-number twin inherits a grant): the ombud path binds SKV system-credential access to an org number, and org numbers are public and tenant-editable, so while more than one live (non-archived) company claims the same 12-digit org number NO company may verify, mint a deep link, or be granted by the nightly sync on it (409 ORG_NUMBER_CONTESTED; cron counts them as contested and changes nothing). This does not re-add the company-creation org-number guard (org-number reuse stays allowed); it only fences the one feature where the org number is the authority boundary. Also: the cron honours summarizeGrants.recognized (unknown role codes = pinning problem, never a denial), mirroring probeViaOmbudsregister.
[2026-09-01] ENABLE_BANKING_SANDBOX removed from the enable-banking manifest and the index.ts header (#2131): the variable was declared as optional but never read anywhere; sandbox vs production is decided by ENABLE_BANKING_API_URL (api.tilisy.com vs api.enablebanking.com, api-client.ts derives isSandbox from the host). A dead variable declared in the manifest is what the self-hosting docs would otherwise have copied. The manifest now lists the two optional variables the code actually reads (API_URL, PSU_TYPE); the _PRODUCTION aliases stay undeclared on purpose, they are a hosted Vercel convention, not an operator contract.
[2026-09-02] Repo-wide bloat sweep (chore/bloat-sweep-2026-09): removed dead files/exports/types/i18n namespaces and deduplicated byte-identical helpers into canonical homes (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text pdfAmount/formatDateSv, lib/browser/panel-request, lib/api/v1/body + v1ValidationError, lib/bookkeeping/booking-template-schemas). Deliberately NOT done: naive Math.round(x*100)/100 helpers were not swapped for roundOre (behaviour change at half-ore values, ratchet campaign owns it); lib/bokslut/rounding.ts shim kept because money.test.ts asserts the back-compat re-export; text-based v1 body parsers (empty body allowed) kept inline because readV1JsonBody has different empty-body semantics; the four HTTP endpoints with no first-party caller (skatteverket agi/underlag + agi/sparad DELETE, invoice-inbox items/:id/history, mail connections/backfill) stay because removing a reachable endpoint is a surface change; VacationBalanceCard deleted as unreachable since #1130, the v1/MCP vacation-year-close routes stay.
[2026-09-01] PR #2130 security-scan round: the register's djuplank is validated (https + skatteverket.se host) before it is returned or navigated to, since the settings page follows it; a contested org number now WITHDRAWS an already-recorded grant nightly (not only blocks new ones), outside the downgrade guards on purpose. NOT done: proof of org-number ownership (Bolagsverket firmatecknare / BankID) before any ombud grant; the org number is tenant-editable across the product (AGI, invoices, årsredovisning) and binding it to a verified identity is a product decision for Emil, tracked as a follow-up rather than declined.
[2026-09-02] Verifikationsserie per bankkonto applies to bank_transaction bookings only (book route, categorize, agent, pending operations), not to invoice settlements matched from the bank or to bulk-book samlingsverifikat: invoice_paid/supplier_invoice_paid series describe the payment kind and match-* routes have eight builder branches that do not take a series; bulk-book resolves its series inside the bulk_book_transactions RPC. One predictable rule beats partial coverage; extend later if a user asks.
[2026-09-02] Grok custom connectors are allowlisted by the exact callback https://grok.com/connectors-oauth-exchange-code/ (trailing slash optional), not a grok.com prefix: the value is published by X Corp at docs.x.com/x-ads-api/mcp ("Grok (web)" redirect URL) and grok.com serves the path itself (slash form 308s to no-slash on the same origin), and a prefix would let any future grok.com path receive authorization codes. Grok gets side doors next to ChatGPT (onboarding) and a row under "Other clients" (settings); Claude stays the visual primary per the 2026-08-27 founder call. No new client marker plumbing: the settings URL uses the existing ?client= param with value grok.
[2026-09-02] Viewer write gate as ONE table-level trigger (enforce_company_writer_role) instead of re-emitting 15 SECURITY DEFINER bodies and ~130 policies: keyed on the JWT role claim so it fires inside definer functions too; no-op for service_role and trigger cascades. agent_conversations/agent_messages and telemetry tables deliberately excluded.
[2026-09-02] Posting-integrity guards key on current_user IN ('anon','authenticated'), not the JWT claim: inside SECURITY DEFINER RPCs current_user is the definer, so commit_journal_entry, SIE import, storno and rättelse keep working while direct PostgREST manipulation of posted vouchers is blocked. Residual: a direct draft->posted flip may still reuse an unused number below the sequence high-water mark.
[2026-09-02] Kept lib/auth/rate-limit-http.ts fail-open on hosted with an error-level log instead of failing closed: production has no Upstash configured, so fail-closed would 503 every rate-limited route until the env is set. Operator action: set UPSTASH_REDIS_REST_URL/TOKEN.
[2026-09-02] api_keys hash columns stay readable to company admins (SELECT restricted to own-or-admin, no column-level REVOKE): app/(dashboard)/page.tsx counts with select('*'), and the hashes are inert now that rotate/validate RPCs are service_role only.
[2026-09-02] arcim-migration callback refuses pre-migration provider_otc rows (user_id NULL) instead of falling back to the old unbound behaviour: the 10-minute state expiry makes the cost at most one retried connect at deploy time.
[2026-09-02] MCP OAuth redirect allowlist: registered URIs resolve with a service-role lookup bound explicitly to the consenting user (registrant or a colleague sharing a company) instead of relying on RLS select_own; DB-registered clients default to read-only pre-checks while the built-in Claude/ChatGPT clients keep the full pre-check; member role keeps approve with the SoD acknowledgement recorded at /token, mirroring settings/api-keys.
[2026-09-02] BankID signup no longer returns a magic link: the account is created unconfirmed and the typed address must click a mailed link before bankid_linked (and the MFA exemption) is set. Chosen over a pending-signup table: one nullable column (bankid_identities.email_verified_at) and the existing auth callback carry the state.
[2026-09-02] Removed the skattekonto drift email (skattekonto.drift_detected event, handler, /api/extensions/skatteverket/skattekonto/drift route, cron hook) instead of fixing it: it alerted on raw saldo-vs-1630 gaps that unbooked rows explain by construction (2026-09-02: Arcim 35 842 kr, 100% explained, while the Hem notice and reconciliation page said nothing was wrong), repeated every 24 h, and was the only surface of a May-2026 feature whose promised dashboard tile was never built. Since 2026-08-25 the reconciliation page and the Hem notice (detectSkvUnexplained, gated on unexplained_difference) are the surface. Considered gating the mail on unexplained_difference + once per episode (built, then dropped): after that gate it only fires on integrity findings the engine itself calls 'never a user task'. skattekonto_drift_tolerance stays (Hem notice reads it); stale skattekonto_drift_last_alert_at rows in extension_data are inert.
[2026-09-02] parties phase 0, golden set stays out of git: the labelling sample is prod voucher text with person names (salary, expense claims) and the repo is public, so the draw SQL is versioned but the rows and labels live in gitignored dev_docs/parties/golden/.
[2026-09-02] parties substrate: customers and suppliers keep their tables and gain party_id; parties dedupe on normalised org number only, never on name at insert time. A name merge is a recorded human decision because the July measurement showed a majority of generic keys still map to one real vendor, so an automatic name merge would fuse unrelated suppliers.
[2026-09-02] MCP eager-auth flag (`auth=required`) on the claude.ai connector links instead of reverting lazy auth: claude.ai's two-step Add-custom-connector dialog probes the URL without credentials and pre-fills Authentication "None" when the lazy handshake answers 200, which blocks the sign-in later; per Anthropic's docs a 401 is the only answer it reads as OAuth. The flag lives in the URL, so the links we control (Settings, onboarding checklist, both docs pages, website) get OAuth detected while the bare URL keeps lazy auth for Claude Code, the plugin, Cursor and ChatGPT, and existing connector records stay untouched. Rejected: keying eager auth off `client=claude-connector` (documented as telemetry-only) and sniffing the probe's user agent (fragile, undocumented).
[2026-09-01] Balance badge in the expense booking dialog uses non-breaking spaces instead of whitespace-nowrap: the nowrap-in-dialog guard forbids the class inside DialogContent, and the label is a single semantic token, so NBSP fixes the wrap without touching the guard allowlist.
[2026-09-05] Expense payout batches book through the create_expense_payout_batch RPC (claims locked FOR UPDATE, verifikat via commit_journal_entry, claims marked paid, one transaction) instead of the application-side select/insert/post/link/mark sequence with storno compensation: three concurrent identical payout requests each booked a transfer for the same claims in the local end-to-end run, and compensation cannot prevent a race that a row lock prevents by construction.
[2026-09-02] Recurring gross deductions keep is_vacation_basis=false on the deduction row (the semester base is NOT reduced): matches the common loneväxling agreement where vacation pay stays on the pre-exchange salary, and diverges deliberately from the manual-line default which follows the row flags as entered. Flagged by review on #2044; change requires a per-line toggle, not a different default.
[2026-09-02] Recurring 'other' additions removed from #2042/#2044 scope: calculateSalary only treats ADDITION_TYPES as additions, so a recurring taxable addition would render on the payslip without entering gross/tax/AGA or AGI. Re-add only together with engine support and engine tests.
[2026-09-02] Offert detail actions: Acceptera + Skapa faktura in the header, Avboj in the overflow menu: convention 9 (one obvious next step, alternatives behind the caret); declining is the rarer branch.
[2026-09-02] Quotes (offert) get their own number series (company_settings.next_quote_number, generate_quote_number, OF-nnn allocated at insert) instead of an OF- prefix on generate_invoice_number: proformas share the F-counter today, so PF-042 leaves F-042 unused, and quotes are far more numerous, so every declined quote would have punched a hole in the faktura series. Delivery notes already use the own-counter pattern. The column next_quote_number pre-existed on prod and staging with no migration file; 20260902220000 adopts it (backfill NULLs, DEFAULT 1, NOT NULL).
[2026-09-02] Quote expiry is derived, not stored: quote_status stays open/accepted/declined and the UI/API compute expired as open AND valid_until < today (lib/invoices/quote-status.ts). Rejected the nightly cron from the original plan: it needed a route, a vercel.json entry, a Docker cron gap and un-expire logic when a user extends valid_until; the derived form needs none of that and cannot race the clock.
[2026-09-02] Converting a quote leaves the quote in place as accepted (converted_from_id on the invoice points back), unlike proforma conversion which cancels the source: a proforma is a pre-invoice for the same sale, a quote is the customer's decision record and the list should keep showing it as Fakturerad. Conversion is refused while an active invoice exists for the quote and for declined quotes; a cancelled converted invoice frees the quote. The conversion itself was extracted into lib/invoices/convert-to-invoice.ts because the route and the MCP staged commit carried two drifted copies (one lost per-line VAT/revenue accounts, the other lost discounts and fakturamarkning).
[2026-09-02] gnubok_create_invoice exposes document_type as invoice | quote only (no proforma / delivery_note on the MCP path): the staged executor never numbered proformas or delivery notes and adding them means adding their number-series branches; the quote branch is the one this feature needs, and the enum can widen later without breaking callers.
[2026-09-02] v1 and MCP read surfaces report quote_status as the EFFECTIVE status (open | accepted | declined | expired) with valid_until beside it, and the quote_status=expired filter is the derived predicate (open AND valid_until < today): a caller filtering on expired must get rows that say expired, and the stored column is still recoverable from valid_until. The write surfaces (POST quote-status, gnubok_set_quote_status) return both the stored decision and effective_quote_status so a client can tell the two apart.
[2026-09-02] Quote migrations keep validated CHECKs and plain CREATE INDEX (CodeRabbit asked for NOT VALID + VALIDATE and CONCURRENTLY): invoices is 13 781 rows on prod so the validating scan is milliseconds, and Supabase branching applies each migration inside a transaction where CONCURRENTLY is refused (the same call as 20260901140000_targeted_fk_indexes.sql and 20260831111519_api_key_unattended_commit_limit.sql). The one-live-conversion rule moved into a partial unique index (20260902221000) because the TS existence check alone could not stop two concurrent conversions.
[2026-09-02] Kundorder (sales orders) ship as their own tables, not as invoices.document_type = 'order': orders have a delivery axis invoices lack, and an order row inside invoices would leak into every AR/VAT/reminder surface that filters on status instead of document_type. Invoiced quantity per order line is DERIVED from invoice_items.sales_order_item_id (sum over non-cancelled, non-credited invoices) and enforced by a BEFORE trigger, never stored: a counter cannot drift and a credited invoice frees the quantity by itself. Header status is four-state (draft, confirmed, completed, cancelled) with completion maintained by DB triggers; delivery and invoicing progress are derived per line. Offert is out of scope because no quote entity exists (only six dead QUOTE_* error codes); proforma -> order reuses the proforma -> invoice convert precedent. Nav key `sales_orders` is repointed to kundorder and the webshop row renamed `webshop_orders`.
[2026-09-02] Kundorder hardening after the skeptic pass (PR #2166): (a) an invoice created from an order refuses to invoice when the customer's type or VAT-number validation differs from the snapshot the order lines were priced under (SALES_ORDER_CUSTOMER_VAT_CHANGED); re-saving the order re-validates, chosen over silently re-deriving rates because a rate change is a pricing decision the user must see. (b) replaceInvoiceItems refuses a line set that drops an existing sales_order_item_id link (INVOICE_UPDATE_DROPS_ORDER_LINK) instead of guessing which new line maps to which order line; this closes the MCP update_invoice / v1 PATCH path that would otherwise free the quantity for double invoicing. (c) Derived quantities are rounded to 6 decimals (roundQty) and compared with an epsilon because Postgres numeric is exact and JS doubles are not. (d) Invoice delivery_date comes from per-line last_delivery_date over the covered lines, only when the covered quantity was delivered; an advance invoice gets none. (e) Kept ON DELETE RESTRICT on invoices.sales_order_id and invoice_items.sales_order_item_id despite the company-cascade ordering hazard (a company hard delete would hit the RESTRICT before invoices cascade): no shipped route hard-deletes companies, transactions.document_id already carries the same RESTRICT-under-cascade shape, and SET NULL would let an order deletion silently erase invoice provenance. (f) Kept sales_orders.user_id NOT NULL ON DELETE CASCADE, matching invoices; the prod user-deletion recipe repoints authorship first. (g) Proforma -> order refuses proformas with ROT/RUT, periodisering or negative-quantity lines rather than dropping those fields.
[2026-09-02] Kundorder MCP tools: only gnubok_list_sales_orders sits in the default tools/list catalog; gnubok_get_sales_order and the four staged writes (create, transition, register delivery, create invoice from order) are catalogVisibility 'search'. The payload-size ceiling (60K tokens, payload-size.bench.test.ts) had ~900 tokens of headroom and the six tools need ~4200 (~2500 trimmed); the test forbids bumping the ceiling and demoting unrelated reads is out of the feature's scope. Same footing as the mileage and skattekonto write families; promoting the writes later means demoting other reads first.
[2026-09-02] Agent-triggerable bank sync shipped (v1 POST /bank-connections/{id}/sync + MCP gnubok_sync_bank), lifting the 2026-09-01 deferral: Emil chose to close every open F2 item in one PR. The cost worry is bounded structurally instead of by policy: the window is never caller-controlled (gap-aware 7 to 90 days, same helper as the cron), a connection synced within 15 minutes answers BANK_SYNC_COOLDOWN with next_allowed_at (MCP returns it in-band as synced=false so agents read on instead of retrying), and failures are throttled per process by attempt time. The web Synka-nu route is left untouched rather than refactored onto the shared runner: it carries UI-only behaviour (caller-chosen days_back up to 365, SIE sweep stamping) and a regression there would hit every user for a code-sharing win.
[2026-09-02] Bank-sync cooldown is a durable lease column (bank_connections.sync_lease_until, migration 20260902150000) claimed with one conditional UPDATE, not a process-local attempt map: the security scan on PR #2165 showed the map is bypassed by a second serverless instance or a cold start, so two agent calls could each bill Enable Banking. A column add was chosen over reusing extension_data because PostgREST cannot express an atomic conditional upsert there; the nightly cron deliberately ignores the lease.
[2026-09-02] Grok links carry auth=required like the claude.ai link (#2159), decided from a live test: on the lazy URL Grok's connector dialog listed all 150+ tools and never opened the sign-in, so it reads the 200 probe as an authless server exactly as claude.ai does. The flag lives in one helper (mcpServerUrl / sideDoorServerUrl in lib/onboarding/checklist.ts) so the settings row, the onboarding side door and the deep link cannot drift; ChatGPT stays lazy because its developer mode honours the 401 on the first protected call.
[2026-09-02] create_supplier_invoice_from_inbox gained exchange_rate_override (+~25 tokens against a tools/list budget at zero headroom) and the Riksbanken retry cap stayed at 5 s: a staged foreign invoice with no rate is unapprovable (SI_FX_RATE_MISSING) and the override is the only agent-side unblock, while honouring the limiter's 54 s ask would park a tool call or the sync cron's fan-out for a minute per 429; passing the supabase client to the shared resolver (cache + last-cached fallback) removes the common case instead.
[2026-09-02] reconcile_match staging folds N:1 groups back together by journal_entry_id (links from the dry run that share a verifikat and carry no allocated_amount), mirroring the existing bank 1:N fold, instead of threading the caller's original pairs through: the dry run is the source of truth for what was validated, a verifikat can only be settled once so shared-JE links can only have come from one N:1 pair, and use_proposals has no caller pairs to thread. The remaining gap (dry run validates only pair shape for non-split pairs) is the engine's to close, not the tool's.
[2026-09-02] parties children/roles reference parties(id, company_id) with composite FKs, not parties(id): a party UUID from another tenant is rejected by construction instead of relying on each writer to check; ON DELETE SET NULL (party_id) on customers/suppliers because a plain SET NULL would null company_id too (Superagent P2 on #2162)
[2026-09-02] Party suggestions attach only by explicit party_id, org number or an exact ledger key already in alias_keys; same-core text is reported as similar_to for a person to decide and identities are withheld when a key mixes org numbers: the selection eval measured 9% false merges on trade names shared by distinct legal entities (Fortnox AB / Fortnox Finans), so text never merges
[2026-09-02] Edited migration 20260902160000 after merge: its backfill failed on prod (ensure_party: name is required; 3 nameless rows) so it was never applied there, the Supabase main branch sat in MIGRATIONS_FAILED and every later migration was blocked behind it. An unapplied file is not a shipped schema; a follow-up migration could not run before it
[2026-09-02] Quote migrations renamed 20260902140000/141000 -> 20260902220000/221000 before merge: #2166 shipped its own 20260902141000 (pending_operations_add_sales_orders) to prod while this PR was in review, and prod's head had moved to 20260902210000; a version below the head is skipped by Supabase branching, which would have left the invoices_quote_defaults trigger off prod and made every dashboard quote create fail the pairing CHECK. Staging rows were renamed to match.
[2026-09-02] MCP gnubok_mark_invoice_as_paid refuses only quotes (parity with the dashboard route), not proformas: marking a sent proforma paid is a supported prepayment record (status flip, no verifikat) on the dashboard and v1, and the round-two regression skeptic showed the broader guard broke that. Transaction match and voucher link refuse every non-invoice on all surfaces, as the dashboard match route always did.
[2026-09-02] Accounted Connect direction (founder decision after a fork was resold): the ledger stays AGPL with no licence change and no ee/ split; provider integration logic moves behind the connector (hosted app/api/connect/* today, a separate Connect service later) one upstream at a time on the existing connector keys, ledger and entitlement sync. Rejected: FSL/BSL relicensing (58 public forks keep the AGPL version; DCO-only contributions cannot be relicensed without consent) and closed first-party extensions alone (the code still ships to every self-hoster, and the provider extensions do not honour the Extension API boundary).
[2026-09-02] Peppol connector proxy (#2177) is operation-shaped (lookup/submit/status/evidence/recipient/inbound), NOT a path passthrough like the bank proxy: Qvalia URLs embed Arcim's partner and account numbers, the account is shared by every hosted company and every instance so reads must be scoped to what the caller owns, and Qvalia's inbound "read" endpoint marks documents read for the whole account. Ownership is bound to (key, company_ref); participants a key may publish are recorded on the key at issuance (connector_keys.peppol_participants) because the hosted side cannot otherwise know which organisations an instance legitimately hosts. Inbound is served from the hosted archive, never by calling Qvalia on the instance's behalf.
[2026-09-02] The connector wire contract is an MIT package (packages/connect-contract, @accounted/connect-contract) consumed in-repo from source through a tsconfig/vitest alias (#2179), and check:guards ratchets the set of files naming a provider API host (#2178): the open repo keeps the contract and the manual file paths, either side of the connection can be implemented outside it, and the grandfathered provider-host set may only shrink. Declined a NOT VALID + later VALIDATE pair for the ledger service CHECK: connector_connections has zero prod rows until keys are issued.
[2026-09-02] Accounted Connect runs as a SEPARATE private service (founder: "Go with A"), not as a private extension inside the hosted build: a clean licence boundary (the adapter code never enters the AGPL repo or the hosted build artefact), Connect becomes sellable on its own, and hosted Accounted becomes an ordinary installation of it (connector key + GNUBOK_CONNECT_URL, no provider keys of its own), so the instance path is exercised by hosted every day. Cost accepted: a second deployment and Supabase project. The service was ported from app/api/connect/* and lib/connect/hosted into the private repo accounted-connect; the hosted routes stay until each installation is pointed at the service, then get deleted.
[2026-09-02] Stock GoTrue email-change redirects detected via a flow=email_change marker on emailRedirectTo rather than by sniffing ?message= / ?code= on every callback: GoTrue's PKCE redirect carries no type, and the same marker rides along on hook-built token_hash links, so one flag covers both link styles without touching signup/recovery/OAuth paths.
[2026-09-02] Sign-off refusals: registered the ReconciliationSignoffError codes in structured-errors with a new thrown_message_sv flag instead of returning err.message from the routes: check:guards forbids raw caught-error messages in user-visible sinks, and the registry keeps the codes discoverable for agents while the dialog still gets the runtime text (dates, amounts).
[2026-09-02] Nyckeltal "Resultat per månad" shows the exact per-month figures as an always-on list under the bars (#2198), not behind an "Anpassa" toggle: a preference would touch the type, the PUT schema, the strict preferences-body validator, the dialog and its tests for a switch nobody turns off. Per-bar compact labels are conditional on a glyph-width fit rule and fall back to the single latest label, so they never overlap. Left alone: the monthly path counts only posted entries while the year-total path also counts reversed originals (pinned as intended in tests/pg/kpi-report-aggregates-rpc.pg.test.ts), so a same-year storno makes the sum of months differ from Nettoresultat; visible as numbers now, founder call whether to align the two.
[2026-09-03] Kontakter register reads money and rhythm through parties.alias_keys joined to get_observed_parties rather than a stored per-party rollup: the numbers stay identical to the import reveal and nothing is written on read; dismissed parties keep their keys claimed so a hidden suggestion never resurfaces under Bara i bokföringen
[2026-09-03] Email-change double-submit gate is a dedicated per-user claim table + SECURITY DEFINER RPC (migration 20260903083000), not idempotency_keys and not an advisory lock: idempotency_keys requires a company_id the account-level route does not have, and a transaction-scoped advisory lock cannot cover the GoTrue call that happens outside the transaction.
[2026-09-03] Suppliers and customers get their party from a BEFORE INSERT/UPDATE trigger (link_party_on_role_write), not from each write path: 108 rows created in the day after the backfill had no party, and dialogs, v1 API, MCP, imports and provider migrations all write these tables. Private customers get a kind=person party with no number at all, so a personnummer never reaches parties
[2026-09-03] Kundorder PGRST201 fixed by hinting the three sales_order_items embeds AND by teaching scripts/checks/ambiguous-embed.mjs to parse composite FOREIGN KEY (a, b) constraints, instead of a bespoke source-scan test: the guard is the sanctioned owner of this class (decision 2026-09-01) and it missed the pair only because its parser was single-column; with the fix it derives the same 17 pairs prod reports and flags all three shipped sites on main.
[2026-09-03] Email-change double-submit gate is a dedicated per-user claim table + SECURITY DEFINER RPC (migration 20260903083000), not idempotency_keys and not an advisory lock: idempotency_keys requires a company_id the account-level route does not have, and a transaction-scoped advisory lock cannot cover the GoTrue call that happens outside the transaction.
[2026-09-03] Cursor MCP OAuth callbacks are built-in allowlist entries, exact-matched: cursor://anysphere.cursor-mcp/oauth/callback and https://www.cursor.com/agents/mcp/oauth/callback (the loopback http://localhost:8787/callback already passes the local rule). Cursor's DCR sends all three in one request and /register rejects the whole set on any unknown URI, so the Cursor path advertised in Settings never worked (reported by a byrå user 2026-09-03). Exact match, not a cursor.com prefix, so no other cursor.com path can receive codes; the custom scheme is accepted despite RFC 8252 section 8.4 because the code is PKCE-bound and the loopback form carries the same local-machine trust. Users cannot self-register the cursor:// form (the settings panel requires https), which is why it is built in. Grok Bot rides on Cursor's MCP stack and stays broken on Cursor's side (forum thread 168052, open as of 2026-09-02); nothing server-side fixes that.
[2026-09-03] Skattekonto through Connect = the existing data proxy plus CONNECT_SKV_CANARY_COMPANIES, not a separate sync operation: the provider logic is two GETs and the dedup keys stay on the ledger; system (certificate) auth is still not brokered because hosted has no certificate configured, so every hosted skattekonto read is a user-token call the proxy already carries.
[2026-09-03] Old-address social identities are unlinked by a BEFORE UPDATE trigger on auth.users (migration 20260903110000), not by the /auth/callback done path: the callback never runs for a completing click from a browser without a session, and admin-side changes bypass it entirely; the trigger covers every path and keeps the email identity, password and BankID intact.
[2026-09-03] Invoice payee accounts live on cash_accounts (payee columns + invoice_payee) with a per-currency default table (invoice_payee_defaults), and company_settings.invoice_payment_accounts plus the legacy SEK bank columns become a trigger-maintained mirror of the default account, NOT a third writable source: one account may be the default for several currencies (a SEK account with an IBAN is the normal EUR payee, so a per-currency boolean on the account was wrong), the mirror keeps every existing PDF/email/Peppol/v1/MCP reader unchanged, and the three legacy writers (PUT /api/settings, v1 settings, MCP update_company_settings) write through to the default account so they can no longer drift from what the PDF prints. Backfill lands only on existing cash accounts (prod 2026-09-03: 227 of 239 entries); the 12 foreign-currency entries with no account stay in the map as the resolver fallback and get an attach action in settings. bg_pg dropped: never read or written, NULL on every prod and staging row.
[2026-09-03] Per-invoice payee is a nullable invoices.payment_cash_account_id plus a payment_details snapshot written when the account is chosen and refreshed at issue (send, mark-sent, Peppol, recurring), not a live join at render time: issued invoices are evidence and must print what they printed, so /pdf re-renders read the snapshot, while invoices that never chose an account keep resolving the company default per currency exactly as before (no snapshot, no behaviour change). A chosen account that is disabled, un-flagged or unusable for the currency at issue blocks with INVOICE_SEND_PAYMENT_ACCOUNT_INVALID (Odoo blocks posting the same way) instead of silently falling back. The editor shows the "Betalas till" select only when the company has two or more usable payee accounts for the invoice currency.
[2026-09-03] Manual mark-paid (dashboard, v1, MCP) debits the invoice's chosen payee account (cash_accounts.ledger_account) instead of the hard-coded 1930 the 2026-07 decision kept for lack of context; without a choice it stays 1930. Bank-transaction matching keeps debiting where the money landed and does NOT filter by the chosen account (Enable Banking cannot tell which giro was paid, and a customer may pay the wrong account): the chosen account is only a tie-breaker between equal-confidence candidates, so nothing that did not auto-match before starts to.
[2026-09-03] Kontakter is not a user-facing noun (founder, after the register walkthrough): the registers people see stay Leverantörer and Kunder, the new page is the queue 'Förslag från bokföringen' plus 'Bara i bokföringen', and confirming a suggestion creates the leverantör or kund row directly (promote_parties). A confirmed party with no role never appears in the UI. The party model underneath is unchanged
[2026-09-03] SCB registry lookups are made for juridiska personer only (org number with 20 or more in the month slot): a sole trader's org number is a personnummer, and sending it to SCB is personal-data processing with SCB as an independent controller; the plan keeps natural persons out of registry enrichment until the Art. 14 notice exists. The SokPaVar wire format sits in one file (lib/parties/scb/client.ts) because SCB replaces the API with an API-key one from September 2026
[2026-09-03] SCB name search is a picker, never a lookup: the user chooses among SCB's matches and the chosen org number is recorded as a fact with source user before any fetch; one match is shown, not auto-picked, because a trade name is not an identity (Adobe Systems Software resolves to an Irish entity and a Swedish one)
[2026-09-03] AGI redovisningsperiod = the payout month (agiReportingPeriod on payment_date), not salary_runs.period_*: Skatteverket files per the month the pay went out (kontantprincipen), so lön i efterskott (August work paid 25 September) is declared in September. The in-period payment-date guard (dashboard PATCH, lib/salary/update-run.ts, v1 PATCH, RunHeader min/max) is lifted rather than widened: its only stated reason was that the AGI keyed on period_*, and any residual month window would bite the next efterskott variant. Existing agi_declarations rows keep their stored period (no backfill): a declaration already filed under the earned month is a real-world correction with Skatteverket, not a re-key. New AGI_PERIOD_CONFLICT (409) refuses to overwrite a live run's declaration for the same payout month, since one month's AGI must cover every payment that month and the generator cannot merge runs. Issue #2191.
[2026-09-03] The cursor:// deeplink is its own allowlist provider (cursor_deeplink) rendered "Din egen dator" and never "Verifierad", after the skeptic, CodeRabbit and Superagent all made the same point: a custom scheme can be claimed by any local app (RFC 8252 section 8.4), so it carries loopback trust, not vendor trust, and the consent page must not say otherwise; https://www.cursor.com/... keeps the verified label. Same pass fixed the consent-page CSP for custom schemes: new URL('cursor://...').origin is the string "null", so form-action became `'self' null` and Chromium would have blocked the post-consent 303 (correctness skeptic refutation); the header now uses the scheme-source (`cursor:`) when the origin is opaque. Not done: rejecting a missing code_challenge at /authorize. A code minted without one is unexchangeable (verifyPkce against an empty challenge is always false, now pinned by a test), so it is fail-closed; making it fail earlier is a separate change touching every client.
[2026-09-03] Inbound mail (#2181): a mail that names the same inbox as both +lev and +ver files once with kind_hint null (extraction classifies) and the conflict is recorded on the InboundMailReceived history event, rather than letting +lev win: the sender said two things, and a silent pick would be indistinguishable from the tag being ignored. The per-attachment catch in the webhook now writes an error row instead of only a console line: prod showed a +lev mail Resend accepted at 14:52Z with no inbox row at all, and the reporter's +ver mail lost its second PDF the same way. Dedupe on (email, attachment) became per company (index replaced in 20260904001000) so one mail to two inboxes files once per inbox. The Inkomna mejl panel defaults to 30 days. After the skeptic pass: the InboundMailReceived payload carries inbox_id, the documented tags and an unknown-tag count, never the address or a sender-typed tag (the local part of an enskild firma's inbox is the owner's name, and a numeric tag trips the PII validator so the record would be lost); the DB strip trigger from 20260901110000 now covers the new type; and a catch-path error row is marked transient so a Resend redelivery replaces it instead of reporting a duplicate, keeping the self-heal main had. Review pass on PR #2244: fan-out capped at five processed inboxes per mail with every addressed inbox still resolved and the ones past the cap recorded as fan_out_capped on their own InboundMailReceived event (Superagent asked for the bound, CodeRabbit and the Swedish review for not losing the trace), the history route reports has_more past 200 rows (CodeRabbit), and a replaced transient row is named on the record (Swedish review, BFL 5 kap 5 § trace). Declined: resolving verified custom-domain recipients alongside a shared one (the plan keeps today's precedence, the feature is gated off with zero verified domains on prod, and the existing test pins it), and CONCURRENTLY index ops (Supabase branching runs migrations in a transaction; same call as 20260706120000).
[2026-09-03] Jämkning valid_to is required on every write path (web, v1, MCP staging and executors, both Zod schemas) through one shared validator (lib/salary/jamkning-rules.ts), closing #2058: the engine never applies a beslut without an end date, so the previously accepted percentage+valid_from shape stored an inert beslut behind a 200. Declined the alternative of defaulting valid_to to 31 December of the from-year: it matches most beslut but silently changes withholding on rows that today do nothing. Existing incomplete rows are listed by scripts/list-incomplete-jamkning.ts and decided per company (set an end date or clear the beslut), since either changes the next payslip.
[2026-09-03] The Lucide Building2 glyph is retired app-wide (founder, from the register walkthrough). Suppliers use Truck, companies and company-scoped things use Briefcase, banks use Landmark; the extension manifest icon name changed with it
[2026-09-03] settleInvoicePayment writes the invoice_payments row BEFORE the CAS status update and removes it in both failure branches, instead of inserting after the update: the kontantmetod cut-off reads invoice_payments only, so a paid invoice without a row is the #2019 defect itself; failing closed on the insert (voucher storno + INVOICE_PAID_BOOK_FAILED) keeps GL, sub-ledger and invoice status in step. The #2019 backfill inserts only where exactly one posted payment voucher exists (invoice_paid / invoice_cash_payment with source_id = invoice); zero or several vouchers are reported, never guessed, and every row is tagged backfill:#2019 in notes so one DELETE reverts a run.
[2026-09-03] #2019 skeptic round: the invoice_payments row is written by one helper (lib/invoices/invoice-payment-row.ts) from all four transaction-less settlement paths (dashboard, v1, MCP mark-paid, Stripe), with amount = applied amount (new paid_amount minus prior) rather than cash received, so a 3740 öre absorption never produces a negative fordran in the cut-off or a wrong storno restore. A payment row with transaction_id NULL does NOT count as "reconciled to a bank line" in the two duplicate detectors: the bank line for a manual settlement arrives later and the voucher must still be offered as a twin. The backfill dates rows from the voucher entry_date (paid_at was wall-clock before #1332), refuses rows that disagree with the voucher's 1510 credit / settlement debit, reports partially covered invoices (rows_short) instead of patching them, and records each executed run in behandlingshistorik (new event type InvoicePaymentRowBackfilled, migration 20260903180000). Not done here, pre-existing: the bank-match and pending-operations match paths still store cash received as the row amount, and the kontantmetod cut-off ignores ROT/RUT deduction_total (1513 share shows as outstanding); both filed as follow-ups.
[2026-09-03] Enable Banking ASPSP_ERROR ladder (#2202): the widest history window a bank has answered is stored per account as accounts_data[].accepted_history_days (no migration; same write-back as dedup_scope and the balance), not as an absolute date, because a bank's limit is a width from today and an absolute known-good date would age into a genuine width rejection. On a rejected window: no wider than accepted = bank unavailable after ONE call; wider = one retry at the accepted width, then unavailable (was up to five calls). AspspUnavailableError maps to 503 BANK_UNAVAILABLE on the web sync route with copy that says the connection does not need renewing; the agent path keeps the contract code BANK_SYNC_FAILED (adding a code touches core contract + structured-errors + v1 docs, left for a follow-up) but no longer persists renewal advice. The envelope's `detail` field is NOT used as a signal: one sample, "Unknown error", identical to a width rejection. A narrowed sync now returns history_from so the UI can say from which date it is complete.
[2026-09-03] KPI monthly breakdown counts reversed originals (#2201): the monthly section of get_kpi_report_aggregates (new migration 20260903160000) and lib/reports/monthly-breakdown.ts now use tb_ex_year_end's entry set verbatim (posted + reversed, minus the undone year-end chain) instead of posted-only. A same-year storno then cancels inside the months as it does in the year total, so sum(months) = Nettoresultat; the reversal shows as negative revenue in its own month, which is the honest month view. The pg-real pin "in tb, not in monthly" was flipped, not worked around. Unblocks the per-month sum on Nyckeltal (#2196).
[2026-09-03] customers.country and suppliers.country are ISO 3166-1 alpha-2 at every writer (form select, internal + v1 REST, MCP, imports, provider migration), normalised through one helper (lib/vat/country-codes.ts) that also accepts the Swedish/English names the form used to write; unknown text is a 400 on write and left as-is by the backfill (migration 20260903173000 keeps the original in country_raw for a one-UPDATE rollback, and derives the country from the VAT prefix for eu_business rows whose country was null or only the old writer default SE: on prod that is one validated row plus sixteen without a country, and without it they would flip from reverse charge to 25% on their next invoice). No CHECK constraint on the column: unmapped legacy rows would violate it, and the periodisk report already warns on those. The country-vs-type rule (swedish_business = SE, eu_business = not SE and either in the EU VAT area with a matching prefix or holding an EU-trade VAT registration such as a Swiss company with a DE number or Northern Ireland XI, non_eu_business = outside the EU) is enforced on customers only, and on update only when type, country or VAT number is part of the change so a contradictory legacy row can still change its email; individuals are free (a foreign private person is still a Swedish-VAT customer) and suppliers get normalisation without the rule, since #2025/#2028 are about sales VAT. An omitted country on create is SE for Swedish types, derived from the VAT prefix for eu_business, and a 400 for non_eu_business: guessing a non-EU country is not possible, and Sweden-by-default was the bug. vat-rules.ts takes the country as a third optional argument and refuses reverse charge only for SE (a VIES-validated number outweighs a non-EU address), and not for an unknown/unmapped country: charging Swedish VAT to a genuine German customer whose row says Deutschland (Bayern) would be the worse error.
[2026-09-03] AGI kvittens cron: dropped the apigw_config bucket (#963) and its warn-once suppression for ACCESS_DENIED (#2226). The bucket existed because the APIGW client was known to lack the AGI hantera subscription in Utvecklarportalen; with that subscription expected in place, a gateway refusal is a regression and belongs in the ordinary error path (error level, generic 'error' status) rather than a status that hides it as a known gap. Same pass: the connector-mode gateway-refusal message names the connector operator by host instead of "kontakta supporten", because hosted is itself a Connect installation for the canary companies and "support" no longer says whose. Merging before the portal subscription is active means the 15-minute cron logs at error level per pending declaration until it is.
[2026-09-03] Per-invoice payee (PR #2233): the MCP gnubok_create_invoice tool does not take payment_cash_account_id; an agent-created invoice prints the company's per-currency default payee. The tools/list payload was 14 tokens over its 60 000 ceiling after main merged #2166/#2163 alongside this branch, the ceiling is not to be bumped, and no read tool on this PR's surface is a candidate for search-only demotion. The v1 REST create/update and the UI editor take the field; add it back to the MCP schema when a demotion elsewhere frees the room. Same pass: payee eligibility is BAS 1920-1999, not 19xx, so a till (1910 Kassa) never prints as a bank account; and a member disabling a synced account (enabled=false) no longer deletes the admin's invoice_payee_defaults rows, only the admin-only invoice_payee=false revoke does, since a member must not be able to undo an admin's payee decision through the SECURITY DEFINER mirror trigger.
[2026-09-03] Payment terms 0 days (#2070): customer and supplier forms plus the three API schemas accept whole days 0-365 (0 = betalning direkt), and every `|| 30` fallback became `?? 30` so a stored 0 does not reopen as 30. Upper bound 365 is a sanity cap, not law; the invoice schema's own cap (90) is unchanged.
[2026-09-03] getErrorMessage passthrough (#2086): kept the keyword list as the first gate and added looksLikeUserFacingSwedish as a second (å/ä/ö, a strong Swedish word, or two weak function words, and no technical-leak pattern) instead of flipping to "show unless technical": several routes still return English free text ("Failed to disconnect") that must keep falling through to the status/context fallback, which existing tests pin. A registry-wide test asserts every message_sv passes.
[2026-09-03] SIE IB-imbalance warning (#2082): names the cause and links the manual IB wizard, but does NOT let the SIE import skip IB (the import refuses a period whose IB is already set), so the copy says the file is not imported on the manual path. Plugging the diff to 2010/2019 for enskild firma and running findUntransferredResults at parse time are separate founder calls.
[2026-09-03] Proforma at creation (#2217): a "Ny proformafaktura" split-button entry driven by ?proforma=1, mirroring ?quote=1, rather than surfacing the Förval document-type select earlier in the editor: the split button is where a Fortnox user looks, and the editor's own title already tracks the type.
[2026-09-03] Delete unused custom dimension (#2219): new DELETE /api/dimensions/[id] leans on the existing DB registry guard (enforce_dimension_registry_guards: system dims undeletable, any posted/reversed line tagged with the number refuses) and surfaces its P0001 verbatim as 409 DIMENSION_REFERENCED; the route only pre-empts the system-dimension case with 400 DIMENSION_SYSTEM_DELETE. No separate "is it used" query: the trigger is the single source of truth. The picker shows the value's name under the code after a pick, as AccountCombobox does, instead of changing the input text (the blur/revert logic keys on the code). Decision lines for #2070, #2086, #2082 and #2217 ride here for the same append-only-log reason as the first batch.
[2026-09-03] Fiscal period "first" = no existing period starts earlier (#2237): POST /api/bookkeeping/fiscal-periods decided first-period from `allPeriods.length === 0`, which refused a mid-month first räkenskapsår backfilled after a Fortnox import (2022-07-22) while the DB trigger enforce_first_of_month_for_subsequent_periods would have accepted the row. The route now mirrors the trigger instead of adding a second definition; the 1st-of-month rule (BFL 3 kap. 1 §) keeps binding subsequent years and its message now says why and what is allowed.
[2026-09-03] Tenant brand logos render with next/image `unoptimized` (#2203): the optimizer's remotePatterns allowlist is fixed at build time and the generic Docker image bakes a sentinel for NEXT_PUBLIC_SUPABASE_URL, so /_next/image rejected the runtime Supabase host with 400 and the sidebar mark broke on self-hosted while the favicon (same URL) worked. Chosen over a same-origin proxy route (more code, a second fetch hop for a 26px image) and over a runtime-configurable allowlist (Next.js has none). The remotePatterns block stays for builds that know the URL.
[2026-09-03] Danger zone (#2214): both company actions (Radera företag, Starta om migrering) and the account deletion already open a dialog that requires the company name / e-mail typed in, so nothing destructive can fire from a tap or swipe. The report came from a user who did not dare press the link to find that out, so the fix is copy on the row ("Inget händer direkt: du bekräftar i nästa steg ...") rather than a second dialog or a different control.
[2026-09-03] Decision lines for PRs #2242 (#2237), #2246 (#2203) and #2245 (#2214) are carried in this PR's commit rather than their own: DECISIONS.md is append-only, so every squash-merge flips every other open PR to CONFLICTING and costs a full CI round each; consolidating the lines into the last PR of the batch turns four rounds into one.
[2026-09-03] The country backfill ships as 20260903173000, not 20260903170000: the first version failed on prod at the customers UPDATE because rows of a migration-reset source company are immutable by trigger (block_migration_reset_source_mutation), and the whole file rolled back. The new file skips those companies in every UPDATE (their legacy text is still normalised at read time) and the old file is removed rather than edited, since prod never recorded it; staging was re-tracked by hand under the new version.
[2026-09-03] Per-invoice payee migrations re-issued as 20260904010000 and 20260904011000 (were 20260903150000 / 20260903193000, merged in #2233 but never applied): the backfill's INSERT into invoice_payee_defaults fired the mirror into company_settings for a company that is a migration-reset source, whose rows are immutable by trigger, so the whole migration rolled back on prod and every later migration queued behind it. Same pattern as #2249: skip company_migration_resets sources in the backfill and re-issue under a fresh version rather than edit the failed file in place, so any environment that did apply the old version (staging, by hand) is reconciled by renaming its schema_migrations row instead of diverging silently.
[2026-09-04] ROT/RUT payout matching models the begäran (rot_rut_payout_requests), not the invoice, as the bank-row match candidate: Skatteverket pays one lump sum per begäran covering several invoices, remaining_amount is net of the deduction so a paid ROT/RUT invoice can never match, and 1513 clears per request. Confirm reuses the settle service with the transaction linked in the same call; its own dialog (RotRutPayoutMatchDialog) rather than a third branch in InvoiceMatchDialog, which carries FX/preview/edit logic this two-leg entry never needs.
[2026-09-04] Parties name extraction is rule-based first (legal-form and country anchors in lib/parties/name-extract.ts), no LLM in the batch: every candidate is a substring of the voucher text, testable and free; an AI read for the leftovers (bank memos with no anchor) waits for the founder's call on automatic vs on-click. The picker makes no SCB call when the best reading is a foreign company: the register holds Swedish legal persons only, so a search there can only mislead.
[2026-09-04] Decision lines for the ten-issue batch (PRs #2272 #2273 #2275 #2276 #2277 #2278 #2279 #2280 #2281 #2282) are carried in PR #2282 alone: DECISIONS.md is append-only and the union merge driver is ignored by GitHub, so one carrier keeps the other nine PRs mergeable in any order.
[2026-09-04] "Fix From First Principles" added to CLAUDE.md (#2283) after the ten-issue batch and applied to it retroactively: six of ten PRs changed shape (a counter removed instead of made filter-aware, a CHECK constraint instead of a trigger, one invoice_payments writer plus a guard instead of a shared amount helper, one customer-share definition instead of a fourth copy, code + message logging instead of redacted free text, a fiscal-year picker instead of an explained cap); the other four kept their shape with the alternative recorded in the PR body.
[2026-09-04] Självfaktura (#2264): the self_billing.received_date_* keys were renamed in place ("Mottaget datum" to "Ankomstdatum", en "Date received") plus a help text, and the footer hint invoice_editor.next_step_received_date follows: the keys are used only by InvoiceEditor's self-billed branch and the supplier-invoice form has its own column and wording, so nothing shared needed a new key.
[2026-09-04] Kontoplan per-class band counter removed rather than re-derived under filters (#2263, fix from first principles): it was computed from the already-filtered class group, the reporter asked for it to go, the issue offered removal, and nothing consumed it; the tab chip and the footer "Visar N av M konton" remain the page's only counts, and a counter that does not exist cannot drift the next time a filter is added.
[2026-09-04] /mfa/verify (#2056) hard-navigates with window.location.assign on the default path, collapsing the /api/ returnTo branch; the invite-problem toast on that path is now lost to the page load, the same trade-off accepted in #1984 and the 2026-07-26 invite-cookie entry, and the invite path keeps its own window.location.href = '/' because it ignores returnTo.
[2026-09-04] Arcim registration-link row (#2045): alreadyLinked is counted into the displayed total rather than shown as a separate row, because the lib buckets it as a terminal "done" outcome (a subset of scanned); the already-linked sentence is joined into the existing single detail paragraph instead of widening entityLines.detail to string[].
[2026-09-04] #2250 fixed from first principles: recordInvoicePaymentRow() is the single writer of invoice_payments rows (options widened with transactionId/exchangeRate/notes, defaults unchanged for #2236 callers) and check:guards refuses direct inserts outside it (rule direct-invoice-payment-insert): the bug was five hand-built inserts with no shared row definition, so a shared amount helper alone would have fixed the instance and left the class; routing the four remaining writers through the helper and guarding it makes "every row means the same thing" a CI property instead of a convention. link-journal-entry.ts was routed too rather than allowlisted (strict plan, amount unchanged).
[2026-09-04] Kontantmetod cut-off (#2248) measures a customer invoice's outstanding as total - deduction_total - paid, floored at zero on the invoice's own side for ROT/RUT rows only, and scales the cut-off moms by the customer share: the deduction is Skatteverket's fordran on 1513 booked by the payment voucher itself and every settlement path records the customer share as the payment row amount, so the gross comparison booked a phantom 1510 fordran with phantom 2618 on fully paid ROT/RUT invoices. Plain invoices keep the byte-identical gross path. The 1513 share of an unpaid ROT/RUT invoice is deliberately not booked by the cut-off (the Skatteverket fordran stays an own change, alongside the 1513 point deferred on the currency revaluation 2026-07-29).
[2026-09-04] #2248 fixed from first principles, not by patching the cut-off's arithmetic: the customer share of an invoice (total minus sign(total) times deduction_total, and the signed residual after payments) now has one definition in lib/invoices/customer-share.ts (invoiceCustomerShare / invoiceCustomerOutstanding) with the SQL guard 20260817191708 named as its twin, used by the kontantmetod cut-off, rot-rut-file and payment-sync; the cut-off's copy was the second drift of the same hand-rolled expression (payment-sync drifted 2026-08-25), so a fourth copy would have invited a third. The shared value is unfloored on purpose: the guard floors because it persists the column, payment-sync mirrors it with Math.max(0, ...), and the cut-off floors sign-aware for ROT/RUT rows so credit notes keep their sign; plain invoices get their total back exactly as stored so those paths are byte-identical. Other deduction_total readers with their own semantics (invoice-entries invoiceOutstandingAmount, rounding.ts getAmountToPay, mark-paid settlement cap) were left for a per-site follow-up.
[2026-09-04] Jamkning both-dates invariant enforced as CHECK constraint employees_jamkning_dates_check NOT VALID, not the trigger #2256 proposed (fix from first principles): a row-level fact belongs on the row; the trigger existed only to spare legacy incomplete rows unrelated edits and cost a plpgsql function, per-column change detection and a custom message convention. Trade-off accepted: legacy rows must be completed or cleared on their next edit (mapped to the validator's Swedish sentence). The constraint checks date ordering even when the percentage is null, mirroring validateJamkning and the Zod update schema exactly. jamkningIssueFromDbError derives the sentence from the merged row and falls back to an umbrella sentence: a CHECK violation cannot say which clause failed, and PostgREST exposes the constraint name only inside message (details carries the failing row and is never forwarded).
[2026-09-04] Supplier payment batches (#2060, migration 20260904121000): the two member INSERT policies are dropped because the SECURITY DEFINER RPC is the only write path; a failed RPC logs only the SQLSTATE code and the message, nested under one rpcError ctx key so the SQLSTATE is never confused with the RPC's own domain refusal code (rpcCode) that the unmapped branch logs; details and hint are never logged. First principles: the diagnostic value lives in code + message (the RPC's RAISE text, "violates check constraint <name>", "duplicate key value violates unique constraint <name>"), while details is exactly where Postgres quotes row data ("Failing row contains (...)", "Key (...)=(...)") and hint adds nothing operational, so not logging them removes the payee/account exposure the Superagent P2 on #2282 flagged with no bespoke redact-and-bound helper (an earlier revision of this PR carried one, plus a regex and a TS-target workaround; deleted). The logger's own redaction stays as the safety net for message. The pg tests cover a plain member as well as the owner (the least-privileged writer that must still succeed via the RPC). The pre-existing anon EXECUTE assertion failing under tool-pg is the reset.sh blanket-grant artifact, not fixed.
[2026-09-04] Fortnox/provider migration (#2211, #2238, fix from first principles): the three-year fiscal-year window (getAllowedFiscalYears, #718) became a DEFAULT selection with a year picker, not a fixed lift to every year: the window gates only the SIE fetch (documents, invoices, assets never were), the import is one request per year (300 s function, 290 s RPC) so its cost is bounded per year, but /preview and /sie-data fetch and parse one SIE export per selected year inside a single 300 s invocation (15 s timeout, 3 attempts, 30 s backoff) and no Fortnox export latency is measured in the repo; a selection keeps the default cost identical and an oversized selection fails in /sie-data before any ledger write. Explaining the cap and pointing at SIE (the first version of #2280) was replaced, not kept: the user's problem was a missing year with its documents, and the SIE path leaves document re-attachment as manual work, which is what the import exists to remove. Source years are keyed by start year in the selection (years=2022,2024), consistent with fileStatuses.fiscalYear; two fiscal years starting in the same calendar year already collide in the existing import and were not fixed here. Splitting /sie-data into per-year requests is a restructure (the mapping step needs the account union across files), deferred until large selections time out in practice. The selection per run is capped at MAX_SELECTED_FISCAL_YEARS = 6, derived from the /sie-data budget (300 s function; 48 s worst case per year = 3 x 15 s timeout + 1 s + 2 s backoff; 6 x 48 = 288 s), enforced in the route before the consent is resolved and mirrored in the picker via /preview rather than a client-side constant; unknown years are refused after the year listing and before the first export (Superagent P2 on #2280). Lifting the cap means per-year /sie-data requests, a restructure, not a bigger number.
[2026-09-04] Supplier-invoice list overflow (#2262) fixed per page, not in a shared list component: none exists (every list hand-writes the overflow-x-auto wrapper) and the three overflow reports had three different causes, so the column budget went into .claude/rules/design.md instead. Fakturadatum was dropped rather than Kvar or the action column (förfaller is the payer's date and the default order, the customer list has no invoice-date column either); the always-visible sort icon from #2091 was kept although it was the proximate regression.
[2026-09-04] Parties: the model reads a counterpart only on demand (picker, review list), never when the queue builds: a five-hundred-row queue would cost five hundred calls nobody asked for and a rebuild would repeat them; the reading is a 'model' fact and a search query, never a hard key. The review list ticks rows with exactly one active SCB match but writes nothing until a person approves: an exact legal name plus one active hit is high precision, auto-attaching would still be the system choosing. Exact legal-form names ("Visma Spcs AB", not "Visma") group keys and attach to existing parties: registered company names are unique in Sweden, so this is a key in all but form; the "name never merges" rule keeps applying to fuzzy and form-less names.
[2026-09-04] reset_fiscal_year's next_year_dependency no longer counts an opening-balance verifikat in the following year as reliance (migration 20260904163000 redefines fiscal_year_reset_snapshot; the block now fires only when the following year is locked, closed or has its own closing entry). Why: the old check (opening_balance_entry_id / opening_balances_set on the next period) fired for the dominant migration shape, import the first year with its own #IB and later backfill the year before it, so a backfilled year could never be reset (Aisen & Adison AB, 2026-09-03), while a next year WITHOUT an IB, whose balansrapport really rolls from this year's books, was allowed: the check was inverted relative to actual reliance. An IB in the next year is its own verifikat with its own underlag and survives the reset untouched; the one IB that IS derived from this year's books, the bokslut-generated one, is still refused via this year's closing_entry_id (year_end_state). The preview now returns next_period {name, has_opening_balances} and the dialog says the following year's IB stays as it is, instead of a blocker. Rejected alternative: stornoing the next year's IB inside the reset (it would destroy a correct migration boundary and re-create the #1022 dead end). Sibling fix in the same change: CreatePeriodDialog now derives the name from the dates the user types until the name is hand-edited, which is how a 2022-07-01..2023-12-31 year got saved as "Räkenskapsår 2027" (the seed suggestion is always the next forward year).
[2026-09-04] company_settings.pays_salaries (and a never-attested employer_registered) now follow the ledger: trigger salary_runs_booked_marks_employer (20260904191000) sets them when a salary run is booked, with a backfill for the 12 companies already booking payroll with the flag at its default. Support case 2026-09-04: the assistant told a payroll-running aktiebolag in every answer that it "betalar inte löner" because lib/agent/ask/snapshot.ts asserted the column default (NOT NULL DEFAULT false, only written by the Skatt settings form) as a fact. The snapshot and the composer's KÄNDA FAKTA now apply the employee-facts doctrine (positive evidence or an attested negative, never the default) and the snapshot names Inställningar > Skatt / > Bokföring so the model can point at the page. Chose a trigger over calling a helper from the booking code because there are three booking writers (lib/salary/book-run.ts, the v1 route's own copy, seeders) and 20260717151000 already treated in-app payroll as employer evidence once as a one-off backfill; the trigger makes that rule continuous. An explicit employer_registered = false stays the user's answer.
[2026-09-04] Supplier credit notes rest at 'credited' from creation (one row builder in lib/supplier-invoices/credit-note.ts for the dashboard route, the MCP executor and the v1 API) and a CHECK (supplier_invoices_credit_note_not_payable, 20260904190000) keeps every writer out of the payable states; the worklist attest count also excludes is_credit_note. Support case 2026-09-04: a credit note was inserted at 'registered' (the attest entry state) while the detail page rightly had no attest button for it, so Att göra showed "1 leverantörsfaktura att attestera" that nobody could clear; 14 such rows on prod plus one MCP-approved credit note, all backfilled by the migration (one immutable reset-source row skipped, hence NOT VALID). Chose the DB CHECK over fixing only the three insert literals because the invariant is "a credit note is never a payable", not "this literal says registered"; chose 'credited' over 'approved'+approved_at (would surface Markera betald and the Att betala tab) and over 'paid' (the customer side forbids paid credit notes by CHECK); the provider importers already used 'credited'. Also: the GET route now hydrates credited_original with a second scoped query because PostgREST cannot pick a direction for a self-referencing embed hint and returned the one-to-many side (an empty array), rendered as "Krediterar: Ankomst #" with no number.
[2026-09-04] Underlag attach on a folder-picked Fortnox export (Loftux, 50 of 50 files refused with UNDERLAG_REF_MISMATCH): the multipart filename is reduced to its basename at the route boundary (lib/documents/upload-file-name.ts), rather than teaching the voucher-ref parser to strip directories or adding a client-supplied file_name field. Chrome writes webkitRelativePath as the multipart filename for folder selections, so the attach check saw "2026/06/Leverantörsfakturor/A166_x.pdf" while the preview had resolved File.name "A166_x.pdf"; the two endpoints received the same file under two names and the guard compared them. Stripping inside the parser would turn a typed manual ref "2024/01/31" into voucher 31 (the manual box shares the parser), and a second client-supplied name is no more trustworthy than the first, so the boundary is the only level that fixes the class.
[2026-09-04] Bankgirot aggregate double booking (Discord, gecko): the fix generalises the existing 1:1 "already booked" check to a SET of unlinked vouchers (exact öre sum, same account, same direction, ±7 days) and runs it at the two doors that could book the money a second time, instead of blocking "Markera som betald" for bank-connected companies or teaching the batch RPC about vouchers. Rejected: a bank-feed gate on mark-paid (payments outside the feed are legitimate and Fortnox keeps both doors), a name-tokenised text guard (the rows this exists for, "BGGIRERING 03447786", carry no payer at all), and storno-then-rebook as the remedy (the mark-paid vouchers are correct bookkeeping; the truthful representation is one bank row linked to two vouchers, which linkTransactionToVouchers already supports). match-batch now refuses with BATCH_TX_POSSIBLE_DUPLICATE unless force=true echoes the detected ids (same binding as the single door); the dialog links the row to the vouchers through the existing 1:N link. The invoice-side guard gained an aggregate sweep (row = this invoice + an exact subset of other open invoices, ±7 days, kronor only) that runs only when the name sweeps found nothing, so one warning is enough and no extra queries hit the common path. Not folded in: 1:N proposals in the reconciliation auto-matcher and the MCP batch staging (follow-up issues).
[2026-09-04] Sandbox seed recovery (#2292) uses a per-anonymous-user database claim and a completion marker written last. A failed or expired attempt archives its exclusively owned demo company and starts a fresh one, preserving partial posted history for normal sandbox expiry instead of reseeding into it. Legacy demos are adopted only when the final payroll links prove completion. All demo vouchers now use the bookkeeping engine; the posting-integrity guards stay unchanged.
[2026-09-04] Migrated sales invoices without rows (Profilio 384/384, Loftux 311/672, Damac 182/542, Clearstoq 1 125/1 125): completed by an hourly re-runnable pass (extensions/general/arcim-migration/lib/complete-invoice-lines.ts, cron /api/extensions/arcim-migration/complete-invoice-lines/cron) that starts from OUR row-less invoices, joins them to the provider register on number + date, hydrates only that subset and writes rows once the detail total matches the stored total to the öre; the header VAT split is rewritten only when the stored one holds no evidence (null rate, or 0 kr VAT beside subtotal = total). Why not a bigger in-run budget: the largest register (1 911 invoices at Fortnox's platform-wide 4 req/s) does not fit one 300 s function whatever the split, and a budget-bounded one-shot pass leaves whatever it misses missing forever, silently (the wizard never showed the hydration report; it does now). Why not re-running fetchSalesInvoicesHydrated: it sorts the whole register open-first every time, so a second run re-spends its budget on the same invoices and never reaches the rest. Why not reset + re-import or an arithmetic backfill: reset deletes rows that payments and vouchers already point at, and total/1,25 asserts a rate the source never stated (DECISIONS 2026-08-22). The pass reuses mapSalesInvoice, so a row it writes is indistinguishable from a fully hydrated import; it never touches totals, status, payments or any journal entry (momsdeklaration and every report read the ledger).
[2026-09-04] Connector-hop failures (timeout, error envelope, wire-contract mismatch) are transient in every sync path: the row keeps its status and the user message says no renewal is needed, same as AspspUnavailableError (#2202), and the cron now treats AspspUnavailableError the same way instead of parking it in 'error'. Why: on 2026-09-04 the Connect service answered a shape the client rejects and the cron flipped four canary companies to 'error' with SYNC_FAILED_MESSAGE, so users re-authorized consents that were fine. The Zod issues are logged (field paths) because a bare 'unexpected shape' left the failure undiagnosable. Rejected: a new 'degraded' connection status (one more state every filter and the probe would have to learn; the health probe already catches a dead session on the same run) and removing the canary companies from the env (hides the contract bug instead of exposing its field paths).
[2026-09-04] Invoice editor: fakturadatum drives förfallodatum by preserving the payment TERM (days between the pair), not by pinning a due date. The term is re-read off the pair on screen (customer default, loaded draft, or a hand-picked due date), so a manual due date becomes the new term instead of something the next date edit clobbers, and a due date the user edits alone is never moved. Also switched the customer default_payment_terms fill to count from the form's invoice_date rather than today: picking a customer after setting fakturadatum to the 30th gave today + terms instead of the 30th + terms.
[2026-09-05] Gmail consent requests gmail.readonly alone and the mailbox address is read from Gmail's profile endpoint instead of asking for openid+email: Google's restricted-scope review (2026-08-31) bounced the submission on a string mismatch between the authorization URL and the console's Data Access list, and one scope in both places is the only shape that cannot drift. Rejected: declaring openid and userinfo.email in the console as well (two more scopes to justify and demonstrate for an address the granted scope already returns). Added app_metadata.mfa_exempt_until (ISO timestamp, service-role only, honoured only while in the future) so Google's reviewers can log in to the demo account without a second factor, which they treat as an authentication blocker. Time-boxed rather than a boolean after Superagent's P1 on the first shape: an exemption that expires on its own cannot be forgotten on an account. Never set it on a customer account.
[2026-09-05] Fortnox VAT-inclusive invoices (VATIncluded: true) now map their rows net of VAT (lib/providers/fortnox/mapper.ts netOfVat, preferring TotalExcludingVAT / PriceExcludingVAT when the payload carries them), and the migrated-row completion pass refuses a row set whose net or VAT disagrees with the header the same payload established by more than 1 kr (rowsMismatch, reported, never stored). Why: the first production run of the completion pass (#2291) wrote 345 Profilio invoices whose rows summed to the gross with 25 % on top, beside a correct header; the mapper had always read row Total as net, and the pass's only cross-check was the invoice total, which the header satisfied. Rows that contradict their own header are worse than no rows: the invoice page shows both, and for an open invoice the booking engine sums the rows. Rejected: comparing against the stored header (it may itself be the pre-#1745 default) and a wider tolerance (öresavrundning is at most 0.50 kr; the real disagreements are kronor).
[2026-09-05] Fortnox header-level Freight and AdministrationFee become synthetic rows in the sales mapper (lib/providers/fortnox/mapper.ts headerChargeLines), free-text rows (no quantity, no amount) land as line_type 'text' and no longer count as a stated 0 % rate in the migration's VAT resolver. Why: Fortnox keeps both charges outside InvoiceRows while Total and TotalVAT include them, so the rows summed to less than the header by exactly the charge, and after #2302 the rows-versus-header check refused those invoices (Profilio 14 of 384); the *VAT fields are amounts, not rates, and the charge is gross when VATIncluded, all verified on live payloads (invoices 295 and 242) rather than the spec, whose endpoint answered 429 all day. Text rows with VAT 0 beside 25 % rows had made roughly half of the Loftux and Clearstoq registers "mixed" with a null header rate. Rejected: dropping the charge into the first priced row (it is its own line on the customer's invoice, often on 3520) and trusting Net for the header (Net excludes the charges; gross minus TotalVAT is the net the rows must reach).
[2026-09-05] New Gmail consents are withheld on hosted unless the company is on GOOGLE_MAIL_CONNECT_COMPANY_IDS (unset = nobody, `*` = everybody, list = those companies; self-hosted never gated), enforced in the mail extension's /oauth/start and mirrored as connectEnabled on /connections so the connect button and the inbox "Koppla brevlådor" card disappear rather than explain. Why: every consent shows "Google hasn't verified this app" until the restricted-scope review closes, and a prospect (Lumaro AB, 2026-09-05) bounced on it; Jakob chose removal over an in-app explanation. Why an allowlist and not a plain off switch: Google's reviewer must be able to press the button on prod in the demo company, and the recording needs it too. Existing connections keep hunting and can be disconnected. Rejected: flipping the console back to Testing (kills every existing grant after 7 days and Google's email forbids it) and hiding the whole Brevlådor page (the four connected companies still need their list and disconnect).
[2026-09-05] SIE preview now says what the import does to the company's chart (accounts new to THIS company vs already present, planChartChanges in lib/import/account-sync.ts) and shows the fiscal-year verdict (match / create / conflict) from precheckFiscalPeriod, the same read-only check ensureFiscalPeriod now consumes, so preview and import cannot drift. Why: the card counted matches against the BAS reference ("150 mappade" on a 41-account company) and said "matchas mot din kontoplan", which a consultant read as "the file's chart replaces mine"; the fiscal-year overlap was only refused after the mapping step. Rejected: an "import chart of accounts" toggle (the chart already imports unconditionally, a toggle would have no off position); filtering to accounts with nonzero IB/UB/saldo (inactive accounts are harmless chart rows and later re-imports reference them); blocking Continue on a conflict (the import refuses anyway with the same text, and a hard block leaves nothing to do but re-upload; display only, no import logic changed). New wizard strings go through the import namespace in messages/sv.json + en.json even though the surrounding wizard copy is inline Swedish: the Definition of Done asks for both, and the wizard is not on the stays-Swedish list; the conflict text itself is the engine's refusal and stays verbatim.
[2026-09-05] The SIE fiscal-year precheck also carries the BFL 3 kap. shape rules (18 months, mid-month start, month-end finish) as a fourth verdict 'invalid', and the chart plan counts mapped targets only (unmapped sources stay in "Ej mappade"). Why: the skeptic pass on #2307 refuted the first cut twice: "Skapas vid import" was shown for a 19-month #RAR the import then refused, and the chart card kept listing accounts under "Läggs till" after "Skapa saknade konton" while the result said 0 created. Moving the shape rules into the precheck keeps ensureFiscalPeriod a consumer of one verdict (same query order, same texts); counting mapped targets only makes the create button move accounts from "Ej mappade" to "Finns redan" with no recompute. Rejected: recomputing the plan in the browser from the refreshed chart (would need the server-side plan helper in the client bundle, and the arithmetic is one addition).
[2026-09-05] Parties: the register's legal name becomes the displayed name of a party and of its supplier or customer row when those still carry the memo the pipeline read ("Webhallen Oktober" becomes "Webhallen Sverige AB"); an all-capitals registry name is shown in title case with legal forms kept, the fact keeps the register's spelling. A name a person typed on the row is never touched. And a party confirmed under the pre-2026-09-04 ledger key keeps its vouchers through legacyLedgerKey: without it every such party came back as a fresh suggestion after the key change.
[2026-09-05] Provider OAuth returns to the initiating validated app/brand origin through a two-minute provider_otc handoff. State rows and handoffs have disjoint consume predicates, handoff consumption also binds the destination origin, and both success and provider denial require the original user on hop 2. The token exchange keeps the original configured provider redirect URI. Staging prerequisites 20260831111519, 20260902090000 and 20260902100000 were replayed from the existing SQL files before 20260905094806; MCP-assigned history timestamps were reconciled to those repository versions. Production and the live customer Fortnox connect remain pending deployment and specific production-write approval.
[2026-09-05] PR #2305 review: encrypt both OAuth handoff payload columns with AES-256-GCM using a purpose-scoped derivation of the existing server-only service-role secret, matching other extension credential storage. Authenticate the handoff token, consent, initiating user, destination origin and column as additional data; reject plaintext or unreadable payloads after atomic consume. This resolves the at-rest encryption finding without new configuration, dependencies, or edits to the already-applied migration.
[2026-09-05] PR #2305 cleanup review: expire provider_otc rows through a service-role cron every five minutes, including abandoned states and encrypted handoffs whose consents remain. Use the existing cron-auth wrapper and generated hosted/self-hosted schedules; the migration is already applied on staging and remains unchanged.
[2026-09-05] Supplier and customer pages: the register fills the row's own contact fields (e-mail, phone, postal address, VAT number) when they are empty or still carry what the register said last time, marked "från SCB" by equality with the registry fact, never a value a person typed. Chosen over a read-only fallback because the row is what payment files and documents use; provenance by equality instead of a source column because it needs no schema and a person's edit ends it by itself. Företagsuppgifter keeps only what the register alone knows (status line, industry, seat, size). Agents get the party read-only first: ?expand=party on v1 supplier/customer detail, party_id on list rows, and gnubok_get_party in MCP; the parties resource (suggest, promote, enrich) comes as its own v1 surface next.
[2026-09-05] Invoice PDF statutory notices follow the document language: the export notice (stored in Swedish on invoices.reverse_charge_text at create time) is matched against the shared EXPORT_NOTICE_SV constant and rendered from LABELS, so existing invoices are fixed too, and the English footer reads "Approved for F-tax (Godkänd för F-skatt)". Rejected translating at create time (would leave every existing export invoice Swedish) and deriving purely from vat_treatment (would override custom or v1-supplied text). The old Swedish-only F-skatt footer cited Peppol SE-R-005, which governs the UBL file, not the PDF; the swedish-invoice-compliance skill (invoice-rules.md §4) states ML has no language requirement for invoice text, and the literal Swedish phrase stays on the English PDF in parentheses. All notice boxes (proforma, quote, VAT notice, notes) now share one neutral style; custom invoice layouts are a separate feature request.
[2026-09-05] Utlägg becomes an answer, not a page: the Underlag pane asks "Vem betalade?" (Företaget / Jag, privat / En anställd / Ingen ännu) and books a privately paid receipt in place through POST /api/expense-claims; the person owed surfaces as a Betala row in Att göra (lib/worklist expense_payout, one item per person) and the Utlägg nav row is gated on existing claims like Körjournal. Chosen over a fourth item in the Bokföring split button (that menu is three ways to type one verifikat, not a list of document kinds) and over keeping the two-step wizard as the entry point: a kvitto paid with a private card differs from any other purchase only in the credit account, and 93 percent of companies on prod are owner-only, for whom a module for that one bit is the wrong shape. Phase 2 (bank-driven repayment, open items shared with leverantörsfakturor, via lön) and phase 3 (retire the wizard, per-person list under Löner) are filed as follow-ups.
[2026-09-05] Cross-tab company guard (WL-09) stays a blocking two-exit dialog, founder re-confirmed today after a forensic pass on a real firing (a switch made elsewhere under the same login, no server-side or agent path involved): auto-follow, per-tab company scoping and a reads-continue banner were offered and declined. Only change: the dialog now names the company the other tab switched to (resolved from the memberships the shell already ships to the client, no request), so the two exits read as a choice between two named companies instead of a named one and "the new one".
[2026-09-05] Björn Lundén connect: a 403 whose body says "out of allowed scope for service provider" is mapped to its own BL_INTEGRATION_NOT_ACTIVATED verdict (the key is right, the company never activated the integration) instead of the generic "leverantören avvisade autentiseringen"; live-verified against a real customer key, where every read endpoint answered exactly that while a made-up key answered 500. Root cause of every failed BL connect in prod (10 consents, only BL's own sandbox company ever got tokens): the integration is still a sandbox listing at BL, so no real company can activate it. Chose a message that names the fix (activate in Lundify, else SIE) over hiding the provider state; the Lundify activation-redirect flow and document/line-level fetching are filed as follow-ups rather than built blind before BL releases the integration.
[2026-09-05] Utlägg phase 2, repayment from the bank line: create_expense_payout_batch takes an optional bank transaction and, under the same row locks, requires an unbooked SEK outflow equal to the claims' total and stamps it (journal_entry_id, is_business, reconciliation_method) in the transaction that posts the verifikat and flips the claims to paid. The pairing bank line ↔ person is computed at read time from the open claims (exact öre match on one person's total, ambiguous totals skipped), surfaced as a suggested match on Hem and as the inbox row's primary button, instead of a new hint column on transactions: the candidate pool is the handful of open claims, so a column written at ingest would add schema and a stale-pointer class for no saving. Old 6-parameter RPC signature dropped in the same migration so a 6-argument call cannot become ambiguous. "Betala ut" on /expenses stays for companies paying from an account without a feed. "Via lön" and the supplier-form control are split into their own issues rather than stretched into this PR.
[2026-09-06] Utlägg gaps closed on the phase 2 PR: (1) a manual "Matcha mot utlägg" picker in the inbox row menu for outflows the exact-amount pairing missed, offered only while the company has open claims; the sum of picked receipts must equal the row to the öre and the same RPC books it, so a partial receipt can never be marked paid (partial payout inside the RPC was considered and left out: it changes money logic for a rare case, rounding a transfer up is not solved). (2) A foreign receipt (currency other than SEK) defaults VAT to 0 in the Underlag dialog with a note: foreign VAT is not deductible on 2641, and the wizard's seller-country step is gone from this path. (3) Enskild firma: a claim on 2018 is egen insättning, not a debt; it is excluded from Att göra, the attention resource, suggestions and the picker, and a payout for it debits 2013 (eget uttag) instead of 2018.
[2026-09-05] SIE precheck refuses a closed or locked containing year up front (conflict verdict with the remedy: Öppna igen / Lås upp) instead of letting the voucher RPC fail with the trigger text; the årsredovisning warns when the comparison year has no entries instead of deriving BR comparatives from the IB voucher: derivation would hide that the RR comparatives are still unknown, and manual/IB comparatives after a migration are a product decision (follow-up issue).
[2026-09-06] Fiscal-year edit dialog (#2287, PR #2337) lets the name follow typed dates only while the existing name has fiscalYearName()'s shape (isDerivedFiscalYearName), not only when it equals the name for the current dates: the customer row's "Räkenskapsår 2027" never matched its dates and would otherwise have stayed wrong after the dates were fixed; a hand-written name is never overwritten. FiscalPeriodEditor (Settings > Företag, first year only, i18n-keyed name that writes "Fiscal year N" in the EN locale) is now a strict subset of Ändra and was left in place as a founder call because removing it is user-visible.
[2026-09-06] Article company-scope for invoice lines (#2059 part 2, PR #2339) enforced once in buildInvoiceWriteData (covers cookie, v1, webshop, sales-order and MCP-update writers) rather than as a DB constraint: invoice_items has no company_id and articles no UNIQUE (id, company_id), so a composite FK needs a denormalized column + backfill + sync trigger and a constraint trigger cannot be validated against prod rows the session could not query; DB enforcement proposed as option B for after Part 1 rebases commitCreateInvoice onto the builder.
[2026-09-06] Migrated invoice rows (#2313, PR #2340) are written only through complete_invoice_rows (FOR UPDATE on the invoice, insert iff no rows, header in the same transaction), shared by the completion pass and the wizard; the unique index on invoice_items (invoice_id, sort_order) is deferred: it needs a renumbering of legacy rows under posted invoices first and is the weaker invariant anyway (two writers with different sort_order sets both pass it while still doubling the invoice).
[2026-09-06] Completion cron (#2309, PR #2341) sizes registers with a PostgREST embed-null HEAD count (invoice_items=is.null) and orders smallest first, no stored state: the pass's own candidate load is a full register read, not a count; the new grammar is proven by a tool-pg test because a count that silently returned 0 would switch the cron off; a persisted fairness counter for the reverse-starvation case was rejected as state for a regime prod is not in (57 consents in 60 days), the deferred list in every run summary is the signal instead. The phantom-column schema guard now recognises the embed-null anti-join (is on a name the same chain embedded) instead of an allowlist entry.
[2026-09-06] Guided/manual SIE mapping step (#2212, PR #2342) renders "created on import" as the target for any in-range source account the chart lacks (incl. nameless ones), instead of adding source accounts to the target list or blocking: the import engine already created them correctly; the defect was the step hiding it, and the shared-component fix covers both wizards.
[2026-09-06] Assistant surfaces (#2204, PR #2343) gate on one client flag (CompanyContext.assistantAvailable from getAiStatus) and hide, the FAB falls back to general.help, no global disable switch: the capability flag already says "cannot run", a second switch is a state nobody asked for, and a disabled control the user cannot enable is noise on a self-host.
[2026-09-06] Migrated-paid invoices (#2213, PR #2344) are detected from data (settled status, zero invoice_payments rows, no posted invoice_paid/invoice_cash_payment voucher keyed on the invoice), not a provenance column: every Accounted settlement path writes the sub-ledger row fail-closed, so the state itself is the marker; external_invoice_number is the self-billing field, not a migration marker.
[2026-09-06] Duplicate-payment needle (#2299, PR #2345) = first non-legal-form token of >= 2 chars (not >= 3 as the issue proposed), one .or() probe on merchant_name+description, one detector shared by supplier and customer mark-paid: two-letter first tokens are initialisms banks keep verbatim (SJ, 3M, DB), and the alnum-only needle is what makes the .or() DSL interpolation safe.
[2026-09-06] MCP already-explained guard (#2294, PR #2346) refuses to stage instead of staging an op with a blocker in its preview: a staged-but-blocked op could still be approved, would auto-reject at commit and would pollute /pending; a coded refusal carrying the voucher set, the link call and the exact force binding gives the agent the same steer with no new op state, and commit re-runs the same shared guard as the last gate.
[2026-09-06] Invoice-to-verifikat links (#2298, PR #2347) are resolved by readers from the invoice side (invoices.journal_entry_id / invoice_payments.journal_entry_id) instead of being written onto the posted entry at link time: writing source_type/source_id to a posted verifikat is outside the rättelse paths (Hard Rule 1) and would erase the SIE-import provenance, so the missing-underlag RPCs, their TS mirrors and the periodisk sammanställning all follow the link; the PS report also stopped filtering on source_type, which had silently dropped kontantmetod (invoice_cash_payment) sales.
[2026-09-06] Recurring invoice month phase (yearly in February, quarterly Feb/May/Aug/Nov) is exposed as a first/next invoice date (start_date on create, next_run_date on update; web dialog + MCP), not the reporter's "months offset" dropdown: an offset is a derived value relative to now that changes meaning when the interval changes, while the date maps one-to-one onto the next_run_date column that already anchors the phase, so no migration and no per-interval range rules. A date off the day_of_month grid is refused (400) instead of normalized, because the cron advances from the due date and an off-grid first run would drift back to day_of_month on the second run.
[2026-09-06] Voucher series names live on the existing Verifikationsserier list in settings, not a separate group: the list already enumerates the letters in use, and a name belongs next to the letter it names. Rows are the union of used, configured and named letters so a freshly assigned series can be named before its first verifikat.
[2026-09-06] Declined the request to import only SIE accounts with IB, UB or saldo <> 0: an inactive account is a harmless row in the chart, and dropping accounts breaks re-imports of later years that reference them. The chart imports whole.
[2026-09-06] #2062 WhatsApp residuals: a send that THREW (timeout) rolls the company question back instead of resending as numbered text, because Meta may already have delivered it; only an HTTP rejection falls back. The two drains got the 30-day media cutoff plus a one-line M20 notice, and a code whose lookup failed gets a neutral M21 retry instead of M2: both are new bot copy written without a founder sign-off, so the wording is his to change, but silent expiry was the exact destroy-work pattern the #1992 review named.
[2026-09-06] Issue #2057 (pre-#1990 stranded rows, is_business = true with no verifikat anchor, invisible in Att bokfora): repaired through a service_role-only RPC repair_stranded_transactions(p_company_id, p_dry_run, p_skip_locked, p_actor) driven by scripts/repair-stranded-categorized-transactions.ts, not a script-side UPDATE and not a worklist predicate change. PostgREST cannot re-assert the three NOT EXISTS anchor legs inside an UPDATE, so a select-then-update script could clobber a row booked in between; the RPC re-checks is_transaction_booked() in the same statement and writes one BankTransactionStrandedRepaired behandlingshistorik event per row in the same transaction. Widening the att-bokfora predicate to "not booked" was rejected: is_business IS NULL is load-bearing (lockPeriod guard, badges, idx_transactions_company_unbooked, RPCs, MCP), the 2026-08-27 decision chose fail-closed over a fourth state, and a predicate change would resurface every company's rows at once with no per-company decision and no audit record. The write is dry-run by default, needs one company id plus an actor, skips sandbox companies unless asked, and skips rows in locked or closed periods unless asked (Swedish review on PR #2350: a row returned to Att bokfora there cannot be booked in place, BFL 5 kap 5 §, so reopening it for triage is an explicit choice). The prod run waits for the founder's go per company.
[2026-09-06] reverseEntry releases the bank rows of a reversed verifikat through one RPC, release_reversed_entry_transactions (migration 20260906172540): a single data-modifying CTE nulls the pointer column (journal_entry_id, is_business, category, reconciliation_method) of every transaction that pointed at the reversed entry AND drops those rows' transaction_voucher_links to other verifikat (a residual booking's role 'other' anchor to the small residual verifikat, #2061, Option B of the issue). Chosen over teaching each role-blind gate (bulk_book_transactions' junction EXISTS, fetchJunctionLinkedTxIds behind the unmatched list, is_transaction_booked(), the reconciliation bridge) the bank_line-only rule: that forks the meaning of "booked" per surface and keeps counting a row as matched while the ledger explains a few kronor of it. First cut was two PostgREST statements (read ids, reset, delete); CodeRabbit's point that a failed read or a link created between the reset and the delete recreates the half-anchored row was right, so the reset and the drop moved into one statement under the UPDATE's row locks. SECURITY INVOKER so RLS and the writer-role trigger apply exactly as to the direct statements. Links to the reversed entry itself stay with the engine's junction cleanup (bulk-book N=1 writes a pointer and a bank_line row to the same entry). The residual verifikat stays posted and surfaces as unmatched, which is honest because its main sibling is gone. The 2026-08-29 bank_line-only re-booking guards stay as defense for rows left behind earlier; measured 2026-09-06 on prod, zero non-bank_line junction rows exist, so nothing needs repair. transaction_voucher_links is a mutable reconciliation index (ON DELETE CASCADE from both sides, plain member delete policy, koppla-bort deletes it freely), not rakenskapsinformation, so a hard delete does not touch the BFL audit chain.
[2026-09-06] Invoice-list status at a glance (#2215) built as settled rows receding to muted text, not the requested sage/ochre/terracotta row tints: tints reverse the 2026-08-03 "not row tints" entry and convention 12 (status colours are data, not chrome), paint the majority state across the Alla view, and stack with the hover/selection tints; muted-foreground gives the open-vs-done split for free in every theme, and the overdue chip stays the one marker on its row. Tone maps through a single Record in the page so a tint or left-edge bar is a three-string swap if the founder prefers colour.
[2026-09-06] Periodisk sammanställning attributes a storno or correction to the invoice explaining the entry it cancels or replaces by following reverses_id / correction_of_id in a new composed resolver (getInvoicesExplainingJournalEntries), not by trusting the storno's copied source_id or adding storno/correction to the engine set as #2351 proposed: correctEntry() copies no source_id onto its storno or correction, and a storno's copied source_id is polymorphic (a bank row on a bank booking), so only the link columns cover both writers; the walk is kept out of getInvoiceReferencesForJournalEntries because that is the exact TS mirror of the RPC customer arm and storno/correction are not doc-requiring source types, so the underlag surfaces have no such hole and would otherwise change verdicts outside the issue.
[2026-09-06] Org-number autofill on the customer/supplier forms is automatic (once per distinct valid legal-person number, silent on 503/failure) over a read-only GET /api/parties/registry, not a button and not the per-party enrich route: SCB's API is free and the hook bounds calls; the enrich route needs a party that does not exist yet; a personnummer is refused on both sides.
[2026-09-06] Inlagd i banken (#2220) is a nullable supplier_invoices.bank_entered_at cleared by a trigger when a payment lands, not a file-less supplier_payment_batch: the batch is an immutable pain.001 snapshot (NOT NULL debtor/payee/reference, RPC refuses suppliers without payee and companies without IBAN), so a manual mark would have to fake every field the file needs and put one file-less batch per tick on Betalfiler.
[2026-09-06] InvoiceRowsCompleted is emitted by one core wrapper (lib/invoices/complete-invoice-rows.ts) on a new Invoice aggregate, not inside the RPC and not as a per-run blob: no processing_history writer lives in SQL (one row shape and PII guard in append.ts), per-invoice before/after is what #2291 asked for, and invoice/provider numbers stay out of the payload because the personnummer guard drops ten-digit ones.
[2026-09-06] Standard verifikationsserier (#2184) use the Fortnox preset letters (B/C/D/E/H/I/K/L/M) rather than the issue's list, apply only to new company_settings rows via column default, and reach existing companies through a fill-then-save action in settings: the presets already name those letters and keep Fortnox imports in one series, and a mid-year remap by migration would start new numbering with no actor in behandlingshistoriken (BFNAR 2013:2 p. 9.16).
[2026-09-06] Covering-set proposals (#2293, PR #2359) are computed at read time in listAccountItems through the batch form of the guard's own detector (detectExplainingVoucherSets), never persisted or auto-applied, and confirmed per row through the existing 1:N links door rather than added to runReconciliation's dry run as the issue proposed: the dry-run result and potential_journal_entry_id are 1:1-shaped, the bridge table is the surface that steers to Bokför, and one definition of "explained by the ledger" at the doors and in the view beats a lookalike search over the RPC's narrower notion of unlinked (it lacks the payment-row anchor).
[2026-09-06] Bundled SKV ROT/RUT payout books ONE voucher (one 1513 leg per begäran) and the set is suggested at read time with no hint column: one bank row = one verifikat (match-batch precedent) and a uuid[] hint would need six clear paths and go stale; N vouchers + the 1:N reconciliation split was rejected because its half-failure state has no UI exit, and begäran, not the invoice, is the unit under fakturamodellen.
[2026-09-06] Utlägg via lön settles claims with an idempotent RPC after the salary verifikat is posted (pre-checked before posting), not with a trigger on salary_runs -> booked: a raise inside that trigger after the entries exist would leave a paid run with posted verifikat and a retry would double-post; the RPC path fails to "booked, claims still open, re-runnable".
[2026-09-06] A privately paid supplier invoice is booked through registerExpenseClaim (verifikat + expense_claims row, source_type expense_claim) with the invoice's kontering as custom lines, and a person-paid inbox document goes to the core route with inbox_item_id instead of the extension's convert endpoint: the form's switch, the second entry generator and the convert bypass were three write paths for one fact, so one writer wins over adding a claims insert beside the old generator (the issue's shape) or copying the branch into the convert handler.
[2026-09-07] Skatteverket OAuth moved from six per-company extension_data keys to one oauth_flows row per flow (migration 20260907120000) with a two-hop brand handoff modelled on PR #2305: hop 1 on the registered callback host consumes the state and 302s an encrypted handoff to the recorded app/brand origin, hop 2 there requires the initiating user's session before the exchange. The hosted no-session exception from PR #2155 is gone (it made the initiator check a no-op on every hosted connect). Rejected: extending provider_otc (its consent FK is NOT NULL and points at migration consents) and a purge cron (the row set is tiny; /authorize sweeps expired rows). In-flight connects at deploy time fail with a state error and are restarted; the legacy cookie-user and optional-PKCE fallbacks were deleted with them.
[2026-09-07] WooCommerce activation gate: the wc-auth callback now only STAGES verified keys on the pending row; activation additionally requires browser_confirmed_at from the session-bound return leg, enforced by a DB CHECK (20260907143000) rather than an application guard, so 'active implies keys plus a recorded confirmation' holds by construction and a connection can never go live headless. Skeptic review established the limit of that guarantee and it is recorded here on purpose: the second signal comes from the INITIATING user, so it does not stop a store admin from approving a link someone else generated (wc-auth delivers keys server-to-server and identifies no approver); that needs a proof of store control from the initiator, tracked as a follow-up. Rejected the proposed separate pending-attempt table and handshake token: the pending row and oauth_state already are that state. Expiry: 15-minute TTL on the return leg plus a sweep at the start of the nightly orders cron; NOT on the callback leg, because WooCommerce treats any non-200 as a failed handshake (deletes the key, store-side error page, no redirect) and would strand slow approvals. Prod audit before the migration found no active row still carrying a state; one hand-inserted demo row (active, no keys, sync off) is parked as error by the migration because it could never sync. Returning the browser to the initiating brand origin is a separate change.
[2026-09-07] Björn Lundén connect: Lundify activation redirect reuses the OAuth state row (provider_otc) and the callback, with the User-Key arriving as publicKey and the state as extra; the manual User-Key field stays as a folded fallback: BL issued the activation key on 2026-09-07, the redirect removes the GUID copy-paste that failed every real customer, and reusing the atomic state consumption plus initiator binding keeps the same tenant boundary as Fortnox/Visma instead of a second callback with its own checks. BL's ON/OFF backend callback and settings URL are not built: they need a registered URL at BL and an orgNr-to-company mapping, filed as follow-up.
[2026-09-07] Auth-link hosts resolve against the brands table only; NEXT_PUBLIC_WHITELABEL_DOMAINS removed (supersedes 2026-08-18): the env var was a second copy of brands.domain compiled into the browser, so every brand needed four registrations (row, env var, GoTrue allowlist, redeploy) and two partners shipped with it stale (canonical-branded reset mails). Password reset moved to POST /api/auth/password-reset so the server resolves the host; invite, email change and signup share the same resolver, which also trusts this deployment's own VERCEL_URL/VERCEL_BRANCH_URL so previews keep working. A drift check between the copies was rejected: it would be a fifth thing to maintain. GoTrue's redirect allowlist stays as the backstop; hosted carries the wildcards https://*.accounted.se/auth/callback** and https://*.accounted.se/invite/** there (config, not code; GoTrue matches the full URL with query, and * stops at . and /) so only bring-your-own-domain partners need a manual entry. A failed brands lookup refuses with 503 (BrandLookupFailedError) instead of a canonical fallback: a canonical link is a wrong-brand mail for a white-label user, which is the bug this replaces.
[2026-09-07] Draft stamp moved to the page margin (absolute + fixed) instead of the reporter's position:fixed corner badge: the 40pt top margin is the only place that is guaranteed empty on every page, and the stamp must not overlap the header title on the right.
[2026-09-07] Hyphenation disabled per Text node in the invoice template, not via a global Font.registerHyphenationCallback: the global hook would also change line breaking in årsredovisning, payslips and every report PDF; that is a separate decision.
[2026-09-07] Invoice PDF word wrapping keeps a word whole whenever an ink-width estimate says it fits its column (ordinary Swedish compounds of 17 to 25 characters always do); only a wider token (URL, e-mail, reference) gets break points, after separators and where the column is full, with react-pdf's hyphen at the break (a break after "/" prints "/-"; unavoidable in textkit, accepted over a dropped token). A flat character cap was tried first and rejected: it split ordinary compounds at arbitrary positions. Free-text rows and notes are kept on one page only while a rendered-line estimate (chunks counted, 12 lines max, safe for any font) says they fit; past that they may split, because a non-splittable block taller than a page is clipped silently.
[2026-09-07] Line breaks in line descriptions are collapsed by every single-line consumer (Peppol cbc:Name, accrual voucher text) and additionally in the SIE writer's quoted-text escaper: SIE is one record per line by spec, so the writer guards the format regardless of where the text came from.
[2026-09-07] Enable Banking callbacks return to the initiating white-label host by recording the allowlisted request origin on the pending row and replaying the callback there, instead of extending the provider_otc handoff from PR #2305: the brand host already holds the session, so a /login?next=<callback> bounce on that host forwards straight back into the callback with cookies, needing one nullable column and no encrypted payload, no second table and no cron. The provider redirect URI stays canonical, so nothing changes in the Enable Banking console. Stripe, Gmail and cloud backup have the same shape but no partner-domain users yet; tracked as a follow-up issue rather than built speculatively.
[2026-09-07] Stripe checkout and portal return URLs resolve through the existing resolveRequestAppOrigin allowlist (NEXT_PUBLIC_WHITELABEL_DOMAINS), not a DB brand lookup: it is the same trust boundary invites and email-change links already use, so one allowlist governs every host we redirect a browser to. Return paths stay fixed literals; no caller-supplied URL is accepted. Session-expiry and company-switch handling were left alone: the middleware already bounces to /login on the same host with the path preserved, and the webhook keys on company_id metadata.
[2026-09-07] Shell v2 = full-bleed frame with the page title in a top bar (founder, after the Kick-layout prototype 8937d889): the centered max-w-5xl column and the 24/32 serif H1 are retired once v2 is default. Shipped as a per-user opt-in (ui_state.shell) rendered as data-shell on #main-content and CSS on PageHeader, so v1 pages stay byte-identical and no page needs editing to join.
[2026-09-07] Home becomes one queue (founder): Att göra on lib/worklist + pending_operations, and ingest creates the proposal so every row arrives with a suggestion. Object pages become registers.
[2026-09-07] Autopilot is allowed as an opt-in (founder): a rule may book on its own after five hits without a correction, if the company has switched autopilot on. Proposals-only (43386b48) stays the default; the autonomy envelope gets a rule-driven tier.
[2026-09-07] One rules model (founder): categorization_templates, booking_template_library and counterparty templates merge into one rules table with provenance, hit and correction counters and a mode (proposed / propose / auto / paused). Users see one word: Regler.
[2026-09-07] Receipt chasing (card-to-person, WhatsApp/mail asks) is deferred (founder): keep the design, do not build it in the shell work.
[2026-09-07] Supplier invoice lifecycle gets "in payment file" and "reconciled" states (founder), designed so that in-system payment initiation can replace the bank file step later without a second state model. Inköp stays one intake for receipts, invoices and files from every source.
[2026-09-07] Cheap removals approved (founder): the defer-booking toggle, the Ny verifikation sub-doors, and hiding supplier-invoice attest for kontantmetod companies.
[2026-09-07] Avstämning v2 redesign folded into UI v2 PR 8 (registers) instead of a tenth stacked PR: the cutover PR's base would have had to move, and the Konton table it sits next to is in the same PR.
[2026-09-07] WooCommerce wc-auth return_url is built on the initiating request's trusted origin (resolveRequestAppOrigin, brands-table validated, canonical on unknown host or lookup failure) while callback_url stays on the canonical app URL: sessions are per domain, so a white-label user returned to the canonical host hit the initiator check with no session and a foreign-branded login. No stored origin column and no provider_otc handoff (PR #2305 pattern) needed, because unlike a registered OAuth redirect URI the wc-auth return_url is free-form per handshake and can simply point at the brand host; the return route resolves its redirect base from the host it was reached on the same way.
[2026-09-07] PR #2373 review: the Skatteverket callback now peeks the flow row for its initiator and binds the session BEFORE consuming state or handoff (Superagent P2: a DELETE-before-auth let a signed-out or wrong-user arrival burn a live consent). A session-less arrival is sent to /login on the initiating origin and resumes into the same URL; a wrong user is refused with the row left claimable. This reverses the earlier 'no login resume' decision for this flow; the consume stays the atomic gate, the peek only decides who may attempt it. Handoff TTL raised from two to five minutes to fit a sign-in.
[2026-09-07] BankID confirmation mail and the Send Email hook resolve their link host through lib/domains/trusted-app-origin instead of the raw forwarded host / GoTrue's redirect_to: the BankID mail is the one auth link GoTrue's redirect allowlist never sees (built here, sent via Resend), and the hook's signature proves the sender, not the destination, while the GoTrue allowlist is a hand-configured glob. Unknown, lookalike, credential-bearing, non-default-port and malformed destinations collapse to the canonical /auth/callback with no next path; a registered brand host over http is upgraded to https. Brand sender identity is resolved from the RESOLVED host so mail branding and link destination always agree. A brands-table read failure refuses (BankID: step resolve_origin, signup rolls back; hook: 500 so Supabase retries) rather than mailing a canonical link to a white-label user. Dropped from the audit's plan 7 as already in place after #2376: signup route, HTTPS enforcement, credential/port checks, recovery/invite/email-change coverage.
[2026-09-07] Booking flow anchors on the underlag (founder-approved): the review shows the document from either door (pinned document_id, else the inbox item matched to the row), states the moms it carries and books that figure by default when it disagrees with the rate by more than 1 kr (ML 8 kap. 17 §, the deduction follows the invoice). Only rate-based treatments are overridden; counterparty patterns and reverse charge keep their lines. Preview, Ändra rader and the server share one adjustment (applyVatAmountToLines / applyVatAmountOverride) so the review never shows a figure it does not book.
[2026-09-07] Missing-underlag prompt threshold = 500 kr (UNDERLAG_PROMPT_THRESHOLD_SEK): every verifikat needs an underlag by law, but prompting before every coffee is what makes people stop booking; above the threshold the review says "Inget underlag" with "Hämta underlag" and the button reads "Bokför utan underlag". A blocking gate was rejected: booking now and completing later is the honest path, not a refusal.
[2026-09-09] Skatteverket reconnect line on Transaktioner only under the Skatteverket source filter (reverses part of 2026-08-25): the Skattekonto page carries the reconnect notice itself, and a line on every bank-list visit read as nagging.
[2026-09-09] One template picker, one vocabulary: the transactions TemplatePicker is the only picker (bank row, supplier invoice line, Ny verifikation, Bokför direkt, Bokför från mall) and library templates are filed into the catalog's families by account (template-groups.ts); the booking_template_library.category column stays as a derived machine field (vat routes the series) and is no longer chosen by hand: founder call, the second vocabulary (EU-handel/Skattekonto/Lön) read as a different product.
[2026-09-09] Inköp lands on Leverantörsfakturor and /purchases redirects there: the v2 landing page with two flow strips said less than the invoice list itself (founder: "maybe this page is not needed at all"); PurchasesFlow stays in the tree for the strips' next home, if any.
[2026-09-10] Granska bokföring opens with one recommendation header (pick, why, Byt) and the assistant as one line under it, not a separate box: the AI block was hidden exactly when a template was set, so the person got a preview with no reason; a rule or a learned counterpart is never overridden by the model, and with a template the assistant's alternative opens the picker on that account instead of setting an override the booking never read.
[2026-09-10] Stripe fees book under reverse charge (template payment_fees_eu, 6570 + ruta 21/30/48) and no longer as VAT-free bank fees: the catalog keyword 'stripe' contradicted the Stripe extension's own payout booking (payouts.ts); Klarna, Zettle, Swish, Nets stay on bank_fees since their VAT status was not verified. Intern representation moved from 7622 (Sjuk- och hälsovård, ej avdragsgill) to 7631 with reduced_12 VAT, the 2017 rules in the note.
[2026-09-10] The assistant reads unbooked rows ahead of the person (transaction_assistant_reads + ten-minute cron) and known rows book from the row's Bokför with Ångra (rule; counterpart seen >= 3; assistant >= 0.8 with a receipt): founder chose both after the review header showed the read arriving ten seconds after the dialog. Undo stays the storno path the toast already had; a delayed commit was rejected because a closed tab would silently drop bookings. Klarna/Zettle/Swish stay on the review since only Stripe's treatment was verified.
[2026-09-10] Mapping-rule templates that did not match the row are 'recent' (picker only, confidence 0.1), no longer 'rule' at 0.85: with row chips in v2 the old boost made every expense row wear the last rule's template and would have let direct booking post it. A row without a match shows Välj kategori instead.
[2026-09-10] One booking proposal (lib/bookkeeping/proposal.ts) for every source and surface, with the booking field as one of the three API shapes: founder chose simplicity and accuracy after the review's per-branch state booked 5890 under a 6071 header. Category suggestions and merchant history dropped from the UI and the assistant's slate (counterpart rules carry that memory); the MCP suggest tool returns the same proposals with categorize_args so Claude books what a person sees. Multi-line counterpart patterns stay app-only for agents (categorize_args null) until the MCP categorize tool accepts a counterparty rule id.
[2026-09-07] ROT/RUT avslag reopens the original invoice instead of issuing a new one: the refused share is not new revenue but a fordran that moves from Skatteverket (1513) to the buyer (1510), so a new invoice would book revenue and moms a second time; the reopen keeps one fordran per job and the payment matcher works unchanged. The invoice document is left as issued (deduction_total untouched) and the refused share lives in a new invoices.deduction_reclaimed_total column; the customer share formula in customer-share.ts and its SQL twin gained the same term. A multi-invoice beslut recorded as one total (PATCH partially_paid) is refused with SPLIT_UNKNOWN rather than split pro rata: the split is Skatteverket's, the beslutsfil carries it.
[2026-09-07] ROT/RUT overview page (/invoices/rot-rut) instead of growing the file dialog: begäran, beslut import, utbetalning and nekat belopp are one lifecycle and the dialog was action-only. The 31 January begäran deadline stays where it is (tax deadlines + file-time notice); no second reminder surface. A company default RUT work type was dropped from the plan: articles already carry housework_type, and the invoice editor already falls back to the customer's stored personnummer.
[2026-09-07] MCP: gnubok_list_rot_rut_payout_requests ships search-only (READ, bridged by gnubok_call_tool); gnubok_settle_rot_rut_payout stays in the default catalog as a WRITE and the tools/list ceiling moved 60K to 60.5K (measured 60 428) rather than demoting a read without usage data. The dashboard match route and the MCP commit executor share one pre-flight + settle in lib/invoices/rot-rut-match-transaction.ts. No MCP reclaim tool yet: the reclaim is a dashboard action until an agent asks for it.
[2026-09-07] Klarmarkera (markPeriodClosedExternally) now asks whether the period has any lines on result accounts (BAS class 3-8) instead of whether it has any non-imported verifikat at all. The old test was a proxy for the thing the guard protects (3xxx-8xxx transferred by a bokslutsverifikat, BFL 5-6 kap) and it shut out the migrated first year whose only native voucher re-keys the opening balance (1930/2081 aktiekapital) after a failed SIE import; with the next year's IB already imported the normal year-end refused too (NEXT_PERIOD_HAS_IB), so the year had no closing path at all (EHAL AB support case 2026-09-07). Existence check via id-only entry fetch plus per-chunk head counts with early exit, no line fetch, no journal_entries!inner embed. Same session: the loaded årsredovisning view got the FyPicker in its header, because the no-period branch auto-jumps to the remembered scope before its own picker is ever seen, which left users with no way to reach another year's årsredovisning.
[2026-09-07] Klarmarkera's new balance-sheet-only leg additionally requires the NEXT period to already carry IB (opening_balance_entry_id set). Without that, a natively bookkept year with only balance-sheet vouchers (dormant AB with just the aktiekapital deposit) could be klarmarkerad and its balances would never reach the next year, since klarmarkera skips the IB posting the normal year-end does. With the IB in place the normal year-end refuses (NEXT_PERIOD_HAS_IB) and klarmarkera is the only path; without it the normal year-end works and is the right path. Raised by the swedish-compliance bot on PR #2393 (its suggested alternative, a dedicated IB-correction tag on entries, was rejected: it adds a concept for the user to know about where the ledger already answers the question). Same round: the result-account range is bounded to class 3-8 (lt '9'), class 9 interna poster are not transferred by a bokslut.
[2026-09-08] Enable Banking's bank_transaction_code object ({description, code, sub_code}) is flattened to a string by one helper in the connect-contract file (normalizeBankTransactionCode: code, code/sub_code, else description), applied by both producers (Connect's normalizeBookedTransaction, the ledger's convertTransaction), and the wire schema stays z.string().nullable(). Rejected: widening the contract to string | object and normalizing only in the ledger's sync.ts. It keeps the wire type dishonest, leaves Connect's own label derivation reading an object, and still needs the direct-path fix, because that path had been writing the object's JSON text into transactions.bank_transaction_code for 78 companies since 2026-08-09 while Connect failed the same type lie loudly on every canary sync from 2026-09-03 (Capstone support case, 2026-09-07). The helper lives in the contract despite its "shape, never behaviour" rule because the two repos already mirror that file byte for byte; a copy per producer is the drift that caused the outage. Repair migration rewrites the stored JSON text with the same rule and nothing else (no transaction_method re-derivation).
[2026-09-07] PR #2397 skeptic + review pass: the reclaim is refused while any of its invoices sits in a later live begäran (avslag → new file is Skatteverket's retry; booking the refused share onto the customer meanwhile would clear 1513 twice), a reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED) until the reclaim voucher is reversed, and a storno of the reclaim voucher syncs the invoices and the begäran back (lib/invoices/rot-rut-reclaim-reversal.ts, hooked into reverseEntry next to the payment sync). Per-invoice reopen goes through one idempotent RPC (apply_rot_rut_reclaim_invoice, item marker + invoice row in one transaction) so a failure after the voucher is resumable instead of stuck at ALREADY_DONE. Crediting an invoice with a reclaimed share is refused (reverse the reclaim first): the credit note reverses the issue-time 1510/1513 split. Declined: CONCURRENTLY for the partial unique index (Supabase migrations run in one transaction; same shape as the shipped rot_rut_payout index in 20260904021000, partial predicate on a source_type that few rows match).
[2026-09-08] PR #2397 review cycle 3: the reclaim RPCs own the accounting values. apply_rot_rut_reclaim_invoice takes only the refused share and validates it against the locked item, request and invoice (never above the item's requested amount, the beslut's refused total, or the 1513 headroom), then derives remaining_amount and status from the same formula as the INSERT guard (rot_rut_customer_outstanding); revert_rot_rut_reclaim_invoice mirrors it for a reversed reclaim voucher and the request link is cleared only after every leg succeeded. Reason: a SECURITY INVOKER function that accepted caller-supplied remaining/status was an unchecked accounting write for any writer-role member (CWE-862). The 20260907160300 signature is dropped in 20260907160400 rather than edited: the preview branch had already applied it.
[2026-09-08] Ideell förening (#2072 step 1) lands as a third EntityType only after every form-dependent fact was routed through lib/company/entity-type.ts (byEntityType with Record arms, resolveCompanyEntityType instead of `?? 'enskild_firma'` / `?? 'aktiebolag'`): widening the union alone compiled everywhere and changed nothing, so a förening would have booked as EF in the app and as AB in bokslut and MCP. 28 silent-default sites replaced; the only compile error the widening produced was one Record<EntityType>.
[2026-09-08] Förening domain facts, chosen from the BAS data in the repo since the compliance skill has no förening chapter (verify with an accountant before Phase 2): result closes to 2069 and the year-open omföring carries it to 2068 (the same result-appropriation service as AB 2099/2098, generalised on resultClosingAccounts; the skeptic showed 2069 would otherwise accumulate across years); no owner accounts, member settlement on 2890 (EF 2013/2018, AB 2893) in category mapping, booking templates (owner accounts in a template's base column translate to 2890), expense claims and privately paid supplier invoices; helårsmoms deadline follows the räkenskapsår schedule like an AB (SFL 26 kap 33 §); accrual default; brutet räkenskapsår allowed (only EF is calendar-locked, BFL 3 kap 1 §); K1 label for the 5 000 kr accrual threshold (BFNAR 2010:1); org number gets the 16 prefix; personnel accounts auto-created on first payroll as for EF. No INK3, no årsbokslut, no förening packs, no deadlines yet: Phase 2 on the tracker.
[2026-09-08] Ideell förening creation is behind NEXT_PUBLIC_IDEELL_FORENING_ENABLED (one flag, read by the onboarding picker and the shared CompanySetupSchema/actions gate) rather than open from day one: the DB CHECK, RPCs and chart seed accept the value regardless, so the first partner (SS Gambit via Roslagens Webbyrå) can be switched on without a migration and the flag can be dropped once Phase 2 lands. stiftelse deliberately not bundled: identical plumbing, no chart block.
[2026-09-08] Invoice list gets an 'Ej skickade' view and the PDF download on an unissued document asks first (#2399): both reports came from the same hidden state, a finalized invoice with an F-number whose DB status is still 'draft'. Chosen: split the status in the UI (lib/invoices/invoice-list-tabs.ts, one predicate for rows, counts and sections) and gate the download with a soft dialog whose primary action is the existing manual mark-sent (and book) path, then download. Rejected: a real 'issued' status in the DB (touches MCP, v1 API, reports and SIE for a distinction invoice_number already carries); removing the UTKAST stamp from numbered drafts (an unbooked invoice is not issued, the stamp is right, the flow around it was wrong); naming the tab 'Godkända' as the user asked (there is no attest step, so the label would promise one; ?status=godkanda aliases to the view).
[2026-09-08] Supplier org_number identity (#2391): one lenient 10-digit key (orgNumberKey in lib/invariants/org-number.ts, digits only, last 10 of 12, no Luhn) now drives the exact matcher, the extractor's self-invoice guard, the web/v1 schemas and the MCP create tool, plus a backfill that strips formatting from existing rows. Luhn stays lenient on suppliers: two rows with the same mistyped number are one supplier, and parties is Luhn-strict at promotion already. Declined: the unique index on (company_id, org_number) in the same PR, because prod holds 94 duplicate groups under the canonical key (18 companies, one group of 124 rows that looks like a placeholder) that need a merge decision first; follow-up issue instead. Declined: rejecting non-Swedish shapes on write, because 68 prod rows hold foreign registration numbers in the column and eu/non-eu suppliers would become unwritable. parties is the end state for counterparty identity: no further constraints go on suppliers beyond this.
[2026-09-08] #2391 skeptic pass: orgNumberKey only strips hyphens and spaces and only unprefixes 12-digit values behind 16/18/19/20. Reason: 26 prod supplier rows hold a VAT number (orgnr + 01, prefixes 55/52/87) in org_number, and 'last 10 of any 12 digits' would have rewritten them to another company's identity; letters stay because BE0123456789 is not the Swedish 0123456789. The matcher scans live suppliers only (archived_at IS NULL), the list and v1 search compare without separators, the CSV import and the provider migration orchestrator key and write through the same rule.
[2026-09-08] correctEntry re-points the original entry's transaction_voucher_links rows to the corrected entry (lib/core/bookkeeping/storno-service.ts relinkTransactionsToEntry) instead of deleting them as issue #2364 proposed. Why: for a samlingsverifikat (bulk-book N>1) the junction is the row's only anchor, so deleting it would push rows the corrected verifikat still explains back into Att bokföra; the pointer column already follows the correction and the junction now follows it the same way, so every reader (is_transaction_booked, fetchJunctionLinkedTxIds, the bulk_book RPC) sees one live anchor. Rejected: a relink_entry_anchors RPC moving pointer and junction atomically (a migration plus pg test for a path that is already best-effort across five other statements; revisit if a partial failure ever shows up in the surfaced transactionRelinkError). Prod repair (planned, runs after merge on the founder's go; completion gets its own dated entry): the 7 stale links (3 companies) all sit on rows whose pointer names a posted entry (4 on a correction chain, 3 from a June 2026 samlingsverifikat storno that predates the junction cleanup and were re-booked 1:1); they will be re-pointed to the pointer's entry, the same rule the fix applies, rather than deleted.
[2026-09-08] delete_last_voucher returns a correction's bank anchors (transactions.journal_entry_id and transaction_voucher_links rows) to correction_of_id before the row is deleted (migration 20260908095907). Why: the #2364 skeptic showed that once the junction follows the correction, the two-step undo (delete the correction, then the storno) cascaded the links away and restored an original that explains bank rows nobody points at, so the rows surfaced as bookable again; before, the links had stayed on the original by accident. Chosen over releasing the rows (the restored original would still explain them, same trap) and over a TS pre-step in the DELETE route (not atomic with the RPC's own guards: a refused delete would leave anchors on a reversed entry). A duplicate of a link the original already holds is dropped, not re-pointed (UNIQUE (transaction_id, journal_entry_id)).
[2026-09-08] Zettle v1 is paid purchases (and refunds) into webshop_orders via the Purchase API, Shopify-shaped feed-only: partner-hosted OAuth with rotating refresh token (encrypted), unpaid IZETTLE_INVOICE-only purchases skipped, line items + groupedVatAmounts as booking underlag. Finance API payouts/fees deliberately out of scope (phase 2, Stripe settlement style).
[2026-09-08] Zettle orders cron pages candidates (range) and caps entitled syncs at 50 instead of limit(50) before hasCapability: entitlement skips must not consume the batch or advance last_order_synced_at (purchase recovery cursor). Declined a separate cron_checked_at column for now; revisit if scanned non-entitled volume becomes a time-budget problem.
[2026-09-08] #2395 missing-underlag: split the supplier-invoice .or() into two .in() lookups instead of halving LOOKUP_CHUNK or moving the list filters into the verifikat_without_documents RPC: halving only moves the proxy-limit wall; the RPC move deletes the TS mirror (one predicate instead of two) but pulls search, series, date and sort into SQL and is the surface owner's design call. Intended next step, not taken here.
[2026-09-08] Onboarding name search picks via chip row, never the top hit blind: Typesense name ranking is fuzzy and common names ("Bygg AB", sole-trader surnames) make a blind pick a wrong company; five hits, active first, fired on Enter only to protect the 3000/mo TIC budget (issue #2418).
[2026-09-08] #2418 skeptic pass: a name-search hit's org number is derived from the Lens registrationNumber via lensRegistrationToOrgNumber (16-prefixed 12 digits and the 16-digit enskild-firma form, century plus 4-digit serial, reduce to the 10-digit key; hits that do not normalize are dropped) instead of being stored as returned. Why: the typed-orgnr path never stores Lens's number, so this was the first place a 16-digit value reached settings.org_number and createCompany refused it at the last step. Sole-trader chips name the form ("Enskild firma") instead of the number, because that number is the owner's personnummer and the repo masks those everywhere else; the number still travels in the search payload since a pick has to store it.
[2026-09-08] Medelantal anställda (Not 2, ÅRL 5:20 §) gets a whole-number override on arsredovisning_narratives (migration 20260908130127) instead of the free-text note override the support request asked for. Why: the number keeps the statutory sentence and the iXBRL MedelantaletAnstallda fact correct; free text would let a non-compliant note through and could not be tagged. One resolver (lib/salary/medelantal.ts resolveMedelantalAnstallda: override, else FTE average over employees) feeds the K2 and K3 note builders and the iXBRL input, which also reads the previous period's override so the jämförelseår column shows the same figure the previous year's document did. Rejected: rounding 0.5 up globally (silently changes every company's note and does nothing for the 148 of 195 aktiebolag with salary but no employees rows); asking the user to backdate employment_start (fixes one company, misstates the hire date).
[2026-09-08] Issue #2413 BAS 2026 kontogrupp 12: kept 1249/1259/1269 in the catalog renamed after their free heads and dropped only 1241/1242/1251/1261, instead of removing all seven retired sub-accounts and moving the asset module's vehicle/computer defaults to BAS 2026 (1226/1224 on 1229): the asset module's DEFAULT_ACCOUNTS_BY_CATEGORY still books vehicles on 1240/1249 and computers on 1250/1259 (31 live assets in prod, guard test requires the triple in BAS_REFERENCE), so dropping the contra accounts would have forced a depreciation-default change into a label fix; that change is the founder's call and lives in #2414. The prod backfill renames only the exact catalog literal next to a free-labelled head, so old-BAS imports (1240 Bilar + 1249 Ack. avskr. bilar) and user renames stay untouched.
[2026-09-08] Migration files must carry their own BEGIN/COMMIT when they use transaction-only statements (LOCK TABLE, SET LOCAL, SET CONSTRAINTS): CI replays each file with psql -f in autocommit and the Supabase branch runner does the same on prod, so the bare LOCK TABLE in 20260908113353 (#2413, PR #2419) failed both and stalled prod's migration queue behind it. Prod never recorded the failed version, so the file was deleted and re-issued as 20260908120449 rather than edited in place.
[2026-09-08] Issue #2196 Nyckeltal per month: the month table (income, expenses, net) is ON by default and toggled by a boolean preference showMonthlyTable instead of a KPI_DEFINITIONS entry. Why: every stored preferences row already carries a complete kpiOrder array, and KPIPanes renders from that array, so a new definition id would stay hidden for every existing company no matter its defaultVisible; a boolean absent from the stored JSON resolves through mergeWithDefaults on read. The payload has carried income and expenses per month since the RPC (only net was rendered after the Recharts chart was dropped), so no API or migration change. #2185's multi-year view stays separate: it needs several fiscal periods fetched side by side, which this page does not do.
[2026-09-08] #2425 ROT/RUT: the picker's begäran rows keep single-click handoff and gain a checkbox per row for a bundled pick, instead of a new multi-select mode or an ambiguity-tolerant matcher. Why: the server (request_ids, settleRotRutPayoutRequestSet) and the confirm dialog already take N begäran since #2360; only the manual fallback was 1:1, and a firm whose jobs share a price hits the matcher's ambiguity refusal often. Guessing between two same-amount begäran would clear the wrong 1513 fordran, so the choice stays with the user.
[2026-09-08] Issue #2426 ROT/RUT begäran state in the invoice list: normal states (Att begära, Skapad, Uppladdad, Beviljad) render as muted text and only Delvis beviljad and Avslagen get a chip (design convention 5: a fönsterputsfirma where every invoice is ROT/RUT would otherwise carry the same chip on every row); the filter is a third ContextPicker (?rotrut=) gated on rot_rut_enabled or an invoiced deduction, not extra entries in the status view, so a ROT/RUT state can be combined with any status view and companies without ROT/RUT never see it. One predicate (lib/invoices/rot-rut-list-status.ts) feeds the column, the filter and its counts; the state is read from a rot_rut_payout_request_items embed on the existing list query, no new API and no migration.
[2026-09-08] Receipt purchase date: fixed the extraction prompt's description of invoice.invoiceDate (schema comment plus an explicit "invoiceDate on receipts" rule) instead of adding a receipt-specific purchaseDate field. Why: prod (last 30 days) showed receipts losing the date on 46% of items (75% via WhatsApp) while supplier invoices lost it on 0.4%, and purchaseTime was filled on nearly every affected receipt: the date was described as a bare ISO field under the invoice block beside a purchaseTime rule marked "receipts only", and the model read that as "invoice-only". A new field would need a schema change, a UI column and every consumer (WhatsApp M4 ack date, the Bokför entry-date default that falls back to today, the worklist) taught a second date; the prompt fix stops the class with no data-shape change. Existing undated items are not re-extracted by this; a backfill is a separate call.
[2026-09-08] Onboarding act two (#2438) lives at /onboarding/books under app/(dashboard) with a bare journey shell in the dashboard layout, not in the (onboarding) route group: the SIE wizard, the migration workspace and the bank panel all read CompanyProvider and reference data, so hosting them outside the dashboard layout would mean a second company context or a rewrite. The first-session gate is a 24 h cookie (gnubok-books-gate) checked in middleware, not a company_settings column: no hot-path DB read, and a returning user is never trapped by a stale flag. The old branch step ("Jag ser mig omkring först") is removed: 3 of 30 companies that chose it were active after week one.
[2026-09-08] Assistant and earlier räkenskapsår (#2185 part 3): the MCP report tools resolve the fiscal period from a date argument (from_date, to_date, as_of_date) when no period_id is given, instead of defaulting to the most recent period and then rejecting the date; the in-app assistant's snapshot and the chat identity block list the company's räkenskapsår with period_id (lib/agent/fiscal-years.ts) plus one rule on addressing them. No fiscal_year parameter was added to the six report schemas because tools/list sits at its 60 000 token ceiling, and the range guard was kept because it was a deliberate fix for silently dropped ranges.
[2026-09-08] #2187 (a) Att göra row "Betala in till Skatteverket" shows the shortfall (next charge minus the last synced saldo, the full charge only when no snapshot exists) and appears only when something has to be paid in, instead of a deadline-style row for every upcoming charge: a company whose saldo covers the dragning has nothing to do, and the /skattekonto page's Nästa dragning math already defines the number, now computed once in lib/worklist for Hem and /api/worklist/counts. Part (b), a betalfil for the skattekonto payment, stays a follow-up: the existing payment-file route (app/api/skatteverket/tax-payments/[period]/payment-file) is AGI-scoped (amount from agi_declarations, reachable only from a salary run) and needs an amount source before it can pay a general dragning.
[2026-09-08] Issue #2224 offert -> kundorder: the quote source was added to the existing proforma -> order service (lib/sales-orders/convert-to-sales-order.ts) and to gnubok_convert_invoice as a target parameter staged under the existing convert_invoice operation type, instead of a second service, a separate MCP tool and a new pending-operation type. Why: one conversion path and one guard pair (a quote with a live order cannot become an invoice, a quote with a live invoice cannot become an order) keep the proforma and quote flows identical, and reusing the operation type keeps the pending-operations type list untouched. The quote flips to accepted like quote -> invoice does; cancelling it like the proforma path would hide the customer's accepted agreement. Skeptic refutation: for an already-accepted quote that accepted -> accepted compare-and-set cannot detect a concurrent conversion, so the "never twice" guard moved into the database (migration 20260908165000: one live kundorder per source, and two triggers that lock the quote row and refuse a live order beside a live converted invoice and vice versa); the service pre-checks stay for the friendly error. Second refutation: order -> invoice booked a foreign-currency invoice 1:1 when Riksbanken had no rate (pre-existing, but the quote now depends on that path and the fail-closed quote -> invoice route is refused while an order lives), so createInvoiceFromSalesOrder now fails closed with SALES_ORDER_INVOICE_FX_RATE_UNAVAILABLE. Locking the quote decision (open/declined) while a live order exists was left out: the DB trigger only knows about converted invoices and changing it is a migration; a declined quote with a live order is harmless since the order carries the sale.
[2026-09-08] Issue #2224 follow-up from the correctness skeptic: the quote decision (open/declined) is now locked in the database while a live kundorder exists (migration 20260908165100 extends invoices_quote_decision_guard), reversing the earlier call to leave it open; a declined offert behind a confirmed, invoiced order was a contradictory agreement trail and the dashboard hid the re-accept button, so the quote was stuck. The three source and decision guards run as SECURITY DEFINER: a SELECT FOR UPDATE under RLS admits only the caller's active company, so a multi-company member writing for another company through raw PostgREST got no row, no lock and no guard. Both landed as a second migration rather than an edit of 20260908165000, which was already applied to staging under that version.
[2026-09-08] Draft invoice PDF marks a draft with one diagonal, faint word (UTKAST / DRAFT) across every page instead of a banner in the top margin (#2437): a banner reads as UI chrome on a document, a watermark reads as a stamp and leaves the preview pixel-identical to the final print. The long legal sentence (saknar löpnummer, ML 17 kap 24 §) is dropped on purpose: the word alone says the document is not a valid invoice, and the download dialog (#2399) already explains why before the file exists. Rotation and opacity sit on a padded wrapper View so the word turns about its own centre. Skeptic refutation accepted: the first cut (#6b7280 at 0.14, about 92% brightness) would drop out of a monochrome print or greyscale scan, and a numbered draft otherwise prints title, number and OCR like a real faktura; now #4b5563 at 0.3 (about 79% brightness), with a test pinning the composited grey between 70% and 85%. A 1-bit scan can still threshold the word away; a second explicit line on numbered drafts was left out because the request was the word alone, and that residual is Emil's call. Second refutation accepted: the overlay is emitted as the LAST child of the Page, because react-pdf paints in document order and `fixed` does not hoist, so an overlay emitted first was painted under the opaque payment and customer boxes and the word vanished on the page that carries totals and OCR; a test now inflates the PDF content streams and asserts the glyph run comes after the last rectangle fill on every page. BETALD and MAKULERAD banners are left as they are.
[2026-09-08] Negative journal-line amounts: fixed the sign at three levels (producers flip the SIDE via lib/bookkeeping/line-side.ts, the engine refuses negative amounts before any write, and a NOT VALID CHECK on journal_entry_lines) instead of only patching the supplier-invoice generator or hiding negative items in the form. Why: the invariant lived nowhere (no Zod rule, no engine check, no constraint), so MCP, templates and any future producer could repeat it; negative items themselves are valid input (rabatt, öresavrundning), so rejecting them at input would break real invoices. reverseEntry now swaps on the net so legacy negative lines storno cleanly before the data repair runs.
[2026-09-08] Björn Lundén migration killed at the function ceiling (Well Done Accounting, 9 415 customer invoices): fixed at four levels instead of only raising maxDuration. (1) BL batch pages are 1 000 rows (production-verified: `rows` honored up to 10 000, 1.4 s a page) instead of 50, so the register is 10 requests, not 189; (2) the invoice steps list first, drop what is already in the database, and hydrate only the rest, so a re-run spends the detail budget on missing rows instead of the same open invoices; (3) the run carries a deadline (660 s budget under an 800 s ceiling, the Pro maximum) and the hydration budget is derived from what is left minus a per-row insert reserve, so a run that cannot finish still hands back its terminal NDJSON line instead of dying mid-stream; (4) one timing log line per invoice step, because the killed runs logged nothing for 300 s. Found on the way and fixed in the same change: BL's /customer and /supplier answer a bare array that getPage read as an empty envelope, so every BL migration imported zero customers and suppliers and rebuilt both as invoice stubs; the register steps now honor the config's paginated flag, and a register row folds onto an org-less stub with the same name (filling the org number) so re-running an already-migrated company does not duplicate its parties. Not done: per-page progress events inside a step (the stream is silent for up to a few minutes on a large register) and splitting the migration into resumable requests; both are the next step if a register turns up that does not fit 660 s.
[2026-09-08] Counterpart resolver = authority record + bands, not a confirm queue: parties stay the record, counterparty_aliases holds the bank-side variants (display-time overlay, verifikat text untouched), link/tentative/nil bands at 0.8/0.6, cheap-tier model reads each new string once with register candidates and a verify pass, shared directory takes a reading only when two companies produced it. Why: no international product confirms merchant records; 4 040 suggested parties sat unconfirmed across 31 companies; Haiku read 200 prod strings at ~96 % high-band precision for 8 cents.
[2026-09-08] Motparter page = one flat list (party or resolver reading, marks, what they sell, account, rows, in, out, last), no Förslag/Bara i bokföringen tabs, actions on the row menu; suggestions take the directory's brand name, confirmed records keep theirs. Why: the confirm queue was not worked (4 040 unconfirmed rows fleet-wide) and no international product has one; unnamed spend belongs on the transactions, not on a counterpart list.
[2026-09-09] SIE import account creation (syncMappedAccounts) inserts in chunks of 100 rows instead of one statement per chart, and executeSIEImport closes its pending sie_imports row in a finally block on every exit: a full-BAS Bokio import (1 242 accounts) hit the authenticated role's 8 s statement_timeout on prod (8.2 s cancelled for one company, 6.5 s passed for the next), and the early `return result` after the failed account sync left the row 'pending', so the user's retry 40 s later was refused by the file-hash unique index with a message that named "gnubok" and an "Ersätt import" button that does not exist. Chunking was chosen over moving the insert into an RPC with SET statement_timeout (the import_sie_journal_entries precedent): the insert is idempotent per row, a partial success is safe to retry, and no migration or pg-real ratchet is needed; the per-row trigger cost (audit log, writer-role guard, RLS WITH CHECK) is untouched and stays a separate concern. The slot-held message is static (no lookup of the holder) so it stays deterministic in the queued-mock tests.
[2026-09-09] Onboarding search-as-you-type (#2448) reads SCB's företagsregister per debounced keystroke and runs TIC once, on the pick, instead of TIC per keystroke: the 3000/month Lens budget is why #2421 fired on Enter only, and SCB is free; the pick still needs TIC because SCB knows no F-skatt, VAT or fiscal year. No rate limit on either route, founder's call (2026-09-09): the debounce, the 3-char minimum and the abort of superseded requests are the only throttles. Sole traders are included in the onboarding search (SCB legal form 10) but stay excluded from the parties picker; the row names the form, never the personnummer.
[2026-09-09] Bank picker: an UNCHECKED account holds its bokföringskonto only as a soft claim (yields to a checked account that wants it), instead of either a hard claim (status quo: 400 "Flera bankkonton kan inte bokföras på samma konto", the support dead end where 1930 could never move from the wrong bank account to the right one because the picker hides the ledger dropdown for unchecked rows and disconnect + reconnect re-claims the same rows by IBAN) or a full release on every save (fewer states, but it demotes every unchecked row to a manual ghost holding its ledger and loses the "re-check lands back on the same account" prefill). Contested rows are demoted to manual in ONE update before the mirror, and upsertFromPsd2 then promotes the manual holder in place, which keeps row ids, transactions.cash_account_id links and the is_primary flag on the 1930 row; the same release pass also makes two checked accounts swapping ledgers work (previously both upserts tripped the unique constraint and were swallowed per-account). Another connection's UNCHECKED row yields the same way (cash_accounts.enabled = false), matching how session sharing already counts only enabled rows as claims; a synced-elsewhere row stays a 400. No new client/UI: the picker needed no change once the server stopped counting unchecked rows.
[2026-09-08] Zettle v1 = paid purchases→webshop_orders via Purchase API; Finance payouts out of scope; OAuth refresh-token store; unpaid IZETTLE_INVOICE-only skipped.
[2026-09-08] Zettle v1 is paid purchases (and refunds) into webshop_orders via the Purchase API, Shopify-shaped feed-only: partner-hosted OAuth with rotating refresh token (encrypted), unpaid IZETTLE_INVOICE-only purchases skipped, line items + groupedVatAmounts as booking underlag. Finance API payouts/fees deliberately out of scope (phase 2, Stripe settlement style).
[2026-09-08] Zettle orders cron pages candidates (range) and caps entitled syncs at 50 instead of limit(50) before hasCapability: entitlement skips must not consume the batch or advance last_order_synced_at (purchase recovery cursor). Declined a separate cron_checked_at column for now; revisit if scanned non-entitled volume becomes a time-budget problem.
[2026-09-09] PR #2416 (Zettle, community-authored) adopted on the contributor's branch instead of re-implemented: kept the Shopify-shaped feed-only design, added migration 20260909100400 for the sites that enumerate platforms/connection tables (platform CHECKs on webshop_orders and webshop_store_settings, writer-role gate trigger, migration-reset snapshot and lock via the 20260826150000 wrapper pattern rather than re-issuing the 400-line reset body), renamed the four PR migrations past prod's 20260908143051, froze the validated connect origin on zettle_connections.return_origin so white-label users return to their brand domain (Zettle has one registered callback URL), and derive per-rate VAT net from Zettle's own product rows (tax / rate drifted from what was charged) with a one-öre-per-row line-sum tolerance instead of 0.005 kr (silently dropped multi-row 12%/6% underlag). Per-purchase rows kept for v1; daily kassarapport aggregation and Finance API fees/payouts are the follow-up.
[2026-09-09] Zettle v1 imports split-tender, gift-card (sale or tender) and tip-carrying purchases as is_paid = false rows titled 'bokför manuellt' and skips their refunds, instead of booking them through the one-account / revenue-per-rate model: the skeptic showed a card+cash split would put the whole gross on 1686, a card+invoice split would count as paid, a 0 % gift-card row would land on 3004 / ruta 42 (it is a 2421 liability), and tips would book as momsfri sale. Proper support needs per-payment amounts and a voucher liability on webshop_orders (follow-up). Sync runs claim the connection (sync_lock_until) before refreshing the rotating token: a concurrent cron + manual sync otherwise reuses a refresh token, Zettle answers 400, and the connection flips to revoked. The cron snapshots its candidate list before syncing because each sync moves the row to the tail of the last_order_synced_at ordering, so live offset paging re-fetched synced rows and skipped unseen ones.
[2026-09-09] SIE #BTRANS/#RTRANS history (#2427) lands in journal_entry_rattelse_log with source='sie_import' instead of a new table or sie_imports.migration_documentation: every reader (verifikat page, Rättad marker, behandlingshistorik, full archive) already renders that table, and the ledger stays #TRANS-only (SIE 4B rule: #RTRANS is twinned by an identical #TRANS, so summing all three double-counts). sie_import_id travels inside each payload entry rather than as a fifth RPC parameter, so the (uuid,uuid,uuid,jsonb) signature, its grants and its statement_timeout stay put and PostgREST sees no overload. Import history rows survive undo/replace like every other log row (no FK on purpose); a re-import writes fresh rows against fresh entry ids, the old ones only appear in behandlingshistoriken as history of the undone import.
[2026-09-09] Resultatrapport and dimension-pnl list account 8999 as a normal class-8 row instead of filtering it out (#2455): our own bokslut verifikat never posts 8999 (it zeroes each P&L account straight against 2099), so the only 8999 balances are manual or SIE-imported omföringar, and hiding those made an account-level report disagree with huvudboken. "Beräknat resultat" now reads zero after such an omföring, the Fortnox/Visma convention. Resultaträkning keeps excluding 8999 because ÅRL's uppställningsform has no such line. This supersedes the 2026-07-29 line that kept Resultatrapport on the same profit as Resultaträkning: they still share 'exclude-all-year-end', and now differ by exactly a booked 8999, pinned in cross-surface-agreement.test.ts. No MCP or v1 surface is built on generateResultatrapport (the income-statement tool and v1 route carry the word "resultatrapport" but serve Resultaträkning), so an agent asked for resultatrapporten still answers the computed figure: follow-up issue filed. Rejected the bigger version (Stage 2 of #1051, 'exclude-final'): with our closing entry shape it would zero every row of a closed year, and it is a separate decision.
[2026-09-09] SIE account creation writes each chunk as an ignore-duplicates upsert (ON CONFLICT (company_id, account_number) DO NOTHING, returning the landed rows) instead of a plain INSERT whose duplicate-key error was swallowed: under PostgREST one request is one transaction, so a single concurrent duplicate rolled back the whole statement while the caller counted it as created and moved on with accounts missing. The conflict clause makes the race a skipped row, `created` counts exactly what landed, and the "duplicate" string special-case is gone. Raised by CodeRabbit and the compliance review on PR #2451 (count accuracy under BFNAR 2013:2 p. 9.16); the audit_log trigger stays the per-row record.
[2026-09-10] Onboarding act two (#2438) hosts its own import surface instead of the SIE wizard, the migration workspace and the bank panel: same routes (sie/parse, sie/execute, arcim-migration/*, enable-banking/connect+accounts, skatteverket/authorize), fewer steps. Dropped on purpose: the Kontomappning page (unmapped accounts are created from the file via sie/create-accounts and mapped to themselves), the VAT review gate (momskoder for imported revenue accounts are set on the genomlysning or later in the chart), the voucher-series question (company default), the confirm modal and the underlag option (locked off; imported verifikat are marked no-doc-required on the SIE path). Nothing is ticked by default in the bank account picker and the fetch button exists only once an account is picked: nothing is fetched by accident. Ended imported years are klarmarkerade after import (reversible under Räkenskapsår). The bank callback keeps its /settings/banking landing; the first-session gate rewrite carries it into the act, so the callback is untouched.
[2026-09-10] Kollektivavtal semesterlön percentage (#2477) is a nullable per-employee column (employees.vacation_pay_rate, 0.135 = 13.5 %) resolved by one helper (lib/salary/vacation-pay-rate.ts) instead of a company-level setting: every other vacation setting (rule, days, semestertillägg) already lives on the employee row, the settings page points there, and a company with both LO and tjänstemannaavtal needs it per person anyway; a company default plus override would be a second state with the same result. The request arrived as "add 13.5 %"; the mechanism was the statutory rate hardcoded in six places (engine accrual, semesterersättning payout, calculateVacationAccrual, absence valuation, year-close dayValueSek, v1 vacation-balance inline copy), so the fix is one definition, and the v1 route now calls dayValueSek instead of carrying its own copy. The rate applies only under procentregeln and semesterersättning; sammalöneregeln keeps semestertillagg_rate. The statutory rate for the employee's entitlement is always the floor (max(CBA, statutory), so 13,5 % on a 30-day employee accrues 14,4 %): Semesterlagen 2 a § only lets a kollektivavtal improve on 16 b §; the first cut treated the CBA rate as absolute and the skeptic plus the compliance review on PR #2480 both flagged the 30-day under-payment. Enforced in the resolver, not the CHECK, because the DB bound cannot see the entitlement. Left alone on purpose: the statutory step (14.4 % only at 30 days, not 0.48 pp per extra day) is a separate decision.
[2026-09-10] Import notices get a severity tier (info | notice | action) and one renderer (#2461) instead of deleting warnings: import behaviour (renames, derived IB, 2099 adjustments) is behandlingshistorik under BFNAR 2013:2 and stays recorded in warnings/import_documentation, only its screen presence changes (info behind a tooltip, notices folded, one ochre action sentence per design convention 6). The structured list rides beside the legacy warnings: string[] rather than replacing it, because v1 API, MCP tools and stored documentation read the strings; the UI prefers notices and wraps unmigrated strings as legacy notices. lib/notices (the Hem slot) was not reused as the model: its ids are dismissal keys for company-wide states with reaping, not per-run import facts.
[2026-09-10] #2469 migration per-step requests instead of a background job with a progress table: the route already accepted per-step flags and dedupe makes reruns idempotent, so the wizard sequencing one request per step gives each step its own 300 s with no new schema or cron; revisit only if a single invoice step still exceeds the ceiling after the fiscal-year scope filter.
[2026-09-10] SIE import RPC (#2472) rewritten as one set-based statement (CTE chain: reserve, headers as posted, lines, history) with whole-file atomicity kept, instead of the proposed Stage B chunking with per-chunk transactions and dedupe: 5,000 vouchers on staging went 27.9 s to 8.3 s, under the 30 s bar set for building Stage B, and chunking would change the atomicity that sie-import-atomic.pg.test.ts and the unit suite pin. No temp table: header ids are matched back to their payload ordinal by (series, voucher_number), unique within the call because each series gets a contiguous block. Headers insert directly as posted so check_balance_on_posted_insert verifies once at commit and the draft-to-posted UPDATE (and its second audit_log row per voucher) is gone. Remaining floor is the per-row triggers on journal_entry_lines (audit, immutability, writer-role, lock date); Stage B stays deferred until a real file exceeds ~30 s.
[2026-09-10] Set-based SIE import (#2472) drops the COMMITTED_AT_OVERRIDE audit row that service-role imports (v1 API key, MCP pending operation) used to get per voucher: the old draft-to-posted UPDATE carried committed_at = now(), so set_committed_at v3 (20260810121000) logged an override whose preset time equalled the wall clock, one noise row per imported voucher (3,678 in prod over 60 days). Behandlingshistoriken still shows the bokföringspost from journal_entries.committed_at, which the new body sets to the same now(). A real preset (seeding flows backdating history) still goes through the engine's UPDATE path and is still logged.
[2026-09-10] Migration result: 'Importerad med varning' label dropped, the untransferred-result (8999) fact is one section-level AttnLine and per-year status is Importerad / Delvis importerad / Misslyckades only: the fact is company scoped, not year status, and painting correct behaviour (renames, derived IB) ochre made users read a good import as broken (#2462). Renames and the continuation IB-skip moved from warnings to result.accountsRenamed / details.openingBalanceSkipped, still recorded for BFNAR 2013:2; severity codes are phase 2 (#2461).
[2026-09-10] BankID #2471: the completed identification is sealed (AES-256-GCM) into the signed flow cookie the first time /poll sees it, and /poll, the probe, /complete and /link read it from there; TIC hands a completed result out at most twice, so a third fetch (the iPhone reload-probe-Fortsätt-collect path) can never succeed. Rejected: retrying collect (cannot work), a bankid_session_results table (migration, pg test, cleanup and a service-role write from a skipAuth route for state the cookie already carries with the same privilege), signing without encrypting (the signup path needs the raw personnummer and the cookie payload is plain base64).
[2026-09-10] Peppol registration errors (#2483): the hosted connector code is kept as a first-class field (PeppolTransportError.code, peppol_registrations.last_error_code) and translated through the error registry; retryable vs permanent is decided from the transport's retryable flag plus a fixed set of hosted verdict codes, not by parsing the prose. Rejected: translating the composed English string with regexes (the code already exists on the wire and the string is built for logs). The stale-pending window (5 min) is one definition in lib/invoices/peppol-registration.ts, so GET reports stale_pending rather than the page re-deriving it from updated_at; a stale pending row is retired as failed before a new attempt because the live-row unique index would otherwise block the insert.
[2026-09-10] Peppol registration errors (#2483, skeptic round): peppol_registrations.last_error_code stores the verdict (PEPPOL_REGISTRATION_REJECTED / _FAILED) unless the transport code is a registry-known verdict of its own; CONNECTOR_UPSTREAM_ERROR and HTTP_<n> are never stored because they wrap whatever the access point said (a permanent Qvalia refusal arrives as UPSTREAM_ERROR with retryable=false), so storing the wrapper would show retry advice and a retry button on a refusal. The transport code still travels in the response details and the log. can_retry is decided server-side next to the registry; the UI never guesses from the code. A bare HTTP_404 keeps the row live (every hosted 404 is a CONNECTOR_NOT_OWNED envelope; a bare one means the connector URL missed the route).
[2026-09-10] Trial balance aggregation moved into SQL (#2470, get_trial_balance_aggregates, migration 20260910163731): one plpgsql RPC returns per-account sums in a 'period' and a 'rollforward' bucket under the three ClosingEntryMode predicates, ported verbatim from the two fetchEntryLines passes (include and exclude-all-year-end from get_kpi_report_aggregates, exclude-final from get_vat_declaration_totals); generateTrialBalance keeps its signature, the period/accounts/OB reads, the fail-closed guard and the row-building tail. plpgsql over LANGUAGE sql because the fail-closed guard and the mode check raise. The guard is duplicated in JS on purpose: the unit suite asserts no journal read is issued for a closed period without closing_entry_id, and the JS already holds the period row. Opening balances stay in getOpeningBalances (unchanged diff surface; folding them in is the follow-up's call). The chunked JS path stays behind REPORTS_TB_RPC=off for one release, pinned by the pre-existing unit suites, and is deleted with the flag when the other fetchEntryLines aggregators move to the RPC. Parity (scripts/parity-trial-balance-rpc.ts, zero öre diffs across every company and period; the only paired error that counts as parity is the exclude-final fail-closed guard, any other error on either path is a diff) runs on staging before merge and on prod read-only right after the migration lands there: a pre-merge prod run would need either a prod Postgres connection string (not in any env file) or DDL on prod, both outside what agents may do.
[2026-09-11] Image CVEs (#2490): exclude the Docker runner stage from the buildx cache instead of bumping the base digest again: the digest bump (#2071) fixes one advisory and re-breaks on the next, the cache filter re-runs apk upgrade on every build for seconds of cost. mailparser pinned to 3.9.20, the last release on nodemailer 9.x, so its nested copy dedupes with the pinned 9.1.1 rather than pulling a second major.
[2026-09-11] Multi-user seat gate switched OFF by default instead of only hiding its banner (#2494): a trial that ends must change nothing for the people in a company, and the 20-day grace countdown ("pausas om 17 dagar") read as an account being paused or deleted. isMultiUserEnforced now returns false on hosted unless MULTI_USER_SEAT_GATE=true; the state model, RPCs and every downstream dormancy check stay intact so one env flip re-arms it. Deleted the user-facing surfaces that can no longer render (grace banner, /paused page, grace reminder cron and mails, their message keys). Rejected: banner-only removal (the freeze would still land silently on day 20), full removal of the seat model (larger diff across middleware, api-v1 and MCP auth paths for the same visible result). The post-trial dialog is reframed as an invitation ("Vill du fortsätta använda Accounted med alla kopplingar och förmåner?") and the nav pill drops "Provperioden är slut"; the trial countdown pill stays because it only states what the trial is.
[2026-09-10] Peppol inbound reprocessing (#2485): the pass selects `routed` rows too, not only received/unrouted/failed as the card listed, because a document held back for lack of XML is left `routed` with last_error 'awaiting xml' and would otherwise never be delivered; rows whose last_error starts with 'terminal:' are excluded in the query (a LIKE filter) as well as skipped in code, since skipping alone would let 25 terminal rows starve every later pass. No JSON-only rendition exists in peppol-inbox-delivery.ts, so a document whose XML is terminally unavailable upstream stays pending and is counted as terminal rather than filed without its underlag. Terminal rows are also skipped by the listing sync (one definition in processInboundPeppolRow) so the two passes cannot disagree. No migration: the existing partial status index covers the predicate at these volumes.
[2026-09-10] Peppol inbound reprocessing (#2485, skeptic round): the listing sync archives oldest first and stops the document type at the first transient archive failure, because the receivedAfter cursor is derived from the newest archived received_at and must never point past a document that is not in the archive; a deterministic insert failure (CHECK / NOT NULL / bad value, Postgres 22xxx and 23502/23514) is recorded as a terminal stub row under the same provider id (ubl_json {}) so it pages once and stops blocking the cursor. A null XML answer is a miss, terminal after 3 (count kept in last_error, cleared when the XML arrives); unreadable payloads and missing recipient endpoints are terminal on sight. The cron pages once per run only for newly terminal documents (provider ids listed) and real failures; transport errors, misses under budget and held documents (awaiting XML, awaiting an owner member) are warned and retried. A held or terminal document has no user-facing surface yet (no inbox item without XML by choice; peppol_inbound_documents has no UI): showing the user an 'underlag saknas' item is a founder call for Emil.
[2026-09-10] Peppol inbound reprocessing (#2485, PR #2492 round): the archive stamps processed_at at insert so the pass predicate is a plain `lt` plus one literal `or` (the column guard cannot read a filter built from a template literal, and the ceiling stays at 404); the recipient resolver reads the exact digits-only identifier first and only then scans registrations in code, paginated with fetchAllRows, for rows stored with formatting; the listing cursor is one second before the newest archived received_at so a same-second sibling listed next run is not skipped (the unique key absorbs the overlap); the cron's single alert covers listing-sync failures as well as reprocess failures and newly terminal documents.
[2026-09-10] Template catalog gap fill: 22 templates chosen from twelve months of prod bank bookings (accounts used by 3+ companies with no template), not from a competitor's catalog; the keyword matcher loses precision with near-duplicates. Skipped on purpose: 1580 payment-service clearing (not in the BAS reference), 2012 EF F-skatt (the skattekonto flow books it), non-EU goods import (needs the tullräkning, not the supplier payment). Hyrbil keywords and MCC 7512 moved from travel_transport and vehicle_leasing to the new vehicle_rental (5820).
[2026-09-10] Account erasure completed (branch fix/account-erasure-gdpr): one public.erase_user_personal_data(uuid) holds all erasure, called by anonymize_user_account after its guards and by the migration's repair pass over every existing tombstone, instead of a second copy of the statements for repair (20260803090000 duplicated its WhatsApp block). Erasure stays an explicit list guarded by a pg ratchet over every public FK to auth.users (tests/pg/account-erasure.pg.test.ts: erased or retained) rather than a catalog loop that deletes from every ON DELETE CASCADE table: the migration template puts `user_id ... ON DELETE CASCADE` on company bookkeeping tables, so a generic loop would delete räkenskapsinformation the first time a new table went unlisted, while under the ratchet an unlisted table fails CI.
[2026-09-10] The tombstone's auth.users.email is now cleared at deletion, SUPERSEDING the re-signup block (route comment; 2026-07-24 BankID signup line): the published privacy policy keeps account data at most 30 days after a deletion request, and blocking re-registration is a product preference rather than a retention basis. Consequence: a deleted address can register a new, empty account. Founder alternative if the block matters: keep the address 30 days and clear it with a purge job.
[2026-09-10] Bank and mail connections of an erased user are revoked in place (status 'revoked'; session, tokens and accounts_data shredded), not deleted: transactions, cash_accounts and imported underlag reference the rows, and both readers (bank sync cron, mail search) select status = 'active' only. In a migration-reset source company, block_migration_reset_source_mutation lets exactly that revoke through, once (the rest of the row must be identical) instead of erasure skipping those rows: a consent is not an accounting record, and skipping would leave its credentials in place (raised in review on #2495). The route's auth.admin.signOut(user.id, 'global') is removed: it takes a JWT and returned an unread error, so it never ended a session; the RPC deletes sessions and refresh tokens instead.
[2026-09-11] set_run_salary hours_worked refuses when the period has calendar days (SALARY_RUN_HOURS_FROM_CALENDAR) and monthly_salary on an hourly row is now SALARY_RUN_SALARY_FIELD_MISMATCH instead of a silent no-op: run-calculation derives hourly hours from salary_worked_days whenever any exist and ignores the per-run column, so staging a value the engine would discard is a lie in the preview; the previous accept-and-ignore behaviour on hourly rows was the digest's 'gross stays 0' report. gnubok_get_salary_run gained the period selector instead of a new list_salary_runs tool: tools/list is at 60 483 of 60 500 tokens, paid for by dropping the three property descriptions.
[2026-09-11] Dead-file sweep (#2510): deleted only zero-reference items; kept data/tax-tables/2026 (input for scripts/import-tax-tables.ts), the two example extensions (cited by docs and the create-extension skill), InboxCustomDomainDialog (gated off on purpose), the push-notifications workspace (extension enabled self-hosted), bolagsverket extension and three comment-referenced lib modules (unwired but not proven dead). Historical comments that name a deleted script were left as-is: they describe what ran, not what to run.
[2026-09-11] Dead-file sweep (#2510) skeptic round: six scripts restored because they are pending or live procedures, not history: repair-stranded-categorized-transactions.ts (#2057 prod run still waits for founder go), backfill-document-storage-paths.ts (Phase B of migration 20260726092000, 16k legacy objects not yet re-homed), repair-result-appropriation.ts (named by year-end-service.ts as the recovery path), repair-customer-personal-number-in-org-number.ts, backfill-has-password.ts, migrate-sie-files-to-company-paths.ts (no completion record found). Rule applied: any doubt means keep.
[2026-09-11] sru_code is derived from the INK2R mapping (lib/reports/ink2/account-mappings.ts), not from a separate range table: the chart, the seed, SIE #SRU and the filed INK2R must agree, and the 20240101000021 table (NE-style 73xx codes, 12xx on 7202, 25xx-29xx on 7231) had 132k prod rows on codes that are not INK2R fields. The backfill only touches rows still holding the legacy value; hand-set codes stay.
[2026-09-11] TIC org-number lookup ranks the related hits (active before ceased, then latest registrationDate) instead of taking Typesense's first hit: a person with two enskilda firmor otherwise gets the one closed in 2012 written into the new company.
[2026-09-11] Year-end sequencing (feedback seq 392722): a pre-locked period is refused by a readiness blocker (PERIOD_LOCKED, kind period_locked) and by gnubok_run_year_end at stage time, not tolerated by silently unlocking as the reporter proposed: a lock a human took stays meaningful, and executeYearEndClosing already locks the period itself after posting the closing entry. period_locked is summarized under "period-state" in the readiness description rather than named: tools/list sits 6 tokens under its 60 500 ceiling and the blocker message carries its own remediation. The skill, the lock_period/run_year_end next-hints and the server instructions now all describe the one flow the service implements (readiness, run_year_end on the open period, verify) instead of the three-step lock, year-end, close prose that predated the service.
[2026-09-11] "Period is already closed" / "already locked" keep their plain-string throw sites (server.ts and period-service.ts) and are mapped in inferCode plus the Swedish pattern map, rather than converting every site to a coded error: one mapping covers the six sites and the registry entry carries the remediation. PERIOD_ALREADY_CLOSED is a new code because TARGET_PERIOD_CLOSED is date-addressed ("the period covering this date") and FX_PERIOD_CLOSED is revaluation-specific.
[2026-09-11] gnubok_book_skattekonto_row(s) descriptions name only the ränta/avgift/prelskatt/moms rules (not the bank in-/utbetalning and AGI rules): the 280-char description budget applies to search-only tools too, and the keywords array (free, never serialized) carries the rest of the searchable vocabulary.
[2026-09-11] MCP vat_declaration_status (feedback seq 330091): the Skatteverket redovisningsperiod for helårsmoms is now resolved by ONE helper, resolveRedovisningsperiod in the skatteverket extension (used by buildMomsuppgift, the HTTP status service and the MCP status tool), instead of delegating the MCP tool to fetchVatDeclarationStatus: that service uses the company-scoped read auth (#1673) and a `{ ok: false }` error contract, while the MCP tool authenticates as the calling user and throws registry-coded errors, so delegation would have widened auth as a side effect of a period fix. The reporter's alternative, accepting an explicit redovisningsperiod argument, was declined: it lets an agent poll or file under a period the books do not describe. resolvePeriodDates now returns `source`; vat_close_check exposes it as `period.source` (the outputSchema keeps `period` an open object, so tools/list cost is zero) and adds a `fiscal_year_not_found` blocker only when the yearly fallback fires for a company whose fiscal year does not start in January (calendar-FY companies with no fiscal_periods row are correct on the fallback).
[2026-09-11] create_supplier_invoice_from_inbox defaults a missing due date to invoice_date + suppliers.default_payment_terms (30 when unset) and labels it due_date_source: 'defaulted' instead of staging null or rejecting: supplier_invoices.due_date is NOT NULL, bank fees and kvitton carry no förfallodatum, and the human approver sees the label; a null total is rejected at staging (SI_CREATE_INVALID_INPUT, remediation gnubok_set_inbox_extracted_data) rather than derived from sum(lineItems), because the header amount is what gets credited on 2440 and must be confirmed against the underlag, not guessed.
[2026-09-11] Onboarding act one: an EF whose orgnr lookup returned a name no longer gets the verksamhetsnamn question (nextCompanyStep treats a lookup name as known for every form): the founder wants orgnr + Enter to be the whole step, the wizard's confirm-the-name beat read as a second step, and the name stays editable in Settings. An EF without lookup data still names its verksamhet.
[2026-09-13] AI connection state per client: the MCP OAuth token route now stores which built-in client minted a key (api_keys.client, from the redirect URI the allowlist already classifies) instead of adding an mcp_connections table or a per-client key name. The provider was derived at consent and discarded; one nullable column with no CHECK (a new provider must not 500 every fresh authorization) is the smallest state that lets the books act's Done step and Hem show Claude/ChatGPT/Grok as connected. Keys from before the column, Cursor, localhost and registered clients stay null and read as none of the three.
[2026-09-13] Hem after the books act: finishing the act (POST /api/onboarding/books/exit outcome done) stamps initial_setup_completed_at, so the getting-started checklist retires and Att göra opens first; a Kopplingar strip under it keeps the declined bank and Skatteverket connections visible (founder call: keep the strip, kill the checklist). The strip replaces the dismissible Skatteverket promo sentence (SkatteverketPromoCard deleted) so one surface offers both connections. The Att göra footer hands the FIRST row to a connected client through the client's ?q= new-chat link (claude.ai/new, chatgpt.com, grok.com) rather than copy-to-clipboard; rows a human must act on (staged operations, the two Betala transfers) are skipped when picking that row.
[2026-09-14] Books act, provider path: the door onward (Fortsätt till banken) now renders only when the import succeeded; a failed import gets Försök igen + Välj en annan källa instead. The manual SIE path already gated on canContinue; the Fortnox path showed the button on any end of the import phase, which is how a 0-of-2,786 import on staging (RPC execute grant missing, repaired 2026-09-13) reached Klart and Hem. Retry is safe by construction: failed sie_imports rows never block the overlap check and an existing opening balance is skipped.
[2026-09-11] SIE import backbone replaces the one-shot writer with database-owned leased jobs, bounded transactions and immutable batch receipts. A lost response is reconciled under the writer lock and fenced attempt number. Per-source and batch-position uniqueness enforce retry safety; a different prepared content hash is a named conflict. Whole-file visibility is intentionally replaced by partial visibility plus database holds and external-report read leases. Direct Postgres bypass and client-only workers were declined because neither solves durable recovery or idempotency.
[2026-09-11] SIE cutover keeps admission, execution pause and compact audit as separate switches. Admission-off drains existing jobs; worker-pause stops claims during investigation. The old import/period-delete RPCs are retired at the coordinated application/schema cutover, so an application-only git revert is not a safe rollback. New owned-source uniqueness ships first; the all-history constraint waits for individually reviewed legacy source-key collisions.
[2026-09-11] SIE undo and duplicate repair retain original vouchers and post bounded storno through the bookkeeping engine. Historical repair uses reviewed exact keep/reverse IDs, immutable full-content and dependent-record fingerprints, and per-chunk receipts rather than assigning guessed batch ownership or deleting a fiscal period. A stopped repair preserves completed corrections and cancels only untouched targets; ordinary import undo/replacement cannot revive it.
[2026-09-11] SIE compact audit is implemented as an opt-in candidate pinned in each job manifest, retaining source archives, mapping choices, exact entry receipts and processing-history rendering. Full audit remains default until separate accounting review and founder enablement. No legal-trigger shortcut or speculative index pruning ships; index removal requires a full observation window and rare-period query review.
[2026-09-11] Emil chose manual review instead of automatic adjacent-year opening-balance replacement during SIE backfill. Completion records the review atomically without writing next-year vouchers; source undo renews its token. Owner/admin acknowledgment is audited and stale confirmations fail without transport retries. Review remains advisory and separate from execution holds.
[2026-09-11] Emil delegated the durable-import reset choice to the most retention-preserving approach: refuse year deletion when durable imports or reviewed repairs exist, including after storno, and direct owners to the existing whole-company archive/start-fresh flow. Preserve that flow's confirmations and eligibility, extend its unfinished-import guard to durable undo, and use the full retained-source archive so vouchers and linked documents remain downloadable.
[2026-09-13] SIE review keeps read-only period observation locks shared while admission remains exclusive, validates parser errors before admission, normalizes new duplicate mapping inputs while preserving existing checkpoint positions, and binds archive reads to generated source identities including retained pre-tenant paths. These close concurrent-write, incomplete-import, post-write finalization and privileged-source-read failures without weakening accounting guards.
[2026-09-11] INK2R ranges (and therefore sru_code) follow the official BAS kopplingstabell checked in as lib/reports/ink2/official-ink2r-coupling.json and pinned by a test: bas.se INK2_P1_intervall-241119.xlsx is the newest coupling (checked 2026-09-11; BAS 2026 v1.1 has no SRU column) and the engine's own table deviated on real accounts (24xx shifted a decade, 213x, 1520-1589, 138x, 128x, 82xx, nedskrivningar, 49xx). Plus/minus rows now file a negative net in the minus-box field (INK2R_SIGN_TWINS) instead of a negative amount. Extensions beyond the file (2010-2079, 48xx, 1670, 2473, 2860-2873, 46xx as handelsvaror) are listed in the test.
[2026-09-11] Large sru_code backfills run inside BEGIN/SET LOCAL gnubok.sandbox_cleanup/COMMIT so write_audit_log skips the per-row jsonb audit rows: sru_code is a tax-form label, not bookkeeping, and the 2026-09-11 backfill (330k rows) would otherwise have written ~0.5 GB of audit rows and stalled the Supabase runner.
[2026-09-11] GET /api/v1/.../accounts orders by account_number instead of documenting its sort_order order: account_number IS the BAS sequence (a 5-digit sub-account like 19301 lands right after 1930), while chart_of_accounts.sort_order is 0 on every seeded system account (61k prod rows) and parseInt(number) elsewhere, so the old order put the seeded block first. Fixing the order kept the documented contract; documenting the wart would have kept a useless one. sort_order stays in the response (removing a field breaks clients) and the data is not backfilled.
[2026-09-11] Account lists (list_company_accounts RPC, its paged fallback, MCP gnubok_list_accounts) order by account_number instead of backfilling chart_of_accounts.sort_order: account_number is already the BAS sequence and unique per company, while sort_order is 0 on every seeded account and written inconsistently by the insert paths. A backfill would keep a second source of truth nobody maintains; the column stays on the row because API clients read it.
[2026-09-11] The NULL-SRU backfill for charts seeded 2026-03-31..06-12 (#2517) writes the current seed_chart_of_accounts() literals per account number, not the INK2R range table: the goal is a chart identical to one seeded today, and none of those codes lies in a range #2520 moves, so it is correct whichever of the two runs first. System rows with NULL only (user codes and user accounts untouched; EF equity and förening accounts stay NULL on purpose); audited (one audit_log row per account, actor_type 'system', label = the migration) unlike the two ~330k-row SRU backfills of 2026-09-11: at 21,840 rows the behandlingshistorik trail (BFNAR 2013:2 p. 9.16) costs a few tens of MB, so size no longer justifies skipping it.
[2026-09-11] create_supplier_invoice_from_inbox under reverse charge (feedback seq 366701): the payable (subtotal, total, remaining_amount) is the sum of the line nets and vat_amount is 0, both at staging and again in the executor, instead of the extraction's gross: the registration entry credits 2440 with the line nets (the 2614/2645 pair nets to zero), so a gross reskontra could never be settled against the GL. VAT a seller charged on a reverse-charge invoice is not deductible ingående moms and is surfaced in the preview (payable_recomputed + warning), not booked; whether to pay it anyway or ask for a corrected invoice is the buyer's call against the underlag. The reporter's alternative, a separate payable_amount next to the gross total, was declined: two totals leave the wrong one in the reskontra.
[2026-09-11] MCP briefing + search_tools report callability instead of promoting search-only writes (feedback seq 372962, 335021, 381082, 414922, 371965): recommended_tools entries carry callable/blocked_by/note (scope from the injected __keyScopes marker, catalog from the search-only WRITE tier) and search hits carry callable_via; nothing is dropped and no tool changes catalog tier, because tools/list sits single-digit tokens under its 60 500 ceiling and a promotion or a write bridge with honest annotations is a separate founder decision. A missing __keyScopes marker fails closed (no scopes) in the briefing, same contract as search_tools, so a direct execute() never vouches for a scoped tool. isDefaultCatalogTool moved to tool-reach.ts (re-exported from server.ts) so recommended-tools.ts can share the classification without an import cycle.
[2026-09-11] Visma unpaid invoices imported as paid (feedback seq 409660/409630/398500, company 5208b894): the paid flag and the balance are two independent signals and the mapper combined them with OR, so a present-but-zero RemainingAmount overrode an explicit PaymentStatus = Unpaid. Fixed by a combination rule, not by re-auditing the enum table (#1462 already did that): PaymentStatusDto carries `source: 'enum' | 'balance'`, the Visma mapper reports the open balance as `remaining > 0 ? remaining : total` whenever the enum says unsettled, and entity-mapper lets the balance declare a settlement only when no enum spoke. Optional field, so the five other provider mappers keep their balance-derived semantics (a residual öre still resolves to paid) until they opt in. Supplier credit notes now carry paid_amount = remaining_amount = 0, as the customer side already did.
[2026-09-11] Duplicate customers/suppliers from a re-run step (987 + 9 rows in company 5208b894): mitigated in code by re-reading the register delta (created_at > snapshot, 30 s lookback) right before each chunk insert and by an in-flight lock on the wizard's submit, instead of a migration. The durable fix is a unique index on customers/suppliers (company_id, org_number); suppliers deliberately have no unique constraint yet (#840 design, prod holds legitimate same-org rows), so that index is a founder call and not taken here.
[2026-09-11] Dropped invoice_inbox_items_journal_entry_unique (migration 20260911120500) instead of making gnubok_list_unmatched_documents / gnubok_list_inbox_items read document_attachments.journal_entry_id as the reporters proposed (feedback seq 389343, 395894, 395931, 366701): the UNIQUE modelled a 1:1 the product never had (an invoice plus its payment confirmation on one verifikat), never guarded the same-row double commit it was added for (a cross-row UNIQUE cannot see two UPDATEs of one row; the CAS predicates on the stamping UPDATEs do), and the link_document_to_voucher stamp swallowed its 23505 silently. Deriving "processed" from the inbox row's own link columns keeps one source of truth instead of two predicates that can disagree; the stamp now logs a warning on zero matched rows instead of tolerating a unique violation.
[2026-09-11] Salary journal preview (feedback seq 384229): the preview route now renders buildSalaryRunEntryLines, the same pure builder createSalaryRunEntries posts, and every booking surface (book-run.ts, the v1 book route) maps rows through one salaryRunDataFromRows, instead of patching the missing benefit-type skip into the preview's own loop. The preview had drifted on four rules (benefit lines, öresavrundning, kostnadsersättning, the tax override) and the v1 route was a third copy of the roster mapping; a rule now lives in one place or nowhere. The response carries balanced/difference per entry and the review card shows a red line when false: the DB trigger refuses an unbalanced voucher at booking, the preview never had a check. A zero-avgifter run still previews no avgifter entry (nollkörning parity), decided by the lines being all-zero rather than by a preview-side recomputation.
[2026-09-11] IB resync series (feedback seq 345150): resyncNextPeriodOpeningBalance reads the replaced IB voucher's voucher_series and passes it to replaceOpeningBalanceEntry instead of letting the engine resolve the company default: commit_opening_balance_replacement stornos the old entry in ITS series (COALESCE(old.voucher_series, 'A')) and no migration changes that here, so the only way to keep storno and replacement together is to follow the old entry. A null series maps to 'A' to mirror the RPC's fallback; a failed read throws rather than guessing. Alternative rejected: resolving both from settings would need the RPC to take the storno series too (a migration), for the same visible result.
[2026-09-11] K2 sweep and 8990-8999 (feedback seq 345150): the unmapped-account sweep now skips årets resultat accounts and pushes one informational line per account into the same warnings array ("ingår inte i K2-mappningen: årets resultat beräknas från resultaträkningens summa. Ingen åtgärd behövs."), rather than staying silent or adding a separate notes channel to K2MappingResult: warnings is the only surface the ÅR preview renders, and a reader who sees 8999 with a balance deserves to be told why it is absent from the mapping. Mapping 8999 to a concept was rejected: årets resultat is derived from the RR sum and a mapped 8999 would double it.
[2026-09-11] Medelantal anställda missing vs zero (feedback seq 366434): one resolveMedelantalNote in build-data.ts serves the K2 and K3 noter builders (the sentence lived twice) and decides "saknas" from period activity on 7000-7399 in the tbFullRows the builders already receive (no new query; period_debit/period_credit because with the closing entry included every 7xxx nets to zero after bokslut). An explicit override, 0 included, is the user's statement and keeps "inte haft några anställda" with no warning: hasMedelantalOverride is exported from lib/salary/medelantal.ts so the resolver and the note cannot disagree on what "set" means. 7400-7699 (pension, avgifter, övriga personalkostnader) are excluded from the signal on purpose: a company can carry those without a single anställd. The note and warning are only produced where the note is produced today (AB, or medelantal > 0), so enskild firma is not nagged about a note it does not file.
[2026-09-11] bank_connection.sync_failed (feedback seq 340107): one classifier + emitter (extensions/general/enable-banking/lib/sync-failure-event.ts) called once at the top of each sync catch (agent trigger, cron route, manual route) with the post-handling status, instead of a log line per branch or an emit inside each branch: three paths already had three copies of the branch ladder, and a per-branch emit would be a fourth thing to keep aligned. The payload carries userId (event_log stores it in its own column; the cron uses the connection owner) and a trigger field beyond the requested shape, since "which path failed" was the first question on 2026-09-03. message is the internal error text capped at 500 chars; the ConnectorSyncError body stays out (it can carry transaction and personal data), the Enable Banking envelope only contributes its code. The emit never throws (a bus failure must not change the sync outcome), and eventBus.emit already settles handler errors. The inline initial backfill after the callback (index.ts, syncPromise.catch(() => {})) is left alone: it swallows on purpose behind a timeout policy and is not a sync users retry.
[2026-09-11] suggestVatTreatment matches the union through two shared constants (UNION = /\b(?:eu|eg)\b/, OUTSIDE_UNION = /utanför\s+(?:eu|eg)\b/) instead of adding "eg" to each of the six literal regexes that spelled it out (#2525): the term list being duplicated per branch is the mechanism, not a missing word, and it is why the class 3 branch sent EG labels to momsfri ruta 42 while the class 4-6 branch dropped them from the declaration entirely. "EG" (Europeiska gemenskapen) is the pre-Lisbon name and one chart carries both spellings, since accounts added later picked up current BAS names while older ones were never renamed. OUTSIDE_UNION gained a trailing \b in the same edit, which also stops "utanför Europa" from reading as an export and being zero-rated. Verified against the full BAS 2026 chart: zero of 1286 accounts change suggestion, and no BAS name matches "eg" that did not already match "eu".
[2026-09-11] The trepartshandel row from #2525 (3057 "Treparts försäljn varor till EG 25%") is knowingly left suggesting reverse_charge_eu_goods/ruta 35 when the correct box is 38: there is no trepartshandel value in ACCOUNT_VAT_TREATMENTS and the column is guarded by chart_of_accounts_default_vat_treatment_check, so a correct suggestion needs a new treatment plus a migration, a pg-real test and UI/i18n, which is a feature rather than a term fix. Ruta 35 was preferred over a null suggestion (which would drop the amount from the declaration) and over the current ruta 05 (which asserts Swedish output VAT on an intermediary sale); class 3 rows always sit behind the import review gate, so the row is shown before anything persists.
[2026-09-11] The #2525 regression fixture (lib/import/__tests__/fixtures/eg-eu-bas97.se) is committed as real CP437 bytes marked `binary` in .gitattributes, not as UTF-8: the decode is part of what the test asserts, since suggestions are computed from the decoded label and a matcher that is correct on a UTF-8 string still produces the wrong momskod if decodeBuffer hands it mojibake ("utanför" arriving broken would stop OUTSIDE_UNION matching and send 3045/3055 to the intra-union branch). A UTF-8 fixture cannot catch that class. The accompanying test runs the whole wizard chain (detectEncoding, decodeBuffer, parseSIEFile, suggestMappings, enrichAccountMappingsWithVat) rather than the matcher alone, because the enrichment is client-side and a green unit suite says nothing about the flow: verified by reverting the fix, where 11 of its 13 assertions fail with the reported symptom. The fixture is anonymised (invented company, Luhn-valid test orgnr, invented amounts); only the EU-BAS 97 account labels are real, and those are already published in the issue.
[2026-09-12] The "momskoder att granska" filter keeps rows edited through the two selects visible via a component-local editedThisStep set (#2537), rather than widening the filter to `requiresVatTreatmentReview` alone or snapshotting a worklist when the filter is selected: each row carries a momskod AND a separate sats, changing either marks the row reviewed, and picking a treatment assigns a default rate (0.25 for reverse charge on class 4-6), so the row dropped out of the list still carrying the wrong rate ("Inköp varor 12% EG" became 25% and vanished). The two predicates now live in lib/import/account-vat-treatment.ts as needsVatTreatmentReview and isInVatReviewList, because the same expression was duplicated between the list filter (AccountMappingStep.tsx:117) and the chip counter (:163) and that duplication is what let them mean different things; the counter keeps the narrow predicate so it still counts down. The per-row confirm button and "Bekräfta alla föreslagna" deliberately do not feed the set: they mean "done with this row". Setting isOverride was rejected outright: it means the account was remapped to a different target, and overloading it would misreport the mapping and risk persisting a mapping override the user never made.
[2026-09-12] Follow-up on #2537 found FOUR more inline copies of `requiresVatTreatmentReview && !vatTreatmentReviewed` beyond the two first consolidated (AccountMappingStep default-filter and per-row confirm gate, import/page.tsx show-the-step and block-continue gates, ArcimMigrationWorkspace auto-skip, and applyVatTreatmentReviewAll), and the confirm-button copy is what made the first fix visibly wrong: the row was held in the list while its confirm control vanished, so it read as unfinished with no way to finish it. All six now call needsVatTreatmentReview, except the confirm button which uses isInVatReviewList so control and row cannot drift apart again. The confirm handler also releases the row from editedThisStep: without that the button is inert on an edited row, since the sticky set keeps the row visible no matter what the button does. That combination (edit keeps the row, confirm releases it) was verified in the running UI, not only in tests; the release itself lives in component state and is outside the repo's test scope.
[2026-09-12] The import mapping table's Konfidens column became Matchning and now renders from the mapping itself rather than from a thresholded score (#2539): suggestMappings emits exactly three confidence values (1.0 exact BAS match, 0.7 valid BAS-range self-map, 0 no match), so the badge re-derived by inequality a three-valued enum that matchType already carried, and the 0.7 band displayed "Trolig" on identity mappings (4057 to 4057, created at import), inventing a doubt that does not exist for 11 of the 12 accounts in the #2527 fixture and for most real charts. The "Osäker" branch was unreachable: the only zero-confidence case carries an empty targetAccount and the badge was guarded on the target being present. Since the only non-identity mappings in the system are the single group-header redirect (2640 to 2641) and a target the user picked, the column now labels all four states positively (BAS-konto, Från filen, Flyttad, Manuell) rather than leaving the majority blank: an empty cell in a migration reads as "nothing known" rather than "this is fine". "Från filen" was chosen over "eget konto" deliberately, because bas_range covers BOTH a source system's invention (a Fortnox 4599) AND a standard sub-account absent from our 1286-entry reference (1241 Personbilar is verifiably not in it); claiming the account is the company's own would assert what the mapper cannot know, repeating the exact mistake "Trolig" made. "Från filen" says only what is true: the number is kept and the name and type come from the upload, which the Målkonto column corroborates by showing the BAS name on BAS-konto rows and the file's name on the others. Rejected: renaming only the 0.7 band, which removes the false claim but keeps a fake numeric scale and the discarded matchType; and hiding the column inside the vat_review filter (the original suggestion in #2538), which treats the symptom. The confidence field itself stays: it is persisted in sie_account_mappings and feeds averageConfidence. The badge strings moved to messages/sv.json and en.json; the import mapping UI is not on the "stays Swedish" list in .claude/rules/i18n.md, so the previous hardcoded literals were a gap rather than an exemption.
[2026-09-12] The "osäkra" filter chip and its low_confidence FilterType were removed from the import mapping step (#2539): the predicate was `targetAccount && confidence < 0.7`, and the only zero-confidence rows are precisely those with an empty targetAccount, so the chip could never be anything but 0 and the filter could never match a row. It read "0 osäkra" on every import. The intro prose lost its referent at the same time and now says "granska de markerade nedan" instead of "de osäkra", since rows needing attention are marked by the warning border on the momskod select rather than by a confidence band. Deliberately NOT removed: the wider lowConfidence surface in SIEPreviewStep, import/page.tsx (which holds a third copy of the 0.7 threshold), account-mapper's stats (0.5) and types.ts. That is a larger cleanup across the preview step's warnings and does not belong in a fix to one column; it is the same duplicated-threshold shape as #2537 and worth its own pass.
[2026-09-12] Invoice type toggles (offert/proforma/återkommande/självfaktura) are four separate company_settings booleans, default true, hiding entry points only: faktura is not toggleable (it is the product) and följesedel is left out (no entry point of its own, only the editor picker); existing documents of a hidden kind stay under Alla and the API/MCP ignore the flags, mirroring sales_orders_enabled.
[2026-09-12] Offerter is its own page (/quotes) and nav row above Kundfakturor instead of a view inside the invoice list: a quote is not an invoice (founder direction). The list page is reused through a variant context rather than copied, quotes leave every invoice view, and quotes_enabled only hides the nav row: the page, the rows and the APIs stay.
[2026-09-13] The decision lines of open PRs #2519 #2521 #2522 #2523 #2524 #2527 #2538 #2541 #2542 are landed on main ahead of the PRs and the branches are made DECISIONS.md-neutral: GitHub has no union merge driver, so nine branches that each append to the end of DECISIONS.md flip every other one to CONFLICTING after each merge, one full CI wave per merge; with the lines already on main the branches stay mergeable and one CI wave covers the batch.
[2026-09-13] SIE reports share observer locks; leases still exclude imports. Validate annual snapshots before persistence. Sandbox cleanup requires trusted context and exact company classification. Real-company retention stays strict; see docs/operations/sie-import-backbone.md.
[2026-09-13] Archived DECISIONS.md entries dated before 2026-08-01 to git history (c36bc125a) and capped new entries at 300 characters: at 1.1 MB the log was too large to check before re-litigating, and long entries had turned into PR bodies.
[2026-09-13] Removed the create-ticket, docker-patterns, mcp-builder and erp-api-route skills plus the vendored .agents Vercel skills (founder call, overrides the 07-29 keep of web-design-guidelines): generic or stale, and erp-api-route taught the forbidden getUser() route.
[2026-09-13] Cite DECISIONS entries by date, never by line number: union merges shift lines, and eight code comments citing DECISIONS.md:632 pointed at an unrelated Google Drive entry.
[2026-09-13] Removed shell v1 (phase 9b of #2390), so v2 is the only shell: /api/user/ui-state still accepts and drops shell, nav_collapsed and nav_folds so open tabs get no 400, no migration, and the Hem components stay because v2 renders them.
[2026-09-13] Invoice entry row commits on pointerdown, ignores IME-composing keys, and gets a visible "Lägg till rad" button (#2447): touch fires no usable mouse event before the blur teardown and Android reports Enter as keyCode 229, so the mouse-and-Enter-only path was unreachable on Android.
[2026-09-13] Peppol per-line VAT rounding is carried in BT-114 PayableRoundingAmount at export (#2544), not by moving creation to per-category rounding: a header-only change breaks the engine balance check and a full move touches five formula copies; band = half an öre per line plus per category.
[2026-09-13] Batch booking collects possible duplicates into one confirmation dialog (#2488) instead of a skip-duplicates switch: the check still runs per row and the user decides once with each matched verifikat in view; match/ignore stay single-row as in BulkBookDialog.
[2026-09-13] WhatsApp transient link lookup gets its own M22 template, sent unthrottled (#2365): M21 is worded about a code while senders on this path are linked users sending receipts, and the fail-closed throttles read the DB that just failed, turning a blip into silence.
[2026-09-13] SKV SESSION_EXPIRED is no longer a health fault (#2567): the 65-minute BankID session is each connected company's resting state, so it left RECONSENT_ERROR_CODES, dead sessions answer from the stored row, legacy latches read as expired, the notice waits 7 days; SKV caps refreshes at 10.
[2026-09-13] Register CSV columns are scored (exact > whole word > substring) with a record-number reject list (#2548), not fixed with more vendor aliases: aliases fix one file while short keywords ("co", "vat") keep stealing columns and the confidence score hides it behind the 0.8 mapping gate.
[2026-09-13] AR reference matching derives its key set from generateOcrReference() in one helper, lib/invoices/ocr-keys.ts (#2555), not a second compare per matcher: the PDF and the matcher deriving the reference independently caused the bug, and one helper keeps print and match from drifting.
[2026-09-14] Supplier-invoice vat_treatment is enforced in the booking engine as a required argument (#2553), not only in the create routes: a route-only guard leaves the inbox-convert route, the MCP executor and every stored 0.25 row able to debit 2641.
[2026-09-13] v1 supplier mark-paid resolves payment_account as explicit > 1930 (#2550), not via company_settings.last_supplier_payment_account: that setting is the dashboard dialog's pre-fill, so reading it on the API door would silently re-point integrations that never send the field.
[2026-09-14] SIE4I identity keeps #2532's numberOmitted flag and fixes sourceId with an ordinal-based formatVoucherRef (#2546), not a retype of SIEVoucher.number to number | null: the collision (sourceId "0" for every SIE4I voucher) was the bug; that retype landed the same day in a 187-file PR.
[2026-09-13] roundOre uses a decimal-string exponent shift instead of the Number.EPSILON nudge (#2544): EPSILON is a fixed absolute quantity while the double gap doubles per power of two, so from 2 SEK up n+EPSILON===n and the helper degenerated to the naive form; a decimal library needs sign-off.
[2026-09-13] creditNoteNeedsJournalEntry moved into lib/bookkeeping/booking-mode.ts beside its supplier twin, re-exported from issue-credit-note.ts (#2552): v1 and the MCP executor each re-implemented the cash-method credit rule inline; one shared helper keeps the two sides symmetrical.
[2026-09-13] Single-side line rule enforced in engine + Zod + a NOT VALID CHECK (#2551), except on MarkInvoiceSentSchema: parseCustomIssuanceLines already owns that path and returns the targeted INVOICE_MARK_SENT_LINES_INVALID, which a schema refinement would pre-empt into a generic 400.
[2026-09-13] Enskild firma customers (#2367): the personnummer-as-org-number guard now covers only eu_business/non_eu_business, and lists mask personnummer-shaped org numbers, via one shared predicate instead of six copies; no sole_trader type (two CHECKs, enums, snapshots, VAT fork, zero gain).
[2026-09-14] Fortnox migration prefills momskod from Account.VATCode on /accounts, translated ruta-to-ruta into the account VAT treatment (#2585), Fortnox only: the SIE export the guided import runs on carries no VAT codes, so the label regex was the ceiling; Fortnox codes are named after the ruta they feed and 16 of the 38 default codes have a treatment landing on the same ruta, the rest (uttag 06, trepartshandel 37-38, import 50, and every class 2 moms code) translate to null and keep the label suggestion. Precedence is chart treatment > provider code > label, because a treatment the company set in Accounted is a deliberate later edit and the provider code is the user's own configuration in the system they are leaving. Visma (opaque VatCodeId needing a /vatcodes lookup), Björn Lundén and Briox (code sets unverified) were left out; fetchAccountingAccountsDirect answers [] for them and applySourceVatCodes takes a generic translator so each can plug in once its codes are documented.
[2026-09-14] The Fortnox momskod prefill (#2585) arrives as a SUGGESTION (unreviewed, in the momskoder-att-granska list, accepted by Bekräfta alla), not as a reviewed value as first built: syncMappedAccounts writes reviewed treatments onto existing chart accounts and the wizard skips the mapping step when nothing needs review, so a reviewed prefill would have overwritten a treatment the user cleared in Accounted on the next re-sync without anyone seeing it (skeptic refutation). The translation is ruta-exact per account number, not per code: IT (24) on a 46xx underentreprenör account answers null because Accounted files class 4 outside 4425-4427 on ruta 23, and asserting a box Fortnox disagrees with is worse than falling back to the label. Reverse-charge purchase rates come from the label (4516 12%, 4517 6% share IVEU with 4515) via vatRateFromLabel, because a flat 25% registers the account under the wrong rate bucket and the filing gate then flags every correct voucher as an rc-basis gap.
[2026-09-14] SIE wizard shows the execute rejection on the review step (inline beside the action row plus a toast) instead of pre-checking admission through a status endpoint: the server text is already the user-facing Swedish message and the pause is an operator state that can flip between render and click, so the rejection path is the only truthful one. Found when SIE_IMPORT_JOBS was unset on prod after #2532 shipped: every import since 07:50Z was refused with 400 and the wizard rendered nothing, because `error` was only passed to the upload step.
[2026-09-14] Invoice-email copies: the login-email CC fallback is removed and the company-email fallback became explicit rows (migration 20260914110000 backfills invoice_email_cc_addresses = [email] where it was NULL), so NULL and [] both mean no fixed copies; the settings UI now shows exactly what is sent. Chosen over keeping a runtime fallback because the fallback was invisible in settings (the founder saw a fixed CC he could not find). Reply-To is a new company setting (invoice_email_reply_to) with fallback company email, then the sending user's address: the sending user was already exposed as the legacy CC, and a working Reply-To is what makes "Svara direkt på detta mejl" true; cron sends (recurring, reminders) and API-key sends have no user and drop that line when no address resolves.
[2026-09-14] Whole-krona truncation rounds to öre first at five sites (#2597): ink2-engine and sruAmount call truncateToWholeKronor, three bokslut floors wrap their input in roundOre. A raw double sum filed 2495 for a ruta worth 2496 and clamped the bolagsskatt base 10 kr low.
[2026-09-14] Migration 20260914110000 was edited after its first production run failed (rolled back at the CC backfill UPDATE, never recorded in schema_migrations): the backfill now skips migration-reset source companies, whose company_settings rows block_migration_reset_source_mutation makes immutable. Edited rather than superseded because the failed file itself must succeed before Supabase applies any later version; the never-edit rule protects shipped schemas, and this one had not shipped. Preview branches carry no data, so the pg-upgrade seed is the only place a data-dependent trigger like this can be caught before main.
[2026-09-14] Recurring schedules: placeholders use the same {key} syntax and matcher as the invoice email texts (applyPlaceholders), with Swedish keys ({månad}, {nästa månad}, {föregående månad}, {år}, {periodstart}, {periodslut}, {nästa periodstart}) rather than the [current_month_name] style the user proposed: one placeholder dialect across the product. {periodslut} is the inclusive last day of the period (2026-10-01 to 2027-09-30 for a yearly schedule), with {nästa periodstart} for the exclusive form the user wrote. period_start advances after the cron run AND after "Skapa faktura nu" (unlike next_run_date, which run-now leaves alone): the invoice just created covers the period, and the stale roll-forward (no invoice) leaves it so run-now still bills the missed period. Period placeholders without a period_start are refused at save time (API + dialog) instead of rendering blank or literal on a customer invoice. Text rows on schedules mirror invoice_items.line_type; at least one product row is required.
[2026-09-14] /select-company is no longer a company picker: it renders only BankID engagements not yet in Accounted, and redirects otherwise (?choose=1 or no company: /onboarding; BankID login with a company: /). Member companies are the switcher's job; listing them again under "Lägg till företag" read as a second switcher (founder).
[2026-09-14] Books act reads the server on every load (lib/onboarding-books/resume): a running/paused import job opens the new resume step following that job; existing posted entries open on the genomlysning; only a fresh company gets "Var fanns bokföringen innan?". Replaces the source step's "du har redan N verifikat" shortcut: the act's position was browser memory while the durable job kept writing after a reload.
[2026-09-14] Books finale shares Hem's task selector and prompt, with an explicit company ID: a verified agent connection leads straight to the first actionable row. Keep manual exit and retry failed completion so the books gate cannot silently send users back into onboarding.
[2026-09-14] SIE job mappings reuse the shared four-digit account schema, including custom accounts such as 9999: the chart and worker already accept them. Keep the BAS 1-8 range for automatic suggestions only; rejecting custom targets at execution broke the preview create-account flow.
[2026-09-14] SIE custom accounts (#2605): keep unused four-digit definitions, but require report-supported targets for financial amounts before admission and preparation. Classes 0/9 lack report classification. Archive all chart pages with classification and import rules; never silently remap amounts.
[2026-09-14] Trepartshandel (ruta 37/38) is reachable: one treatment triangulation_eu_goods, resolved by account class (38 on revenue, 37 on purchases) like reverse_charge_eu_goods, plus BAS 3107/4512 in ACCOUNT_RUTA. Both boxes existed in the type and the eSKD map; nothing could fill them.
[2026-09-14] Source-chart CSV reads Spiris Bokföring (formerly Visma eEkonomi) momskoder, which name the ruta directly. The Fortnox API prefill skipped the visma provider because its VatCodeId is opaque; the same product's chart CSV needs no lookup.
[2026-09-14] Source-chart CSV is applied as a REPLACEMENT, not a merge: a second file first restores every unreviewed row the previous one touched (via enrichAccountMappingsWithVat), so "Byt fil" actually undoes a wrong-year chart. A reviewed row is never overwritten, by the user or by the company's own chart treatment; applySourceVatCodes skips it.
[2026-09-14] Source-chart warnings emit ImportNotice (code + severity + params under import_notices.*), not a string[] rendered in a bespoke AttnLine: the import already has one notice system with severity, folding and sv/en, and a second one beside it is what notices.ts was built to remove. The catch in the wizard still uses legacyNotices(), the sanctioned bridge for an already-mapped error string.
[2026-09-14] The mapping step's source-code line has four states, not three: a code that did not translate, on an account no BAS number routes and no label rule matches, gets its own sentence saying the amount reaches no ruta until a momskod is picked. It used to borrow the "suggestion comes from the account name" sentence and point at a suggestion that was not there. An import basis (ruta 50) on a number outside 4545-4547 is the live case.
[2026-09-15] Ruta 06 (momspliktiga uttag) and ruta 50 (beskattningsunderlag vid import) get one treatment each, own_use and import_goods, resolved by account class. Both boxes were reachable only through ACCOUNT_RUTA's 3401-3403 and 4545-4547, so any other number dropped the amount out of the declaration in silence. Neither fixes its own sats (both boxes exist at 25/12/6), so vatRateComesFromLabel now also covers treatments whose box spans rates. The label reads "beskattningsunderlag ... import" but still declines a plain import cost account, whose balance is the invoiced amount and not tullvärde + tullar + bikostnader.
[2026-09-15] The momsdeklaration view draws all of SKV 4700, not a subset: rutor 06/07/08 join 05 in the base section, and a new section E "Försäljning undantagen från moms" holds 35-42, with 39 and 40 moved into it from the output-VAT table. Nine boxes were computed, filed in the PDF and written to the eSKD file but never shown on screen, ruta 35 (EU goods) most damagingly. Section and row both hide at zero, as the reverse-charge and import sections already did.
[2026-09-15] A source chart may update a treatment the COMPANY chart carries, but never one the user answered in the mapping step: enrichAccountMappingsWithVat clears requiresVatTreatmentReview when the chart answered, applyVatTreatmentReview leaves it set, so reviewed AND required is the signature of a human answer. A settled row is left alone while the file agrees and re-opened only when it does not, so a multi-year migration neither re-confirms dozens of unchanged rows nor keeps a stale code. Evidence: Spiris swapped the names AND codes of 3541/3542 between 2022 and 2023 in the founder's own six yearly exports.
[2026-09-15] A stored sie_account_mappings row supplies the TARGET choice on a re-import, never the source name: suggestMappings now takes sourceName from the file being imported. A source system renames accounts between fiscal years (Spiris swapped 3541/3542 to match BAS between 2022 and 2023), and the stored name showed last year's label beside this year's momskod, which makes a correct suggestion look wrong and feeds suggestVatTreatment a stale label whenever the source chart carries no code.
[2026-09-15] The source-chart rate override follows the same ownership rules as the treatment: it acts only on rows the current file changed (identity against the pre-application array, so a providerVatCode left by an earlier file cannot be re-applied), and a rate change on a row the company chart had settled re-opens it for review. defaultVatRate buckets a reverse-charge account per sats in rcBasisTotalsByRate, so a silent change moves the FK004 reconciliation that gates filing.
[2026-09-15] suggestVatTreatment declines any account whose name says "motkonto", before every other rule: a counter-account repeats the basis account's own words, so "Motkonto beskattningsunderlag import" read as the ruta 50 account and would have subtracted its own credit from the box the basis filled. BAS states the same about 4598, which nets the basis out of the income statement while the 45xx accounts carry it to ruta 20-24.
[2026-09-14] SIE preview must not create targets that execution rejects: retain unused non-four-digit source definitions in the original archive with an explicit preview notice, and require deliberate target mapping for used source accounts. Keep the ledger's four-digit invariant and validate the create-accounts endpoint before any writes. Canonical validation responses expose bounded field details in both the API message and UI instead of hiding them behind the generic 400 text.
[2026-09-14] Reconcile stale OSS account constraints with a guarded migration: staging recorded the OSS migration but rejected reviewed OSS defaults. Keep the established class restrictions and leave already-correct constraints unchanged.
[2026-09-14] Legacy SIE recovery starts with a read-only, company-scoped year assessment (#2566): legacy status and year-wide counts do not prove voucher ownership, so no failed_with_data status or undo/reset permission is inferred. Resolve metadata and reviewed reversals in separate changes.
[2026-09-14] Support delivery is PostHog Support first, email fallback (lib/support/submit-feedback.ts), and the support dialog shows the conversation (components/ui/support-link.tsx): founder decided PostHog is the inbox; replies typed there were invisible because the app never rendered the thread, and the mail copy duplicated every ticket.
[2026-09-14] Onboarding progress reads committed transactions_count directly, independent of animation. Preparation stays indeterminate and final checks stay below 100%. Native SIE skips provider registers; only the selected Bokio or Visma connection is offered.
[2026-09-14] Asset defaults follow BAS 2026 non-production accounts (vehicle 1226, computer 1224, both on 1229) and vehicle/computer ranges span 1210-1269: BAS 2026 splits kontogrupp 12 by production use, and the 31 prod assets on the old 1240/1250 accounts must stay editable.
[2026-09-14] Customer list Kundnr column renders only when at least one customer has a number: the field is optional, and design.md's column budget forbids spending a permanently blank sixth column on registers that do not use numbers.
[2026-09-14] Salary runs surface as underlag references from the entry's own source_type/source_id rather than a new journal_entries column: a salary_run_id column cannot be backfilled onto posted entries (Hard Rule 1) and would duplicate a relation already stored and already read forward.
[2026-09-14] Inbox dedupe adopts a later delivery's +lev/+ver hint instead of dropping it: identical bytes are a duplicate but the sender's plus-tag is new information; applied only while the item is unconsumed (no supplier invoice, journal entry or transaction match).
[2026-09-14] WhatsApp staged media: probe Meta (lookupMedia) before releasing a parked receipt instead of trusting STAGED_MEDIA_MAX_AGE_MS: Meta returned 400 at 11 days (#2363), so the constant is a ceiling, not a retention promise; 429/5xx leave the row parked.
[2026-09-14] Invoice/article unit is free text with a shared datalist (lib/invoices/units.ts), not a closed dropdown: the API already accepts any unit up to 32 chars, so the list only hid units stored via API/import and forced a deploy per new unit.
[2026-09-14] Zettle first sync starts at the connection moment, not a 90-day BACKFILL_DAYS window; explicit POST /backfill moves the cursor rather than adding a sync_from column: the cursor already means "everything before this is handled".
[2026-09-14] v1 draft cancel = status flip via new engine cancelDraftEntry, not delete_last_voucher: the RPC gates on auth.uid() owner/admin and v1 runs service-role; drafts hold no voucher_number so cancelling leaves no lopnummer gap.
[2026-09-14] Bypassed-guard records go to audit_log (new guard-agnostic GUARD_BYPASSED action), not processing_history: behandlingshistorik is reported and archived out of audit_log (2026-08-21), so a processing_history row is invisible to the reader it exists for; the next guard needs no DDL.
[2026-09-14] Kontantmetod cut-off identity moved from journal_entries.description to a kontantmetod_cutoff_entries marker table joined by both VAT RPCs: a dedicated source_type was rejected (posted rows are immutable, so legacy rows would leave two matching paths forever).
[2026-09-14] Bokio supplier invoices: read remainingAmount, the only payment field Bokio publishes, never status/paidAmount; a zero total never declares a settlement; migrated rows are repaired by asking the provider (POST /reconcile), not by a blanket SQL update that would fabricate payments.
[2026-09-14] Onboarding drafts use scoped, expiring sessionStorage and browser history: Back and refresh retain answers without storing provider credentials or replaying completed writes; server import jobs remain authoritative.
[2026-09-14] AI handoff uses local review and explicit copy with clean external URLs: avoids URL disclosure without a new server-side prompt store. Removed the Skatteverket development token bypass instead of adding another consent exception.
[2026-09-14] Bank connect prefills Enable Banking's companyId credential from the company org number (10 digits) with credentials_autosubmit off, generic on the declared credential: Handelsbanken's hosted page asks for "Företags-ID" with no format hint and rejects the hyphen (Morfosia feedback). The signer's personnummer is never sent; we do not hold it.
[2026-09-14] A 'pending' bank_connections row older than 10 minutes renders as an abandoned attempt (retry or remove), not "Väntar på banken": Enable Banking's page can time out without ever redirecting to our callback, so no cleanup runs. Presentation only; a late callback still activates the row and the connect route sweeps it on retry.
[2026-09-14] A document attached inside the review dialog is read by the assistant right away (POST /api/agent/categorize document_id), never stored as the row's read: the row does not carry that document until booked, so a stored read would be stale at once. The dialog waits for extraction via the existing extraction-status poll.
[2026-09-15] InfoTooltip's children branch gets tabIndex={0} and a focus ring, not a <button>: six of its nine call sites sit inside a <label htmlFor>, where a button would swallow the click that focuses the field. No role="button" either, since there is nothing to activate; focus is the whole interaction and Radix already wires aria-describedby. Radix TooltipTrigger with asChild forwards props but never adds tabIndex, so a bare span could not take focus and every labelled tooltip was mouse-only.
[2026-09-15] The source-chart parser tokenizes RECORDS over the whole file, not lines then quotes: honouring a quoted delimiter but not a quoted line break is half a guarantee, and the torn field is the harder half to diagnose. Verified against six real yearly exports, which parse identically to the line-based version. The NOT VALID + VALIDATE pair stays inside each VAT-treatment migration, following the oss precedent: each constraint is a strict superset, so validation cannot fail, and splitting it would diverge from the established shape for no correctness gain.
[2026-09-15] A bank account another of the user's companies already books is shown in the picker and can be ticked, never blocked (issue #2647): the callback guard's claim is a notice, not a refusal. Rejected blocking the connect (the guard is per account, and which accounts a consent covers is only known after the BankID round trip, so a pick-time block would refuse a legitimate second company at the same bank) and a unique IBAN across companies in the schema (40 IBANs already sit in more than one company in prod, so it needs a data cleanup first). One bank account belongs to one legal entity, but the app cannot tell which of the user's companies is its right home, so it names the claimant and leaves the choice; nothing is auto-booked, the rows land unbooked and the user decides what becomes a verifikat. Founder direction.
[2026-09-15] Every momsdeklaration section guard tests !== 0, not > 0 (CodeRabbit, PR #2641): VatRutaRow already renders a negative box and hides only at exact zero, so a period dominated by credit notes hid the whole reverse-charge and import sections. Touched two pre-existing guards rather than leaving the new section E alone with the correct test; the rule now sits in one comment above all three.
[2026-09-15] A verifikat lists the bank and skattekonto händelser anchored to it ("Visa transaktion", desk crm#46) through a new read-only route (journal-entries/[id]/transactions) rather than by widening the underlag references resolver: a bank line is the trace of the affärshändelse, not its underlag, and folding it into references would silence "saknar underlag" for expenses that still need a kvitto. The deep link reuses /transactions?highlight, which now also switches a booked target into the history view and accepts skattekonto row ids; the reverse direction (transaction to verifikat) never existed before, the customer misremembered.
[2026-09-15] The transactions-page match dialog links one bank row to several verifikat itself (desk crm#45) instead of pointing at Bankavstämning, Matcha manuellt: the customer looked for "dela upp raden" where the row is, and the 1:N engine (linkTransactionToVouchers, #1553) already existed, the dialog only lacked the worksheet sum arithmetic (lib/reconciliation/split-selection.ts). Rejected: a Bokio-style split of the bank row into two transactions, because the row is the bank own record and splitting it breaks the statement reconciliation; the truthful shape is one row linked to N verifikat. An already-matched verifikat still cannot join a split (N:M has no engine shape), same rule as the worksheet.
[2026-09-16] Replacing the SIE mappings clears sourceChart and bumps the pick ref (CodeRabbit, PR #2641): handleFileSelect and handleNewImport reset everything else, so a second import showed the previous file's chart summary above mappings that never carried its codes. The bump is the half the finding did not name: a chart read still in flight resolves against mappingsRef, which by then holds the new file's rows.
[2026-09-16] A chart CSV that ends inside a quoted field is not applicable, and a stated 0 % never overrides a reverse-charge rate (CodeRabbit, PR #2641): the rows before a cut parse cleanly, so returning them would replace the chart in force with half of another one; and 0 is no acquisition rate, so writing it buckets the row nowhere in the rc-basis check and drops the basis out of the FK004 reconciliation. Rejected dropping the treatment for a 0 % code: the ruta is unambiguous, only the rate is not.
[2026-09-15] INK2S 4.14 a (SRU 7763) is a third manual tax adjustment (adjustment_type deficit_carryforward) rather than a non_taxable_income row: it has its own INK2S field and its own sign, and riding on 4.5c would misfile it. The saved deficit is deducted in the bolagsskatt base and in the periodiseringsfond/SLP cap bases too, because those bases are the skattemässiga resultatet after IL 40 kap. 2 § (the year-end skill: 25 % of skattemässigt resultat), the same place the two existing manual bridges already apply.
[2026-09-16] The MCP livsmedel note states the last 6 % day (2026-04-01 through 2027-12-31, then 0.12) instead of "reverts 2027-12-31" (CodeRabbit, PR #2641): the old wording reads as the reversion happening a day early, and the compliance skill puts the change on 2028-01-01. Paid the tools/list ceiling inside the same two account tools, per that guard's rule. The reversion is still not date-driven anywhere in code, only the 2026-04-01 transition is (counterparty-templates.ts); that is its own change, not a doc fix.
[2026-09-16] A source chart takes codes from active accounts only, and a human answer establishes its own review marker (CodeRabbit, PR #2641): the export is the vendor catalogue plus IsActive, so an inactive row's code is a BAS default the company never chose, and across six real yearly exports ~50 such coded accounts a year are never posted to in the matching SIE file. applyVatTreatmentReview set only the reviewed flag and inherited the required one, which held until a row could be settled by the company chart; that pair is what applySourceVatCodes reads as "a human answered this", so the answer was overwritable by the next chart. The fix existed on the onboarding branch only; it belongs here, which merges first.
[2026-09-16] clearPreviousChart protects a row only when it carries BOTH human-review markers (CodeRabbit, PR #2651): reviewed alone is also what a row carries when the company chart settled it and a chart file agreed, and what an onboarding accept leaves, so a later chart that did not mention the account at all left the previous file's providerVatCode sitting on it. Verified by running both paths before the fix.
[2026-09-16] Voucher reference matching: whole-number hits only, and a reference earns 0.99 solely with an amount that settles the invoice (else 0.90, below the 0.95 auto-link bar): every one of the 31 "Ankomstnummer N omnämnt" auto-links in prod was a substring hit ("14" in "(1814)") paying another supplier's amount onto the invoice (desk crm#64). The arrival number is Accounted's own sequence and never appears in a bank text on purpose, so it counts only with a corroborating amount; dropping it entirely was the alternative, kept because a whole-token hit plus the exact amount still separates two same-amount vouchers.
[2026-09-16] SIE import routes a used class 9 source account (Fortnox/Visma 9999 OBS-konto) to BAS 2999 OBS-konto as a low-confidence suggestion in sie-preview-mappings instead of leaving it blank: the onboarding books flow has no mapping page, so a blank target became a self-created 9999 and the job refused the whole import with the registry sentence (desk crm#63). Adding a mapping page to that flow was the alternative; rejected because the flow is deliberately one-shot and 2999 is the BAS answer for every observation posting anyway. The class error now also carries the account numbers so the message can name them.
[2026-09-17] A Spiris momskod translates only if its treatment files the ruta the code wrote (filesTheRutaItNames asks resolveVatTreatmentRuta, not the six BAS numbers again). Ruta 23/24 is the ambiguous pair: outside 441x/442x a "24" filed as varor silently.
[2026-09-16] Voucher reference matching: whole-number hits only, and a reference earns 0.99 solely with an amount that settles the invoice (else 0.90, below the 0.95 auto-link bar): every one of the 31 "Ankomstnummer N omnämnt" auto-links in prod was a substring hit ("14" in "(1814)") paying another supplier's amount onto the invoice (desk crm#64). The arrival number is Accounted's own sequence and never appears in a bank text on purpose, so it counts only with a corroborating amount; dropping it entirely was the alternative, kept because a whole-token hit plus the exact amount still separates two same-amount vouchers.
[2026-09-16] SIE import routes a used class 9 source account (Fortnox/Visma 9999 OBS-konto) to BAS 2999 OBS-konto as a low-confidence suggestion in sie-preview-mappings instead of leaving it blank: the onboarding books flow has no mapping page, so a blank target became a self-created 9999 and the job refused the whole import with the registry sentence (desk crm#63). Adding a mapping page to that flow was the alternative; rejected because the flow is deliberately one-shot and 2999 is the BAS answer for every observation posting anyway. The class error now also carries the account numbers so the message can name them.
[2026-09-16] TIC name picker: one pickCompanyName helper preferring legalName, shared by /lookup and /profile, instead of making Bolagsuppgifter editable (PH#80): the panel mirrors the registry, so the fix is to read the registry right, not to let users override it.
[2026-09-16] Editing a payee bank account that has full details makes it the default for its currency when none is set (same rule as creating one): a saved account no invoice prints reads as "nothing was saved" (PH 79). Clearing the default on purpose still works; it only auto-fills when the currency has no default.
[2026-09-16] The DB releases a webshop order when its draft invoice stops being a document: FK ON DELETE SET NULL on hard delete, AFTER UPDATE trigger on invoices for makulering, no app unlink (crm#56). First cut unlinked in app code after the cancel; a second statement can fail and pin the order.
[2026-09-16] CHF added via one CURRENCIES tuple in types/index.ts that every TS list derives from, instead of patching 18 literals: the public.currencies table cannot feed a TS type, so the tuple plus a test pinning it to the migration seed is the single source (crm#66).
[2026-09-18] Skills v2 follows the full written plan except receipt hunting. Emil waived ChatGPT/Grok acceptance runs; Claude remains primary. The Codex plugin stays conditional on demand, not an unconditional deliverable.
[2026-09-18] Workflow bodies moved into lib/agent-skills and are re-exported by MCP: the Skills page and MCP share one source without a forbidden core-to-extension import.
[2026-09-18] OAuth keys keep their classification name; proposal labels derive from the stored provider or registered client. Renaming keys would break existing connected-client discovery.
[2026-09-18] Skill provenance uses durable, tenant/key/session-scoped retrieval events with hashes, not process-local session state. It records observed retrieval, never model obedience; missing or truncated evidence is explicit.
[2026-09-18] get_task fits the unchanged 60,500-token ceiling by shortening list/load/briefing prose and using a compact validated input schema. No tool was demoted; accounted projection measures 60,491 approximate tokens.
[2026-09-18] Handoffs use explicit copy and clean chat URLs, including a copy path for CLI users. Unverified application schemes are not launched, and company data is not put in third-party URL parameters.
[2026-09-18] company_skills extends the deployed atom-tier constraint instead of replacing it: staging already has a product tier. Only staging received the byte-identical migration and rollback-only RLS fixture.
[2026-09-18] Community publication freezes consented text and requires human review, a merged registry PR and verified live body before Published. The app has no GitHub token; withdrawals disable the atom before a removal PR.
[2026-09-18] Month-close handoff uses the existing reconciliation workspace and preserves its selected account/period; no parallel monthly-close page or new accounting engine was introduced.
[2026-09-17] Legal forms become capability profiles (docs/LEGAL-FORMS.md, rule .claude/rules/legal-forms.md) instead of more byEntityType arms and literal gates: main still carries 69 `=== 'aktiebolag'` / `=== 'enskild_firma'` comparisons in 45 files that each send a new form down the branch it was not written for (all four ledger-affecting förening defects of the 09-17 audit live there), so the fourth form would multiply them again. Call sites read named capabilities from one profile per form; the profile registry is keyed (jurisdiction, code) with jurisdiction fixed to 'SE' so a second country adds a key, not a refactor; enforcement is the Record type at profile construction plus a literalLegalForm ratchet in check:guards, not the document. Forms as extensions rejected: a form varies law the triggers must know at commit time, so an extension that is off would book wrongly rather than fail. Adopted incrementally: the ideell förening pre-flip fixes are slice 1, the rest converts as files are touched, the chart-seed table waits for the next scheduled form.
[2026-09-17] Legal-form facts moved into one profile per form (lib/company/forms/, contract docs/LEGAL-FORMS.md); lib/company/entity-type.ts keeps its readers as one-line views of the profile so no caller changes, and gains filesIncomeReturn/booksCurrentTax/supportsCorporateTaxDispositions/supportsAccountingFramework/hasOwners/legalFormGlossary for the sites that still compare the form to a string. Two fields from the contract sketch were dropped (memberCapital, auditorAlwaysRequired): every current form answers them the same way, and a unit test now refuses any field all forms share, so the ekonomisk förening PR adds them when it adds the form that differs. The creation-flag read stays a literal process.env spelling in entity-type.ts (Next.js inlines only that shape into client bundles) with a test that every flagged profile has a reader. New check:guards counter literal-legal-form at baseline 96 (67 comparisons, 29 defaults and single-string tags on main today) may only go down; the pg test legal-form-profiles asserts supported_entity_types() equals the registry and that each form's seed carries its closing and settlement accounts (2098 deliberately not: the AB seed leaves it to the engine's on-demand creation from the BAS reference).
[2026-09-17] SIE import books the IB difference on the form's result-closing account (resultClosingAccounts: 2099 AB, 2010 EF, 2069 ideell förening), resolved once per import from companies.entity_type and never defaulted: the unallocated_result branch parks a whole prior-year result there, and a förening year-end carries 2069 to 2068, never 2099. The parser warning (validateSIEFile has no company) names eget kapital instead of an account; threading the form through its four callers for one sentence was the alternative.
[2026-09-17] SIE job finalize names the account the preparation sealed (manifest.openingBalanceDifferenceAccount) and falls back to 2099 only for jobs sealed before the field existed: those jobs did book on 2099, so any other text would misdocument them. OpeningBalanceRowEditor keeps its 2099 auto-balance row: it knows rows and BAS data, not the company; plumbing the form through the wizard and the correction dialog is UI work, not a ledger gate.
[2026-09-17] The bokslutsdispositioner route refuses POST and PUT with YEAR_END_DISPOSITIONS_WRONG_LEGAL_FORM (400) unless supportsCorporateTaxDispositions(form), every kind at once: the wizard never offers them to another form, so this only closes the hand-made request path. sarskild_loneskatt is gated with the rest because whether SLP applies to a förening employer is a domain question not yet settled; a per-kind carve-out waits for that answer. The överavskrivningar calculator reads the same capability instead of comparing the form to a string.
[2026-09-17] Non-VAT-registered companies book bank transactions with no moms line through ONE seam, the VAT treatment: lib/bookkeeping/vat-registration.ts resolves a rate-bearing treatment to exempt (reverse charge untouched, mirroring the supplier-invoice guard, 13 kap. ML 2023:200) and the four mapping-result builders (category, static template, counterparty template incl. learned line patterns, DB rule) apply it before emitting lines, with callers passing company_settings.vat_registered as loaded (only an explicit false changes anything). Rejected: stripping 26xx lines in createTransactionJournalEntry (would diverge preview from booking and need account sniffing) and reading the flag inside the pure builders. Income resolves to 3004 like any exempt supply; a vat_amount override is refused, not stripped. This changes bookings for every existing non-registered company of any form, which is the bug.
[2026-09-17] computeEfDeclarationPreview refuses any form whose profile does not file NE (EfDeclarationNotApplicableError, code EF_DECLARATION_WRONG_LEGAL_FORM, registered as a 400) before any other read, and takes the caller's already-resolved form as an input hint so the readiness aggregator, which switched from `=== 'enskild_firma'` to filesIncomeReturn(form) === 'NE', does not pay a second companies read. The MCP tool is unchanged: its dispatcher already maps a thrown `.code` through getStructuredError, so the code reaches the agent without a per-tool catch.
[2026-09-17] Year-end Dispositioner step is three-way by capability (supportsCorporateTaxDispositions, filesIncomeReturn === NE, else depreciation plus a "nothing prepared yet" note) and DispositionsProposal.entityType is narrowed to EntityType: the builder only ever assigns resolveCompanyEntityType() and the wizard needs the real union to read capabilities; the old six-member string union (handelsbolag, kommanditbolag, ekonomisk_forening) was never produced and let `!== aktiebolag` route a förening into the NE-bilaga inputs.
[2026-09-17] getAnnualReportCapabilities takes the model entity_type as its first argument and gates paper, iXBRL preview and connected filing on preparesArsredovisning(form); eligibility (AR-SCOPE-ENTITY) reads the same capability instead of `!== aktiebolag`: the document model is AB-shaped, so an AB-shaped PDF was producible for a förening once its period closed. iXBRL was gated with the paper PDF, not only the PDF the ticket named, because it is the same document in another container. ResultStep reads the form from the company context (no new fetch), names the closing and carry accounts from resultClosingAccounts(form), and offers "Skapa årsredovisning" only when the form prepares one; with no known form the copy is account-neutral and no årsredovisning is offered. lib/bokslut/checklist.ts line 53 has no legal-form literal (it is the neutral "Årsbokslut eller årsredovisning" checklist item), so it was left alone.
[2026-09-17] Onboarding picker is data-driven: selectable cards from creatableEntityTypes() (enskild firma, aktiebolag, then any other creatable form), and plannedLegalForms() plus every flagged-off form under a collapsed "Förening eller annan form" group as non-selectable "Kommer snart" rows. A registry hit on a planned or flagged-off form (mapPlannedLegalForm, or mapEntityType with the creation flag off) goes to a new "planned" step ("<Form> stöds inte ännu") with "Ändra numret" and "Välj företagsform ändå"; the escape hatch stays because the registry can be wrong and the picker itself now names the form in an attn line. The planned code is derived in the reducer from legalEntityType rather than added to CompanyLookupResult (which the MCP lookup shares), and the SCB code map gained 51/53/62/71/72 so a stiftelse or samfällighet, which Bolagsverket does not hold, still reaches the stop via the search-as-you-type prefill. nextCompanyStep re-asks a prefilled name only when usesPersonnummerAsOrgNumber(form), replacing `=== aktiebolag`: a förening has a registered name like an AB.
[2026-09-17] Onboarding journey copy: isEf replaced by two capabilities, personOwned (usesPersonnummerAsOrgNumber: the company IS the person, so "du"/"firman") and calendarOnly (fiscalYearLockedToCalendar, BFL 3 kap 1 §); the non-EF strings were made form-neutral in place ("företaget", "Bolagsverket eller Skatteverket", "standard för alla företagsformer utom enskild firma") instead of adding _forening key triplets, so an AB reads "företaget" where it read "bolaget" before. The F-skatt question logic, the "Påminnelse skapad" done-card line and the 120 000 kr VAT threshold sentence were left as they are (pre-existing for all forms; the threshold is a domain question).
[2026-09-17] MCP onboarding skill body is a getter that renders the supported-forms sentence from creatableEntityTypes()/plannedLegalForms() per read (the creation flag is a NEXT_PUBLIC_ variable, so process.env answers on the Node server), replacing the hand-written "Only aktiebolag and enskild_firma"; gnubok_run_year_end resolves the company form and previews resultClosingAccounts(form) (closing_account, closing_account_name, and the "will" sentence) instead of a hard-coded 2099, and its description is account-agnostic. The longer description tipped tools/list onto the 60 500-token ceiling exactly, so it was paid for inside the same tool ("class 3-8 into the form's result account, then lock, close, seed next IB. High-risk, staged."); ceiling unchanged, no read demoted. The MCP skills applicability filter (server.ts ~4750) compares AB/EF with aktiebolag/enskild_firma and hides year-end-close for every company; reported, not fixed here.
[2026-09-17] Expense claimant fallback is ownerFallbackName(form) (legalFormGlossary(form).owner: Ägare for AB/EF, Medlem for a förening) at the three callers that have the form (supplier-invoices route, ExpenseClaimantFields via the company context, RegisterExpenseDialog); OWNER_FALLBACK_NAME stays as the AB/EF value for a form not yet known so the label never flips mid-render. The "egen insättning, no debt" help in PayerChoiceSelect and RegisterExpenseDialog keys on usesPersonnummerAsOrgNumber(form), because the reason an EF owes its owner nothing is that it is not a separate legal person; "Bolaget blir skyldigt ..." became "Företaget blir skyldigt ..." in sv (en already said "the company"). Payroll navigation (DashboardNav, useSettingsNavItems) shows by default for every juridisk person (!usesPersonnummerAsOrgNumber) and an enskild firma opts in via pays_salaries, so a förening sees Lön without flipping "Betalar löner". The agent composer fallback selects atoms by capability (preparesArsredovisning, filesIncomeReturn === INK2, supportsCorporateTaxDispositions, hasOwners && !usesPersonnummerAsOrgNumber for the shareholder modifier) and the narrative names the form without an article ("Byalaget. Företagsform: Ideell förening."), since a per-form article map would be one more literal site. The literal-legal-form baseline is ratcheted from 96 to 81, the count after this PR. The entityOnly nav tags for INK2/NE/årsredovisning (DashboardNav, nav-v2) were left: they gate pages that exist for exactly one form each and are a data tag, not a comparison.
[2026-09-18] The onboarding picker stays two chips (aktiebolag, enskild firma): the TIC /lookup route falls back to SCB's företagsregister on a miss for a juridisk person and answers 404 with a registry hint (name, legal form, address, F-skatt when stated), so an ideell förening (SCB form 61, never in Bolagsverket) is settled before the picker and an ekonomisk förening or BRF lands on the 'stöds inte ännu' stop; the hint is not a lookup result on purpose (lookupRan stays false) so moms and a missing address are still asked. Every other creatable form is reached through one muted line with a text link, and becomes a third chip only when the org number is 8-series (Skatteverket's series for föreningar and stiftelser, a hint not a determination) after every lookup missed. The collapsible 'Förening eller annan form' group from the same day was replaced: the founder wants the screen uncluttered and the lookup carries the load.
[2026-09-17] A bounced, failed or suppressed provider report naming nobody in the send gets "(reported for ***@domain)", or "(reported without a recipient)", appended to provider_status_detail, no new column (PH 27): the detail is shown raw and survives PII redaction, so only the domain is stored.
[2026-09-18] Avvikelseperiod is snapshotted per salary run (salary_runs.deviation_period_start/_end) from company_settings.salary_deviation_period or explicit API dates, and the engine, the AGI frånvarouppgifter and the payslip read the run columns, never the setting: a later settings flip must not move an already-calculated run. Only two settings (same_month, previous_month), with arbitrary windows reserved for the explicit per-run dates, and a window that overlaps another live run is refused at creation instead of guarded at calculation: the double-deduction happens at calculate, but the caller can only fix it at create. A mid-stream switch is therefore deliberately unsupported (the previous month was already read by the previous run); the setting is meant to be chosen before the first run. Rejected: a company-wide day-offset column on company_settings read at calculate time (unfrozen, silently retroactive) and adding salary_deviation_period to the v1 settings PATCH surface (that endpoint mirrors the MCP bank/contact tool field for field; integrators pass the dates per run instead).
[2026-09-18] Payroll gap-closure 4 (operator onboarding, Frey): the three pieces an API-only payroll operator still had to do in the app become v1 endpoints under the existing payroll scopes rather than a new scope or a widened /settings PATCH: GET/PATCH /salary/settings (its own resource, so the bank/contact settings endpoint keeps mirroring the MCP tool field for field), GET/PUT/DELETE /employees/:id/worked-days (shared lib/salary/worked-days.ts, the absence.ts shape; the two internal worked-hours routes call it and keep their delete+insert and 207 contracts because their tests pin them), and POST /salary-runs/:id/payment-file (shared lib/salary/payment/build-payment-file.ts; the file is returned inline like generate-agi, no execution_date option because neither generator honours one and an agent must not believe it controls the transfer date). Rejected: a GET for the payment file (it stamps the run) and exposing the 92-day range error as ABSENCE_RANGE_TOO_LARGE on worked days (its Swedish text says frånvaro); worked days map the shared 24h trigger to ABSENCE_HOURS_CONFLICT since that is the only hours-cap code and the trigger is one and the same.
[2026-09-19] Absence double deduction (Frey, Simon Hällqvist, PR #2729): the engine's Step 4 (bruttolöneavdrag) now excludes the rows Step 3 already consumed by type instead of the derived rows dropping is_gross_deduction: the flag also drives the booking split in salary-entries.ts (flagged rows stay out of the per-account debit and fall into the salary remainder), so flipping it would have moved sjuklön and VAB between accounts in the same change. Sick day one now receives sjuklön with the karensavdrag capped at the sjuklön the period yields and the rest carried into the next sick day, also across a month boundary (SjLL 6 § as amended 2019; the old code was the pre-2019 karensdag shape and overpaid 0.2 day per sjukperiod), and partial absence rows are weighted by hours against the scheduled day (hours_per_week / workdays_per_week). Taken from #2729 with credit; the calculation-policy toggles, calendar-day long leave, one-off tax and the categorized cutover balances in that PR stay in review as their own change: seven migrations, unfinished rollover and a rewrite of the day-old salary settings route do not belong in a bug fix.
[2026-09-18] Momsruta for 26xx accounts is a per-account override (chart_of_accounts.vat_box), not a wizard remap (Easy Online Stores brief F1): the declaration mapped 26xx by BAS number only, so a Fortnox chart with EU-förvärv on 2615, import on 2616 and tjänster utanför EU on 2617 put ruta 30/60 wrong every month and 2617 nowhere. Renaming the accounts on import would rewrite the company's history, and the class-3-6 momskod cannot express "this account IS the output VAT", so the account carries the ruta it feeds; the dialog only hints from the name, it never sets the box by itself. The import wizard flagging such deviations is a follow-up.
[2026-09-18] vat_box on gnubok_update_account carries no schema enum and the account tool descriptions were shortened (is_active, account_number, the tool line): the tools/list ceiling (payload-size.bench) sat at the limit, and the guard's rule is to pay inside the same tools. The value set is validated in execute and the pending-operation schema instead.
[2026-09-19] API parity, fixed assets: the anläggningsregister reaches both API-key doors (v1 REST: list/get/create/update/dispose; MCP: gnubok_list_assets, gnubok_get_asset, gnubok_create_asset, gnubok_update_asset, gnubok_dispose_asset) through one shared module, lib/bokslut/assets/asset-api.ts, that owns the Zod contracts, the K3/K2 gates and the wire shape; the dashboard routes now import the same schemas. Why: the three doors had started as three copies of the same validation, and the register is a legal requirement that had no machine door at all. The dashboard routes keep their inline gate logic for now (only the schemas moved) so this PR does not change their behaviour; switching them to checkCreateAssetGates/checkUpdateAssetGates is a follow-up.
[2026-09-19] MCP payload ceiling stepped 60.5K → 62K for the asset family instead of hiding gnubok_create_asset behind search: get/update/dispose are search-only and named by the list tool, but a new register needs its read and its create in the default catalog or "registrera en inventarie" finds nothing. Schemas were trimmed to bare formats first (61 849 after the trim; the ~1.35K left is two staged tools plus a 20-field row schema with no earlier tool to share).
[2026-09-19] Asset disposal on the MCP door stages (dispose_asset, medium) rather than writing directly: it posts a voucher, so it sits with post_annual_depreciation, and the preview is the exact plan (previewAssetDisposal) the commit books, not a paraphrase.
[2026-09-18] The vacation-liability detector declines with a snapshot-level `notices` entry instead of a proposal with a warning when 2920 has a balance but no employees exist (Easy Online Stores brief F4): a warning sits beside a card the user can still tick, and one tick would reverse a 1,4 mkr liability that lives in another payroll system; a notice has nothing to approve. The POST route still resolves the kind to null, so an agent posting `vacation_liability_change` for such a company books nothing.
[2026-09-18] gnubok_list_accounts compact/limit/offset paid for inside the touched tools (Easy Online Stores brief, F6): the tools/list ceiling had 2 tokens left, so the reconciliation tool descriptions lost their repeated key list and list_accounts its prose instead of demoting a read. The account_key names stay in gnubok_get_reconciliation_status because search ranking on "skattekonto" reads the description, not only keywords. The five "Unknown account_key" throw sites (status, items, match, signoff, residual) share one helper so every reconciliation tool explains an unresolved key the same way.
[2026-09-18] gnubok_sie_preflight and gnubok_create_sie_upload require reports:read, not bookkeeping:write (Easy Online Stores brief, F5): preflight is analysis and the upload URL only stages bytes in the documents bucket (50 MB cap, two-hour TTL, per-company pending prefix), so a read key can check a file before anyone holds a write key; gnubok_import_sie and gnubok_undo_sie_import stay bookkeeping:write. Rejected a separate "sie:preflight" scope: one more grant to explain for a read that mutates nothing.
[2026-09-18] validateSIEJobInput skips line-less vouchers in the size and date checks: they are skipped at preparation as empty and never posted, so one #BTRANS-only voucher dated in the previous year must not refuse the whole file (it did, three times, and both routes then replaced the validator's sentence with a generic one; sieJobValidationResponse now carries that sentence).
[2026-09-19] Payroll gap-closure 5 (benefits, recurring lines, :correct on v1): each register got a shared command module in lib/salary/ (employee-benefits.ts, employee-recurring-lines.ts, correct-run.ts) that the dashboard routes now call, the absence.ts shape, so the two doors cannot drift; the internal recurring-lines GET now answers 404 for a foreign employee instead of an empty list, matching every sibling. Delete semantics were kept as the dashboard had them: benefits and recurring lines delete-first and fall back to is_active=false when a payslip line references them, and :correct keeps the dashboard's exact write order (storno each entry, flip to corrected, revoke links, insert the draft, copy the roster). Two behaviour changes on purpose: a retried correction resumes past entries an earlier attempt already reversed (CannotReverseNonPostedError with status reversed counts as done) and, when the original is already corrected but its draft was never inserted, resumes at the insert without touching the ledger; before, either half-failure left a run that could never be corrected through the verb again. Benefits check for referencing payslip lines explicitly before deleting (their provenance FK is SET NULL, so unlike recurring lines the database would not have blocked the delete) and are deactivated instead, which keeps the chain from a booked verifikat to its förmån (BFL 5 kap 6-7 §, #2695). Module failure details on the wire carry the SQLSTATE only; the raw PostgreSQL message stays on cause for the dashboard. The partial-storno state rides valid_alternatives, not details, because the legacy error path prefers a bookkeeping error's own details and lib/errors was out of scope.
[2026-09-19] Salary register integrity (#2723, #2724, #2732): absence and worked-days writes on dates inside the avvikelseperiod of a run in review, approved, paid or booked are refused with 409 SALARY_REGISTER_DATES_LOCKED_BY_RUN from one shared helper (lib/salary/register-locks.ts) that both command modules call, so dashboard, v1 and MCP behave the same; drafts and corrected originals never lock, and the way out is revert-to-draft or the :correct verb. Rejected: a who/when history table for register rows (the payslip lines already snapshot what was paid; the guard removes the divergence instead of recording it). Every generated salary payment file is archived as an immutable salary_payment_files row (WORM trigger with the sandbox-teardown escape, RLS insert+select only, sha256 and byte_size over the exact bytes the download carries, iso-8859-1 for LB) before it is handed out, mirroring the supplier_payment_batches snapshot, with GET /salary-runs/:id/payment-files to read the archive back over the API; an archive failure fails the generation because an unarchived file must never reach the bank. VAB and parental rows split at the 120th calendar date of the semestergrundande cap into a qualifying and a non-qualifying row (SemL 17, 17 a §), each priced on its own weighted days.
[2026-09-19] Payroll calculation conventions ported from #2729 (Frey) as company_settings.salary_calculation_policy (partial_month, sick_rate, long_leave, leave_context, net_rounding, one_off_tax_rounding), each a pure opt-in whose default reproduces the historical engine; Fortnox is the parity reference. The v1 PATCH merges the policy key by key (the contributor replaced it whole) so one convention cannot reset the others. Engångsskatt is a per-line one_off_tax_percent grouped per rate before öre are dropped. Manual semesterersättning rows survive recalculation through salary_line_items.calculation_source; pre-existing engine rows are backfilled by their exact signature in the migration instead of the contributor's hard block on unclassified rows (no UI existed to classify them and today's code deletes every such row anyway). Left for the cutover evaluation: categorized cutover balances, vacation review holds, zero-vacation tracking, unknown opening defaults, ytd/vacation-ledger rewrites, effective-tax-override totals, degree-adjusting the absence salary.
[2026-09-19] link_invoice_to_voucher's SEK fallback opens to kontantmetoden only when invoices.journal_entry_id IS NULL (new migration after #2718): an invoice booked a