Commit Graph

15 Commits

Author SHA1 Message Date
Jakob Wennberg fa394e3759 fix(skattekonto): look-alike beslut rows, the list-to-voucher round trip, makulerad rendering, huvudbok discoverability (#1297)
Four fixes from the exit mail Anders Orback (Center Node AB) sent hours
after churning. His five points were mostly one job: reconciling
skattekontot against banken before årsredovisningen.

Skattekonto look-alike rows. Skatteverket splits a retroactive
omprövningsbeslut across every month it re-charges and sends one
transaction per month, sharing date, text and amount; only
ranteberakningsdatum separates them, and we stored it but rendered it
nowhere. A real company posted 15 such vouchers (67 785 kr across Feb
2025-Apr 2026) unable to tell them from duplicates of the automatic
hämtning. Surface the field when it carries information: its month
differs from the Datum column, or another row in the same band is
otherwise indistinguishable.

The list-to-voucher round trip. The verifikat list collapsed to a
skeleton on every refetch and sprang back, moving rows under the
pointer; only the first load shows a skeleton now. Filter state is
React-only, so leaving the list loses it: add a hover-revealed
open-in-new-tab affordance on the voucher list and the skattekonto page,
where the link had been behind a hand-rolled opacity-0 that coarse
pointers never trigger.

Makulerad rendering. A stornoed verifikat now reads as struck out, per
data cell rather than on the row, because text-decoration propagates and
a child cannot opt out.

Vouchers-per-account discoverability. /reports/huvudbok?account=1930
already existed; the palette matcher requires every token and the entry
never contained the word "verifikat". Add ReportDescriptor.searchTerms
plus a report-library search box.

Also fixes a false "Saknar underlag" compliance chip that flashed before
attachment counts resolved, and a keyboard-access regression where
HOVER_REVEAL_CLASS carried focus-visible only, hiding controls inside a
non-focusable wrapper from keyboard users.

No migration. No write paths, storno paths or posted entries touched.

Follow-ups filed: #1300 #1301 #1302 #1303 #1304 #1305 #1306 #1307 #1308.
Open decision: #1305 (Omförd vs Makulerad).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:27:29 +02:00
Jakob Wennberg bcbe9b0903 feat(assistant): say what the conversation is anchored to (#1222)
* feat(assistant): say what the conversation is anchored to

agent_conversations.context_ref has been written since the first intents
shipped and read by nothing. The panel ignored it, so a thread resumed three
days later showed the messages with no indication of which invoice or which
bokslut it concerned, even though the row knew. /chat did worse: it printed the
ref raw, so the subtitle under someone's own conversation read
"invoice:5f3a-9c21-...", a database identifier shown to an accountant.

Both surfaces now render the same chip, which names the thing and links to it.
This matters more since the panel docks: sitting beside the page, "what is this
about" is a question the surface should answer rather than the user's memory.

The mapping is a data map in route-mapping.ts, not a switch in a component
(plan seam 8.5), so a flow run's ref renders in both surfaces with no change to
either. A ref it cannot read renders nothing rather than a broken chip.

Two refs deliberately have no link. There is no /transactions/[id] route, so a
transaction chip points at the list. The document inbox is an extension mounted
under /e/[sector], and core must not hardcode a path that exists only when the
extension is enabled, so that one is named without being linked.

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

* fix(assistant): review triage: make the colon test observable, drop an overclaim

The colon-splitting test asserted on a kpi ref, and kpi discards its id, so it
passed even with a parser that dropped everything after the second colon. Moved
to invoice:abc:2026, where the id reaches the href. Verified by switching
indexOf to lastIndexOf and confirming the test fails.

ContextChip's comment said a flow run's ref renders with no change to either
surface. It does not: an unknown kind maps to null and renders nothing until
the map gains an entry. The seam is that adding one is a single entry in one
file, which is what the comment now says.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:26:16 +02:00
Jakob Wennberg 16e1a84b4b fix(assistant): defuse memory prompt injection and bound replayed history (#1219)
* fix(assistant): defuse memory prompt injection and bound replayed history

The last two blocking items from dev_docs/assistant_redesign_readiness.md that
were never shipped.

Agent memory rendered into the system prompt verbatim. gnubok_remember_fact
commits immediately with no staging, and the model can be induced to call it by
untrusted text it read from a document or inbox item; the content then renders
for every member of the company, on every future turn, outside the
<tool_output> framing that exists for exactly this. A payload carrying newlines
and markdown could open what reads as a new prompt section. Memory lines are now
flattened before rendering (whitespace collapsed, structure-opening characters
defused at the start of a line) and the block carries the same
these-are-not-instructions framing tool output already had. The words survive:
this is about structure, not censorship.

Conversation history loaded unbounded, so every persisted tool result replayed
on every turn. Cost grew linearly with thread age and a long-lived pinned
conversation would eventually exceed the context window, at which point every
turn fails and, because the store is append-only, the thread is unusable for
good. The load is now newest-first with a cap and flipped back. Slicing a tail
can orphan a tool_result whose tool_use fell off the top: repairDanglingToolUse
already normalizes both directions, which is what makes the cap safe.

Verified: 11321 tests pass (7 new pinning the flattening, including that an
injected heading is defused while its words survive), lint and tsc clean,
guards pass.

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

* fix(agent): review triage: stop the memory flattener flipping a minus sign

The leading-marker strip removed any leading dash, so a stored fact of
"-50 kr i avvikelse" became "50 kr i avvikelse": a different number, in the one
part of the prompt that exists to carry facts about money, with nothing
downstream able to notice. A Markdown bullet is a dash, star or plus followed by
whitespace, so require that; inline emphasis stays as literal characters since
it cannot open a block anyway.

Also tie-break the 200-message history cap on id so the cutoff row is stable
across replays when created_at ties. Insertion order is deliberately not what
this restores: the ordering that matters, tool_use before its tool_result, is
already reconstructed by repairDanglingToolUse, which is what makes slicing a
tail safe in the first place.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:18:54 +02:00
Jakob Wennberg 2d543ac999 feat(agent): move every model call to Sonnet 5 (#1218)
* feat(agent): move every model call to Sonnet 5

Sonnet 5 is verified enabled on our Bedrock account already: a live probe of
eu.anthropic.claude-sonnet-5 in eu-north-1 answered normally, so no model-access
request was needed. The bare anthropic.claude-sonnet-5 is rejected (on-demand
throughput needs the cross-region inference profile), so the eu. prefix we
already use stays.

This is not a model-string swap. Sonnet 5 REJECTS the fixed thinking budget
outright: thinking {type:'enabled', budget_tokens} returns 400 "not supported
for this model. Use thinking.type.adaptive and output_config.effort". Every
chat intent set a budget, so the assistant would have failed on the first turn
after a bare ID change. Reasoning depth is now an effort level (STANDARD high,
DEEP xhigh), and max_tokens is explicit per tier rather than derived from a
budget that no longer exists.

display:'summarized' is load-bearing, not cosmetic. The default is 'omitted',
which still emits thinking blocks but with empty text. Measured on our own
account at xhigh effort: summarized returned ~1k characters of reasoning, the
default returned none. Without it the collapsible "Tänker ..." block in the
chat would have gone silently empty, which no mocked test would have caught.

Ceilings are raised (16k standard, 24k deep) because Sonnet 5's tokenizer
produces roughly 30% more tokens for the same text and max_tokens now caps
thinking and the visible reply together.

Also resolves the Opus 4.7 landmine recorded in the readiness doc: the composer
comment told ops to flip BEDROCK_OPUS_MODEL_ID to Opus 4.7, which would have
400d every thinking intent against the legacy budget shape. Both model
constants now point at Sonnet 5 and the stale instruction is gone.

Checked but deliberately unchanged: forced tool_choice in atom-selection. The
Sonnet 5 docs require thinking:{type:'disabled'} alongside a forced tool_choice
on Bedrock; probed against our account, the forced call succeeds without it, so
no change was made rather than adding a guard we cannot show is needed.

Other call sites moved too: invoice-inbox extraction, document extraction, the
compliance config, and the CI/CD workflows (pr-agent MODEL and MODEL_WEAK,
swedish-compliance-review, compliance-swarm).

Verified: 11315 tests pass, lint and tsc clean on every touched file, guards
pass.

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

* fix(agent): review triage: keep the no-thinking output ceiling, finish the model sweep

max_tokens now caps thinking and the visible reply together, so collapsing the
two tiers into one made every non-thinking intent inherit a 16000 ceiling where
it used to have 4096. Give it its own MAX_TOKENS_NO_THINKING instead, set to the
old 4096 scaled ~30% for Sonnet 5's tokenizer so the effective reply length is
unchanged rather than quietly cut.

scripts/swedish-compliance-review.mjs still fell back to Sonnet 4.6 when
REVIEW_MODEL was unset, so a manual run silently used the old model. The initial
sweep only covered .ts and .yml.

pr-agent's FALLBACK_MODELS listed the primary model as its own fallback, which is
not a fallback; dropped it and rewrote the surrounding comments, which still
described Opus 4.8 and a 200k window.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:16:40 +02:00
Mattsson f24b26a139 fix: similar-sweep currency remediation, security hardening and v1 API fixes (#1215)
* fix(security): gate replace_sie_import behind owner/admin membership

The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no
company_members lookup, no auth.uid() reference and no unauthorized raise,
while setting gnubok.allow_delete to disarm the BFL immutability and
retention triggers. Any caller holding a company_id and an import id could
hard delete another tenant's verifikationer. Confirmed live in production.

Applies the same fail closed owner/admin guard that undo_sie_import already
carries (migration 20260624120000), resolving the actor from
COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then
revokes EXECUTE from PUBLIC and anon. search_path and the raised
statement_timeout are restated, since CREATE OR REPLACE drops settings that
are not repeated.

userId is a required parameter on replaceSIEImport: the service client has a
NULL auth.uid(), so a caller without an explicit actor now fails to compile
rather than hitting the closed gate at runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): validate arcim OAuth callback state server side

The callback route is skipAuth and decoded the state parameter as plain
base64url JSON, trusting consentId and provider from it. A one time code was
minted at flow start and never read. An unauthenticated attacker who learned
a consent id could run an OAuth flow on their own provider account and post
the callback with a forged state, landing their tokens on another tenant's
consent, so the victim's next migration imported the attacker's ledger.

State is now an opaque randomBytes(32) pointer to a provider_otc row,
consumed by a single atomic UPDATE guarded on used_at IS NULL and
expires_at, so a replay loses the row lock race and updates nothing.
provider is read from provider_consents rather than trusted from the client.
provider_otc already existed for exactly this purpose and was never wired up.

Also scopes getConsent to an owning company, closing a cross tenant status
oracle where the preview and migrate paths echoed a consent's status before
the scoped check ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): scope documents storage to company_id (phase A)

The documents bucket policies matched on auth.uid(), and upload keys were
documents/{userId}/..., so company membership was never consulted. Removing a
member revoked nothing: their session still authenticated and they kept
direct Storage read access to every receipt, supplier invoice and bank
statement they had uploaded. The same bug was fixed for sie-files in
20260416120000; this bucket was left behind.

Phase A is additive. Company scoped policies are added alongside the
uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and
reads accept either layout so nothing breaks mid migration. Phase C, which
drops the old policies, is gated on the backfill reporting zero remaining
legacy prefix objects.

The policy compares the company segment as text rather than casting to uuid
the way sie-files does: this bucket holds keys whose second segment is not a
uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix
qual runs before the cast, so a planner reordering would raise 22P02 and fail
the whole query instead of filtering the row out.

deleteDocument now removes both candidate keys. Removing only the stored
pointer would leave a readable orphan copy of a document the user asked to
erase.

The backfill script is included but has never been run. It defaults to dry
run, refuses .env.local by name, and verifies each copy is readable and
SHA-256 identical before repointing the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(security): enforce events:read scope and membership on /api/events

This was the only one of the three validateApiKey call sites with no
downstream guard: v1 and the MCP server both check scope and re-verify
company membership, this route did neither. An events:read scope existed and
was documented as gating the endpoint but was never called, so a legacy key
falling back to DEFAULT_SCOPES read the full log. The bound company id went
straight from the api_keys row into a service role query, so a key whose user
had been removed from the company kept reading.

Adds the scope check before any database access, re-verifies company_members
with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead
of ignoring it, applies minimisePayload so the pull surface can never return
a wider payload than the push surface, and replaces the three flat error
strings with the canonical envelope.

Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is
gated on mutations in with-api-v1, so a read gets the same treatment as every
other v1 read endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(bookkeeping): sweep remaining journal_entries!inner embeds

A previous refactor removed this pattern from lib/reports and introduced
fetchEntryLines, but the class was never swept. Seventeen sites remained and
had become the top application consumer of production database time:
measured across the resulting query shapes, 32,694 calls and 25,848 seconds
of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at
7,962ms against the 8s statement_timeout, which surfaced to users as 500s on
the booking path.

PostgREST compiles an embed with filters on the embedded side into a
correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops
Postgres reordering the join, so each query walked the whole
journal_entry_lines table across all tenants. Driving from the entries side
instead turns that into two indexed round trips.

Converted sites keep their existing shape: the helper reattaches the parent
entry under the same key the embed produced. Several conversions also remove
a latent silent truncation where an unpaginated query was capped at
PostgREST's 1000 row ceiling.

Two deliberate exceptions. The free text ilike legs of the MCP display query
stay on the embed, because each is capped at legLimit and that cap drives the
truncation contract the tool reports, while the helper is unbounded. The
accounts route moves to the existing get_account_usage_counts RPC instead,
since its embed was a head count and the helper returns rows.

commitEntry's write path is untouched: the change there is confined to the
read query of the pre-commit dimension rule check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): anchor v1 list cursors on created_at

Page two returned page one, forever, while still advertising a fresh
next_cursor. The three routes sorted by and encoded a Postgres date column,
which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor
timestamp as full ISO-8601 and returned null, so the keyset filter was never
applied and has_more never went false. An integrator syncing verifikat looped
on the newest rows indefinitely.

The transactions route already solved this and its comment names the trap;
the fix was never ported. All three now order and encode on created_at with
an id tie break, matching the transactions keyset predicate exactly.
ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change
sort semantics on the route that currently works.

Default ordering therefore moves from business date to insert order. Every
business date is still on the row, and the invoices list gains date_from and
date_to filters so a date range is still reachable; the other two already had
them.

The tests use an in-memory PostgREST that actually evaluates the filters,
because the repo's pass-through mock cannot catch this class of bug: the bug
is that the filter is never sent. They walk to exhaustion with a hard
iteration cap, so an unterminated walk fails instead of hanging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(api): separate dry run from commit in the idempotency hash

The request hash was built from url.pathname, which excludes the query
string, so a dry run and its commit hashed identically. Following the flow
documented in dry-run.ts, re-issuing the request with the same
Idempotency-Key returned the cached preview with Idempotent-Replayed set and
wrote nothing, while reporting 200. An agent or integrator saw success for a
write that never happened.

dry_run is folded into the hash only when true, not as an unconditional
boolean. Including it as false would change the hash of every ordinary write,
and with a 24h idempotency TTL any key in flight across the deploy would fail
the request_hash comparison and 409 on a legitimate retry. Both hash call
sites now go through one shared helper so they cannot drift into a permanent
cache miss, and dry run responses are no longer stored at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: install the Bedrock SDK out of tree in the compliance review

The Swedish accounting compliance gate had failed ten consecutive runs and so
was posting nothing. With --no-package-lock npm discarded the lockfile and
re-resolved the whole tree from package.json, floating @hookform/resolvers to
5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0.

Installing into the parent of the checkout resolves only that one package, so
an unrelated peer conflict can never take the gate down again. Node still
finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH
would not have worked, as it is CommonJS only. --legacy-peer-deps was
rejected because it masks future genuine peer conflicts and still reifies the
full tree.

The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that
package.json and check:guards enforce after the streaming outage. That drift
went unnoticed because the pin guard only inspects package.json and the
lockfile, never workflow files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build(docker): generate crontabs from vercel.json

vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were
byte identical to each other. Self hosted deployments therefore never sent
recurring invoices, never dispatched webhooks and never cleaned up
idempotency keys. tax-deadlines also ran once a year on 2 January instead of
daily, and documents/verify weekly instead of daily.

Extension crons are included rather than excluded. The Dockerfile copies the
whole tree before building, so every extension cron route is compiled into
the image regardless of the enabled preset, and each returns 200 when its
extension is unconfigured, so curl -sf logs no failure. Two such entries were
already present in the crontab for extensions absent from the preset, which
settles the intent.

documents/verify is treated as drift rather than a self hosted concession:
the weekly cadence was present in the hosted crontab too, and the run is
capped at 200 documents walking a nulls-first queue, so weekly drains the
integrity queue seven times slower on a check that exists for BFL retention.

webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day
on self hosted. A gentler tick would silently stretch the first retry, since
the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line
place to change that.

A parity test asserts the path sets match minus a documented exclusion list,
and ratchets three cron routes that are currently scheduled nowhere so they
are named rather than silently rotting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(observability): add a provider agnostic error sink

There is no error tracking in this codebase: logs go to console and Vercel
retention and nowhere else, nothing alerts on the 16 cron jobs, and seven
code comments across lib, app, components and extensions asserted that Sentry
captures errors when Sentry is not a dependency. The two most recent bug
fixes on this repo were both discovered by customer email.

This adds the sink, not a vendor. No dependency is taken: the interface has a
no-op default and a registration point, so behaviour is unchanged until an
adapter is registered. Releases are tagged from the build id already inlined
by next.config.ts.

Redaction moved out of lib/logger.ts into a leaf module that both the logger
and the sink import, so there is one denylist and no path from application
data to a third party can skip the personnummer regex, including direct sink
calls that bypass the logger. That matters here because these logs carry
personnummer and financial data.

verifyCronSecret now reports its own 401s, which covers all 16 jobs without
touching a route file and catches the case where CRON_SECRET is rotated
without updating the scheduler and every job silently 401s forever. The
threshold is one failure rather than the backup alert's three: suppressing
the first occurrence is precisely how an outage stays invisible.

The seven misleading comments are corrected to describe what the code
actually does, including the two cases that still are not covered: the client
side one, since the sink is server side, and a warn level call that is not
forwarded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: remediate the 2026-07-26 similar-sweep findings across all surfaces

Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with
one agent per finding; every behavioural fix carries a regression test proven
to fail at HEAD. Full status, corrections to the sweep, refusals and open
decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md.

Structural roots closed:
- resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking
  1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING
- ledger-line-amount.ts: journal_entry_lines.currency labels the document,
  not the amount; SQL pre-filter decoy proven and fixed
- sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the
  exploitable salary payslip-line PATCH and KPI preferences sinks fixed
- tests/schema: migration-replay phantom-column guard (13k+ refs, closed
  CHECK sets, onConflict targets); found 28 real defects, all fixed, all
  four baselines now empty
- three new ratchet guards: sek-labelled-amount, cross-extension-import,
  ungated-extension-route

Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap),
RC input VAT mismatch wired on web + both MCP callers, missing-underlag
resource delegates to the shared RPC predicate, push-notifications consent
polarity fail-closed, deadlines undo honours requested state, silent-failure
and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/
Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites
with isSwedishUserMessage extended.

Also includes the parallel session's MCP invoice tools (update_invoice,
recurring schedules, invoice deliveries) which share files with the sweep
work and are verified green together.

