Two handlers hand-rolled supabase.auth.getUser() and therefore skipped the
MFA (AAL2) gate on hosted: DELETE /api/transactions/[id] and
GET /api/transactions. Both sat next to a sibling handler that was already
wrapped, and the raw-route-auth ratchet exempted a file as soon as any
withRouteContext call appeared in it, so they were never flagged.
GET /api/documents/[id]/integrity and POST /api/agent/categorize called
requireAuth() directly (MFA enforced, but no request id, no completion log,
no canonical error envelope). All four are now withRouteContext handlers
with identical company scoping and responses; the transaction delete keeps
its viewer rejection via requireWrite.
The guard now judges each top-level export segment of a route file on its
own, so a wrapped handler no longer exempts a hand-rolled sibling. Baseline
is unchanged (mcp-oauth/authorize remains the one grandfathered file).
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The highest-leverage quality lever for real users. A prod read showed the
majority are cold-start (365 companies, 32.7k unbooked transactions, median 0
counterparty templates), so the LLM selector carries them — and it was only
seeing the bank line (merchant + amount), never the receipt.
- lib/agent/categorize/underlag.ts: gathers the matched receipt/invoice text
for a transaction (receipts.matched_transaction_id + invoice_inbox_items
.matched_transaction_id + the transaction's own attached document) and renders
it as bounded Swedish text — supplier, date, total, moms, line items. Same
sources the categorization intent reads, as a string not a tool loop. Core
queries the tables directly (no @/extensions import). Best-effort: '' on any
failure.
- POST /api/agent/categorize gathers it server-side when the caller didn't
supply `underlag`, so the model reasons over the actual supplier + line items.
Server-side only, no client change. 31 categorize tests green; lint + guards +
scoped typecheck clean.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Turns the selector's raw confidence into a score that means what it says.
- lib/agent/categorize/calibration.ts: the engine. Isotonic regression
(pool-adjacent-violators, distribution-free + monotonic) over
(confidence, was_correct) samples → a calibrator; plus reliabilityByBucket,
ECE, and bandFor(). bandFor NEVER returns 'auto' without a fitted calibrator
(no silent booking on an unproven score) and never auto-books above an amount
cap. 12 engine tests (overconfidence pulled down, underconfidence lifted,
monotonicity, ECE, band gating).
- Measurement loop: migration categorize_calibration_samples (append-only,
company-scoped RLS, confidence CHECK [0,1]) + POST /api/agent/categorize/
outcome logging one sample (proposed vs actually booked) fire-and-forget from
QuickReviewDialog on a successful book (sandbox skipped). AiCategorizeProposal
surfaces the proposal metadata via onProposal.
- scripts/fit-categorize-calibration.ts (read-only): prints the reliability
diagram + ECE + fitted calibrator once data has accumulated.
Fitting needs a few hundred real outcomes, so nothing calibrates today — the
loop starts collecting, and "säker" stays uncalibrated (no auto-book) until the
data proves it. 131 unit tests green; RLS covered by a pg-real test.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The auto-booking cascade end to end (retrieval → selector), minus the write.
- lib/agent/categorize/candidates.ts (Tier 1): assembles the deterministic
candidate slate for a transaction — the learned counterparty template
(strongest, carries its own VAT) plus mapping rules / patterns / per-merchant
history via the same engine gnubok_suggest_categories uses. No model call.
Deduped by account (highest confidence wins), capped; suggestions get the
category's default VAT treatment derived.
- POST /api/agent/categorize: loads the transaction + company VAT context,
runs Tier 1 → Tier 2 selectAccount, returns the proposed account + VAT +
confidence + reasoning + the candidate slate. Never posts anything — the
caller renders an approval card. Gated on configured (any provider incl.
local), same gates as /api/agent/ask.
12 tests: candidate merge/dedupe/VAT-derivation, and the route (401/429/400/
403/404/503 + happy path threading entity type, VAT, underlag, samples).
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>