* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade The engine half of the reconciliation page (design: Avstämningsmotorn). - lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket, händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad, bokfört), the item buckets the page shows (proposed, unmatched external, unmatched ledger, matched, ignored, upcoming), opening_difference, unexplained_difference (0,00 by construction when data is consistent), dead-link handling (a link to a reversed/draft entry counts as unlinked and is flagged), awaiting_external for ledger lines within 5 days of the snapshot, staleness, and a window that scopes item lists without hiding older rows. Core reads skattekonto_transactions and the extension's snapshot row directly; no @/extensions import. - lib/reconciliation/gl-balance.ts: one ledger-balance helper with the trial-balance predicate status IN (posted, reversed). The drift check summed posted only, which misstated 1630 for any company with a storno on the account; skattekonto-drift.ts now delegates to the helper. - Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id / suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now assigns one-to-one across rows (AGI period first, then nearest date) and falls back to an entry whose 1630 lines net to the amount (split lines); a proposal is never a link. - lib/reconciliation/service.ts + schemas.ts: the account-keyed facade (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts (enabled cash accounts folded per IBAN, skattekonto when configured) and getAccountStatus dispatching to the bank engine or the new one; shared Zod shapes for the v1 registry, MCP schemas and the UI (PR 2). Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window, window scoping, failed ledger read, live-linked entries never proposed; matcher one-to-one and split-line cases; proposal refresh writes/clears; service dedupe and dispatch. No UI in this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet) The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in five places. Switch to roundOre from @/lib/money and ratchet the baseline down by the three occurrences this removes net of the matcher rewrite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link. - lib/reconciliation/items.ts: listAccountItems per account_key, the page's buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored, upcoming), limit/offset; skattekonto from the engine, bank from the scoped transactions + unlinked GL lines (netted per entry). - lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run, partial success with codes), unmatchLink, setItemIgnored; emits reconciliation.matched / reconciliation.unmatched. - lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a skattekonto row (single line or entry net on 1630, live-link guard, race-safe update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry until its tests are ported. - Dashboard routes /api/reconciliation/accounts[...]: list, status, items, links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply directly (a human clicked). - v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six, withApiV1, new scopes reconciliation:read / reconciliation:write (write is a staging scope for SoD), Idempotency-Key + dry_run on writes, registered for OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes and their transactions:* scopes unchanged. - MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path untouched), new gnubok_list_reconciliation_items (default catalog), gnubok_reconcile_match (stages reconciliation_match, preflight = status) and gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry moved to search. Executors in commit.ts; risk tiers medium/low; migration pair 20260823130000/130001 adds the two op types to the CHECK constraint (value list = live prod as of 2026-08-23 + the two); close_period loadout updated. Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/ happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and apiskill:check green; no type errors in changed files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard The six new v1 reconciliation endpoints and the two new scopes were not recorded in the spec snapshot, and setSkattekontoRowIgnored updated through one conditional payload, which the phantom-column scanner cannot read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): the Avstämning page, one body for every account with an outside truth /reconciliation in Arbeta (after Transaktioner), on the approved layout: an account rail on the left (bank accounts and the skattekonto, logo or monogram, last fetch, status dot, URL-owned selection), and for the selected account four tiles (outside, ledger, difference, unexplained), the bridge that explains the difference, an actions row (link the proposed pairs, book the unbooked skattekonto events, run the bank matcher) and a full-width table banded by bucket with proposal rows linkable one by one. Every read and write goes through the PR 2 dashboard routes, so the page shows exactly what the v1 API and the MCP tools see. Also: nav item, command palette entry, sv/en strings. Period picker, manual match mode and sign-off are deliberately not here (PR 4/5). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): sign-off, period picker, Hem row and the three doors for it "Markera som avstämd t.o.m. <datum>" as an append-only attestation: account_reconciliations (who signed which account through which date, with the numbers as they stood; reopen stamps instead of deletes; RLS members write as themselves, viewers read). Policy in one place (lib/reconciliation/signoff.ts): refused with an unexplained difference unless forced with a note, refused past today or past the skattekonto snapshot, refused at or before an active sign-off; reopen is the undo. Every status read now carries the latest active sign-off and the rail shows "avstämt t.o.m.". Three doors: dashboard routes (GET/POST .../signoff, POST .../reopen), v1 (same, scope reconciliation:signoff, Idempotency-Key, dry-run, registry + regenerated API skill), MCP gnubok_reconcile_signoff (search catalog, stages reconciliation_signoff after a policy dry run; executor + risk tier + op-type CHECK migration pair). Events reconciliation.signed_off / reconciliation.reopened, and the four reconciliation events join the public webhook set (additive; API version unchanged, changelog section added). Page: räkenskapsår + range picker in the header (own preset memory, opens on this month) scoping the bridge, the items and the default sign-off date; sign-off dialog with the forced-with-note path; reopen on hover. Hem: worklist category reconciliation_due ("Konton att stämma av"), zero until the company has signed anything off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): classify reconciliation:signoff as a tenant write for the MCP role guard gnubok_reconcile_signoff carries the deliberately separate reconciliation:signoff scope; the central viewer guard keys on the :write/:approve/:manage suffixes, so a viewer could reach the tool (RLS would still refuse the row, but the guard is the intended layer). Add :signoff to the classifier; the strictness test that caught it now passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(providers): serve local rate-limiter waiters in arrival order Two callers that both found the in-memory bucket empty each set their own timeout; the timeouts expired at the same instant from different timer lists and which woke first was platform-dependent. hydrateInvoices relies on "started first, requested first" to serve open invoices before paid ones, so lib/providers/__tests__/hydrate-invoices.test.ts flipped on CI (twice on #1817) while holding locally. A promise queue makes the local waiters FIFO without changing the rate; the Upstash path is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 14a7599bf2c6fa7f97de6ffab3dc4cf4d0e1827d) --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
348 lines
22 KiB
TypeScript
348 lines
22 KiB
TypeScript
import type {
|
|
JournalEntry,
|
|
Invoice,
|
|
Transaction,
|
|
Customer,
|
|
Supplier,
|
|
Article,
|
|
FiscalPeriod,
|
|
DocumentAttachment,
|
|
Receipt,
|
|
CreditNote,
|
|
ReconciliationMethod,
|
|
InvoiceInboxItem,
|
|
SupplierInvoice,
|
|
} from '@/types'
|
|
|
|
// ============================================================
|
|
// Core Event Types: discriminated union of all system events
|
|
// ============================================================
|
|
|
|
/**
|
|
* Who runs AI extraction on an uploaded document.
|
|
* - 'invoice-inbox': the inbox extracts and mirrors the result itself.
|
|
* - 'none': nobody should; the caller already holds the booking.
|
|
* Unset: the document-extraction extension extracts (default).
|
|
*/
|
|
export type DocumentExtractionOwner = 'invoice-inbox' | 'none'
|
|
|
|
export type CoreEvent =
|
|
// Bookkeeping
|
|
| { type: 'journal_entry.drafted'; payload: { entry: JournalEntry; userId: string; companyId: string } }
|
|
| { type: 'journal_entry.committed'; payload: { entry: JournalEntry; userId: string; companyId: string } }
|
|
| { type: 'journal_entry.corrected'; payload: { original: JournalEntry; storno: JournalEntry; corrected: JournalEntry; userId: string; companyId: string } }
|
|
| { type: 'journal_entry.reversed'; payload: { originalEntry: JournalEntry; reversalEntry: JournalEntry; userId: string; companyId: string } }
|
|
| { type: 'journal_entry.deleted'; payload: { entryId: string; voucherSeries: string; voucherNumber: number; userId: string; companyId: string } }
|
|
// Documents
|
|
// extractionOwner: set by the invoice inbox on documents it extracts itself,
|
|
// so the document-extraction extension yields instead of racing it (the
|
|
// inbox row does not exist yet when this event fires inside uploadDocument).
|
|
// 'none' is an explicit opt-out: the uploader already knows the booking
|
|
// (provider underlag import links each file to its posted verifikat), so
|
|
// running a paid model over it would buy nothing. The extension stamps the
|
|
// row as skipped instead of extracting.
|
|
| { type: 'document.uploaded'; payload: { document: DocumentAttachment; userId: string; companyId: string; extractionOwner?: DocumentExtractionOwner } }
|
|
| { type: 'document.accessed'; payload: { document: { id: string; file_name: string }; userId: string; companyId: string } }
|
|
| { type: 'document.deleted'; payload: { document: { id: string; file_name: string }; userId: string; companyId: string } }
|
|
// Invoicing
|
|
| { type: 'invoice.created'; payload: { invoice: Invoice; userId: string; companyId: string } }
|
|
// Hard delete of an un-finalized, unnumbered draft (no F-series number was
|
|
// consumed). Carries only the identifiers (the row is gone) so the audit
|
|
// log can record who removed which draft and when. Numbered drafts are
|
|
// makulerade instead and surface via the journal, not this event.
|
|
| { type: 'invoice.draft_deleted'; payload: { invoiceId: string; userId: string; companyId: string } }
|
|
| { type: 'invoice.sent'; payload: { invoice: Invoice; userId: string; companyId: string } }
|
|
| { type: 'invoice.paid'; payload: { invoice: Invoice; paymentAmount: number; paymentDate: string; userId: string; companyId: string } }
|
|
| { type: 'credit_note.created'; payload: { creditNote: CreditNote; userId: string; companyId: string } }
|
|
// Recurring invoices: emitted by the daily cron after a schedule spawns
|
|
// an invoice. `autoSent` tells observers whether the email also went out
|
|
// (false means it was created as draft for manual review).
|
|
| { type: 'recurring_invoice.executed'; payload: {
|
|
scheduleId: string
|
|
invoice: Invoice
|
|
autoSent: boolean
|
|
warning: string | null
|
|
userId: string
|
|
companyId: string
|
|
} }
|
|
// Banking
|
|
| { type: 'transaction.synced'; payload: { transactions: Transaction[]; userId: string; companyId: string } }
|
|
| { type: 'transaction.categorized'; payload: { transaction: Transaction; account: string; taxCode: string; userId: string; companyId: string } }
|
|
| { type: 'transaction.reconciled'; payload: { transaction: Transaction; journalEntryId: string; method: ReconciliationMethod; userId: string; companyId: string } }
|
|
// Account-keyed reconciliation (lib/reconciliation/actions.ts): one event per
|
|
// link made or removed on any reconcilable account (bank:<cash_account_id>,
|
|
// skattekonto, later manual:NNNN). `transaction.reconciled` keeps firing for
|
|
// bank links made through the bank engine; these are the kind-agnostic
|
|
// signals the flows builder triggers on.
|
|
| { type: 'reconciliation.matched'; payload: { accountKey: string; externalId: string; journalEntryId: string; method: 'manual' | 'proposal'; userId: string; companyId: string } }
|
|
| { type: 'reconciliation.unmatched'; payload: { accountKey: string; externalId: string; previousJournalEntryId: string | null; userId: string; companyId: string } }
|
|
// Sign-off: the human (or agent-staged, user-approved) assertion "reconciled
|
|
// through this date" on one account (lib/reconciliation/signoff.ts), and its undo.
|
|
| { type: 'reconciliation.signed_off'; payload: { accountKey: string; signoffId: string; throughDate: string; unexplainedDifference: number | null; userId: string; companyId: string } }
|
|
| { type: 'reconciliation.reopened'; payload: { accountKey: string; signoffId: string; throughDate: string; reason: string | null; userId: string; companyId: string } }
|
|
// Bank connection lifecycle: consent + account selection are the
|
|
// GDPR/PSD2 audit points; emitted to event_log for compliance trail.
|
|
| { type: 'bank_connection.consent_granted'; payload: { connectionId: string; bankName: string | null; accountCount: number; consentExpiresAt: string | null; userId: string; companyId: string } }
|
|
| { type: 'bank_connection.account_selection_changed'; payload: { connectionId: string; bankName: string | null; previousStatus: string; newStatus: string; enabledCount: number; totalCount: number; userId: string; companyId: string } }
|
|
| { type: 'bank_connection.revoked'; payload: { connectionId: string; bankName: string | null; userId: string; companyId: string } }
|
|
// Emitted when a new or renewed connection supersedes an older row for the
|
|
// same bank in the same company: the old row is parked as 'revoked' with
|
|
// superseded_by pointing at the replacement, and its transactions are
|
|
// re-pointed. connectionId is the SUPERSEDED (old) row, mirroring .revoked.
|
|
| { type: 'bank_connection.superseded'; payload: { connectionId: string; supersededById: string; bankName: string | null; userId: string; companyId: string } }
|
|
// Emitted when the PSD2 callback fails to mirror a returned account into
|
|
// cash_accounts. ASVS V16 / ISO 27001 A.8.15: security-relevant failures
|
|
// must land in a structured audit log (event_log, 30-day TTL) rather than
|
|
// being lost to console.error.
|
|
| { type: 'bank_connection.cash_account_mirror_failed'; payload: {
|
|
connectionId: string
|
|
bankName: string | null
|
|
accountUid: string
|
|
ledgerAccount: string
|
|
currency: string
|
|
reason: string
|
|
userId: string
|
|
companyId: string
|
|
} }
|
|
// Stripe Connect lifecycle: connect/disconnect are outward-facing consent
|
|
// transitions (a third party gains/loses access to payment data), so they
|
|
// land in event_log for the audit trail, mirroring bank_connection.*.
|
|
| { type: 'stripe.connected'; payload: { connectionId: string; stripeAccountId: string; livemode: boolean; userId: string; companyId: string } }
|
|
| { type: 'stripe.disconnected'; payload: { connectionId: string; stripeAccountId: string | null; reason: 'user' | 'revoked_upstream'; userId: string; companyId: string } }
|
|
// WooCommerce store lifecycle: same audit doctrine as stripe.* (a third
|
|
// party's API credentials are granted/dropped).
|
|
| { type: 'woocommerce.connected'; payload: { connectionId: string; storeUrl: string; userId: string; companyId: string } }
|
|
| { type: 'woocommerce.disconnected'; payload: { connectionId: string; storeUrl: string | null; reason: 'user' | 'revoked_upstream'; userId: string; companyId: string } }
|
|
// Shopify store lifecycle: same audit doctrine as stripe.*/woocommerce.*.
|
|
| { type: 'shopify.connected'; payload: { connectionId: string; shopDomain: string; userId: string; companyId: string } }
|
|
| { type: 'shopify.disconnected'; payload: { connectionId: string; shopDomain: string | null; reason: 'user' | 'revoked_upstream'; userId: string; companyId: string } }
|
|
// Periods
|
|
| { type: 'period.locked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
|
|
| { type: 'period.unlocked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
|
|
| { type: 'period.year_closed'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
|
|
// Customers
|
|
| { type: 'customer.created'; payload: { customer: Customer; userId: string; companyId: string } }
|
|
// Articles (artikelregister)
|
|
| { type: 'article.created'; payload: { article: Article; userId: string; companyId: string } }
|
|
| { type: 'article.updated'; payload: { article: Article; userId: string; companyId: string } }
|
|
| { type: 'article.deleted'; payload: { articleId: string; userId: string; companyId: string } }
|
|
// Suppliers
|
|
| { type: 'supplier.created'; payload: { supplier: Supplier; userId: string; companyId: string } }
|
|
// Receipts
|
|
| { type: 'receipt.extracted'; payload: {
|
|
receipt: Receipt;
|
|
documentId: string | null;
|
|
confidence: number;
|
|
userId: string;
|
|
companyId: string;
|
|
}}
|
|
| { type: 'receipt.matched'; payload: {
|
|
receipt: Receipt;
|
|
transaction: Transaction;
|
|
confidence: number;
|
|
autoMatched: boolean;
|
|
userId: string;
|
|
companyId: string;
|
|
}}
|
|
| { type: 'receipt.confirmed'; payload: {
|
|
receipt: Receipt;
|
|
businessTotal: number;
|
|
privateTotal: number;
|
|
userId: string;
|
|
companyId: string;
|
|
}}
|
|
// Supplier Invoice Lifecycle
|
|
| { type: 'supplier_invoice.registered'; payload: { supplierInvoice: SupplierInvoice; userId: string; companyId: string } }
|
|
| { type: 'supplier_invoice.approved'; payload: { supplierInvoice: SupplierInvoice; userId: string; companyId: string } }
|
|
| { type: 'supplier_invoice.paid'; payload: { supplierInvoice: SupplierInvoice; paymentAmount: number; userId: string; companyId: string } }
|
|
| { type: 'supplier_invoice.credited'; payload: { supplierInvoice: SupplierInvoice; creditNote: SupplierInvoice; userId: string; companyId: string } }
|
|
| { type: 'supplier_invoice.uncredited'; payload: { supplierInvoice: SupplierInvoice; reversedCreditNoteId: string; reversalEntryId: string | null; userId: string; companyId: string } }
|
|
// Payment Matching
|
|
| { type: 'invoice.match_confirmed'; payload: { invoice: Invoice; transaction: Transaction; userId: string; companyId: string } }
|
|
| { type: 'supplier_invoice.match_confirmed'; payload: { supplierInvoice: SupplierInvoice; transaction: Transaction; userId: string; companyId: string } }
|
|
// Supplier Invoice Inbox
|
|
| { type: 'supplier_invoice.received'; payload: { inboxItem: InvoiceInboxItem; userId: string; companyId: string } }
|
|
| { type: 'supplier_invoice.extracted'; payload: { inboxItem: InvoiceInboxItem; confidence: number; userId: string; companyId: string } }
|
|
| { type: 'supplier_invoice.confirmed'; payload: { inboxItem: InvoiceInboxItem; supplierInvoice: SupplierInvoice; userId: string; companyId: string } }
|
|
// Salary
|
|
| { type: 'salary_run.created'; payload: { salaryRunId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
|
|
| { type: 'salary_run.approved'; payload: { salaryRunId: string; approvedBy: string; userId: string; companyId: string } }
|
|
| { type: 'salary_run.approval_reverted'; payload: { salaryRunId: string; revertedBy: string; deletedAgiDeclarationId: string | null; userId: string; companyId: string } }
|
|
| { type: 'salary_run.booked'; payload: { salaryRunId: string; entryIds: string[]; userId: string; companyId: string } }
|
|
| { type: 'agi.generated'; payload: { agiId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
|
|
| { type: 'agi.submitted'; payload: { salaryRunId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
|
|
// Bolagsverket: digital inlämning av årsredovisning. Status values follow
|
|
// GUIDE §5.2.2 (arsred_inkommen → … → arsred_registrerad). `uploaded` fires
|
|
// when the iXBRL lands in eget utrymme; the undertecknare then signs the
|
|
// fastställelseintyg at Bolagsverket and the webhook drives the rest.
|
|
| { type: 'arsredovisning.uploaded'; payload: { submissionId: string; fiscalPeriodId: string; idnummer: string; environment: 'test' | 'accept' | 'prod'; userId: string; companyId: string } }
|
|
| { type: 'arsredovisning.status_changed'; payload: { submissionId: string; fiscalPeriodId: string | null; previousStatus: string; status: string; bolagsverketStatus: string; userId: string; companyId: string } }
|
|
| { type: 'arsredovisning.registered'; payload: { submissionId: string; fiscalPeriodId: string | null; userId: string; companyId: string } }
|
|
| { type: 'arsredovisning.forelagd'; payload: { submissionId: string; fiscalPeriodId: string | null; userId: string; companyId: string } }
|
|
// Skatteverket: Skattekonto sync
|
|
| { type: 'skattekonto.synced'; payload: { booked: number; upcoming: number; balanceSkv: number; balanceKfm: number; userId: string; companyId: string } }
|
|
| { type: 'skattekonto.balance.changed'; payload: { previousBalance: number; currentBalance: number; userId: string; companyId: string } }
|
|
| { type: 'skattekonto.transaction.upcoming'; payload: { transaktionsdatum: string; forfallodatum: string; transaktionstext: string; beloppSkatteverket: number; userId: string; companyId: string } }
|
|
| { type: 'skattekonto.connection.expired'; payload: { reason: 'REFRESH_EXHAUSTED' | 'SESSION_EXPIRED' | 'TOKEN_CORRUPTED'; userId: string; companyId: string } }
|
|
// Fired when the SKV saldo and GL 1630 sum diverge beyond the configured
|
|
// tolerance. The drift handler emails the company contact; UI surfaces a
|
|
// dashboard tile via /api/extensions/skatteverket/skattekonto/drift.
|
|
| { type: 'skattekonto.drift_detected'; payload: {
|
|
drift: number // SKV saldo - GL 1630 sum (signed)
|
|
saldoSkatteverket: number
|
|
glSum1630: number
|
|
fetchedAt: number // ms epoch from the snapshot
|
|
unbookedCount: number // skattekonto rows without journal_entry_id ≤ fetchedAt
|
|
userId: string
|
|
companyId: string
|
|
} }
|
|
// Company & account lifecycle
|
|
| { type: 'company.deleted'; payload: { companyId: string; userId: string; archivedAt: string } }
|
|
| { type: 'account.deleted'; payload: { userId: string; deletedAt: string } }
|
|
// MCP telemetry: fired from the MCP dispatcher.
|
|
// Persisted to event_log (180-day TTL for mcp.*/agent.* rows, vs 30 days for
|
|
// delivery events) for hot-tool / error-rate / latency analytics.
|
|
// Intentionally lightweight: no args, no result body, only metadata.
|
|
| { type: 'mcp.tool_called'; payload: {
|
|
tool: string // e.g. 'gnubok_create_invoice'
|
|
requiredScope: string | null // from TOOL_SCOPE_MAP, null if unscoped
|
|
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
|
actorId: string | null // api_key id, oauth client, etc.
|
|
actorLabel: string | null // human-readable actor label
|
|
latencyMs: number // wall-clock time inside execute()
|
|
success: boolean // true iff the tool returned without throwing AND was invoked (not denied)
|
|
isError: boolean // matches the JSON-RPC tool-result isError flag returned to the client
|
|
errorCode: string | null // structured error code from tool-result.toToolError when applicable
|
|
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'company_access_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
|
|
errorMessage: string | null // human-readable error message (truncated to 500 chars), null on success.
|
|
// Raw material for clustering real agent failures into curated gotchas:
|
|
// errorCode alone can't distinguish "period locked" from "unbalanced".
|
|
requestId: string | number | null // JSON-RPC request id (helps correlate with client-side logs)
|
|
userId: string
|
|
companyId: string
|
|
sessionId: string | null // from Mcp-Session-Id header; null if absent
|
|
client: string | null // distribution marker (X-Accounted-Client, legacy X-Gnubok-Client, or ?client=).
|
|
// Client-supplied (allow-list-sanitized): telemetry only, never identity or authz.
|
|
}}
|
|
// tools/list: informs us whether agents are using progressive discovery
|
|
// (gnubok_search_tools) or pulling the full list. Tool counts vary with
|
|
// the caller's scope set.
|
|
| { type: 'mcp.tools_list_called'; payload: {
|
|
toolCount: number // tools actually returned (post scope filter)
|
|
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
|
actorId: string | null
|
|
actorLabel: string | null
|
|
latencyMs: number
|
|
requestId: string | number | null
|
|
userId: string
|
|
companyId: string
|
|
sessionId: string | null // from Mcp-Session-Id header; null if absent
|
|
client: string | null // distribution-channel marker; null if absent
|
|
}}
|
|
// resources/read: informs us which skills/widgets/data resources actually
|
|
// get loaded by agents. `kind` discriminates by URI scheme so we can
|
|
// GROUP BY skill vs widget vs data without parsing URIs.
|
|
| { type: 'mcp.resource_read'; payload: {
|
|
uri: string // e.g. 'Accounted://skill/month-end-close'
|
|
kind: 'widget' | 'skill' | 'data' | 'unknown'
|
|
success: boolean
|
|
errorCode: string | null
|
|
latencyMs: number
|
|
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
|
actorId: string | null
|
|
actorLabel: string | null
|
|
requestId: string | number | null
|
|
userId: string
|
|
companyId: string
|
|
sessionId: string | null // from Mcp-Session-Id header; null if absent
|
|
client: string | null // distribution-channel marker; null if absent
|
|
}}
|
|
// Workflow lifecycle: agents declare "I'm starting month-end-close" via
|
|
// gnubok_load_skill (or implicitly by following a skill's recommended tool
|
|
// sequence). Phase 3A captures these to measure: how often is a workflow
|
|
// started? How often does it complete? Where do agents abandon?
|
|
| { type: 'mcp.workflow_started'; payload: {
|
|
slug: string // e.g. 'month-end-close'
|
|
sessionId: string | null
|
|
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
|
actorId: string | null
|
|
actorLabel: string | null
|
|
userId: string
|
|
companyId: string
|
|
}}
|
|
| { type: 'mcp.workflow_completed'; payload: {
|
|
slug: string
|
|
sessionId: string | null
|
|
outcome: 'success' | 'abandoned' | 'failed'
|
|
stepsCompleted: number | null // null when not tracked granularly
|
|
durationMs: number | null
|
|
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
|
actorId: string | null
|
|
actorLabel: string | null
|
|
userId: string
|
|
companyId: string
|
|
}}
|
|
// Fires on EVERY successful gnubok_load_skill: all tiers, unlike
|
|
// mcp.workflow_started which fires only for workflow-tier skills. Records
|
|
// WHICH skill/atom bodies agents actually pull, the denominator needed to
|
|
// correlate a loaded atom with downstream tool-error rates (a skill can
|
|
// make the model worse: measure, don't assume).
|
|
| { type: 'mcp.skill_loaded'; payload: {
|
|
slug: string // e.g. 'modifier/holding-ab', 'month-end-close'
|
|
tier: 'workflow' | 'horizontal' | 'vertical' | 'modifier'
|
|
sessionId: string | null
|
|
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
|
actorId: string | null
|
|
actorLabel: string | null
|
|
userId: string
|
|
companyId: string
|
|
}}
|
|
// Fires when the agent's next tool call matches the previous response's
|
|
// nextHint.tool: measures whether `next` hints are actually followed.
|
|
// Computed dispatcher-side by comparing the last response shape to the
|
|
// current call.
|
|
| { type: 'mcp.next_hint_followed'; payload: {
|
|
fromTool: string
|
|
toTool: string
|
|
sessionId: string | null
|
|
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
|
actorId: string | null
|
|
actorLabel: string | null
|
|
userId: string
|
|
companyId: string
|
|
}}
|
|
// Agent self-reported feedback (gnubok_feedback tool). The product team
|
|
// queries event_log for `agent.feedback` and routes to a backlog.
|
|
| { type: 'agent.feedback'; payload: {
|
|
context: string
|
|
sentiment: 'positive' | 'negative' | 'neutral'
|
|
suggestion: string | null
|
|
toolName: string | null
|
|
skillSlug: string | null
|
|
sessionId: string | null
|
|
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
|
actorId: string | null
|
|
actorLabel: string | null
|
|
userId: string
|
|
companyId: string
|
|
}}
|
|
|
|
// ============================================================
|
|
// Helper Types
|
|
// ============================================================
|
|
|
|
/** All possible event type strings */
|
|
export type CoreEventType = CoreEvent['type']
|
|
|
|
/** Extract the payload type for a given event type */
|
|
export type EventPayload<T extends CoreEventType> = Extract<CoreEvent, { type: T }>['payload']
|
|
|
|
/** Handler function for a specific event type */
|
|
export type EventHandler<T extends CoreEventType> = (payload: EventPayload<T>) => Promise<void> | void
|
|
|
|
/** Subscription: event type + handler */
|
|
export interface EventSubscription<T extends CoreEventType = CoreEventType> {
|
|
eventType: T
|
|
handler: EventHandler<T>
|
|
}
|