13 new migrations are NOT applied anywhere; they apply via branch merge.
20260726120000 backfills 1247 supplier-invoice rows. pg tests for new
DDL are written but unrun (no local Postgres).

Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0
errors, check:guards passing, MCP payload 57475/57500.

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

* fix(migrations): rename replace_sie_import migration off main's 20260726090000 version

origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping
our replace_sie_import migration on the same version would abort the Supabase
apply with a schema_migrations_pkey duplicate at merge time.

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

* fix(review): remediate pre-publish deep-review findings across all slices

A 13-agent review of the full branch diff surfaced 1 critical, 5 high and
~45 further findings; this commit resolves them in one pass:

- replace_sie_import / undo_sie_import: p_user_id honored only for
  service_role callers; any other caller is pinned to auth.uid()
  (impersonation gate bypass), authz raise errcode 42501 mapped to a
  Swedish 403 in the route, new caller-guard migration for undo
- bulk_book_transactions refuses homogeneous non-SEK batches instead of
  writing foreign magnitudes into SEK ledger columns
- credit-note cap trigger: company-match on credited_invoice_id, no
  cross-tenant figures in exception text
- link_voucher RPCs resolve NULL invoice currency as SEK end to end
- personal-number ciphertext CHECK split into NOT VALID + VALIDATE
- same-currency foreign settlements clear 1510 at booking rate and book
  realized diff to 3960/7960; rate-less foreign write paths refuse
- receivables revaluation covers partially_paid and outstanding amounts
- period lock guard paginates candidates past the PostgREST 1000 cap
- documents: service-client storage removals after authz, dual-layout
  reads in integrity cron and archive export, backfill delete-source
  sweep actually deletes with hash verification and shared-key grouping
