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:
co-authored by
Claude Opus 4.8
parent
076bb169f8
commit
bc61862e76
+110
-4
@@ -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.`)
|
||||
|
||||
Reference in New Issue
Block a user