feat(agent): telemetry + CI-gate quick wins from the "AI systems that ship" audit (#677)

* feat(agent): telemetry completeness + durability, CI gates, commit_method provenance

Quick wins from the "Building AI systems that ship" audit:

- mcp.tool_called gains errorMessage (message_sv, truncated 500 chars) on
  all failure exits; new mcp.skill_loaded event on every gnubok_load_skill
  (all tiers) so atom usage is finally measurable
- event_log: (event_type, created_at) index; cleanup cron keeps
  mcp.*/agent.* telemetry 180 days (delivery events stay 30)
- CI: lint ratchet (npm run check:lint — 60 legacy errors baselined,
  fails only on NEW errors) and a pg-real coverage gate (migrations
  touching trigger/RPC/RLS/DEFERRABLE require a *.pg.test.ts change;
  escape hatch: -- pg-test: covered-by/skip)
- journal_entries.commit_method CHECK widened with 'api_key'/'agent';
  the MCP approve path records 'api_key' truthfully instead of
  'user_accept' (agent_first_vision §8 P0-1). 'agent' is reserved — ALL
  MCP traffic (incl. claude.ai OAuth, whose access_token is a minted
  API key) authenticates as api_key today

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

* feat(import): derive opening balances from prior-year #UB when SIE lacks #IB (#675)

SIE files exported without #IB 0 rows (only #UB -1) previously imported
with zero opening balances. getEffectiveOpeningBalances() now derives IB
from prior-year UB for balance-sheet accounts when explicit #IB is
absent, surfaces the derivation as an info issue in the import preview,
and excludes share-capital vouchers from opening-balance detection.
Detection regexes are shared between parser and importer so the two
checks cannot drift. 507 lib/import tests pass.

(Authored in a parallel session in this checkout; included per request.)

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

* fix(review): address PR #677 bot findings — RoPA entry, execFileSync, gate scope note

Triage of the compliance-swarm + Greptile findings:

Applied:
- .compliance/ropa.yaml: new mcp.telemetry processing activity declaring
  the 180-day mcp.*/agent.* retention, lawful basis, data categories, and
  the no-args/no-results minimisation (ISO A.8.10, GDPR Art.5(1)(c) —
  the retention split is now formally documented, referenced from the cron)
- check-pg-test-coverage.mjs: execFileSync with argv array — no shell, so
  a hostile base-ref can't inject (ASVS V13.2.1); verified an injection
  attempt exits 2 without executing
- check-pg-test-coverage.mjs: documented the PR-level (not per-migration)
  scope of the gate so reviewers know to check coverage per migration when
  a PR carries several risky migrations (Greptile P2)

Acknowledged, no change:
- errorMessage PII risk: messages are domain-mapped strings; event_log
  already persists far richer delivery payloads under the same RLS; now
  declared in ropa.yaml
- cron error envelope: errorResponse maps to the canonical safe envelope
  and the endpoint is CRON_SECRET-gated
- two-pass delete "partial state": TTL deletes are idempotent — the next
  daily run sweeps whatever a failed pass left behind
- skill_loaded actorLabel/sessionId: mirrors the pre-existing
  mcp.tool_called payload; sessionId is the join key the analytics exist for

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-05 15:47:13 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 076bb169f8
commit bc61862e76
24 changed files with 1733 additions and 62 deletions
+110 -4
View File
@@ -777,6 +777,104 @@ export function parseSIEFile(content: string): ParsedSIEFile {
}
}
/**
* Wording that identifies a voucher as the year's opening balance
* (ingående balans). Shared between the parser's OB-voucher candidate
* detection below and the importer's isLikelyOpeningBalance tagging
* (lib/import/sie-import.ts) so the two checks can never drift apart.
*/
export const OPENING_BALANCE_DESCRIPTION_RE = /ing[åa]ende balans|ing[åa]ende saldo|opening balance/i
/**
* Vouchers mentioning share capital are never treated as opening balances —
* a share-capital deposit dated on the FY start is a real bank movement.
*/
export const SHARE_CAPITAL_DESCRIPTION_RE = /aktiekapital/i
/**
* Determine if an account is balance sheet (class 1-2) or P&L (class 3-8)
*/
export function isBalanceSheetAccount(accountNumber: string): boolean {
const firstDigit = parseInt(accountNumber.charAt(0), 10)
return firstDigit >= 1 && firstDigit <= 2
}
/**
* Format a Date to "YYYY-MM-DD" using LOCAL components.
* parseSIEDate() builds local-time Dates, so toISOString() would shift the
* day across the UTC boundary in non-UTC timezones — never use it here.
*/
function formatLocalDate(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
/**
* True when the file contains a voucher that looks like the year's opening
* balance: dated on the fiscal-year start, only balance-sheet accounts,
* IB wording in the description and no share-capital mention.
*
* Raw-file mirror of the importer's isLikelyOpeningBalance check
* (lib/import/sie-import.ts), but deliberately MORE eager: it runs on
* source account numbers with no knowledge of account mappings, so a
* candidate containing an unmapped line still counts here even though the
* importer would later skip that voucher as unmapped. In that residual case
* no IB is created at all — the user falls back to the manual
* "Märk som ingående balans" action in Bankavstämning.
*/
export function hasOpeningBalanceVoucherCandidate(parsed: ParsedSIEFile): boolean {
const fyStart = parsed.stats.fiscalYearStart
if (!fyStart) return false
return parsed.vouchers.some(
(v) =>
v.lines.length > 0 &&
formatLocalDate(v.date) === fyStart.slice(0, 10) &&
v.lines.every((l) => isBalanceSheetAccount(l.account)) &&
OPENING_BALANCE_DESCRIPTION_RE.test(v.description || '') &&
!SHARE_CAPITAL_DESCRIPTION_RE.test(v.description || '')
)
}
/**
* Resolve the opening balances the import should actually book (issue #675).
*
* Some systems export no #IB 0 records at all — the current year's IB exists
* only implicitly via the SIE continuity invariant IB(year 0) = UB(year -1).
* Every IB consumer goes through this helper so the precedence below is the
* single source of truth:
*
* 1. Explicit #IB 0 records — trusted as-is, never merged with #UB -1.
* 2. An opening-balance #VER candidate — the voucher itself serves as IB
* during voucher import (tagged source_type 'opening_balance');
* deriving from #UB -1 as well would double-count every
* balance-sheet account.
* 3. #UB -1 records, re-labeled to yearIndex 0 and filtered to
* balance-sheet accounts (result accounts must always open at zero).
* 4. Nothing — the file genuinely carries no opening balances.
*/
export function getEffectiveOpeningBalances(parsed: ParsedSIEFile): {
balances: SIEBalance[]
derivedFromPriorYearUB: boolean
} {
const explicit = parsed.openingBalances.filter((b) => b.yearIndex === 0)
if (explicit.length > 0) {
return { balances: explicit, derivedFromPriorYearUB: false }
}
if (hasOpeningBalanceVoucherCandidate(parsed)) {
return { balances: [], derivedFromPriorYearUB: false }
}
const derived = parsed.closingBalances
.filter((b) => b.yearIndex === -1 && isBalanceSheetAccount(b.account))
.map((b) => ({ ...b, yearIndex: 0 }))
return { balances: derived, derivedFromPriorYearUB: derived.length > 0 }
}
/**
* Validate a parsed SIE file
*/
@@ -866,10 +964,18 @@ export function validateSIEFile(parsed: ParsedSIEFile): ValidationResult {
)
}
// Check opening balance is balanced (for balance sheet accounts)
const ibTotal = parsed.openingBalances
.filter((b) => b.yearIndex === 0)
.reduce((sum, b) => sum + b.amount, 0)
// Check opening balance is balanced (for balance sheet accounts).
// Uses the effective set so files without #IB 0 — where IB is derived from
// #UB -1 (issue #675) — still get the 2099-adjustment heads-up.
const effectiveIB = getEffectiveOpeningBalances(parsed)
if (effectiveIB.derivedFromPriorYearUB) {
warnings.push(
'Filen saknar ingående balanser (#IB) för aktuellt räkenskapsår — de härleds från föregående års utgående balans (#UB -1) vid import.'
)
}
const ibTotal = effectiveIB.balances.reduce((sum, b) => sum + b.amount, 0)
if (Math.abs(ibTotal) > 0.01) {
warnings.push(`Ingående balanser balanserar inte (differens: ${ibTotal.toFixed(2)} kr). En automatisk justeringspost mot konto 2099 skapas vid import.`)