@substrat-run/adapter-sqlite 0.98.0 → 0.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import Database from 'better-sqlite3';
4
- import { accessLogEntry, adminLogEntry, opsFailureEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, platformRequest, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, subjectRef, requestFingerprint, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, outboundOfManifestJson, listLimitOf, substratError, assertReplayableDump, } from '@substrat-run/contracts';
5
- import { asPrincipal, assertAllowed, assertReadOnlyQuery, attachmentBlobKey, entitlementDenial, foldMeterReading, guardSpine, parseValidationRecords, resolveScopeRecord, ulid, OPS_FAILURE_RETENTION_DAYS, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialBucketRow, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertSessionUsable, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, impersonationStampOf, mapImpersonationRow, newImpersonationSession, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, } from '@substrat-run/kernel';
4
+ import { accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, platformRequest, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, subjectRef, requestFingerprint, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, outboundOfManifestJson, listLimitOf, substratError, assertReplayableDump, } from '@substrat-run/contracts';
5
+ import { asPrincipal, assertAllowed, assertReadOnlyQuery, attachmentBlobKey, entitlementDenial, foldMeterReading, guardSpine, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialBucketRow, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertSessionUsable, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, impersonationStampOf, mapImpersonationRow, newImpersonationSession, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, } from '@substrat-run/kernel';
6
6
  import { ScopeActor } from './actor.js';
7
7
  import { createTupleChecker } from './checker.js';
8
8
  const KERNEL_DDL = `
@@ -26,8 +26,19 @@ const KERNEL_DDL = `
26
26
  -- session/by). NULL is the ordinary case — nobody was impersonating — and the
27
27
  -- actor column above stays the principal the permission model answered about.
28
28
  impersonation TEXT,
29
+ -- #1231: the invoke() string this event was emitted from. NULL is two facts the
30
+ -- spine cannot tell apart afterwards: a consumer emit (no operation ran), and a
31
+ -- row written before the column.
32
+ operation TEXT,
33
+ -- #1242: the version REGISTRY id of the vertical version the host ran when this
34
+ -- event was emitted (the signals version dimension, #1231). NULL = the host was
35
+ -- handed no version id, or the row predates the column.
36
+ version TEXT,
29
37
  drained_at TEXT
30
38
  );
39
+ -- #1232: the freshness evaluator's read — MAX(occurred_at) per type, every pass,
40
+ -- over a table that is never pruned. Unindexed, that is a full history scan.
41
+ CREATE INDEX IF NOT EXISTS _substrat_outbox_type_at ON _substrat_outbox (type, occurred_at);
31
42
  -- platform-intents.md: durable intents a vertical enqueues (ctx.requestPlatform) for the platform
32
43
  -- to drain and execute with HostAdmin authority — the sandbox-clean way a vertical asks for a
33
44
  -- privileged action. Written by the kernel (spine), settled by the platform drain.
@@ -196,6 +207,23 @@ function keysetTail(where, params, key, page, defaultOrder = 'asc') {
196
207
  }
197
208
  return tail;
198
209
  }
210
+ /** snake_case row → the camelCase `issueEntry` shape (#1233). */
211
+ function issueOf(r) {
212
+ return {
213
+ fingerprint: r.fingerprint,
214
+ operation: r.operation,
215
+ stage: r.stage,
216
+ origin: r.origin,
217
+ code: r.code,
218
+ status: r.status,
219
+ count: r.seen_count,
220
+ firstSeen: r.first_seen,
221
+ lastSeen: r.last_seen,
222
+ lastMessage: r.last_message,
223
+ lastVertical: r.last_vertical,
224
+ resolvedAt: r.resolved_at,
225
+ };
226
+ }
199
227
  /** A ledger row → the wire entry, the attribution re-nested. Shared by both adapters' shape. */