- invoice matching normalizes NULL/lowercase currencies (regression),
  duplicate candidates stop claiming amount matches they never ran
- match-invoice aborts on any booking failure (no paid-without-verifikat)
- refresh-exchange-rate reverts on concurrent booking (TOCTOU window)
- KPI preferences upsert arbiter aligned to the company-scoped constraint
- personnummer_last4 stripped from all salary responses incl. MCP tools
- worked-hours batch restores destroyed rows on conflict and error paths
- MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit
  on tag_journal_lines overflow, auto_send schedules stage as high risk
- observability sink redacts emails/IBANs/API keys and keeps redacted
  stacks in prod; assorted small guards (safe-return-to /@, dry_run=True,
  cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings
  call removed)

Full dispositions, deferred items and hand-verified accounting numbers
are documented in the PR body and DECISIONS.md.

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

* feat(personnummer): implement masking and encryption for personal numbers with tests

* fix(review): address CI and compliance-bot findings for PR #1215

pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role
GUC, so both service-role simulations (runAsServiceRole and the
invoice-delivery test's local helper) never satisfied auth.role() =
'service_role' and every legitimate p_user_id path failed closed; the
shared helper now sets both GUC shapes plus SET LOCAL ROLE with a
fail-loud sanity check, and the delivery test reuses it. The link-voucher
migration had recreated both RPCs from pre-rewrite file text,
reintroducing the NULL-unsafe membership pattern the
null-safe-tenant-guards ratchet bans; both guards now use
public.caller_is_company_member() with all currency changes preserved.

Compliance bots: the customers export now emits the standard masked form
instead of raw AES-256-GCM ciphertext in the Org-/personnummer column,
and maskCustomerRow returns a non-round-trippable placeholder on decrypt
failure instead of 500ing the list. MCP parity: gnubok_lock_period's
staging pre-check now runs the exact countUnbookedInPeriod the commit
path enforces (exported from period-service; local mirror deleted), and
gnubok_agi_status resolves AGI state run-scoped so a correction run no
longer renders as already filed.

Declined with evidence: PR-Agent's opening-balances null-zeroing concern
(all mergeable columns are NOT NULL with defaults per 20260713101000).

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

* fix(review): address codex review findings on PR #1215

- restore 20260726140000 to its preview-recorded content and restate the
  NULL-safe tenant guard under 20260727130000: a recorded migration version
  never re-runs, so the in-place edit could not reach the preview branch
- replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap
  warning texts and update the pinned test expectations
- drop the em dash in the fiscal-periods route comment
- strip trailing whitespace in import-existing.test.ts

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

* test(reports): raise timeout on real PDF render tests

renderToBuffer does real @react-pdf layout work and exceeds the 5s
default when the full suite saturates the CPU; tests pass in isolation.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 03:34:56 +02:00
Jakob Wennberg f0f3050f54 fix(assistant): stop the chat loading in stages (#1210)
* fix(assistant): stop the chat loading in stages

PR2 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7).
No redesign; this is the "it loads in different stages" complaint, traced to
four separate staging points and one dead link.

Resumed conversations rendered a column of EMPTY bordered cards until the
markdown chunk arrived, then filled in all at once and reflowed the thread. The
chunk was deferred with a null fallback, which is invisible while a reply
streams (nobody reads that fast) but very visible on hydrate, where every
assistant bubble is already text. The chunk is now prefetched as soon as any
chat surface mounts, and until it resolves the raw text renders instead of
nothing, so a bubble is never blank.

Clicking the assistant launcher showed NOTHING until the sheet chunk loaded:
the dynamic import had no loading state at all. It now renders a skeleton in
the same geometry, and the chunk is warmed on idle so the click usually hits an
already-loaded module.

/chat's route skeleton drew a 320px sidebar while ChatSidebar mounts collapsed
as a 48px rail, so every load snapped one to the other. The skeleton now matches
what actually mounts, per breakpoint.

The first turn read agent_profiles twice: once in the route to build the intent's
prompt template, once again in run-turn for the system prompt. The route now
hands its result over. Ranked memory is deliberately NOT shared: the two queries
differ (the route's selects fewer columns and orders without is_pinned, and
run-turn needs ids to stamp last_accessed_at), so reusing it would silently
change both the prompt and memory touch.

Command palette's "Fråga Anna: ..." pointed at /chat?prompt=, but only /chat/new
reads ?prompt=, so the typed question was silently dropped and the user landed
on an empty state.

Verified: 9526 unit tests pass, lint clean and tsc clean on every touched file,
guards pass.

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

* fix(assistant): make the prefetches fail-safe and bounded

Review follow-ups on the staged-loading batch.

A rejected markdown import left the cached promise permanently rejected, so
every bubble for the rest of the session stayed on the plain-text fallback and
the rejection went unhandled. The cache is now cleared on failure so a later
surface retries, and the rejection is swallowed.

requestIdleCallback can defer indefinitely on a page that never goes idle; the
2s fallback only applied where the API is missing. The idle request now carries
a 2s timeout, and the warm import cannot produce an unhandled rejection either.

Adds the first-turn test for the profile-summary handover: it asserts the value
read for the prompt template is what reaches the turn, so a regression that
re-introduces the second read (or drops the template) fails.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:10:47 +02:00
Jakob Wennberg ee8ddb3849 fix(assistant): stop cross-user conversation access, bricked threads and lost sessions (#1209)
* fix(assistant): stop cross-user conversation access, bricked threads and lost sessions

Hotfix batch (PR1 of the assistant UI makeover, dev_docs/assistant_redesign_plan.md
section 7). No visual change; each of these is wrong today regardless of which
design lands, and three are unrecoverable per incident.

/api/agent/invoke never checked who owns a resumed conversation_id. RLS on
agent_conversations/agent_messages is company-scoped, not user-scoped
(20260517204000), so a member could post a colleague's conversation id, have
their history loaded into the prompt and read it back, while their own turns
were appended to that thread. The conversations list route filters on user_id
for exactly this reason. Also pins company and intent: resuming a thread from
another company would mix ledgers, and resuming under a different intent would
swap the tool whitelist under history the model has already seen.

A turn persists the assistant message carrying tool_use blocks before the tools
run, and their results only after the batch finishes. Dying in between (client
disconnect terminating the function, a deploy, a slow tool) left history ending
on an unanswered tool_use, which the Messages API rejects on replay: every later
turn 400s, and agent_messages is append-only for the BFL trail, so nothing could
repair it. History is now patched on read by synthesizing is_error tool_results,
leaving the stored trail untouched.

check_and_increment_agent_quota is SECURITY DEFINER in public with a
caller-chosen p_user_id, so any authenticated user could drain a colleague's
minute/day budget and lock them out of every agent endpoint. A plain REVOKE
would break the limiter (all three callers use the user's RLS client) and, as it
fails open, silently remove the spend cap: the function now refuses to act for
anyone but the caller, while service-role connections keep passing an explicit
id.

The single reject route re-read status and then wrote unguarded, so losing the
race with commit's atomic pending -> committing claim stamped `rejected` over an
operation that had already posted a verifikat, invisible to the committing-state
recovery sweep. Guarded on status like bulk-reject already is; a lost race is
now a 409.

The sheet's Escape handler listened on window with no defaultPrevented or target
check while the sheet is deliberately non-modal, so pressing Esc to dismiss the
reject-reason Select inside an approval card, the command palette or any dialog
unmounted the sheet and discarded the conversation, the streaming turn and the
un-actioned proposal. It now yields to open overlays and to focus outside the
sheet.

Verified: 9526 unit tests pass, lint clean on touched files, guards pass, and
the new pg-real test proves the quota guard against real Postgres (attacker
raises 42501, victim counters stay at 0). The four unrelated pg-real failures on
this machine reproduce identically with these changes stashed.

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

* fix(assistant): close anon path on the quota RPC, order the ownership check ahead of writes

Review follow-ups on the hotfix batch.

The caller guard used auth.uid() alone, which is NULL for the `anon` role just
as it is for backend roles, so an unauthenticated caller holding the public anon
key (it ships in the browser bundle) could still pick any p_user_id and drain
that user's quota. The guard now keys on the request role: anon and
authenticated may only ever spend their own quota, backend roles keep passing an
explicit id. The default PUBLIC execute grant is revoked as a second layer, with
execute granted only to authenticated and service_role. Covered by a new pg test
for the anon path.

The ownership check ran after the onboarding.intake stamp, so a request that was
about to be rejected could still write intake_completed_at. It now sits directly
after the capability gate, ahead of every side effect and ahead of the company
and profile reads, which also makes a rejected request cheaper.

The tool-result repair matched ids anywhere in the history, but the API needs
results in the message IMMEDIATELY after the tool_use. A result persisted after
an intervening turn (two turns racing on one conversation) left a shape that
still 400s. The repair is now positional, and orphaned or late-duplicate
tool_results are dropped, since an unmatched tool_result is rejected just as an
unanswered tool_use is.

The Escape guard matched the Radix popper wrapper, which stays mounted when a
popper is force-mounted; it now requires data-state="open" so a closed popper
cannot block Escape for the rest of the session.

Both new route errors are Swedish, per the user-facing error rule.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:48:18 +02:00
Mattsson 3a3c4adbc6 Bug/ai assistant config (#939)
* revert(agent): restore plain AWS_* Bedrock credential handling

Undoes the credential-name change from #937 (ae489cfd) in both Bedrock
clients (lib/agent/composer/client.ts and the invoice-inbox extractor).
The BEDROCK_AWS_* rename assumed Vercel/Lambda shadows AWS_*, but the
plain AWS_* client ran on prod for six weeks (since #584), so it was
never shadowed. The current assistant outage predates #937 and is
environmental (prod AWS credentials / Bedrock access), not this code.
Leaves the unrelated JournalEntryForm.tsx change from #937 intact.

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

* feat(agent): log real Bedrock failure + credential-load diagnostics

When someone uses the agent, surface why it fails on prod instead of the
opaque "request ended without sending any chunks":

- client.ts getAnthropic(): on cold start, log the resolved region and
  whether the AWS key/secret loaded from env (error-level if missing),
  plus the 4-char access-key-id prefix (AKIA = our IAM key, ASIA = a
  platform/STS credential) and whether a session token is present. No
  secret is logged.
- run-turn.ts: on a stream failure, extract err.status / err.code /
  err.cause / err.stack explicitly. The logger keeps only name+message
  from an Error and drops the stack in production, so the true failure
  (auth 403 vs bad region/model 400 vs throttle 429 vs transport cut)
  was invisible until now.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 20:19:02 +02:00
Mattsson ae489cfdcb fix(client): update AWS credential handling to prefer BEDROCK_AWS_* environment variables (#937) 2026-07-08 18:09:34 +02:00
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00
Mattsson f63d3e3100 Bug/open banking flow (#854)
* fix(enable-banking): pin Mobile BankID (decoupled) auth_method so Handelsbanken corporate connects

We never sent auth_method to Enable Banking, so it fell back to the ASPSP's
visible default — REDIRECT for Handelsbanken. For Handelsbanken *corporate*
PSUs the redirect flow does not support Mobile BankID, so authorization failed
right after the user approved in the BankID app. Mobile BankID at Handelsbanken
is a DECOUPLED method flagged hidden_method=true, which Enable Banking only uses
when requested explicitly.

Resolve the bank's preferred auth method before /auth: query the ASPSP's
auth_methods and pick the DECOUPLED (Mobile BankID) method when present,
otherwise leave auth_method unset so banks that already work are untouched.
The method name is read dynamically per psu_type, so it is robust across
sandbox/production naming.

- api-client: add approach/hidden_method to AuthMethod, fix ASPSP.auth_methods
  field name (was available_auth_methods, never populated), add
  getPreferredAuthMethod(), thread optional authMethod through startAuthorization
- index: resolve authMethod in /connect and pass it on both fresh + reconnect
- tests: cover method selection and request-body shaping

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(invoice-inbox): clean up bulk-selection toolbar UI

Redesign the selection toolbar shown when inbox items are checked:
one solid primary "Bokför valda" button with outlined secondary
actions ("Fråga assistenten", "Ta bort") and a plain selection
count. Removes the redundant "Avmarkera" button (users uncheck the
still-visible box), fixes label clipping, and gives the toolbar more
breathing room.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(entitlements): bypass paywall in local development

Add isPaywallBypassed() so all gated capabilities are testable locally
without a subscription. Fires only on NODE_ENV=development (npm run dev)
or an explicit DISABLE_PAYWALL=true escape hatch — production builds run
under NODE_ENV=production and the entitlement suite runs under 'test',
so both keep exercising the real gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tic): resolve enskild firma bolagsuppgifter via 12-digit personnummer

TIC's Lens search is fuzzy and only resolves an enskild firma from the 12-digit (century-prefixed) personnummer; a 10-digit form fuzzy-matched an unrelated entity. Expand personnummer to 12 digits before querying and reject hits whose registration number is unrelated to the request. Add a "Hämta" action to the settings Bolagsuppgifter panel to (re)fetch on demand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(transactions): implement categorize core for bank transaction categorization

- Added `categorize-core.ts` to handle categorization of bank transactions, supporting single and bulk operations.
- Introduced `categorizeMatchedTransaction` and `bulkBookMatchedInboxItems` functions for transaction processing.
- Implemented fiscal period validation and duplicate booking detection.
- Enhanced logging and error handling for transaction categorization.

feat(scripts): add diagnostic script for Handelsbanken ASPSP metadata

- Created `check-handelsbanken-aspsp.mjs` to fetch and display available authentication methods for Handelsbanken.
- Outputs metadata for business and personal PSU types, including default authentication methods.

fix(migrations): increase statement timeout for SIE bulk delete operations

- Updated `20260629160000_sie_bulk_delete_statement_timeout.sql` to set a longer statement timeout for bulk delete RPCs to prevent cancellations during large imports.

feat(migrations): add bulk book inbox items to pending operations

- Expanded `pending_operations` table to include `bulk_book_inbox_items` operation type in `20260630120000_pending_operations_add_bulk_book_inbox_items.sql`.
- Supports bulk booking of matched inbox items against bank transactions.

test(pg): add tests for replace_period_opening_balance_link RPC

- Implemented tests in `replace-period-opening-balance-link.pg.test.ts` to validate the functionality of the opening-balance correction flow.
- Ensured immutability of opening balance links and proper handling of posted vs. non-posted entries.

* fix(sie-export): update journal entries and lines handling in SIE export tests

* fix(migrations): resolve version collision on 20260629160000

The SIE bulk-delete statement_timeout migration shared version
20260629160000 with journal_entries_list_series_filter (merged from
main via #798/#823), causing a schema_migrations_pkey duplicate key
error on apply. Rename the branch's migration to 20260629160100.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compliance): resolve compliance-swarm + review findings

- opening-balance/correct: compensating rollback for the non-atomic
  storno+rebook so a mid-sequence failure never leaves two posted OB
  entries (ASVS V2.3); durable audit event on every failure path
  (V16); reference the original verifikationsnummer in the corrected
  entry per BFL 5 kap 5§; document that requireWrite already enforces
  write-role + membership (V8.2.1 was a false positive)
- reports sources routes: validate the cursor date component as ISO
  (/^\d{4}-\d{2}-\d{2}$/) before use, 400 on malformed (ASVS V1.2),
  applied to both the VAT-declaration and trial-balance routes
- AgentSessionList: await the rename PATCH, revert the optimistic
  title and toast on failure (ASVS V4.5)
- bank booking: exclude same-batch siblings from the booking-time
  duplicate guard so bulk-booking distinct same-(date,amount)
  transactions no longer false-positives; pre-existing duplicate
  detection is preserved
- BulkBookInboxDialog: drop the unsafe currency-based reverse_charge
  default, add an omvänd skattskyldighet advisory, and type VAT
  options to the backend VatTreatment union
- OpeningBalanceRowEditor: hold onChange in a ref (synced in effect,
  not during render) so an unstable callback can't cause a render loop

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:13:00 +02:00
Mattsson 2a8bf9b42e Bug/year end numbers (#744)
* fix(bookkeeping): allow creating a fiscal year that fills an interior gap

Fiscal-period creation only allowed chaining a new räkenskapsår before the
earliest or after the latest existing period, so a company with a gap between
years (e.g. 2024 + 2026 from an SIE import, missing 2025) could not create the
missing year — it failed with "New period must chain before the earliest or
after the latest existing period".

Generalise forward chaining onto the new period's immediate predecessor, which
covers both appending a new latest year and filling an interior gap. The
"prior year must be locked" guard now applies only to true appends, not gap
fills (a backfill, like backward chaining). previous_period_id is set to the
predecessor and the successor is relinked so the BFNAR 2013:2 continuity chain
stays intact. The create dialog suggests the missing year (capped so it never
overlaps the next period), the settings page seeds the dialog at the earliest
gap, and the default suggested name is now "Räkenskapsår <year>".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): omföra föregående års resultat (2099 → 2098) at year-end

Year-end closing posts the result to 2099 "Årets resultat" and the opening
balance carried it forward on 2099 every year, so 2099 accumulated across
years and the prior result never moved off "Årets resultat".

executeYearEndClosing now posts a separate "Omföring av föregående års
resultat" verifikat (Dr 2099 / Cr 2098 for a profit, reversed for a loss)
into the new period after the continuity check passes, so 2099 starts each
year at zero. Kept as a standalone entry rather than folded into the opening
balance so the IB stays a faithful mirror of the prior UB and IB/UB
continuity still holds. Aktiebolag only; idempotent; no-op when 2099 is flat.
The 2098 → 2091/2898 disposition (bolagsstämma decision) is intentionally
left to a separate step.

- new source_type 'result_appropriation' (migration + type + Zod enum)
- generateResultAppropriation helper (planner + poster) wired as step 11
- ResultStep surfaces the omföring voucher
- unit tests + pg-real invariant
- scripts/repair-result-appropriation.ts: retroactive catch-up (dry-run default)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(transactions): shadow-detect date-drift duplicate bank transactions

The content-dedup bridge buckets on exact (date, ore), so the same
transaction re-imported with a booking date that drifted a day lands in
a different bucket and slips past every dedup layer. Add a measure-only
("shadow") detector that flags would-be +/-1-day duplicates and counts
them, without changing what is inserted - so the gap can be validated on
real data before any enforcement, mirroring the scope-drift shadow.

- shiftIsoDate(): pure, deterministic adjacent-date helper
- ingest: DEDUP_DATE_DRIFT_MODE flag (default on), pre-loop bucket
  snapshot, per-row gate with desc-bridge + cross-channel-symmetry
  signals; logs shadow_date_drift_candidates, never alters inserts
- fail-safe date guard so the measurement can never abort an import
- regression tests for both signals, account/window/distinct guards,
  no-double-count, and the malformed-date fail-safe

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(bookkeeping): anonymize a customer reference in fiscal-period tests

Remove a real customer name ("AXMD AB") from regression-test comments;
no logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(workflows): enhance Docker image scanning and caching mechanisms

* fix(bookkeeping): enhance year-end result appropriation handling and error reporting

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:22:09 +02:00
Jakob Wennberg 88f49c0ccc fix(bookkeeping): harden correction flow and align VAT/cashflow reports (#726)
Bundles a set of bookkeeping-correctness fixes developed together.

Correction / storno flow
- correctEntry resolves (and seeds standard BAS) accounts for the
  corrected lines BEFORE writing the storno. The old order created and
  posted the storno first, then hit AccountsNotInChartError on the
  corrected lines and had to cancel it again — leaving a voided 0 kr
  storno in the chain and permanently burning a voucher number (an
  unexplained BFNAR 2013:2 gap). It now fails fast with nothing written.
- correctEntry re-points the bank transaction and underlag from the
  reversed original to the live corrected entry, so the transaction keeps
  reading as booked (and stays correctable) and the underlag travels with
  it. recordateEntry delegates both relinks to correctEntry.
- reverseEntry (engine) clears transactions.journal_entry_id for rows
  booked by the reversed entry, so a plain storno returns the bank row to
  "Att bokföra" with a re-booking affordance. The agent paths did this
  manually; the dashboard reverse route did not.
- findUnresolvableAccounts replaces findMissingActiveAccounts in the
  categorize routes: a standard BAS account merely absent from the chart
  is seeded on demand by the engine, so pre-validation must not 400 on it
  — only unknown numbers or deactivated accounts block.
- CorrectionChain dims cancelled (0 kr) entries and labels them so they
  no longer render like a live storno.

Report accuracy
- calculateVatLiability() (lib/reports/kpi.ts) is shared by the KPI route,
  the KPI xlsx export and the MCP period-summary tool, and uses the same
  26xx accounts as the momsdeklaration (ruta 49). Reverse-charge and
  import pairs (e.g. 2614 credit + 2645 debit) net to zero instead of
  inflating the receivable (#715). VAT_OUTPUT_ACCOUNTS / VAT_INPUT_ACCOUNTS
  are derived from ACCOUNT_RUTA so the widget can never drift from the
  declaration.
- Kassaflödesanalys records erhållna aktieägartillskott (2093) as a
  financing inflow and counts överkursfond (2086/2097) toward nyemission.
  2093 was previously unmapped, so any contribution broke the 19xx
  reconciliation by exactly the contributed amount (#716). Wired through
  the report type, both PDF templates, the K3 PDF, the dashboard client
  and the årsredovisning summary type.

Agent guidance
- shared-rules: describe the real Accounted correction flow (Rätta rader /
  Rätta datum / Radera verifikat, on-demand BAS backfill) so the assistant
  stops inventing flows that don't exist.
- verifikation-draft: clearer locked-period guidance.

Tests cover all of the above (storno fail-fast + seeding + relink,
reverseEntry unlink, findUnresolvableAccounts, VAT netting and the
cashflow reconciliation cases).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:17:44 +02:00
Jakob Wennberg c74b19df1b Accounted rebrand + swarm-skill cleanup + bank-reconciliation fixes (#643)
* feat(reconciliation): close the bank-feed loop on voucher links and re-tag mis-typed opening balances

Two related fixes to bank reconciliation correctness:

1. Auto-reconcile on voucher link. Linking an invoice or supplier invoice to
   an existing voucher previously advanced only the invoice — the bank
   transaction that paid it kept sitting in the Transactions inbox with a null
   journal_entry_id. linkInvoiceToVoucher / linkSupplierInvoiceToVoucher now
   call autoReconcileTransactionForLinkedVoucher (lib/reconciliation), which
   links the bank transaction to the same verifikat when exactly one unbooked
   line matches it. Best-effort and post-commit: a failure here never fails the
   link. The result surfaces reconciledTransactionId; the inbox row leaves the
   list and the UI shows link_success_tx_reconciled.

2. Re-tag mis-typed opening balances. getReconciliationStatus and the GL-line
   matching RPCs identify a cash account's ingående balans solely by
   journal_entries.source_type='opening_balance'. Companies migrated from other
   systems often booked the bank IB as an ordinary voucher (source_type
   'import' or 'manual'), so it was never excluded and surfaced as a phantom
   reconciliation difference equal to the opening balance. Adds:
   - migration mark_entry_as_opening_balance: a GUC-gated carve-out in the
     immutability trigger plus a SECURITY DEFINER RPC that validates the entry
     (balance-sheet lines only, dated on a fiscal-period boundary), flips the
     source_type, and writes an audit row — no blanket data sweep.
   - POST /api/reconciliation/bank/mark-opening-balance + MarkOpeningBalanceSchema.
   - BankReconciliationView action to trigger it from the IB diff.

The gnubok_create_voucher executor now accepts a typed is_opening_balance flag
and derives source_type='opening_balance' only after validating class 1/2 lines
on the period start, so new IBs land correctly typed.

Covered by lib/reconciliation auto-reconcile tests, voucher-executors tests,
and a mark-entry-as-opening-balance pg-real test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: rebrand gnubok → Accounted and prune swarm agent skills

Product rebrand and skills housekeeping. No runtime behaviour change.

Rebrand: replace user-visible "gnubok" with "Accounted" across docs, READMEs,
in-code comments, doc-site content, MCP skill/resource prose, and the
gnubok-mcp package description. The MCP resource URI scheme is moved gnubok://
→ Accounted:// consistently across resource registrations, the event-type
comment, and the resource/skill tests. Deliberately preserved as stable
identifiers (NOT rebranded): the gnubok-company-id cookie, gnubok_sk_ / gnubok_inv_
token prefixes, the gnubok-mcp npm bridge name, and the AGI <gem:Programnamn>
value (kept 'gnubok' per its source comment — it is the software identifier sent
to Skatteverket and must not churn across visual rebrands).

Skills: remove the 27 swarm-* agent SKILL.md atoms (no longer used; already
absent from the agent_atom_registry in prod), refresh the remaining skill docs,
add the .claude/rules/ path-scoped rule set, and regenerate the
seed_agent_atom_bodies migration + .skill-body-manifest.json via
`npm run skills:generate` so the DB-backed skill bodies match the trimmed set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:52:01 +02:00
Jakob Wennberg f53725b20a Agent v1 bundle: TIC v2 onboarding, in-app assistant gating, sidebar nav, MCP fixes (#584)
* fix(sie-import): accept tab as field separator (Bollbok exports)

The SIE 4 spec allows either space or tab between fields, but
splitSIELine() only treated space (0x20) as a separator. Bollbok
exports tab-separated lines for every record except #RAR, which
silently swallowed all #IB / #UB / #KONTO / #KTYP / #VER / #TRANS
records — imports appeared empty even though the file was well-formed.

Also adds a parser-side diagnostic that emits a warning when raw #IB
or #VER lines are present in the input but parsing produced none. The
previous silent failure is how this bug stayed hidden; the warning
gives the import preview something visible to surface next time.

Verified against two real reproducer files (Sean / Erik Hellqvist):
  erik h 2025.SE (UTF-8): 166 accounts, 66 IB, 4 UB, 11 RES, 95 vouchers, 198 TRANS.
  erik h 2026.SE (CP437): 166 accounts, 66 IB, 4 UB, 0 vouchers.
Both now parse with zero warnings/errors.

Tests:
  + 8 Bollbok-shape tab-separated fixtures (2025 + 2026 quoting variants).
  + 4 silent-failure diagnostic-warning tests.
  All 74 sie-parser tests pass; 155/155 in lib/import; 64/64 downstream callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sie-import): address PR #513 review — strip #KTYP quotes, suppress redundant aggregate warning

Two non-blocking P2 findings from Greptile review on PR #513:

1. #KTYP handler stored fields[2] directly, so Bollbok 2026 exports
   (#KTYP\t1510\t"T") stored '"T"' with literal quotes instead of 'T'.
   Latent defect — accountType is unused downstream today, but my tab-
   separator fix made the quoted-value path reachable. Now routes through
   parseStringField so both Bollbok 2025 (unquoted T) and 2026 (quoted "T")
   land as 'T'.

2. The aggregate "kontrollera fältavskiljare och teckenkodning" warning
   fired alongside per-record 'error'-severity issues for malformed #IB /
   #VER records, producing a misleading hint when the parser had already
   pinpointed the structural problem. Now suppressed when an error-severity
   issue with the same tag already exists.

Test coverage:
  + accountType asserted to be 'T' (not '"T"') in both 2025 + 2026 shapes.
  + VER aggregate-warning test now uses #VER lines without { } blocks
    (silent loss, no per-record error) — the canonical case the diagnostic
    is designed for.
  + New suppression test: bare #VER produces per-record errors AND the
    aggregate warning is absent.

75/75 sie-parser tests pass; 156/156 in lib/import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wip: agent chat + composer + memory + document extraction

In-progress work on this branch beyond the SIE-import fixes:
- Specialized accountant agent (composer + intents + chat loop)
- Persistent agent_conversations/messages, agent_profiles, agent_memory
- /chat surface + /onboarding/agent + /settings/agent-memory
- document-extraction extension with status hooks
- MCP server staging refactor + new skills (atoms, bank reconciliation,
  customer onboarding, kreditfaktura)
- pending_operations rejection feedback (category + reason) + realtime
- TIC company profile cached snapshot on companies
- 17 migrations (all additive — see prior conversation analysis)

Parked while branch waits for review/merge. Migrations are already
applied to prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(tic): migrate company-data client from api-core v1 to Lens v2

Swaps the seven TIC company-data endpoints we call from the api-core
paths (`/datasets/companies/{companyId}/...`, `/search/companies`) to
the Lens equivalents (`/companies/{id}/...`, `/search-public/companies`).
Hard cutover; proxy pattern preserved.

Schema shifts handled inside the extension so consumers (TicWorkspace,
Step2CompanyDetails) don't need changes:

- `/companies/{id}/bank-accounts` now returns Bankgirot only — map to
  the existing `{ type, accountNumber, bic }` shape, drop terminated.
- `/companies/{id}/industries` returns a discriminated array — filter
  to `companyIndustryCodeType === 'sni2007'` to preserve v1 behavior.
- `/companies/{id}/phone-numbers` renamed the field to
  `phoneNumberFormatted` (fall back to `e164PhoneNumber`).
- `/companies/{id}/documents` replaces `/financial-report-summaries`;
  filter `type === 'annualReport'` and read nested
  `financialReportMetadata` to rebuild the legacy summary shape.
- `isCeased` is now a top-level boolean; `activityStatus` is an enum.
  Translate enum -> 'ceased' for the workspace's existing check.

BankID identity flow (id.tic.io) is untouched — separate TIC product.

Note: deploy gated on the TIC proxy being flipped to lens-api.tic.io
with an `x-api-key` Lens key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(tic): expose v2 onboarding & workspace data

Adds six new Lens (v2) fetchers on top of the migration that already
landed in this branch, surfacing the data through /lookup and /profile.

New fetchers in lib/tic-client.ts:
- getFiscalYears          /companies/{id}/fiscal-years
- getAccountingPeriods    /companies/{id}/accounting-periods
- getPayrolls             /companies/{id}/payrolls
- getSignatory            /companies/{id}/signatory
- getRepresentatives      /companies/{id}/representatives
- getCompanyStatus        /companies/{id}/status

/lookup gains a fiscalYear field (current fiscal-year configuration)
so onboarding Step 2 can skip manual MM-DD entry. CompanyLookupResult
extended with optional fiscalYear; consumers without it keep working.

/profile gains five new sections on TICCompanyProfile:
- fiscalYear + fiscalYearHistory   current + deduped period list
- signatory                        firmateckning descriptions
- board + representatives          board-composition summary + active
                                   officers (positionEnd in future)
- payrolls                         payroll2 array newest-first, with
                                   deviation vs annual-report
- statuses                         current+historical status entries
                                   with red/yellow/green/neutral color

TicWorkspace renders the new data as four cards (Status, Fiscal year +
Signatory, Board + Representatives, Payroll history) plus a Badge
mapping for the traffic-light status color.

Tests: 52 -> 60 passing. Added unit tests for the new fetchers' v2
paths, fiscal-year auto-fill in /lookup, and full v2 profile coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(onboarding,agent): lean on TIC v2 to skip Steps 1 & 3 and sharpen Opus

Three small wins that unlock more of the v2 cutover. No new endpoints — the
data was already in the snapshot, just not flowing where it should.

Step 1 (entity_type) — deep-link path only:
- /lookup now returns `legalEntityType` and `registrationDate` (added to
  CompanyLookupResult).
- /onboarding/page.tsx does a server-side /lookup prefetch when
  ?org_number= is present (BankID picker path), maps "AB"/"EF" to the
  EntityType enum, and seeds Step 1's radio. Falls through silently for
  unsupported codes (HB, KB, …) and on TIC errors.
- WelcomeOnboarding hydrates ticLookup state from the server prefetch so
  Step 2's debounced client fetch and Step 3's first-year inference both
  have data on first render — no flash.

Step 3 (is_first_fiscal_year) — every path:
- deriveFirstYearDefaults() parses ticLookup.registrationDate and returns
  { isFirstFiscalYear, firstYearStart } when registered <12 months ago.
  Step 3's initialData picks it up; the user only confirms the end date.
- Settings value wins when present so existing users with a saved choice
  don't get overridden.

Composer prompt:
- redactTic allowlist was the bottleneck — it stripped beneficialOwners,
  signatory, board, representatives, payrolls, statuses, fiscalYear
  before Opus ever saw the JSON. Existing filterRedundantQuestions
  ownership logic was effectively dead because the data path was severed.
  Expanded allowlist to include those v2 sections; kept bankAccounts/
  email/phone/fiscalYearHistory/financialReports out (token cost > signal).
- SYSTEM_PROMPT now documents each v2 section and the rules Opus should
  apply: payroll signal switches from "registration.payroll" to "actual
  payrolls[] filings" (kills the false-positive swedish-payroll selection
  for newly registered employers); beneficialOwners[] becomes the
  authoritative ownership source (single owner → FMB modifier; multiple →
  multi-owner); statuses[] isCeased/red triggers an uncertainty_note.

Tests: 4112 unchanged. Build: green. No schema or migration changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): onboarding polish + composer signal fixes from first-run feedback

UX:
- AgentOnboarding: drop the 10s "Hoppa över — fortsätt med standardval"
  escape hatch. The fallback path runs automatically on timeout; the
  manual skip just teased users into a degraded build.
- ReviewCard step 2 title: "Stämma av detaljerna" → "Stäm av detaljerna"
  (imperative form matches the rest of the steps).
- Drop em-dashes from user-visible Swedish strings in AgentOnboarding +
  ReviewCard (fallback labels, subtitles, placeholder, error message,
  final CTA). Em-dashes survive in code comments only.
- "Fråga min revisor" → "Fråga min assistent" everywhere it surfaced:
  AgentTrigger, AgentSparkleButton, ReviewCard preview, ReviewCard
  fallback comment, general.help intent buttonLabel + prompt text.
- AgentTrigger / AgentSparkleButton / EmptyState.AgentHelpLink /
  TransactionInboxCard ask-button all gated on identity.isVerified.
  Pre-onboarding users no longer see the floating FAB or per-page
  Sparkle buttons. AgentSheetProvider.identity gained an isVerified
  field; (dashboard)/layout.tsx selects agent_profiles.verified_at and
  passes it through.

TIC verksamhetsbeskrivning:
- tic/index.ts /profile: /companies/{id}/purposes returns every
  historical verksamhetsföremål filing. Picking [0] was returning the
  oldest "äga och förvalta" holding-company boilerplate for companies
  whose later filings narrowed the purpose ("tillhandahålla
  företagskrediter och finansiella teknologilösningar"). Sort the
  array by lastUpdatedAtUtc desc and take the most recent non-empty
  purpose.

Composer banking signal:
- loadBankingSummary now reads journal_entry_id alongside
  description/amount/date and returns per-counterparty `direction`
  ('in' | 'out' | 'mixed') and `has_unbooked` (any row not yet booked).
  Aggregate `unbooked_count` accompanies the rollup.
- buildUserPrompt emits each counterparty as
  `Name: 12 345 kr (ut, OBOKFÖRD)` so Opus can tell income from cost
  on sight and tell which counterparties are still open questions.
- SYSTEM_PROMPT now explicitly forbids verification questions about
  counterparties whose direction is unambiguous AND status is 'bokförd'.
  Should kill the regressions from the first agent build:
  * "Konsult, J 98 565 kr — intäkt eller kostnad?" when the amount is
    clearly negative.
  * "ALMI AB 493 000 kr — lån eller bidrag?" when the transaction is
    already categorized.

Tests: 4112 unchanged. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent,ui): representation needs deltagare+syfte, drop duplicate doc icon

Representation booking:
- transaction-categorization prompt now requires the agent to capture
  participants (name + company) AND purpose before staging a
  representation categorization. SKV's representationsregler + ML 8 kap
  require the verifikation to document who attended and what the
  meeting was about; without that the avdrag is denied and the post
  should be booked as non-deductible / personalkostnad.
- The agent confirms back in plain text (audit trail in the chat),
  writes the deltagare + syfte to gnubok_remember_fact (long-term),
  THEN stages. Saknas deltagare/syfte: explicitly tell the user the
  avdrag won't go through and offer the non-deductible alternative.
- Known gap (followup, not this commit): the staged op's journal entry
  description doesn't yet carry the deltagare text. Until we add a
  `notes` field to gnubok_categorize_transaction, the audit trail
  lives in chat + agent_memory only.

TransactionInboxCard duplicate attachment indicator:
- Drop the FileCheck2 "open document" button from the trailing slot.
  TransactionAttachmentIndicator (Paperclip) next to the description
  already opens the underlag on click. Two icons doing the same thing
  was noise. Cleaned up the unused state (isOpeningDoc, hasAttachment,
  handleOpenAttachment) and dropped now-unused imports (FileCheck2,
  useToast).

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent,nav): notes on verifikation + redesigned sidebar

Audit-trail notes for representation:
- gnubok_categorize_transaction gains an optional `notes` string.
  Threaded through stagePendingOperation → commitCategorizeTransaction →
  createTransactionJournalEntry, which now appends notes to the entry's
  description (capped at 500 chars). The verifikation an external auditor
  reads now carries deltagare + syfte directly — not just chat history /
  agent_memory.
- transaction-categorization prompt updated: representation flow now
  REQUIRES the agent to pass deltagare+syfte via the notes parameter.
  Without it the booking is non-deductible / personalkostnad per SKV.

DashboardNav redesign:
- Top section: flat, no header — Hem (/chat), Underlag (was
  Dokumentinkorg), Transaktioner, Granskning. Always visible; the inline
  badge on /pending shows the count when there are pending ops.
- Mid section: four collapsible dropdowns (Försäljning, Inköp,
  Redovisning, Personal). Each auto-expands when the active route lives
  inside it. KPI moved from main to Redovisning. Extension nav items
  (TIC workspace, etc.) fold into Redovisning.
- Bottom-left: new account popover (DropdownMenu, opens upward) holding
  CompanySwitcher, Inställningar, Hjälp, Support, Logga ut. Replaces
  the old top company-switcher card + the bottom Support/Logout block.
- Mobile drawer mirrors the new structure: top items as flat list,
  same four dropdown groups, separate "Tillägg" section when
  extensions exist, "Mitt konto" section at the bottom.
- i18n: invoice_inbox label renamed "Dokumentinkorg" → "Underlag"
  ("Documents" in en). New keys: mitt_konto, group_extensions.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nav): unhide Leverantörer under Inköp

The /suppliers entry existed in navItems but was marked hidden — leftover
from when the supplier list lived elsewhere in the IA. Removing the
hidden flag puts Leverantörer in the Inköp dropdown alongside
Leverantörsfakturor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nav): CompanySwitcher back to top-left, user account moves bottom-left

The previous pass collapsed both concepts into the bottom popover. They
mean different things: the company is the org context everything below
operates against (top-of-sidebar, scannable); the user is the
account-holder (bottom-of-sidebar, where settings/logout live).

- (dashboard)/layout.tsx: fetch profiles.full_name alongside the
  existing identity queries; pass userName + userEmail into
  DashboardNav.
- DashboardNav: restore CompanySwitcher at the top of the sidebar
  (pre-redesign placement). Bottom-left popover trigger now shows the
  signed-in user's name + single-letter initial (accountInitial helper
  falls back to email's first char, then "?"). Popover header carries
  full name + email; items unchanged (Inställningar, Hjälp, Support,
  Logga ut). CompanySwitcher removed from inside the popover — nested
  dropdowns were awkward and the top placement is where it belongs.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(pending): trim the agent context strip

The row-level AgentContextStrip on /pending was rendering the model
name (eu.anthropic.claude-sonnet-4-6) and the full atoms array
(horizontal/swedish-vat, vertical/konsult-it, …) inline, which made
each row 60–80 chars of mostly-the-same metadata. Reviewers never
scan that text; they scan amounts and decide approve/reject.

Now the strip shows only the conversation deep-link
(Konversation #<short id>) — the one piece that's actually useful for
diving into context. Model + atoms remain available in agent_metadata
for debugging surfaces; they're just not in the list view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): shared ground rules + paragraph breaks after tool calls

Two regressions surfaced in real usage. Both are systemic.

Shared agent ground rules:
- /chat surface (general.help) was happily inventing four-digit BAS
  account numbers ("Debet 6212 - Molntjänster…", "Kredit 2614 - Ingående
  moms…") and proposing booking decisions on invoices it had never
  seen, with no follow-up questions about currency/scope/etc.
- transaction-categorization had those rules baked into its prompt;
  general-help / bokslut-step / invoice-draft / supplier-invoice-review
  / verifikation-draft / vat-review never inherited them.
- Extracted lib/agent/intents/shared-rules.ts with five cross-cutting
  rules: underlag first (check inbox + ask user to upload to
  Dokumentinkorgen when missing), ask follow-ups when ambiguous, never
  write four-digit BAS account numbers in chat (category names only),
  cite atoms / load skills (don't guess), check counterparty history
  before proposing.
- Injected renderAgentGroundRules() into all six intents above.
  transaction-categorization left alone — it has more detailed inline
  rules tied to its specific underlag-flow.

Paragraph break after tool calls:
- text_delta from the model often resumes after a tool call without a
  leading newline ("kategoriseras." → gnubok_query_journal runs → "Inget
  historik hittades…" appended directly). Markdown rendered the
  concatenation as one paragraph.
- AgentChat text_delta handler now inserts \n\n when (a) the buffer
  ends with text content, (b) the incoming delta starts with text
  content, (c) at least one tool call has run, and (d) the buffer
  doesn't already end with a blank line.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(nav): default-open dropdown groups; closing is per-user

Dropdowns started collapsed which meant first-time users had to open
each group to discover what's inside. Inverted the state: default open,
user can collapse, active route still forces a group open.

- manualExpanded → manualCollapsed (semantics flip)
- toggleGroup unchanged externally; flips the bit
- isGroupExpanded returns !manualCollapsed[g] || hasActiveChild

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent): rate-safe v1→v2 TIC upgrade, counterparty defaults, profile settings

Three pre-ship quality wins.

Rate-limit-safe TIC v2 upgrade:
- The /profile endpoint fans out to ~13 Lens calls; the account has a
  ~3000/mo ceiling. Force-refreshing every pre-v2 (v1) snapshot across
  the customer base would blow the budget.
- ensureTicSnapshot gains an `upgradeV1` flag. A cached snapshot still
  inside the 7-day window is re-fetched only when (a) the caller passes
  upgradeV1 AND (b) the snapshot is v1-shaped (missing the v2-only
  `statuses` key). Gated to the two agent-onboarding call sites — a
  deliberate, once-per-company action and the only consumer of the v2
  sections. Workspace + signup keep the natural 7-day staleness, so the
  v1→v2 migration is lazy and bounded to companies actually building an
  agent.

Known-counterparty defaults (shared-rules):
- Agent now proposes a sensible default for well-known counterparties
  instead of asking the same question monthly: Almi → lån, Tillväxtverket/
  Vinnova/EU-stöd → bidrag, Skatteverket → skatt/avgift or återbäring,
  Bolagsverket → avgift, Försäkringskassan → ersättning, EF private
  withdrawal → eget uttag. Stated as an assumption the user can correct,
  not a hard rule — underlag/history still wins.

Företagsprofil settings page:
- New /settings/agent-profile (Företagsprofil / "Company profile"):
  view + edit the agent's company profile after onboarding — assistant
  name + avatar, the profile summary the agent reasons from, and a
  read-only chip view of loaded specialities (atoms). Backed by the
  existing GET/PATCH /api/agent/profile.
- New GET /api/agent/atom-titles?ids= resolves atom slugs → human titles
  for the chips (registry is globally-readable reference data).
- Added to SettingsSidebar; i18n keys agent_profile (sv "Företagsprofil"
  / en "Company profile").

Note: /chat already redirects unverified users to / (chat layout guard),
and / renders WelcomeGate → /onboarding/agent. No redirect work needed.
AgentSetupBanner.tsx is orphaned dead code (WelcomeGate superseded it).

Tests: 4112. Build: green. Both new routes compile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(nav,agent): Hem=Översikt + separate Assistent button; memory dedup

Nav restructure:
- "Hem" now points to / (Översikt dashboard) again, not /chat. The agent
  chat gets its own top-level nav entry "Assistent" (Sparkles icon) → /chat.
  Mobile bottom nav mirrors this (Hem / Assistent / Transaktioner).
- / restored to render DashboardContent (the Översikt) for built-agent
  users instead of redirecting to /chat. Users who haven't built their
  assistant yet still get WelcomeGate (the build-agent checklist); once
  verified, / shows the dashboard. Chat is reachable anytime via its nav
  entry. Restored main's dashboard data-fetch; added an agent_profiles
  verified_at probe to drive the WelcomeGate branch.
- i18n: nav.assistant ("Assistent" / "Assistant").

agent_memory dedup (gnubok_remember_fact):
- The agent re-remembers the same fact constantly (e.g. "Vercel = omvänd
  skattskyldighet" on every Vercel categorization), which would bloat
  agent_memory with paraphrases over months.
- Before insert, compare the incoming fact against the 300 most-recent
  active memories by word-set Jaccard similarity (lowercased, punctuation-
  stripped, stopwords dropped). A near-duplicate (≥0.82) is treated as
  already-known: bump its relevance toward the new score + refresh
  updated_at instead of writing a new row. Embedding-free, zero added
  latency beyond one bounded SELECT.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent,nav): företagsprofil=Bolagsuppgifter, avatar nav icon, dedupe greeting

Företagsprofil settings page (the right content this time):
- Replaced the agent atoms/summary panel with CompanyProfileView — a
  read-only "Bolagsuppgifter" view of the cached TIC company snapshot
  (name, org-nr, form, address, F-skatt/Moms/Arbetsgivare, SNI, bank,
  verksamhet, employees, latest financials, status traffic-lights,
  fiscal year, firmateckning, företrädare). Server component reads the
  companies.tic_snapshot column directly — no extension import, stays
  inside the core-build boundary.
- Route renamed /settings/agent-profile → /settings/company-profile.
  Removed the old AgentProfilePanel + the now-unused /api/agent/atom-titles
  endpoint.

"Assistent" nav icon = the agent's chosen avatar:
- DashboardNav reads agent identity from AgentSheetProvider and renders
  the onboarding-chosen avatar for the /chat ("Assistent") entry across
  desktop sidebar, mobile drawer, and mobile bottom nav. Falls back to
  the Sparkles glyph pre-onboarding (no avatar yet).

Nav cleanup:
- Dropped the beta badge from Underlag.
- Filtered the TIC workspace (/e/general/tic, "Företagsprofil") out of
  the nav — the same Bolagsuppgifter now lives under Inställningar →
  Företagsprofil, so it shouldn't appear in two places.

Doubled intake greeting fix:
- /chat/intake fires an invoke with no conversation_id, then swaps the
  URL to /chat/[id] the instant the `conversation` event lands — which
  can beat the greeting being persisted. /chat/[id] then hydrated with 0
  messages and, because the auto-fire guard keyed on (id && messages>0),
  fired a SECOND invoke on the same conversation → two greetings.
  Guard now keys on conversation-id presence alone: a set id means
  resume, never bootstrap. Closes the race.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): paragraph-break-after-tool split words mid-stream

The earlier "insert \n\n when text resumes after a tool call" heuristic
re-evaluated on EVERY text_delta (any delta not starting/ending with
whitespace, once a tool had run). Streaming deltas arrive in sub-word
chunks, so it injected breaks between fragments of the same word:
"minnes\n\nno\n\nterna", "kund\n\nrep\n\nresentation".

Replace the per-delta heuristic with a consume-once ref:
- tool_use sets breakBeforeNextTextRef = true
- the next text_delta consumes it: prepends \n\n exactly once (only when
  the buffer has content, doesn't already end in whitespace, and the
  delta doesn't start with whitespace), then clears the flag

So the break fires once per tool→text resume, never mid-word.

Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): much shorter replies, representation headcount + VAT cap, dot separator

Brevity (system-prompt Svarsformat — affects every reply):
- Hard "korthet är regel nummer ett": aim for 2-4 sentences, lead with
  the answer/action, no warm-up ("Här är vad som gäller…"), don't derive
  VAT in prose, don't restate what the approval card shows, one question
  at a time. The agent was writing textbook-length essays.

Representation rule now in shared-rules (so verifikation-draft, vat-review,
etc. all get it — previously only transaction-categorization had it, which
is why the verifikation flow guessed 25% VAT and skipped the cap):
- Require ANTAL deltagare (headcount), not just one name — the moms
  deduction is per person (underlag cap 300 kr/person ex moms).
- Use the receipt's ACTUAL VAT rate (usually 12% on food), never assume
  25%.
- Meal representation isn't income-tax deductible (post-2017); whole cost
  booked as non-deductible representation.

Verifikation description separator:
- createTransactionJournalEntry appended notes with an em-dash
  ("Utlägg Eatnam — Deltagare:…"), violating house style. Switched to a
  middle dot " · ". journal_entries has no separate notes column — the
  description IS the BFL verifikationstext / audit field, so deltagare +
  syfte correctly live there.

Tests: 4112. Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): tidy Bolagsuppgifter — no status colours, clean firmateckning

From first-look feedback on the Företagsprofil page:

- Status: dropped the coloured traffic-light badges (red/yellow/green).
  Per the design system semantic colour is data-only, never chrome, so
  status now renders as plain label + date. Also filtered to dated
  entries only — Bolagsverket emits flags like "Har aldrig varit verksam"
  with no date that read as noise next to the real status. Ceased status
  gets muted destructive text (the one chrome colour the system keeps).

- Firmateckning: the source text carries ">" list markers and crams
  several rules onto one line, and repeats "Firman tecknas av styrelsen"
  across rows. cleanSignatory() strips the markers, normalises whitespace,
  splits run-on "Firman tecknas …" clauses onto separate lines, and the
  render dedupes — so each rule reads as its own sentence.

Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mcp): inbox items expose all terminal links + processed flag

The Eatnam receipt was booked against its bank transaction (so the inbox
row had matched_transaction_id + created_journal_entry_id set), yet the
agent reported it as loose/unmatched and a duplicate risk. Root cause:
gnubok_list_inbox_items only selected and returned matched_supplier_id +
created_supplier_invoice_id — the supplier-invoice path. The
transaction-match and direct-journal-entry paths were invisible, so any
receipt cleared via /transactions looked unprocessed.

- list_inbox_items now selects + returns matched_transaction_id and
  created_journal_entry_id alongside the supplier fields, plus a derived
  `processed` boolean (true when ANY of the three terminal links is set).
- New unprocessed_only=true input filters to items with no terminal link
  — the "what still needs handling" view that prevents the agent from
  flagging already-booked docs as duplicates. (Fetches a wider window
  then filters client-side so limit applies post-filter.)
- Description updated to document the processed semantics, within the
  280-char tool-description budget.

The DB linkage itself already worked: /transactions attach-document sets
matched_transaction_id, and commitCategorizeTransaction stamps
created_journal_entry_id. This was purely a read/surface gap.

Tests: 4112 (+ MCP description guard). Build: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mcp): repair stage-but-never-commit tools + consolidate tool surface

- post_annual_depreciation AND reverse_entry were never in the pending_operations operation_type CHECK, so both staged then died with check_violation at INSERT. Add the CHECK migration, a commitPostAnnualDepreciation executor (reusing commitAnnualPostings), risk tier, and the PendingOperationType union member.
- Salary tools de-risked: calculate_salary_run calls runSalaryCalculation() directly (no self-fetch/forged cookie); create_salary_run uses a transactional create-run helper with compensating delete; generate_agi actually generates + persists the declaration.
- import_sie parses + validates at stage time with a content-rich preview (company, fiscal year, voucher/account counts, balance) instead of a blind byte count.
- batch-match-invoices passed user.id where companyId was expected (silently matched zero).
- VAT report+widget merged behind render_ui; gnubok_search_tools ranks by relevance; gnubok_feedback readOnlyHint corrected; tools/list instruction text fixed; income decision-tree + GL/query_journal cross-refs added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent): load skill atom bodies from the DB so they survive the build

Skill bodies were read from disk at runtime (.claude/skills/**/SKILL.md); on Vercel the dynamic readFile path isn't traced into the lambda and on Docker .claude/ is excluded, so atoms loaded EMPTY in production — a despecialized agent. Inline the bodies into agent_atom_registry instead:
- Migration adds body + mcp_exposed columns; a build-time generator (scripts/generate-skill-bodies.ts) emits a deterministic dollar-quoted seed migration with a content-hash manifest + --check CI guard.
- Read sites (mcp-server atoms.ts, chat system-prompt.ts, composer prewarm) read body from the DB, with a dev-only disk fallback. mcp_exposed curates which atoms the MCP exposes (swarm-* never become atoms).
- The seed script + generator share scripts/lib/atom-discovery.ts; estimated_tokens now reflects SKILL.md only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent): safe the in-app assistant — gating, FAB de-confliction, rate limit, friendly errors

- Hide all agent entry points until verified_at: the Assistent nav tab (sidebar + mobile) and the agent-memory settings tab now match the floating FAB's gate.
- FAB de-confliction: /kpi -> kpi.explain and /bookkeeping/year-end -> bokslut.step so the floating button opens the SAME assistant as the page button (no two-agents-on-one-page).
- Generous per-user rate limit (30/min, 1000/day) on /api/agent/invoke, /onboarding/stream, /composer via a new agent_rate_counters table + check_and_increment_agent_quota RPC; fails open. Bounds runaway Bedrock spend without touching normal users.
- Friendly errors: Bedrock 429/timeout/5xx normalized to Swedish (friendlyModelError) in run-turn + the invoke route; the chat client surfaces the server's friendly message instead of a raw HTTP status.
- /chat/new validates ?intent= against the registry so bad deep-links fall back to general.help instead of rendering a broken-looking error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): keep /chat read-only — redirect categorization + swap the "categorize" suggestion for a VAT-report question

general.help (the /chat assistant) is read-only, but it still gave per-transaction bokföringsförslag in prose and asked "godkänner du dessa?" — an analysis the user can't act on (no write tool, no per-tx underlag). Strengthen the prompt to redirect categorization/bokföring to the per-transaction flow (open the transaction -> "Fråga om denna transaktion", where the agent sees the underlag and stages a real ApprovalCard); a short overview is still allowed. Add a guard test locking in no-write-tools + the redirect language. Swap the /chat empty-state "Hjälp mig kategorisera" chip (which lured users into exactly this dead-end) for a VAT-report question the read-only assistant can actually answer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(pending): declutter the review queue rows + header

Fold the conversation deep-link onto the actor label (drop the separate
"Konversation #xxxx" strip and its icon), hide the quick-pick when there's
only one operation type (it duplicated "Markera alla"), and drop the "(0)"
from the disabled bulk-approve button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(vat): enhance VAT handling by integrating document validation and improving error messaging

* feat(settings): add assistant knowledge surface + consolidate settings tabs

Expose the agent's skill atoms (agent_atom_registry) in a read-only surface
beside the existing memory view, and tighten the settings tab bar from 14 to
10 tabs.

- New GET /api/agent/skills + AgentSkillsPanel: lists active, mcp_exposed
  atoms grouped by tier (Kärnkompetens / bransch / bolagssituation), flags
  which are active for the company from agent_profiles, and lazy-loads each
  SKILL.md body on expand.
- New /settings/assistant tab with a Minne/Kompetens toggle (?view=skills);
  /settings/agent-memory and /settings/agent-skills redirect into it.
- Merge Företagsprofil (TIC snapshot) into the Företag tab via
  CompanyProfileSection; /settings/company-profile redirects.
- Merge Skatteverket-anslutningen into the Skatt tab — OAuth returnTo and the
  callback toast now target /settings/tax; /settings/skatteverket redirects.
- Drop the Säkerhetsbackup tab (already under Importera/Exportera).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(inbox): keep booked underlag out of the unmatched queue + widen match window

- categorize: after booking an inbox underlag onto a verifikat, backfill the
  inbox row's matched_transaction_id + created_journal_entry_id so it stops
  showing as unmatched (mirrors the /attach-document paperclip path).
- TransactionMatchPicker: bias the candidate window forward (60d before →
  180d after the invoice date) so late payments aren't dropped before scoring,
  and widen the ranking date tolerance to 120d so the true match floats to the
  top instead of collapsing to "Svag match". Fix "okatigoriserade" typo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wip: bundle in-progress branch work + agent onboarding chat optimizations

Captures the uncommitted work-in-progress on this branch so it lives on the
remote. Heterogeneous changeset — bundled as one commit since the work was
already entangled across files.

Headline change in this commit (from this session):
- Remove the double interview in agent onboarding. Phase B's verification-
  question form stepper is gone — the Phase C chat (onboarding.intake) now
  owns the entire interview and reads the composer's verification_questions
  server-side as its question bank.
- ReviewCard collapses from 3 steps to 2 (meet → review-and-confirm) with
  value-first ordering: profile + "vad jag kan hjälpa dig med" + facts +
  optional seed note. CTA reads "Möt {namn}" to signal the chat follows.
- ChatIntakeStarter handoff subcopy updated to match reality (assistant
  greets first; user can leave anytime).
- Stamp agent_profiles.intake_completed_at server-side in
  app/api/agent/invoke/route.ts on the first user-typed reply in any
  onboarding.intake conversation (idempotent IS NULL guard, best-effort).
  Closes the previously dead-write column and unlocks the opportunistic-
  follow-up hook the migration anticipated.

Plus in-progress branch work being carried forward (not introduced here):
agent runtime + intent prompts, composer + atom-discovery scripts, MCP
server skills surface, onboarding flow components, dashboard/inbox tweaks,
two new agent_atom_registry migrations, additional agent-chat tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(agent): drop inline "Fråga assistenten" affordances — rely on the FAB

The bottom-right "Fråga {namn}" FAB (AgentTrigger) is already route-aware
and picks the right intent per page, so duplicating it as inline page-
header buttons and empty-state links is noise. Removed:

- EmptyState `agentHelp` link ("Eller fråga {namn} hur du kommer igång")
  + the AgentHelpLink component + agent_default_name/agent_ask_link i18n
  keys + the agentHelp props on EmptyInvoices/EmptyCustomers/EmptyTransactions.
- AgentSparkleButton on /bookkeeping (verifikation.draft) and /kpi
  (kpi.explain) page headers.

The FAB stays — when verified, it appears on those routes and routes to
the right intent automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): gate the last two ungated "Fråga assistenten" affordances

Both surfaces previously called useAgentSheet directly without checking
identity.isVerified, so they appeared pre-onboarding (everywhere else the
FAB / sparkle buttons / /chat / Assistent nav are all gated on verified_at).

- Settings page header: remove the "Fråga {namn}" pill entirely. The FAB
  covers /settings routes route-aware (settings.help) — no need for a
  duplicate inline trigger.
- Invoice inbox transaction picker: hide the "Fråga assistenten" button
  when the agent isn't built. Done at the parent (InvoiceInboxWorkspace)
  by passing onAskAssistant only when identity.isVerified is true; the
  child renders the button only when the callback is present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(tic,onboarding,agent): single-call TIC lookup + director-aware narrative voice

- TIC: collapse the company lookup from 6 endpoint calls to 1
  (search-public already exposes sniCodes, bank accounts, emails, phones,
  and registration flags). Derive fiscal-year MM-DD from
  mostRecentFinancialSummary; newly-registered companies fall through to
  the client's first-year defaults.
- Onboarding: BankID picker no longer auto-provisions companies. Every
  pick routes through the wizard with orgnr (and entity_type via the
  CompanyRoles match) prefilled; F-skatt/VAT/address get confirmed in
  steps 2-4 instead of being auto-fetched. createCompanyFromOnboarding
  reuses CompanyLookupResult and adds a defensive top-level catch so
  server-action errors surface to the UI instead of being redacted.
- Agent composer: loadUserDirectorship() checks BankID CompanyRoles for
  a director-like position (ceo/boardMember/chairman/externalSignatory,
  active) before the narrative uses second-person ownership voice
  ("Du driver…"); unknown users get neutral third-person voice so we
  never put ownership words in the user's mouth.

Tests cover loadUserDirectorship, narrative voice, tic-fetch path,
onboarding page, and updated TIC client + lookup/profile suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tic): extend agent-onboarding TIC budget to 10s + backfill stranded org_numbers

The 5s TIC fetch timeout aborted client-side before the upstream Lens
fan-out (~13 calls) could complete, but the in-flight upstream calls
still counted against quota — actions.ts already documents ~530 wasted
calls from this in May. Same bug still applied to the agent-onboarding
stream path. Adds an optional `timeoutMs` to `ensureTicSnapshot` so
deliberate wait-screen callers (agent onboarding stream) can run with
10s while background/dev callers stay on the conservative 5s default.

Page-level server fetch (page.tsx) intentionally stays at 5s to avoid
blocking TTFB without a visible progress affordance.

Backfill migration mirrors `company_settings.org_number` to
`companies.org_number` for the 105 cases where it's safe (after dedup
+ conflict filtering). 56 of those are on active companies — unblocks
duplicate guards, SIE/SRU exports, and TIC fallback chain. Zero TIC
API calls — pure data move. Idempotent.

