diff --git a/.github/workflows/core-build.yml b/.github/workflows/core-build.yml
index a9751da4..29b60217 100644
--- a/.github/workflows/core-build.yml
+++ b/.github/workflows/core-build.yml
@@ -58,6 +58,19 @@ jobs:
# baseline ratchets down as legacy errors get fixed.
run: npm run check:lint
+ - name: Typecheck ratchet (no new TypeScript errors)
+ # `npm test` does not typecheck: vitest transpiles and discards types,
+ # so a type error passes all 18 000 tests and only surfaces in the
+ # build. That happened twice on 2026-08-27. This also covers __tests__
+ # files, which the Next.js build never compiles, so it is not merely a
+ # faster copy of the build job.
+ #
+ # Baselined per FILE in scripts/checks/typecheck-baseline.json (539
+ # legacy errors across 83 files, all in test files) and ratchets down.
+ # Per-file rather than per-error-code on purpose: a code-keyed budget
+ # would let a real regression hide behind a legacy fix elsewhere.
+ run: npm run check:types
+
- name: Antipattern ratchet (no new MFA-bypassing routes / naive öre-rounding)
# Fails only if a PR ADDS a route that hand-rolls supabase.auth.getUser()
# instead of the MFA-enforcing guard, or a new Math.round(x*100)/100.
diff --git a/CLAUDE.md b/CLAUDE.md
index cc3f70c3..6149521a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -60,6 +60,7 @@ npm test # All Vitest tests
npx vitest run
# Tests in one directory
npm run test:pg # pg-real tests against real Postgres
npm run check:guards # Ratchet guard (e.g. no hand-rolled route auth)
+npm run check:types # Typecheck ratchet. `npm test` does NOT typecheck: run this before the build
npm run setup:extensions # Regenerate extension registry from extensions.config.json
npm run skills:generate # Regenerate agent_atom_registry seed after editing an atom SKILL.md
```
diff --git a/DECISIONS.md b/DECISIONS.md
index 416b28d7..41e738d6 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1296,3 +1296,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and
[2026-08-27] gnubok_get_agent_briefing's outputSchema condensed 7 743 -> 4 565 chars by replacing four sub-schema interiors (ledger_context, dimensions, skatteverket_connection, recommended_tools) with a permissive `{type:'object'}` plus a fuller description, keeping the property declared so the top-level `additionalProperties: false` still holds. Deleting the properties was not an option for that reason, and adding `additionalProperties:false` to the condensed forms would have rejected the real payload. Safe because agent-briefing.test.ts pins the RUNTIME shape of all four blocks, so the contract stays guarded while the schema stops carrying 3 178 chars of documentation into every tools/list. Kept intact: `company` (its accounting_method prose drives the settlement posting), `atoms` and `memory` (they tell the agent to fetch bodies via gnubok_load_skill). Net with the new tool: guarded (accounted) projection 63 491 -> 62 942 tokens, and the payload ceiling TIGHTENS 63.6K -> 63.1K, the first downward move in that ledger.
[2026-08-27] Cockpit auto-landing gated to byrå owner/admin (isCockpitLandingRole; landing route + '/' bounce), superseding the 2026-08-05 all-members widening: plain members land like regular users and open the cockpit from the nav; the middleware zero-company steer stays ungated because a member with no client companies has nowhere else to land. Allowlist over role!=='member' so future roles default to the regular landing.
[2026-08-27] Klarmarkera (markPeriodClosedExternally) gets an undo, reopenExternallyClosedPeriod, allowed only while the closed state still comes from klarmarkera (closed_externally set, no closing entry): that close was a person's control decision without a bokslutsverifikat, so reversing it strands nothing, whereas a closePeriod close keeps its closing entry and stays irreversible here. The reopen clears the lock too, because the reason to reopen is to change the period's contents (Forsslund Systems 2026-08-27: five imported years klarmarkerade, then the prior-year SIE turned out wrong; replace refused the closed year, unlock refused the closed state, no way back). Audit_log row plus period.unlocked event; the MCP staged-op surface (lock/unlock) does not get a reopen op yet, follow-up.
+[2026-08-27] Added `npm run check:types`, a typecheck ratchet (scripts/checks/no-new-type-errors.mjs + typecheck-baseline.json), wired into the core-build `checks` job next to check:lint. Reason: `npm test` does NOT typecheck. Vitest transpiles and discards types, so a type error passes all 18 000 tests and only surfaces in `npm run build` minutes later; that happened TWICE on 2026-08-27 (a widened errorKind union in the MCP server that lib/events/types.ts still contradicted, and an `interface` that would not assign into `Record[]` because interfaces have no implicit index signature). It is not merely a faster copy of the build job: `tsc --noEmit` also covers `__tests__` files, which the Next.js build never compiles, and that is where all 539 baseline errors live. Baseline is keyed per FILE, deliberately unlike the per-RULE lint ratchet: the legacy errors are concentrated in a handful of old test files and TS2322 is common enough that a code-keyed budget would silently absorb a real regression somewhere else, whereas per-file trips the moment a previously-clean file gains an error. Verified the gate actually fires by introducing a deliberate `const x: number = 'str'` and watching it fail with the exact location, then restoring. Cost measured: 36 s cold (what CI pays, since tsconfig.tsbuildinfo is gitignored) and 4.4 s warm locally via the existing `incremental: true`. The script sets NODE_OPTIONS=--max-old-space-size=8192 because a bare tsc dies with "Ineffective mark-compacts near heap limit" on this graph after about two minutes, which reads like a hang rather than a misconfiguration; it also detects that OOM string and exits 2 with a "raise HEAP_MB" message rather than silently reporting zero errors. NOT changed: Definition of Done item 1 still says only lint + test. CI enforcement is the stronger mechanism and does not need the policy edit; adding it to DoD is a founder call.
diff --git a/package.json b/package.json
index 19bc9055..b1efad04 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"validate:packs": "npx tsx scripts/validate-packs.ts",
"validate:registry": "npx tsx scripts/validate-registry.ts",
"check:lint": "node scripts/checks/no-new-lint-errors.mjs",
+ "check:types": "node scripts/checks/no-new-type-errors.mjs",
"test": "vitest run --project unit",
"test:pg": "vitest run --project pg-real"
},
diff --git a/scripts/checks/no-new-type-errors.mjs b/scripts/checks/no-new-type-errors.mjs
new file mode 100644
index 00000000..01a70988
--- /dev/null
+++ b/scripts/checks/no-new-type-errors.mjs
@@ -0,0 +1,139 @@
+#!/usr/bin/env node
+/**
+ * Ratchet guard for TypeScript errors (sibling of no-new-lint-errors.mjs).
+ *
+ * ## Why this exists
+ *
+ * `npm test` does not typecheck. Vitest transpiles and throws the types away,
+ * so a type error passes the entire 18 000-test suite and only surfaces in
+ * `npm run build`, several minutes later. That happened twice on 2026-08-27
+ * alone: a widened union in the MCP server that a second declaration in
+ * lib/events/types.ts still contradicted, and an `interface` that would not
+ * assign into `Record[]` because interfaces have no implicit
+ * index signature. Both were caught by the build. Neither was caught by 18 000
+ * green tests, which is exactly the wrong order to learn it in.
+ *
+ * `tsc --noEmit` finds both in about two minutes, and unlike the build it also
+ * covers `__tests__` files, which the Next.js build never compiles.
+ *
+ * ## Why the baseline is keyed by FILE, not by error code
+ *
+ * The lint ratchet counts per rule, and accepts the tradeoff that fixing one
+ * legacy error of a rule lets a new one in. For types that tradeoff is worse:
+ * the pre-existing errors are concentrated in a handful of old test files, and
+ * TS2322 ("not assignable") is common enough that a per-code budget would
+ * silently absorb a real regression somewhere else entirely. Keyed by file, a
+ * new error in a previously-clean file trips immediately, which is the case
+ * that actually matters.
+ *
+ * Usage:
+ * node scripts/checks/no-new-type-errors.mjs # check
+ * node scripts/checks/no-new-type-errors.mjs --update # re-baseline
+ *
+ * Exit code 1 if any file's error count exceeds its baseline.
+ */
+import fs from 'node:fs'
+import path from 'node:path'
+import { spawnSync } from 'node:child_process'
+import { fileURLToPath } from 'node:url'
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
+const BASELINE_PATH = path.join(ROOT, 'scripts', 'checks', 'typecheck-baseline.json')
+
+/**
+ * This project's graph does not fit in Node's default heap: a bare
+ * `tsc --noEmit` dies with "Ineffective mark-compacts near heap limit" after
+ * about two minutes of work, which reads like a hang rather than a
+ * misconfiguration. The build sets the same flag for the same reason.
+ */
+const HEAP_MB = 8192
+
+function runTsc() {
+ const tscBin = path.join(ROOT, 'node_modules', 'typescript', 'bin', 'tsc')
+ const result = spawnSync(process.execPath, [tscBin, '--noEmit', '--pretty', 'false'], {
+ cwd: ROOT,
+ encoding: 'utf8',
+ maxBuffer: 64 * 1024 * 1024,
+ env: { ...process.env, NODE_OPTIONS: `--max-old-space-size=${HEAP_MB}` },
+ })
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`
+ if (/Ineffective mark-compacts|JavaScript heap out of memory/.test(output)) {
+ console.error(`no-new-type-errors: tsc ran out of memory at ${HEAP_MB} MB. Raise HEAP_MB.`)
+ process.exit(2)
+ }
+ // tsc exits non-zero when errors exist, which is the normal case here.
+ return output
+}
+
+/** `path/to/file.ts(12,34): error TS2322: ...` */
+const ERROR_RE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/
+
+function collect(output) {
+ /** @type {Record} */
+ const perFile = {}
+ /** @type {Record} */
+ const locations = {}
+ for (const line of output.split('\n')) {
+ const match = ERROR_RE.exec(line.trim())
+ if (!match) continue
+ const [, file, lineNo, col, code, message] = match
+ const rel = path.relative(ROOT, path.resolve(ROOT, file)).split(path.sep).join('/')
+ perFile[rel] = (perFile[rel] ?? 0) + 1
+ ;(locations[rel] ??= []).push(`${rel}:${lineNo}:${col} ${code} ${message}`)
+ }
+ return { perFile, locations }
+}
+
+const { perFile, locations } = collect(runTsc())
+const total = Object.values(perFile).reduce((a, b) => a + b, 0)
+
+if (process.argv.includes('--update')) {
+ const sorted = Object.fromEntries(Object.entries(perFile).sort(([a], [b]) => a.localeCompare(b)))
+ fs.writeFileSync(
+ BASELINE_PATH,
+ JSON.stringify({ totalErrors: total, perFile: sorted }, null, 2) + '\n',
+ )
+ console.log(
+ `no-new-type-errors: baseline updated: ${total} error(s) across ${Object.keys(perFile).length} file(s).`,
+ )
+ process.exit(0)
+}
+
+if (!fs.existsSync(BASELINE_PATH)) {
+ console.error(`no-new-type-errors: baseline missing at ${path.relative(ROOT, BASELINE_PATH)}.`)
+ console.error('Run: node scripts/checks/no-new-type-errors.mjs --update')
+ process.exit(2)
+}
+
+const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'))
+const baselineFiles = baseline.perFile ?? {}
+
+const regressions = []
+for (const [file, count] of Object.entries(perFile)) {
+ const allowed = baselineFiles[file] ?? 0
+ if (count > allowed) regressions.push({ file, count, allowed })
+}
+
+if (regressions.length > 0) {
+ console.error('no-new-type-errors: FAILED: new TypeScript errors beyond the baseline:\n')
+ for (const { file, count, allowed } of regressions) {
+ console.error(` ${file}: ${count} (baseline ${allowed})`)
+ for (const loc of (locations[file] ?? []).slice(0, 10)) {
+ console.error(` ${loc}`)
+ }
+ }
+ console.error(`
+Fix the new error(s): run \`NODE_OPTIONS=--max-old-space-size=${HEAP_MB} npx tsc --noEmit\` to see them all.
+(If you fixed MORE legacy errors than you added and a file still trips,
+re-baseline with: node scripts/checks/no-new-type-errors.mjs --update)
+`)
+ process.exit(1)
+}
+
+const improved = total < (baseline.totalErrors ?? 0)
+console.log(
+ `no-new-type-errors: OK: ${total} error(s), baseline ${baseline.totalErrors}.` +
+ (improved
+ ? ' Count went DOWN: ratchet it: node scripts/checks/no-new-type-errors.mjs --update'
+ : ''),
+)
diff --git a/scripts/checks/typecheck-baseline.json b/scripts/checks/typecheck-baseline.json
new file mode 100644
index 00000000..32c7115a
--- /dev/null
+++ b/scripts/checks/typecheck-baseline.json
@@ -0,0 +1,88 @@
+{
+ "totalErrors": 539,
+ "perFile": {
+ "app/api/assets/__tests__/id.test.ts": 9,
+ "app/api/auth/email-hook/__tests__/route.test.ts": 1,
+ "app/api/auth/heartbeat/__tests__/route.test.ts": 1,
+ "app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts": 23,
+ "app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/__tests__/route.test.ts": 4,
+ "app/api/bookkeeping/journal-entries/__tests__/route.test.ts": 26,
+ "app/api/bookkeeping/journal-entries/[id]/chain/__tests__/route.test.ts": 2,
+ "app/api/bookkeeping/no-doc-required/batch/__tests__/route.test.ts": 6,
+ "app/api/bookkeeping/no-doc-required/bulk-missing/__tests__/route.test.ts": 9,
+ "app/api/bookkeeping/voucher-gaps/__tests__/route.test.ts": 9,
+ "app/api/customers/__tests__/viewer.test.ts": 2,
+ "app/api/documents/counts/__tests__/route.test.ts": 11,
+ "app/api/export/articles/__tests__/route.test.ts": 3,
+ "app/api/export/suppliers/__tests__/route.test.ts": 2,
+ "app/api/extensions/shopify/orders/cron/__tests__/route.test.ts": 2,
+ "app/api/extensions/skatteverket/agi/kvittenser/cron/__tests__/route.test.ts": 6,
+ "app/api/import/articles/__tests__/execute.test.ts": 23,
+ "app/api/import/bank-file/__tests__/route.test.ts": 1,
+ "app/api/import/bank-file/check-duplicates/__tests__/route.test.ts": 8,
+ "app/api/import/opening-balance/__tests__/correct.test.ts": 28,
+ "app/api/import/opening-balance/__tests__/execute.test.ts": 18,
+ "app/api/import/opening-balance/correct/__tests__/route.test.ts": 10,
+ "app/api/invoices/__tests__/route.test.ts": 24,
+ "app/api/invoices/[id]/peppol/__tests__/route.test.ts": 1,
+ "app/api/invoices/[id]/send-payment-confirmation/__tests__/route.test.ts": 8,
+ "app/api/invoices/bulk-book/__tests__/route.test.ts": 1,
+ "app/api/invoices/self-billed/__tests__/route.test.ts": 8,
+ "app/api/onboarding/state/__tests__/route.test.ts": 5,
+ "app/api/pending-operations/bulk-commit/__tests__/route.test.ts": 11,
+ "app/api/pending-operations/bulk-reject/__tests__/route.test.ts": 10,
+ "app/api/reports/audit-trail/__tests__/route.test.ts": 4,
+ "app/api/reports/bokslutsbilagor/__tests__/route.test.ts": 8,
+ "app/api/reports/full-archive/__tests__/route.test.ts": 11,
+ "app/api/rot-rut/__tests__/routes.test.ts": 13,
+ "app/api/rot-rut/beslut/__tests__/route.test.ts": 4,
+ "app/api/settings/api-keys/__tests__/route.test.ts": 6,
+ "app/api/settings/booking-templates/sync/cron/__tests__/route.test.ts": 1,
+ "app/api/settings/eu-trade-signal/__tests__/route.test.ts": 3,
+ "app/api/settings/ku-signal/__tests__/route.test.ts": 4,
+ "app/api/settings/rot-rut-signal/__tests__/route.test.ts": 4,
+ "app/api/supplier-invoices/__tests__/route.test.ts": 41,
+ "app/api/supplier-invoices/payment-batches/__tests__/route.test.ts": 12,
+ "app/api/tax-assessment-notices/__tests__/route.test.ts": 5,
+ "app/api/transactions/bulk-book/__tests__/route.test.ts": 16,
+ "app/api/v1/companies/[companyId]/customers/__tests__/route.test.ts": 1,
+ "app/api/webshop-orders/__tests__/bulk-book.test.ts": 1,
+ "app/api/webshop-orders/__tests__/list-and-settings.test.ts": 11,
+ "extensions/general/enable-banking/__tests__/session-expired.test.ts": 4,
+ "extensions/general/enable-banking/__tests__/supersede.test.ts": 1,
+ "extensions/general/invoice-inbox/__tests__/match-transaction.test.ts": 1,
+ "extensions/general/invoice-inbox/__tests__/retry-extraction.test.ts": 1,
+ "extensions/general/invoice-inbox/__tests__/sandbox-skip-extraction.test.ts": 1,
+ "extensions/general/invoice-inbox/__tests__/upload-page-count-gate.test.ts": 1,
+ "extensions/general/mcp-server/__tests__/account-tools.test.ts": 2,
+ "extensions/general/mcp-server/__tests__/connect-links.test.ts": 1,
+ "extensions/general/mcp-server/__tests__/payroll-staged-tools.test.ts": 21,
+ "extensions/general/mcp-server/__tests__/salary-tools.test.ts": 4,
+ "extensions/general/mcp-server/__tests__/set-voucher-note.test.ts": 1,
+ "extensions/general/mcp-server/__tests__/skills.test.ts": 2,
+ "extensions/general/skatteverket/__tests__/api-client.test.ts": 5,
+ "extensions/general/skatteverket/__tests__/skattekonto-mappers.test.ts": 1,
+ "lib/__tests__/logger.test.ts": 1,
+ "lib/api/v1/__tests__/with-api-v1.test.ts": 9,
+ "lib/auth/__tests__/require-write.test.ts": 15,
+ "lib/bokslut/__tests__/k3-framework-dispositions.test.ts": 2,
+ "lib/bokslut/__tests__/readiness-aggregator.test.ts": 6,
+ "lib/bookkeeping/__tests__/own-account-detector.test.ts": 1,
+ "lib/bookkeeping/__tests__/supplier-payment-lines.test.ts": 14,
+ "lib/branding/__tests__/public-brand.test.ts": 1,
+ "lib/email/__tests__/brand-mail-snapshots.test.ts": 1,
+ "lib/email/__tests__/brand-sender.test.ts": 1,
+ "lib/email/__tests__/reminder-templates.test.ts": 6,
+ "lib/import/__tests__/account-mapper.test.ts": 1,
+ "lib/import/__tests__/sie-import.test.ts": 1,
+ "lib/invoices/__tests__/pdf-template-amounts.test.ts": 1,
+ "lib/invoices/__tests__/peppol-bis-billing.test.ts": 1,
+ "lib/invoices/__tests__/reminder-processor.test.ts": 3,
+ "lib/invoices/__tests__/supplier-invoice-matching.test.ts": 1,
+ "lib/pending-operations/__tests__/commit-authorization-recoverable.test.ts": 1,
+ "lib/reports/__tests__/vat-declaration.test.ts": 5,
+ "lib/supabase/__tests__/middleware.test.ts": 2,
+ "tests/pg/categorize-calibration-samples.pg.test.ts": 2,
+ "tests/pg/match-batch-allocate.pg.test.ts": 1
+ }
+}