200
228
  function modelUsageEntryOf(r) {
201
229
  return modelUsageEntry.parse({
@@ -337,10 +365,12 @@ export class SqliteScopeHost {
337
365
  secretBox;
338
366
  fetchImpl;
339
367
  clock;
368
+ versionId;
340
369
  constructor(options) {
341
370
  this.secretBox = options.secretBox ?? unconfiguredSecretBox;
342
- this.fetchImpl = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
371
+ this.fetchImpl = options.fetch ?? globalFetch;
343
372
  this.clock = options.clock ?? (() => instant.parse(new Date().toISOString()));
373
+ this.versionId = options.versionId ?? null;
344
374
  this.dir = options.dir;
345
375
  mkdirSync(this.dir, { recursive: true });
346
376
  this.directory = new Database(join(this.dir, '_directory.sqlite'));
@@ -822,15 +852,69 @@ export class SqliteScopeHost {
822
852
  tenant_id TEXT,
823
853
  scope_id TEXT,
824
854
  vertical TEXT,
855
+ version TEXT,
825
856
  status INTEGER,
826
857
  message TEXT NOT NULL,
827
858
  reference TEXT,
859
+ origin TEXT,
860
+ code TEXT,
861
+ fingerprint TEXT,
828
862
  at TEXT NOT NULL
829
863
  );
830
864
  CREATE INDEX IF NOT EXISTS _substrat_ops_failures_vertical ON _substrat_ops_failures (vertical, id);
831
865
  CREATE INDEX IF NOT EXISTS _substrat_ops_failures_tenant ON _substrat_ops_failures (tenant_id, id);
832
866
  CREATE INDEX IF NOT EXISTS _substrat_ops_failures_reference ON _substrat_ops_failures (reference);
833
867
  CREATE INDEX IF NOT EXISTS _substrat_ops_failures_at ON _substrat_ops_failures (at);
868
+ -- #1233: fingerprint-grouped failure classes ("issues"), materialized on
869
+ -- every ops-failure insert. The row OWNS its counters: the evidence beneath
870
+ -- it prunes at 90 days, and a count must survive its own exemplars.
871
+ CREATE TABLE IF NOT EXISTS _substrat_issues (
872
+ fingerprint TEXT PRIMARY KEY,
873
+ operation TEXT NOT NULL,
874
+ stage TEXT,
875
+ origin TEXT,
876
+ code TEXT,
877
+ status TEXT NOT NULL DEFAULT 'new',
878
+ seen_count INTEGER NOT NULL DEFAULT 0,
879
+ first_seen TEXT NOT NULL,
880
+ last_seen TEXT NOT NULL,
881
+ last_message TEXT NOT NULL,
882
+ last_vertical TEXT,
883
+ resolved_at TEXT
884
+ );
885
+ CREATE INDEX IF NOT EXISTS _substrat_issues_seen ON _substrat_issues (last_seen);
886
+ -- The durable sweep record (#1232): one row per unit outcome per pass —
887
+ -- a connection swept/skipped/failed, a schedule fired/skipped/failed. What
888
+ -- makes "when was this last swept" answerable after the log line rolls off.
889
+ -- Retention-bounded harder than ops failures (SWEEP_RUN_RETENTION_DAYS):
890
+ -- high-frequency telemetry whose job is the recent-runs strip.
891
+ CREATE TABLE IF NOT EXISTS _substrat_sweep_runs (
892
+ id TEXT PRIMARY KEY,
893
+ kind TEXT NOT NULL,
894
+ unit TEXT NOT NULL,
895
+ outcome TEXT NOT NULL,
896
+ tenant_id TEXT,
897
+ scope_id TEXT,
898
+ vertical TEXT,
899
+ version TEXT,
900
+ operation TEXT,
901
+ connection_id TEXT,
902
+ error TEXT,
903
+ elapsed_ms INTEGER,
904
+ -- #1232: the platform-intent id a drained batch arrived under (NULL for the
905
+ -- direct sweep path). The unique index below is what makes a replayed drain
906
+ -- write nothing twice; NULLs are distinct, so direct writes never collide.
907
+ request_id TEXT,
908
+ -- #1232 freshness rows: the judged event type (its own dimension, never
909
+ -- the operation column) and the newest matching evidence. NULL on other kinds.
910
+ event_type TEXT,
911
+ observed_at TEXT,
912
+ at TEXT NOT NULL
913
+ );
914
+ CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_unit ON _substrat_sweep_runs (kind, unit, id);
915
+ CREATE UNIQUE INDEX IF NOT EXISTS _substrat_sweep_runs_intent ON _substrat_sweep_runs (request_id, unit);
916
+ CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_tenant ON _substrat_sweep_runs (tenant_id, id);
917
+ CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_at ON _substrat_sweep_runs (at);
834
918
  -- Model usage (#1054, meter 3): one line per model call a vertical made through
835
919
  -- the platform's model host, drained here as a model-usage intent. request_id is
836
920
  -- the intent id and UNIQUE, which is what makes a retried drain write nothing twice.
@@ -1069,6 +1153,7 @@ export class SqliteScopeHost {
1069
1153
  migrations: [...migrations, ...searchMigrations, ...listMigrations],
1070
1154
  consumers,
1071
1155
  schedules: manifest.schedules ?? [],
1156
+ freshness: manifest.freshness ?? [],
1072
1157
  });
1073
1158
  for (const rel of manifest.entityRelations ?? []) {
1074
1159
  const parents = this.relations.get(rel.entityType) ?? new Set();
@@ -1466,7 +1551,7 @@ export class SqliteScopeHost {
1466
1551
  // like an operation invoke (including K-35 denial recording and prompt dispatch of
1467
1552
  // the events it emitted).
1468
1553
  const serialized = (operation, fn) => rt.actor.enqueue(async () => {
1469
- const ctx = this.operationContext(rt, subject);
1554
+ const ctx = this.operationContext(rt, subject, undefined, undefined, undefined, operation);
1470
1555
  rt.db.exec('BEGIN IMMEDIATE');
1471
1556
  let result;
1472
1557
  try {
@@ -1498,7 +1583,7 @@ export class SqliteScopeHost {
1498
1583
  */
1499
1584
  const reentrant = async (operation, fn) => {
1500
1585
  try {
1501
- return await fn(this.operationContext(rt, subject));
1586
+ return await fn(this.operationContext(rt, subject, undefined, undefined, undefined, operation));
1502
1587
  }
1503
1588
  catch (err) {
1504
1589
  if (err instanceof PermissionDenied)
@@ -1692,6 +1777,10 @@ export class SqliteScopeHost {
1692
1777
  // KERNEL_DDL is all IF NOT EXISTS, so it fills only the gaps and never disturbs a
1693
1778
  // table the dump carried.
1694
1779
  db.exec(KERNEL_DDL);
1780
+ // …and the additive columns, for the same reason: a dump captured before a
1781
+ // column existed replays DDL WITHOUT it, and IF NOT EXISTS cannot widen a
1782
+ // table the dump did bring.
1783
+ this.ensureSpineColumns(db);
1695
1784
  // Rebuild the derived search indexes over the rows just loaded (#827). The DDL
1696
1785
  // drops and recreates, so this also repairs an index the dump left stale, and
1697
1786
  // the triggers it recreates are what keep the restored scope in step from here.
@@ -1956,8 +2045,99 @@ export class SqliteScopeHost {
1956
2045
  }
1957
2046
  return out;
1958
2047
  }
2048
+ registeredFreshness() {
2049
+ const out = [];
2050
+ for (const mod of this.modules.values()) {
2051
+ if (mod.freshness.length > 0) {
2052
+ out.push({ moduleId: mod.id, freshness: mod.freshness });
2053
+ }
2054
+ }
2055
+ return out;
2056
+ }
2057
+ /**
2058
+ * The freshness evaluator (#1232): judge each declared expectation against this
2059
+ * scope's own outbox, and return only what should be RECORDED — a verdict that
2060
+ * changed since the last recorded one, or one whose hourly heartbeat lapsed.
2061
+ * State rides `_substrat_schedule_state` under a `freshness:<eventType>` key
2062
+ * (operation names are `module/verb`, event types `ns.verb` — the prefix cannot
2063
+ * collide), reusing its columns as (last recorded at, last recorded outcome).
2064
+ * Aggregates across EVERY registered module, not just the asking one: two
2065
+ * modules declaring one type share a single gating-state key and a single
2066
+ * dedupe unit, so per-module evaluation would have them fighting over both —
2067
+ * flip-flopping state, and colliding rows the dedupe index silently eats.
2068
+ * Duplicates collapse to the TIGHTEST window; the caller invokes this once
2069
+ * per scope, with any registered module's id as the entry ticket.
2070
+ *
2071
+ * Deliberately NOT gated on the system grant — see the interface doc.
2072
+ */
2073
+ async checkFreshness(moduleId, tenantId, scopeId) {
2074
+ const report = { checks: [] };
2075
+ if (!this.modules.has(moduleId))
2076
+ return report;
2077
+ const scope = this.directory
2078
+ .prepare('SELECT status FROM scopes WHERE scope_id = ? AND tenant_id = ?')
2079
+ .get(scopeId, tenantId);
2080
+ if (!scope || scope.status !== 'active')
2081
+ return report;
2082
+ const rt = this.runtime(tenantId, scopeId);
2083
+ await this.applyPendingMigrations(rt);
2084
+ // Same lazy create as runDueSchedules — a freshness-only module must not hit
2085
+ // `no such table` on a scope no schedule ever swept.
2086
+ rt.db.exec(`CREATE TABLE IF NOT EXISTS _substrat_schedule_state (
2087
+ schedule_op TEXT PRIMARY KEY,
2088
+ last_run_at TEXT,
2089
+ last_status TEXT
2090
+ )`);
2091
+ // Collapse duplicate event types to the tightest window, ACROSS modules.
2092
+ const windows = new Map();
2093
+ for (const m of this.modules.values()) {
2094
+ for (const f of m.freshness) {
2095
+ const prev = windows.get(f.eventType);
2096
+ if (prev === undefined || f.within.hours < prev)
2097
+ windows.set(f.eventType, f.within.hours);
2098
+ }
2099
+ }
2100
+ if (windows.size === 0)
2101
+ return report;
2102
+ const types = [...windows.keys()];
2103
+ const observed = new Map();
2104
+ for (const row of rt.db
2105
+ .prepare(`SELECT type, MAX(occurred_at) AS at FROM _substrat_outbox
2106
+ WHERE type IN (${types.map(() => '?').join(', ')}) GROUP BY type`)
2107
+ .all(...types)) {
2108
+ if (row.at !== null)
2109
+ observed.set(row.type, row.at);
2110
+ }
2111
+ const nowIso = this.clock();
2112
+ const now = Date.parse(nowIso);
2113
+ for (const [eventType, withinHours] of windows) {
2114
+ const observedAt = observed.get(eventType) ?? null;
2115
+ const outcome = observedAt === null
2116
+ ? 'skipped' // never observed — the never-run analogue, not a failure
2117
+ : now - Date.parse(observedAt) <= withinHours * 3_600_000
2118
+ ? 'ok'
2119
+ : 'failed';
2120
+ const stateKey = `freshness:${eventType}`;
2121
+ const state = rt.db
2122
+ .prepare('SELECT last_run_at, last_status FROM _substrat_schedule_state WHERE schedule_op = ?')
2123
+ .get(stateKey);
2124
+ const changed = state?.last_status !== outcome;
2125
+ const heartbeatDue = !state?.last_run_at ||
2126
+ now - Date.parse(state.last_run_at) > FRESHNESS_HEARTBEAT_MINUTES * 60_000;
2127
+ if (!changed && !heartbeatDue)
2128
+ continue;
2129
+ rt.db
2130
+ .prepare(`INSERT INTO _substrat_schedule_state (schedule_op, last_run_at, last_status)
2131
+ VALUES (?, ?, ?)
2132
+ ON CONFLICT(schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
2133
+ last_status = excluded.last_status`)
2134
+ .run(stateKey, nowIso, outcome);
2135
+ report.checks.push({ eventType, outcome, observedAt, withinHours });
2136
+ }
2137
+ return report;
2138
+ }
1959
2139
  async runDueSchedules(moduleId, tenantId, scopeId) {
1960
- const report = { fired: 0, skipped: 0, failed: 0, errors: [] };
2140
+ const report = { fired: 0, skipped: 0, failed: 0, errors: [], runs: [] };
1961
2141
  const mod = this.modules.get(moduleId);
1962
2142
  if (!mod || mod.schedules.length === 0)
1963
2143
  return report;
@@ -2000,6 +2180,7 @@ export class SqliteScopeHost {
2000
2180
  const dueAt = lastRun === null ? -Infinity : lastRun + schedule.cadence.everyMinutes * 60_000;
2001
2181
  if (now < dueAt) {
2002
2182
  report.skipped += 1;
2183
+ report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
2003
2184
  continue;
2004
2185
  }
2005
2186
  // Due — invoke through the system door (a fresh stub per schedule keeps the
@@ -2024,6 +2205,7 @@ export class SqliteScopeHost {
2024
2205
  ON CONFLICT(schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
2025
2206
  last_status = excluded.last_status`)
2026
2207
  .run(schedule.operation, new Date(now).toISOString(), status);
2208
+ report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
2027
2209
  }
2028
2210
  return report;
2029
2211
  }
@@ -2105,7 +2287,7 @@ export class SqliteScopeHost {
2105
2287
  const invoked = await rt.actor.enqueue(async () => {
2106
2288
  // Fresh per operation: the context carries the K-34 authorization accumulator,
2107
2289
  // which must not leak across operations (invokes are serialized per scope).
2108
- const ctx = this.operationContext(rt, subject, undefined, signals, session);
2290
+ const ctx = this.operationContext(rt, subject, undefined, signals, session, operation);
2109
2291
  const clonedInput = structuredClone(input);
2110
2292
  // #893: parse, don't trust — at the scope door, from the operation's own
2111
2293
  // declaration. BEFORE `BEGIN`, so a malformed call never opens a
@@ -2648,6 +2830,8 @@ export class SqliteScopeHost {
2648
2830
  // was impersonating, because `DomainEvent.impersonation` is optional — the
2649
2831
  // shape module code never sees is also the shape it cannot branch on.
2650
2832
  ...(row.impersonation ? { impersonation: JSON.parse(row.impersonation) } : {}),
2833
+ // #1231: absent rather than null, the same shape rule as the stamp above.
2834
+ ...(row.operation ? { operation: row.operation } : {}),
2651
2835
  payload: row.payload === null ? undefined : JSON.parse(row.payload),
2652
2836
  });
2653
2837
  }
@@ -5087,19 +5271,39 @@ export class SqliteScopeHost {
5087
5271
  }));
5088
5272
  },
5089
5273
  recordOpsFailure: async (entry) => {
5274
+ const at = new Date().toISOString();
5275
+ const fingerprint = opsFailureFingerprint(entry);
5090
5276
  this.directory
5091
5277
  .prepare(`INSERT INTO _substrat_ops_failures
5092
- (id, actor, operation, stage, tenant_id, scope_id, vertical, status, message, reference, at)
5093
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
5094
- .run(ulid(), entry.actor, entry.operation, entry.stage ?? null, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.status ?? null,
5278
+ (id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
5279
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
5280
+ .run(ulid(), entry.actor, entry.operation, entry.stage ?? null, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.status ?? null,
5095
5281
  // Bounded here, not trusted from the catch site: one runaway upstream
5096
5282
  // body must not become a runaway directory row (#559).
5097
- entry.message.slice(0, 2000), entry.reference ?? null, new Date().toISOString());
5283
+ entry.message.slice(0, 2000), entry.reference ?? null, entry.origin ?? null, entry.code ?? null, fingerprint, at);
5098
5284
  // Prune-on-write (#559): retention lives here, not in a cron — every insert
5099
5285
  // pays for its own housekeeping, so the table stays bounded even where no
5100
5286
  // scheduled pass runs (this adapter has none).
5101
5287
  const horizon = new Date(Date.now() - OPS_FAILURE_RETENTION_DAYS * 86_400_000).toISOString();
5102
5288
  this.directory.prepare('DELETE FROM _substrat_ops_failures WHERE at < ?').run(horizon);
5289
+ // The issues materialization (#1233): the group's counters live on their
5290
+ // own row, bumped in the same call, because the evidence self-prunes above
5291
+ // and a count must survive its own exemplars. A fresh arrival regresses a
5292
+ // resolved issue; an ignored one stays ignored — that is what ignoring means.
5293
+ this.directory
5294
+ .prepare(`INSERT INTO _substrat_issues
5295
+ (fingerprint, operation, stage, origin, code, status, seen_count, first_seen, last_seen, last_message, last_vertical, resolved_at)
5296
+ VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, NULL)
5297
+ ON CONFLICT (fingerprint) DO UPDATE SET
5298
+ seen_count = seen_count + 1,
5299
+ last_seen = excluded.last_seen,
5300
+ last_message = excluded.last_message,
5301
+ last_vertical = COALESCE(excluded.last_vertical, last_vertical),
5302
+ origin = COALESCE(excluded.origin, origin),
5303
+ status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`)
5304
+ .run(fingerprint, entry.operation, entry.stage ?? null, entry.origin ?? null, entry.code ?? null, at, at, entry.message.slice(0, 2000), entry.vertical ?? null);
5305
+ const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
5306
+ this.directory.prepare('DELETE FROM _substrat_issues WHERE last_seen < ?').run(issueHorizon);
5103
5307
  },
5104
5308
  listOpsFailures: async (actor, filter) => {
5105
5309
  const where = [];
@@ -5116,10 +5320,22 @@ export class SqliteScopeHost {
5116
5320
  where.push('vertical = ?');
5117
5321
  params.push(filter.vertical);
5118
5322
  }
5323
+ if (filter?.version) {
5324
+ where.push('version = ?');
5325
+ params.push(filter.version);
5326
+ }
5119
5327
  if (filter?.operation) {
5120
5328
  where.push('operation = ?');
5121
5329
  params.push(filter.operation);
5122
5330
  }
5331
+ if (filter?.code) {
5332
+ where.push('code = ?');
5333
+ params.push(filter.code);
5334
+ }
5335
+ if (filter?.fingerprint) {
5336
+ where.push('fingerprint = ?');
5337
+ params.push(filter.fingerprint);
5338
+ }
5123
5339
  if (filter?.reference) {
5124
5340
  where.push('reference = ?');
5125
5341
  params.push(filter.reference);
@@ -5158,12 +5374,130 @@ export class SqliteScopeHost {
5158
5374
  tenantId: r.tenant_id,
5159
5375
  scopeId: r.scope_id,
5160
5376
  vertical: r.vertical,
5377
+ version: r.version,
5161
5378
  status: r.status,
5162
5379
  message: r.message,
5163
5380
  reference: r.reference,
5381
+ origin: r.origin,
5382
+ code: r.code,
5383
+ fingerprint: r.fingerprint,
5384
+ at: r.at,
5385
+ }));
5386
+ },
5387
+ recordSweepRun: async (entry) => {
5388
+ this.directory
5389
+ .prepare(`INSERT OR IGNORE INTO _substrat_sweep_runs
5390
+ (id, kind, unit, outcome, tenant_id, scope_id, vertical, version, operation,
5391
+ connection_id, error, elapsed_ms, request_id, event_type, observed_at, at)
5392
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
5393
+ .run(ulid(), entry.kind, entry.unit, entry.outcome, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.operation ?? null, entry.connectionId ?? null,
5394
+ // Bounded here, not trusted from the sweep: one runaway provider body
5395
+ // must not become a runaway directory row (the #559 rule).
5396
+ entry.error == null ? null : entry.error.slice(0, 2000), entry.elapsedMs ?? null, entry.requestId ?? null, entry.eventType ?? null, entry.observedAt ?? null, entry.at ?? new Date().toISOString());
5397
+ // Prune-on-write, like ops failures and for the same reason: the table stays
5398
+ // bounded even on a deployment whose scheduled pass is broken.
5399
+ const horizon = new Date(Date.now() - SWEEP_RUN_RETENTION_DAYS * 86_400_000).toISOString();
5400
+ this.directory.prepare('DELETE FROM _substrat_sweep_runs WHERE at < ?').run(horizon);
5401
+ },
5402
+ listSweepRuns: async (actor, filter) => {
5403
+ const where = [];
5404
+ const params = [];
5405
+ const eq = (col, v) => {
5406
+ if (v !== undefined) {
5407
+ where.push(`${col} = ?`);
5408
+ params.push(v);
5409
+ }
5410
+ };
5411
+ eq('kind', filter?.kind);
5412
+ eq('unit', filter?.unit);
5413
+ eq('outcome', filter?.outcome);
5414
+ eq('tenant_id', filter?.tenantId);
5415
+ eq('scope_id', filter?.scopeId);
5416
+ eq('vertical', filter?.vertical);
5417
+ eq('connection_id', filter?.connectionId);
5418
+ if (filter?.since) {
5419
+ where.push('at >= ?');
5420
+ params.push(filter.since);
5421
+ }
5422
+ if (filter?.until) {
5423
+ where.push('at < ?');
5424
+ params.push(filter.until);
5425
+ }
5426
+ // Default DESC: "last run" and the recent-runs strip both read newest-first.
5427
+ const order = (filter?.order ?? 'desc') === 'desc' ? 'DESC' : 'ASC';
5428
+ if (filter?.cursor) {
5429
+ where.push(order === 'DESC' ? 'id < ?' : 'id > ?');
5430
+ params.push(filter.cursor);
5431
+ }
5432
+ let sql = 'SELECT * FROM _substrat_sweep_runs' +
5433
+ (where.length ? ` WHERE ${where.join(' AND ')}` : '') +
5434
+ ` ORDER BY id ${order}`;
5435
+ if (filter?.limit !== undefined) {
5436
+ sql += ' LIMIT ?';
5437
+ params.push(filter.limit);
5438
+ }
5439
+ const rows = this.directory.prepare(sql).all(...params);
5440
+ // Rows can name tenants and scopes, so reading them is recorded (K-24).
5441
+ this.recordAccess(actor, 'listSweepRuns', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
5442
+ return rows.map((r) => sweepRunEntry.parse({
5443
+ id: r.id,
5444
+ kind: r.kind,
5445
+ unit: r.unit,
5446
+ outcome: r.outcome,
5447
+ tenantId: r.tenant_id,
5448
+ scopeId: r.scope_id,
5449
+ vertical: r.vertical,
5450
+ version: r.version,
5451
+ operation: r.operation,
5452
+ connectionId: r.connection_id,
5453
+ error: r.error,
5454
+ elapsedMs: r.elapsed_ms,
5455
+ eventType: r.event_type,
5456
+ observedAt: r.observed_at,
5164
5457
  at: r.at,
5165
5458
  }));
5166
5459
  },
5460
+ listIssues: async (actor, filter) => {
5461
+ const where = [];
5462
+ const params = [];
5463
+ if (filter?.status) {
5464
+ where.push('status = ?');
5465
+ params.push(filter.status);
5466
+ }
5467
+ if (filter?.operation) {
5468
+ where.push('operation = ?');
5469
+ params.push(filter.operation);
5470
+ }
5471
+ if (filter?.code) {
5472
+ where.push('code = ?');
5473
+ params.push(filter.code);
5474
+ }
5475
+ // No cursor by design: grouping IS the compression — cardinality is the
5476
+ // number of distinct failure shapes, not the number of failures.
5477
+ const sql = 'SELECT * FROM _substrat_issues' +
5478
+ (where.length ? ` WHERE ${where.join(' AND ')}` : '') +
5479
+ ' ORDER BY last_seen DESC, fingerprint LIMIT ?';
5480
+ params.push(filter?.limit ?? 100);
5481
+ const rows = this.directory.prepare(sql).all(...params);
5482
+ // Issues aggregate fleet-wide failures; the read is recorded like the rows' own (K-24).
5483
+ this.recordAccess(actor, 'listIssues', {}, filter, rows.length);
5484
+ return rows.map((r) => issueEntry.parse(issueOf(r)));
5485
+ },
5486
+ setIssueStatus: async (actor, fingerprint, status) => {
5487
+ const existing = this.directory
5488
+ .prepare('SELECT * FROM _substrat_issues WHERE fingerprint = ?')
5489
+ .get(fingerprint);
5490
+ if (!existing)
5491
+ return undefined;
5492
+ const resolvedAt = status === 'resolved' ? new Date().toISOString() : null;
5493
+ this.directory
5494
+ .prepare('UPDATE _substrat_issues SET status = ?, resolved_at = ? WHERE fingerprint = ?')
5495
+ .run(status, resolvedAt, fingerprint);
5496
+ const after = issueEntry.parse(issueOf({ ...existing, status, resolved_at: resolvedAt }));
5497
+ // A lifecycle flip is a staff mutation — audited with the diff (K-33).
5498
+ this.recordAdmin(actor, 'setIssueStatus', { tenantId: null, vertical: after.lastVertical }, { fingerprint, status: existing.status }, { fingerprint, status: after.status });
5499
+ return after;
5500
+ },
5167
5501
  recordModelUsage: async (input) => {
5168
5502
  const l = input.line;
5169
5503
  // INSERT OR IGNORE on the UNIQUE request id: the drain may replay a settled-then-
@@ -5330,6 +5664,22 @@ export class SqliteScopeHost {
5330
5664
  ]) {
5331
5665
  this.ensureColumn(this.directory, '_substrat_entitlements', col, ddl);
5332
5666
  }
5667
+ // The signals `version` stamp (#1231): a directory created before the column
5668
+ // must still open, and an old row's NULL reads as "predates the stamp".
5669
+ this.ensureColumn(this.directory, '_substrat_ops_failures', 'version', 'version TEXT');
5670
+ // The error shape (#1233): who refused + the taxonomy code, on a directory that predates them.
5671
+ this.ensureColumn(this.directory, '_substrat_ops_failures', 'origin', 'origin TEXT');
5672
+ this.ensureColumn(this.directory, '_substrat_ops_failures', 'code', 'code TEXT');
5673
+ // #1233: the grouping key + its exemplar-walk index — created only after the
5674
+ // column exists on every path, the sweep-runs pattern.
5675
+ this.ensureColumn(this.directory, '_substrat_ops_failures', 'fingerprint', 'fingerprint TEXT');
5676
+ this.directory.exec('CREATE INDEX IF NOT EXISTS _substrat_ops_failures_fingerprint ON _substrat_ops_failures (fingerprint, id)');
5677
+ // #1232: the drained batch's dedupe key, on a directory created before it. The
5678
+ // unique index rides here too — created after the column exists on every path.
5679
+ this.ensureColumn(this.directory, '_substrat_sweep_runs', 'request_id', 'request_id TEXT');
5680
+ this.ensureColumn(this.directory, '_substrat_sweep_runs', 'event_type', 'event_type TEXT');
5681
+ this.ensureColumn(this.directory, '_substrat_sweep_runs', 'observed_at', 'observed_at TEXT');
5682
+ this.directory.exec('CREATE UNIQUE INDEX IF NOT EXISTS _substrat_sweep_runs_intent ON _substrat_sweep_runs (request_id, unit)');
5333
5683
  const existing = new Set(this.directory.prepare('PRAGMA table_info(scopes)').all().map((c) => c.name));
5334
5684
  for (const [column, ddl] of [
5335
5685
  ['parent_scope_id', 'parent_scope_id TEXT'],
@@ -5400,7 +5750,13 @@ export class SqliteScopeHost {
5400
5750
  * on it, and "hide this row when support is looking" is the one behaviour
5401
5751
  * this whole feature exists to make impossible.
5402
5752
  */
5403
- impersonation) {
5753
+ impersonation,
5754
+ /**
5755
+ * #1231: the exact `invoke()` string this context runs on behalf of — stamped
5756
+ * onto every event it emits. Absent for consumer dispatch: a consumer runs on
5757
+ * behalf of no operation, and the emitted row's NULL says so.
5758
+ */
5759
+ operation) {
5404
5760
  const principal = subject.id;
5405
5761
  const checker = this.checker;
5406
5762
  const relations = this.relations;
@@ -5522,14 +5878,21 @@ export class SqliteScopeHost {
5522
5878
  : principal),
5523
5879
  ...(passed.length ? { authorization: passed.map((p) => ({ ...p })) } : {}),
5524
5880
  ...(stamp ? { impersonation: stamp } : {}),
5881
+ // #1231: kernel-stamped like the two above — `domainEventInput.parse`
5882
+ // already stripped anything module code tried to smuggle under this key.
5883
+ ...(operation ? { operation } : {}),
5525
5884
  });
5526
5885
  rt.db
5527
5886
  .prepare(`INSERT INTO _substrat_outbox
5528
5887
  (id, type, schema_version, occurred_at, tenant_id, scope_id, actor,
5529
5888
  entity_type, entity_id, pii_class, subject_id, authorization,
5530
- impersonation, payload)
5531
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
5532
- .run(full.id, full.type, full.schemaVersion, full.occurredAt, full.tenantId, full.scopeId, JSON.stringify(full.actor), full.entity.entityType, full.entity.entityId, full.piiClass, full.subjectId ?? null, full.authorization ? JSON.stringify(full.authorization) : null, full.impersonation ? JSON.stringify(full.impersonation) : null, full.payload === undefined ? null : JSON.stringify(full.payload));
5889
+ impersonation, operation, version, payload)
5890
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
5891
+ .run(full.id, full.type, full.schemaVersion, full.occurredAt, full.tenantId, full.scopeId, JSON.stringify(full.actor), full.entity.entityType, full.entity.entityId, full.piiClass, full.subjectId ?? null, full.authorization ? JSON.stringify(full.authorization) : null, full.impersonation ? JSON.stringify(full.impersonation) : null, full.operation ?? null,
5892
+ // #1242: host configuration, not envelope data — the version is a fact
5893
+ // about the process, so it never rides `DomainEvent` for module code to
5894
+ // branch on; it exists for the observability joins the column serves.
5895
+ this.versionId, full.payload === undefined ? null : JSON.stringify(full.payload));
5533
5896
  },
5534
5897
  requestPlatform: (request) => {
5535
5898
  assertImpersonationWrites(impersonation, 'ctx.requestPlatform');
@@ -5815,14 +6178,15 @@ export class SqliteScopeHost {
5815
6178
  throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
5816
6179
  return this.runtime(tenantId, scopeId).db;
5817
6180
  }
5818
- runtime(tenantId, scopeId) {
5819
- const key = `${tenantId}/${scopeId}`;
5820
- const existing = this.scopes.get(key);
5821
- if (existing)
5822
- return existing;
5823
- const db = new Database(join(this.dir, `${tenantId}__${scopeId}.sqlite`));
5824
- db.pragma('journal_mode = WAL');
5825
- db.exec(KERNEL_DDL);
6181
+ /**
6182
+ * The additive spine-column migrations, shared by `runtime()` and the dump replay.
6183
+ * KERNEL_DDL is all IF NOT EXISTS, so a scope DB created before a column keeps the
6184
+ * old shape — and so does a table a DUMP replay just recreated from legacy DDL,
6185
+ * which is why `loadDump` re-runs this after its replay: `runtime()` already ran
6186
+ * for that scope, and without the re-run the very next emit in this process fails
6187
+ * with `no such column`.
6188
+ */
6189
+ ensureSpineColumns(db) {
5826
6190
  // KERNEL_DDL is all IF NOT EXISTS, so a scope DB created before K-21 keeps the
5827
6191
  // old shape — ALTER the tombstone in.
5828
6192
  this.ensureColumn(db, '_substrat_tuples', 'revoked_at', 'revoked_at TEXT');
@@ -5847,6 +6211,22 @@ export class SqliteScopeHost {
5847
6211
  this.ensureColumn(db, '_substrat_outbox', 'impersonation', 'impersonation TEXT');
5848
6212
  this.ensureColumn(db, '_substrat_platform_requests', 'impersonation', 'impersonation TEXT');
5849
6213
  this.ensureColumn(db, '_substrat_denials', 'impersonation', 'impersonation TEXT');
6214
+ // #1231: the emitting operation, on a scope DB created before the column. Nullable
6215
+ // so every legacy row reads as unrecorded rather than claiming a name nobody stamped.
6216
+ this.ensureColumn(db, '_substrat_outbox', 'operation', 'operation TEXT');
6217
+ // #1242: the signals `version` dimension on the outbox, for a scope DB created
6218
+ // before the column. NULL stays honest — unstamped, not a version nobody named.
6219
+ this.ensureColumn(db, '_substrat_outbox', 'version', 'version TEXT');
6220
+ }
6221
+ runtime(tenantId, scopeId) {
6222
+ const key = `${tenantId}/${scopeId}`;
6223
+ const existing = this.scopes.get(key);
6224
+ if (existing)
6225
+ return existing;
6226
+ const db = new Database(join(this.dir, `${tenantId}__${scopeId}.sqlite`));
6227
+ db.pragma('journal_mode = WAL');
6228
+ db.exec(KERNEL_DDL);
6229
+ this.ensureSpineColumns(db);
5850
6230
  const appliedMigrations = new Set(db.prepare('SELECT module_id, version FROM _substrat_migrations').all().map((r) => `${r.module_id}@${r.version}`));
5851
6231
  const created = { tenantId, scopeId, db, actor: new ScopeActor(), appliedMigrations };
5852
6232
  this.scopes.set(key, created);