Also sweeps a pre-existing SSRF guard on the stream route's origin
derivation that was sitting unstaged in the working tree — it lives in
the same diff hunks as the TIC budget change and couldn't be split cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wip: bundle in-progress branch work

Sweep up uncommitted agent/MCP/RLS work-in-progress so the branch is fully
backed up to origin. Not reviewed in detail — committed as-is to preserve
working state alongside the TIC fixes in the previous commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent): tag the "Bygg din bokföringsassistent" CTA as Beta

Adds a Beta badge next to the assistant-setup heading on the dashboard
banner, dashboard inline card, and onboarding checklist row. Also drops
the stale "Gratis i 30 dagar" subline from the dashboard card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(build,migrations): PendingOperationType salary ops + resolve migration version collisions

PR #584 went red on three things:

1. core-only build / Vercel: `lib/pending-operations/commit.ts:2666` switched on
   'create_salary_run' and 'generate_agi' but `PendingOperationType` was missing
   both literals. Add them to the union.

2. Supabase preview: migration version 20260526120000 collided with main's
   newly-merged 20260526120000_fix_replace_sie_import_hard_delete.sql.
   Bump the branch's pair to 20260526120050 / 20260526120051 — still ahead of
   20260526120100_restvardeavskrivning so ordering is preserved.

3. 20260527170000 was used twice on this branch
   (_agent_rls_with_check + _journal_entry_no_doc_required). Bump the second
   to 20260527170100 so the pair stays orderable and Supabase doesn't choke
   on the duplicate schema_migrations PK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): reword comment so core-only guard stops flagging it

The "Check no core imports from extensions" step greps for the literal
\`from '@/extensions/\` across lib/, app/api/, components/. A comment in
lib/agent/composer/tic-fetch.ts quoted the exact pattern verbatim to
explain *why* the file does a self-fetch instead of importing the TIC
extension directly — which the grep matched even though no actual
import exists.

Rewrite the line to keep the same meaning without the literal pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
2026-05-28 11:27:01 +02:00