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
@@ -107,12 +107,40 @@ describe('gnubok_approve_pending_operation', () => {
// commit options always include commitMethod; userEmail is added when
// the supabase mock supports auth.admin.getUserById (it doesn't here, so
// the resolution silently fails and we fall back to just commitMethod).
expect(commitSpy.mock.calls[0][4]).toMatchObject({ commitMethod: 'user_accept' })
// An api_key actor records 'api_key' in the immutable layer — MCP-relayed
// acknowledgment, not a first-party human session (vision §8 P0-1).
expect(commitSpy.mock.calls[0][4]).toMatchObject({ commitMethod: 'api_key' })
expect(result.status).toBe('committed')
expect(result.operation_id).toBe('op-1')
expect(result.data?.invoice_id).toBe('inv-1')
})
// No 'mcp_oauth' row: handleMcpRequest hardcodes actor.type='api_key' for
// ALL MCP traffic (the OAuth connector's access_token is a minted API key),
// so 'api_key' is the only agent-credential value a live request produces.
it.each([
{ actorType: 'api_key', expected: 'api_key' },
{ actorType: 'user', expected: 'user_accept' },
] as const)(
'records commit_method=$expected when the approving actor is $actorType',
async ({ actorType, expected }) => {
const { supabase, enqueue } = createQueuedMockSupabase()
const op = { id: 'op-1', operation_type: 'create_invoice', company_id: 'company-1', status: 'pending', risk_level: 'medium', params: {} }
enqueue({ data: op, error: null }) // fetch
commitSpy.mockResolvedValue({ status: 'committed' })
await approveTool.execute(
{ operation_id: 'op-1' },
'company-1',
'user-1',
supabase as never,
{ type: actorType }
)
expect(commitSpy.mock.calls[0][4]).toMatchObject({ commitMethod: expected })
}
)
it('refuses to approve a risk_level=high op without confirmed=true', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const op = {
@@ -97,6 +97,7 @@ interface ToolCalledPayload {
isError: boolean
errorCode: string | null
errorKind: 'execution' | 'scope_denied' | 'unknown_tool' | null
errorMessage: string | null
requestId: string | number | null
userId: string
companyId: string
@@ -177,6 +178,7 @@ describe('mcp.tool_called telemetry', () => {
expect(event.isError).toBe(false)
expect(event.errorCode).toBeNull()
expect(event.errorKind).toBeNull()
expect(event.errorMessage).toBeNull()
expect(event.actorType).toBe('api_key')
expect(event.actorId).toBe('key-1')
expect(event.actorLabel).toBe('Test Key')
@@ -206,6 +208,9 @@ describe('mcp.tool_called telemetry', () => {
expect(event.isError).toBe(true)
expect(event.errorKind).toBe('scope_denied')
expect(event.errorCode).toBe('INSUFFICIENT_SCOPE')
// The human message rides along for failure clustering.
expect(typeof event.errorMessage).toBe('string')
expect((event.errorMessage as string).length).toBeGreaterThan(0)
// Scope denial exits before tool.execute() runs.
expect(event.latencyMs).toBe(0)
})
@@ -224,6 +229,9 @@ describe('mcp.tool_called telemetry', () => {
expect(event.isError).toBe(true)
expect(event.errorKind).toBe('unknown_tool')
expect(event.errorCode).toBe('UNKNOWN_TOOL')
// Short deterministic message — NOT the full available-tools list the
// client response carries (that would blow the truncation budget).
expect(event.errorMessage).toBe('Unknown tool: "gnubok_does_not_exist"')
expect(event.latencyMs).toBe(0)
})
@@ -245,6 +253,11 @@ describe('mcp.tool_called telemetry', () => {
expect(event.isError).toBe(true)
expect(event.errorKind).toBe('execution')
expect(event.errorCode).toBeTruthy()
// The structured error's human message is captured and bounded at 500
// chars — the raw material for clustering execution failures into gotchas.
expect(typeof event.errorMessage).toBe('string')
expect((event.errorMessage as string).length).toBeGreaterThan(0)
expect((event.errorMessage as string).length).toBeLessThanOrEqual(500)
// Execution path measures real latency, even if the tool exits quickly.
expect(event.latencyMs).toBeGreaterThanOrEqual(0)
})
@@ -356,8 +369,67 @@ describe('mcp.resource_read telemetry', () => {
})
})
describe('mcp.skill_loaded telemetry', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('emits on every successful load — alongside mcp.workflow_started for workflow tier', async () => {
const skillLoadedPromise = new Promise<Record<string, unknown>>((resolve) => {
const off = eventBus.on('mcp.skill_loaded', (payload) => {
off()
resolve(payload as Record<string, unknown>)
})
})
const workflowStartedPromise = new Promise<Record<string, unknown>>((resolve) => {
const off = eventBus.on('mcp.workflow_started', (payload) => {
off()
resolve(payload as Record<string, unknown>)
})
})
await handleMcpRequest(
mcpRequest('tools/call', {
name: 'gnubok_load_skill',
arguments: { slug: 'month-end-close' },
})
)
const event = await skillLoadedPromise
expect(event.slug).toBe('month-end-close')
expect(event.tier).toBe('workflow')
expect(event.actorType).toBe('api_key')
expect(event.actorId).toBe('key-1')
expect(event.userId).toBe('user-1')
expect(event.companyId).toBe('company-1')
// The pre-existing workflow-funnel event still fires for workflow tier.
const wf = await workflowStartedPromise
expect(wf.slug).toBe('month-end-close')
})
it('does not emit when the slug is unknown (load throws before emission)', async () => {
const seen: unknown[] = []
eventBus.on('mcp.skill_loaded', (payload) => {
seen.push(payload)
})
await handleMcpRequest(
mcpRequest('tools/call', {
name: 'gnubok_load_skill',
arguments: { slug: 'nope-not-real' },
})
)
// Flush microtasks — emission is fire-and-forget.
await new Promise((resolve) => setTimeout(resolve, 0))
expect(seen).toHaveLength(0)
})
})
describe('event_log persistence registration', () => {
it('includes all three MCP telemetry events in the persisted event types', async () => {
it('includes all MCP telemetry events in the persisted event types', async () => {
// Read the file as text — the constant is module-private. This is a
// deliberate string-level guard so a future refactor that drops one
// of the events from the list trips the test.
@@ -368,5 +440,6 @@ describe('event_log persistence registration', () => {
expect(text).toMatch(/'mcp\.tool_called'/)
expect(text).toMatch(/'mcp\.tools_list_called'/)
expect(text).toMatch(/'mcp\.resource_read'/)
expect(text).toMatch(/'mcp\.skill_loaded'/)
})
})
+80 -4
View File
@@ -1678,6 +1678,12 @@ export const tools: McpTool[] = [
const available = all.map((s) => s.slug).join(', ')
throw new Error(`Skill not found: "${slug}". Available skills: ${available}`)
}
// Every load, every tier — records which skill/atom bodies agents
// actually pull (mcp.skill_loaded). Without this, "which atom was
// loaded" is unanswerable and atom effectiveness can't be measured.
if (actor) {
emitSkillLoaded({ slug: skill.slug, tier: skill.tier, actor, userId, companyId })
}
// Workflow-tier skills are the closed-form processes (month-end-close,
// year-end-close, payroll-monthly). Loading one is a strong signal the
// agent is starting that workflow — emit so we can track completion
@@ -7516,7 +7522,7 @@ export const tools: McpTool[] = [
// entries, what balances) and a broken/unbalanced file is rejected HERE,
// not after they approve a blind byte count. commitImportSie re-parses on
// commit (defense-in-depth — the staged string could be tampered).
const { parseSIEFile, validateSIEFile } = await import('@/lib/import/sie-parser')
const { parseSIEFile, validateSIEFile, getEffectiveOpeningBalances } = await import('@/lib/import/sie-parser')
let parsed
try {
parsed = parseSIEFile(fileContent)
@@ -7528,7 +7534,10 @@ export const tools: McpTool[] = [
throw new Error(`SIE-filen är ogiltig och importeras inte: ${validation.errors.join('; ')}`)
}
const ibCurrent = parsed.openingBalances.filter((b) => b.yearIndex === 0)
// Effective set: explicit #IB 0, or IB derived from #UB -1 when the
// source system exports none (issue #675) — so the approver sees the
// real IB total and UB-1-only files pass the coverage check below.
const ibCurrent = getEffectiveOpeningBalances(parsed).balances
const ibTotal = Math.round(ibCurrent.reduce((s, b) => s + b.amount, 0) * 100) / 100
// Mapping-coverage check. The executor's per-voucher loop silently
@@ -8591,12 +8600,34 @@ export const tools: McpTool[] = [
log.warn('Failed to resolve user email for MCP approval', { userId, err })
}
// commit_method provenance (agent_first_vision.md §8 P0-1): MCP
// approvals are relayed through an agent credential — record that in
// the immutable layer instead of claiming 'user_accept'. The positive
// acknowledgment (confirmed=true for high risk) is agent-attested, not
// a first-party human session; an auditor reading the GL can now tell
// the difference (BFNAR 2013:2 kap 8 behandlingshistorik).
//
// ALL MCP traffic authenticates as an api_key actor — the claude.ai
// OAuth connector's access_token is itself a minted gnubok_sk_ key
// (app/api/mcp-oauth/token/route.ts), indistinguishable from the
// bridge at this layer — so 'api_key' is the truthful value for every
// path through this handler. 'agent' (also in the CHECK) is reserved
// for first-party agent surfaces (e.g. in-app agent chat) once they
// commit through this layer with a distinguishable actor type.
//
// Note: commitPendingOperation currently threads commitMethod into the
// journal only for create_voucher ops (pre-existing); other operation
// types keep their per-handler defaults, with this approval's actor
// recorded in processing_history below either way.
const commitMethod =
actor?.type === 'api_key' ? ('api_key' as const) : ('user_accept' as const)
const result = await commitPendingOperation(
supabase,
userId,
companyId,
operation,
{ commitMethod: 'user_accept', ...(userEmail ? { userEmail } : {}) }
{ commitMethod, ...(userEmail ? { userEmail } : {}) }
)
// Audit the MCP-initiated approval. Failure must not break the user
@@ -8613,7 +8644,7 @@ export const tools: McpTool[] = [
operation_type: operation.operation_type,
risk_level: operation.risk_level,
outcome: result.status,
commit_method: 'user_accept',
commit_method: commitMethod,
channel: 'mcp',
confirmed: args.confirmed === true,
},
@@ -8902,6 +8933,7 @@ function emitToolCallTelemetry(payload: {
isError: boolean
errorCode: string | null
errorKind: 'execution' | 'scope_denied' | 'unknown_tool' | null
errorMessage: string | null
requestId: string | number | null
userId: string
companyId: string
@@ -8920,6 +8952,10 @@ function emitToolCallTelemetry(payload: {
isError: payload.isError,
errorCode: payload.errorCode,
errorKind: payload.errorKind,
// Truncated: domain error messages are short, but unknown-tool /
// validation messages can embed long lists. 500 chars is plenty for
// clustering failures into gotchas without bloating event_log rows.
errorMessage: payload.errorMessage ? payload.errorMessage.slice(0, 500) : null,
requestId: payload.requestId,
userId: payload.userId,
companyId: payload.companyId,
@@ -9059,6 +9095,36 @@ function checkAndEmitNextHintFollowed(
.catch((err) => console.error('[mcp] next_hint_followed emit failed:', err))
}
/**
* Fire-and-forget telemetry for every successful gnubok_load_skill, all tiers.
* Unlike mcp.workflow_started (workflow tier only), this records WHICH skill
* or atom body the agent pulled — the denominator for correlating a loaded
* atom with downstream tool-error rates.
*/
function emitSkillLoaded(payload: {
slug: string
tier: 'workflow' | 'horizontal' | 'vertical' | 'modifier'
actor: ActorContext
userId: string
companyId: string
}): void {
void eventBus
.emit({
type: 'mcp.skill_loaded',
payload: {
slug: payload.slug,
tier: payload.tier,
sessionId: payload.actor.sessionId ?? null,
actorType: payload.actor.type,
actorId: payload.actor.id ?? null,
actorLabel: payload.actor.label ?? null,
userId: payload.userId,
companyId: payload.companyId,
},
})
.catch((err) => console.error('[mcp] skill_loaded emit failed:', err))
}
/** Fire-and-forget telemetry for workflow lifecycle. */
function emitWorkflowStarted(payload: {
slug: string
@@ -9259,6 +9325,10 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
isError: true,
errorCode: 'UNKNOWN_TOOL',
errorKind: 'unknown_tool',
// Just the requested name — the full available-tools list returned
// to the client would blow the truncation budget without adding
// analytical signal.
errorMessage: `Unknown tool: "${toolName}"`,
requestId: id ?? null,
userId,
companyId,
@@ -9285,6 +9355,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
isError: true,
errorCode: scopeError.error.code,
errorKind: 'scope_denied',
errorMessage: scopeError.error.message_sv,
requestId: id ?? null,
userId,
companyId,
@@ -9347,6 +9418,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
isError: false,
errorCode: null,
errorKind: null,
errorMessage: null,
requestId: id ?? null,
userId,
companyId,
@@ -9364,6 +9436,10 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
isError: true,
errorCode: structured.error.code,
errorKind: 'execution',
// message_sv is the canonical domain message ("Verifikationen
// balanserar inte", "Perioden är låst", …) — the text worth
// clustering when mining failures for gotchas.
errorMessage: structured.error.message_sv,
requestId: id ?? null,
userId,
companyId,