@substrat-run/adapter-sqlite 0.117.0 → 0.119.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, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, 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, identityMembership, 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, delegatedReadRecord, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
5
- import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityTokenHash, createCapabilityVerbs, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, redactSecrets, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, entitlementDenial, foldMeterReading, guardSpine, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, seatScopeTuple, switchSystemSchedules, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, 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, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, JOB_RUN_DDL, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, domainEventOf, readUndrainedOutbox, undrainedEventsOf, } from '@substrat-run/kernel';
4
+ import { accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, exportReadInput, importBatch, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, peerSwitch, verticalCaller, verticalSlug as verticalSlugOf, 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, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, callsOfManifestJson, outboundOfManifestJson, listLimitOf, substratError, assertReplayableDump, delegatedReadRecord, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
5
+ import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, redactSecrets, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, entitlementDenial, foldMeterReading, guardSpine, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, switchSystemSchedules, admitPeer, collectPeers, peerSeats, resolveVerticalInstanceFrom, switchPeer, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, 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, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, domainEventOf, readUndrainedOutbox, undrainedEventsOf, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, } from '@substrat-run/kernel';
6
6
  import { ScopeActor } from './actor.js';
7
7
  import { createTupleChecker } from './checker.js';
8
8
  /** The kernel's schedule-switch SQL (#1666), over one scope's database handle. */
@@ -167,6 +167,10 @@ const KERNEL_DDL = `
167
167
  -- builds and the shape production builds cannot part company; the column
168
168
  -- comments, and the reason coalescing is NOT a unique index, are in there.
169
169
  ${JOB_RUN_DDL}
170
+ -- #1705: cross-vertical delivery. The consumer's journal of what it received (envelope
171
+ -- only) and its watermark per producer, plus the producer-side (type, id) outbox index the
172
+ -- export read seeks on. Shared with the DO adapter from @substrat-run/kernel.
173
+ ${VERTICAL_EVENTS_DDL}
170
174
  CREATE TABLE IF NOT EXISTS _substrat_migrations (
171
175
  module_id TEXT NOT NULL,
172
176
  version TEXT NOT NULL,
@@ -433,6 +437,8 @@ export class SqliteScopeHost {
433
437
  tenantStoreDbs = new Map();
434
438
  operations = new Map();
435
439
  modules = new Map();
440
+ /** #1705: what this deployment exports to other verticals and imports from them. */
441
+ crossVertical = new CrossVerticalRegistry();
436
442
  /** operation name → guards declared before it, in registration order (K-17). */
437
443
  guards = new Map();
438
444
  /** predicate name → the module-contributed implementation. Names are global. */
@@ -488,6 +494,7 @@ export class SqliteScopeHost {
488
494
  systemPrincipal = principalId.parse(ulid());
489
495
  secretBox;
490
496
  fetchImpl;
497
+ connectorCalls;
491
498
  clock;
492
499
  versionId;
493
500
  // The mint for event ids (#956) is NOT here: it lives on `ScopeRuntime`, one per
@@ -498,6 +505,7 @@ export class SqliteScopeHost {
498
505
  constructor(options) {
499
506
  this.secretBox = options.secretBox ?? unconfiguredSecretBox;
500
507
  this.fetchImpl = options.fetch ?? globalFetch;
508
+ this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
501
509
  this.clock = options.clock ?? (() => instant.parse(new Date().toISOString()));
502
510
  this.versionId = options.versionId ?? null;
503
511
  this.dir = options.dir;
@@ -1183,6 +1191,9 @@ export class SqliteScopeHost {
1183
1191
  // request pending across a rotation still opens.
1184
1192
  unseal: async (sealed) => openSealed(await this.openSealingKeys(open.id), sealed),
1185
1193
  fetch: async (input, init) => {
1194
+ // #1691: timed here, where the call is made, so the data point the host
1195
+ // records beside the health line carries a duration.
1196
+ const started = Date.now();
1186
1197
  try {
1187
1198
  const res = await fetchImpl(input, {
1188
1199
  ...init,
@@ -1191,22 +1202,11 @@ export class SqliteScopeHost {
1191
1202
  // A 5xx is the provider failing; a 4xx is usually us. Both are
1192
1203
  // worth recording, because "the connection stopped working" is the
1193
1204
  // question a health view answers.
1194
- if (!res.ok) {
1195
- await admin.recordConnectionUse(open.id, {
1196
- ok: false,
1197
- error: `HTTP ${res.status} from ${provider}`,
1198
- });
1199
- }
1200
- else {
1201
- await admin.recordConnectionUse(open.id, { ok: true });
1202
- }
1205
+ await admin.recordConnectionUse(open.id, settleConnectionUse(provider, Date.now() - started, { response: res }));
1203
1206
  return res;
1204
1207
  }
1205
1208
  catch (err) {
1206
- await admin.recordConnectionUse(open.id, {
1207
- ok: false,
1208
- error: err instanceof Error ? err.message : String(err),
1209
- });
1209
+ await admin.recordConnectionUse(open.id, settleConnectionUse(provider, Date.now() - started, { error: err }));
1210
1210
  throw err;
1211
1211
  }
1212
1212
  },
@@ -1227,7 +1227,10 @@ export class SqliteScopeHost {
1227
1227
  }
1228
1228
  seen.add(m.version);
1229
1229
  }
1230
- const declaredConsumes = new Set(manifest.events.consumes.map((c) => c.type));
1230
+ // IN-SCOPE consumes only (#1705). A `from` entry names another vertical's export, and its
1231
+ // handler lives in `imports`. Letting it satisfy this check would let a local consumer of
1232
+ // the same type name register against a declaration that was reviewed as an import.
1233
+ const declaredConsumes = new Set(manifest.events.consumes.filter((c) => c.from === undefined).map((c) => c.type));
1231
1234
  const consumers = Object.entries(registration.consumers ?? {}).map(([eventType, handler]) => {
1232
1235
  if (!declaredConsumes.has(eventType)) {
1233
1236
  throw new Error(`${manifest.id} registers a consumer for undeclared event type: ${eventType}`);
@@ -1296,12 +1299,15 @@ export class SqliteScopeHost {
1296
1299
  }
1297
1300
  this.listPlans.set(plan.entityType, plan);
1298
1301
  }
1302
+ // #1705: validated whole and kept only if it passes, so a refused module leaves nothing.
1303
+ this.crossVertical.register(manifest, registration.imports);
1299
1304
  this.modules.set(manifest.id, {
1300
1305
  id: manifest.id,
1301
1306
  migrations: [...migrations, ...searchMigrations, ...listMigrations],
1302
1307
  consumers,
1303
1308
  schedules: manifest.schedules ?? [],
1304
1309
  freshness: manifest.freshness ?? [],
1310
+ peers: manifest.peers ?? [],
1305
1311
  });
1306
1312
  for (const rel of manifest.entityRelations ?? []) {
1307
1313
  const parents = this.relations.get(rel.entityType) ?? new Set();
@@ -1470,6 +1476,14 @@ export class SqliteScopeHost {
1470
1476
  rt.db.prepare(seat.sql).run(...seat.params);
1471
1477
  }
1472
1478
  }
1479
+ // #1706: every declared PEER holds its keys on the scope from provisioning on — the one
1480
+ // call that makes "declared and installed" mean "live" (`peerSeats`). Same seat, same
1481
+ // rules: a grant a switch tombstoned stays tombstoned, and a switched-off peer gets
1482
+ // nothing seated.
1483
+ for (const peer of peerSeats(this.peerDeclarations(), input.scopeId)) {
1484
+ const seat = seatScopeTuple(peer.subject, peer.relation, peer.object, null);
1485
+ rt.db.prepare(seat.sql).run(...seat.params);
1486
+ }
1473
1487
  });
1474
1488
  // Audit a real provision only; an idempotent re-provision changed nothing.
1475
1489
  if (!existing) {
@@ -1679,7 +1693,22 @@ export class SqliteScopeHost {
1679
1693
  * the per-entity ref, with a spine event in the same transaction. Bytes go straight to
1680
1694
  * the per-tenant blob store — never through the scope pipe (the issue's point).
1681
1695
  */
1682
- buildAttachments(rt, subject, store, opts = {}) {
1696
+ buildAttachments(rt, authority, store, opts = {}) {
1697
+ // #1686: a capability session is resolved per CALL, inside the call's own actor task —
1698
+ // the invoke door's discipline, so a revoke or an expiry refuses the next read on a
1699
+ // surface built before it. Throws `unauthenticated`/`forbidden` for a dead session or
1700
+ // an allowlist, before any key is checked (not a K-35 denial). A read takes no use.
1701
+ // THE ORDER, for every verb: the session (here, in the runner), then the target lookup,
1702
+ // then the check — so every `targetGate` call on a capability path sits inside a
1703
+ // `guarded` callback, and a dead link is told `unauthenticated` and nothing else.
1704
+ const capabilitySession = authority.kind === 'capability-session' ? authority.hash : undefined;
1705
+ const actingAs = (operation) => authority.kind === 'capability-session'
1706
+ ? {
1707
+ kind: 'capability',
1708
+ id: resolveCapabilitySession(spineSql(rt.db), authority.hash, this.clock(), operation),
1709
+ }
1710
+ : authority;
1711
+ const node = { tenantId: rt.tenantId, scopeId: rt.scopeId };
1683
1712
  const targetGate = (entityType) => {
1684
1713
  const gate = this.attachmentTargets.get(entityType);
1685
1714
  if (!gate) {
@@ -1707,11 +1736,12 @@ export class SqliteScopeHost {
1707
1736
  // like an operation invoke (including K-35 denial recording and prompt dispatch of
1708
1737
  // the events it emitted).
1709
1738
  const serialized = (operation, fn) => rt.actor.enqueue(async () => {
1739
+ const subject = actingAs(operation);
1710
1740
  const ctx = this.operationContext(rt, subject, undefined, undefined, undefined, operation);
1711
1741
  rt.db.exec('BEGIN IMMEDIATE');
1712
1742
  let result;
1713
1743
  try {
1714
- result = await fn(ctx);
1744
+ result = await fn(ctx, subject);
1715
1745
  rt.db.exec('COMMIT');
1716
1746
  }
1717
1747
  catch (err) {
@@ -1742,8 +1772,9 @@ export class SqliteScopeHost {
1742
1772
  * runner does it after its ROLLBACK.
1743
1773
  */
1744
1774
  const reentrant = async (operation, fn) => {
1775
+ const subject = actingAs(operation);
1745
1776
  try {
1746
- return await fn(this.operationContext(rt, subject, undefined, undefined, undefined, operation));
1777
+ return await fn(this.operationContext(rt, subject, undefined, undefined, undefined, operation), subject);
1747
1778
  }
1748
1779
  catch (err) {
1749
1780
  // #1525: the ENCLOSING call's id, deliberately. This runner takes no turn of its
@@ -1758,7 +1789,18 @@ export class SqliteScopeHost {
1758
1789
  const guarded = opts.reentrant ? reentrant : serialized;
1759
1790
  return {
1760
1791
  upload: async (input) => {
1792
+ if (capabilitySession !== undefined) {
1793
+ // #1686: refused BEFORE the bytes go anywhere — through the serialized runner, so
1794
+ // the session is resolved first (a dead one is `unauthenticated`, no K-35 row) and
1795
+ // the refusal is recorded against `{ capability }` like any enforced denial. The
1796
+ // target lookup is INSIDE, after the session: a dead link learns nothing about
1797
+ // which entity types take attachments.
1798
+ return guarded('attachments.upload', async (_ctx, subject) => {
1799
+ throw capabilityAttachmentWriteRefused(subject.id, 'attachments.upload', node, targetGate(input.entity.entityType).write);
1800
+ });
1801
+ }
1761
1802
  const gate = targetGate(input.entity.entityType);
1803
+ const creator = authority;
1762
1804
  const id = ulid();
1763
1805
  const key = attachmentBlobKey(rt.scopeId, id);
1764
1806
  const record = attachmentRecord.parse({
@@ -1769,7 +1811,7 @@ export class SqliteScopeHost {
1769
1811
  size: input.body.byteLength,
1770
1812
  sha256: await sha256Hex(input.body),
1771
1813
  visibility: input.visibility,
1772
- createdBy: subject.id,
1814
+ createdBy: creator.id,
1773
1815
  createdAt: new Date().toISOString(),
1774
1816
  });
1775
1817
  // Bytes first, row second: a crash between the two leaves an orphaned object
@@ -1800,17 +1842,18 @@ export class SqliteScopeHost {
1800
1842
  throw err;
1801
1843
  }
1802
1844
  },
1803
- list: async (entity) => {
1845
+ list: async (entity) =>
1846
+ // The target lookup inside the runner, after `actingAs` (#1686): a capability's
1847
+ // session is decided before anything about the entity type is.
1848
+ guarded('attachments.list', async (ctx) => {
1804
1849
  const gate = targetGate(entity.entityType);
1805
- return guarded('attachments.list', async (ctx) => {
1806
- assertAllowed(await ctx.check(gate.read, entity));
1807
- const rows = rt.db
1808
- .prepare(`SELECT * FROM _substrat_attachments WHERE entity_type = ? AND entity_id = ?
1850
+ assertAllowed(await ctx.check(gate.read, entity));
1851
+ const rows = rt.db
1852
+ .prepare(`SELECT * FROM _substrat_attachments WHERE entity_type = ? AND entity_id = ?
1809
1853
  ORDER BY id DESC`)
1810
- .all(entity.entityType, entity.entityId);
1811
- return rows.map(rowToRecord);
1812
- });
1813
- },
1854
+ .all(entity.entityType, entity.entityId);
1855
+ return rows.map(rowToRecord);
1856
+ }),
1814
1857
  open: async (attachmentId) => {
1815
1858
  const record = await guarded('attachments.open', async (ctx) => {
1816
1859
  const row = rt.db
@@ -1839,10 +1882,15 @@ export class SqliteScopeHost {
1839
1882
  return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
1840
1883
  },
1841
1884
  remove: async (attachmentId) => {
1842
- const removed = await guarded('attachments.remove', async (ctx) => {
1885
+ const removed = await guarded('attachments.remove', async (ctx, subject) => {
1843
1886
  const row = rt.db
1844
1887
  .prepare('SELECT * FROM _substrat_attachments WHERE id = ?')
1845
1888
  .get(attachmentId);
1889
+ if (capabilitySession !== undefined) {
1890
+ // #1686: refused whether or not the id exists — one answer, so a holder learns
1891
+ // nothing about ids — but recorded only when there is a write key to record.
1892
+ throw capabilityAttachmentWriteRefused(subject.id, 'attachments.remove', node, row ? targetGate(row.entity_type).write : undefined);
1893
+ }
1846
1894
  if (!row)
1847
1895
  return null;
1848
1896
  const gate = targetGate(row.entity_type);
@@ -2201,6 +2249,168 @@ export class SqliteScopeHost {
2201
2249
  await this.applyPendingMigrations(rt);
2202
2250
  return this.buildStub(tenantId, scopeId, rt, { kind: 'system', id: moduleId });
2203
2251
  }
2252
+ /**
2253
+ * #1705: the producer's release, decided by THIS deployment's own declarations. The admin
2254
+ * verb wraps it with the directory gate and the access row.
2255
+ *
2256
+ * The order is the safety argument. What leaves is `exports ∩ wants`, computed from the
2257
+ * modules registered here, and then only if the consumer's principal holds every key that
2258
+ * intersection needs. Only then is a row read. A missing key reads nothing, so the
2259
+ * watermark cannot move past events that authority would have released later.
2260
+ */
2261
+ async readExports(tenantId, scopeId, input) {
2262
+ const plan = exportReadPlan(this.crossVertical.exports(), input.wants);
2263
+ const quiet = {
2264
+ events: [],
2265
+ withheld: [],
2266
+ unexported: plan.unexported,
2267
+ paused: null,
2268
+ next: input.after,
2269
+ more: false,
2270
+ };
2271
+ if (plan.types.length === 0)
2272
+ return quiet;
2273
+ const missing = await this.peerMissing(tenantId, scopeId, input.consumer, plan.keys);
2274
+ if (missing.length > 0)
2275
+ return { ...quiet, paused: { missing } };
2276
+ // The committed outbox (#1624): an in-flight operation's event must not reach another
2277
+ // vertical before its transaction decides whether it exists.
2278
+ const db = this.scopeReadDbFor(tenantId, scopeId);
2279
+ const q = exportReadQuery(plan.types, input.after, input.limit);
2280
+ const rows = db.prepare(q.sql).all(...q.params);
2281
+ const hops = db.prepare(EXPORT_HOPS_SQL);
2282
+ const batch = planExportBatch({
2283
+ rows,
2284
+ wanted: plan.wanted,
2285
+ after: input.after,
2286
+ limit: input.limit,
2287
+ hopsBefore: (row) => row.caused_by ? (hops.get(row.caused_by)?.hops ?? 0) : 0,
2288
+ });
2289
+ return { ...batch, unexported: plan.unexported, paused: null };
2290
+ }
2291
+ /**
2292
+ * #1705: the read keys `consumer`'s principal does NOT hold at this scope — #1706's
2293
+ * `peerCovers`, the checker's own `covers`. A peer whose kill switch is off holds nothing,
2294
+ * so it pauses the edge here with no marker read of its own.
2295
+ */
2296
+ async peerMissing(tenantId, scopeId, consumer, keys) {
2297
+ const coverage = await this.peerCovers(tenantId, scopeId, consumer, keys);
2298
+ return coverage.filter((c) => !c.held).map((c) => c.permission);
2299
+ }
2300
+ /** #1705: this scope's watermark per producer, oldest source first. */
2301
+ importCursors(db) {
2302
+ return db.prepare(IMPORT_CURSORS_SQL).all().map((r) => ({
2303
+ source: r.source_scope_id,
2304
+ vertical: r.source_vertical,
2305
+ cursor: r.cursor,
2306
+ updatedAt: r.updated_at,
2307
+ }));
2308
+ }
2309
+ async deliverToPeer(tenantId, scopeId, raw) {
2310
+ // Parsed at the door, like an operation's input: HostAdmin callers are trusted to be the
2311
+ // platform, not to have built a well-formed batch.
2312
+ const batch = importBatch.parse(raw);
2313
+ const source = batch.source;
2314
+ const rt = await this.openActiveScope(tenantId, scopeId);
2315
+ return rt.actor.enqueue(async () => {
2316
+ const result = {
2317
+ delivered: 0,
2318
+ deadLettered: 0,
2319
+ duplicates: 0,
2320
+ withheld: 0,
2321
+ cursor: batch.after,
2322
+ stale: false,
2323
+ paused: null,
2324
+ };
2325
+ // #1706's door, for a delivery (`operation: null`): the producer must be a declared peer
2326
+ // of this vertical with its switch on. Admitted inside the actor task, before anything
2327
+ // runs, so a switch pulled between two passes refuses the second. A refusal is a PAUSE:
2328
+ // nothing ran, the watermark stays, and the producer's outbox keeps the backlog.
2329
+ let subject;
2330
+ try {
2331
+ subject = admitPeer(switchSqlOf(rt.db), this.peerDeclarations(), { vertical: source.vertical, scope: source.scopeId }, null);
2332
+ }
2333
+ catch (err) {
2334
+ return { ...result, paused: { reason: err instanceof Error ? err.message : String(err) } };
2335
+ }
2336
+ // The compare-and-set. Inside the actor, so nothing moves the watermark between this
2337
+ // read and the advance at the end.
2338
+ const current = rt.db.prepare(IMPORT_CURSOR_OF_SQL).get(source.scopeId)?.cursor ?? null;
2339
+ if (current !== batch.after)
2340
+ return { ...result, cursor: current, stale: true };
2341
+ const at = new Date().toISOString();
2342
+ const record = rt.db.prepare(IMPORT_RECORD_SQL);
2343
+ const journaled = rt.db.prepare('SELECT 1 FROM _substrat_deliveries WHERE event_id = ? AND consumer_module = ?');
2344
+ const deadLetter = rt.db.prepare(`INSERT OR IGNORE INTO _substrat_deliveries
2345
+ (event_id, consumer_module, delivered_at, error, invocation_id)
2346
+ VALUES (?, ?, ?, ?, NULL)`);
2347
+ // Named and not carried: a dead letter per importing module, so the operator who reads
2348
+ // this scope's dead letters sees what the producer sent and why it did not arrive.
2349
+ for (const w of batch.withheld) {
2350
+ record.run(w.id, source.scopeId, source.vertical, w.type, w.schemaVersion, w.occurredAt, w.entity.entityType, w.entity.entityId, 0, w.reason, at);
2351
+ for (const moduleId of this.crossVertical.modulesImporting(source.vertical, w.type)) {
2352
+ deadLetter.run(w.id, moduleId, at, withheldNote(w.reason, source.vertical));
2353
+ }
2354
+ result.withheld += 1;
2355
+ }
2356
+ for (const e of batch.events) {
2357
+ record.run(e.id, source.scopeId, source.vertical, e.type, e.schemaVersion, e.occurredAt, e.entity.entityType, e.entity.entityId, e.hops, null, at);
2358
+ let ran = false;
2359
+ for (const imp of this.crossVertical.handlersFor(source.vertical, e.type)) {
2360
+ if (journaled.get(e.id, imp.moduleId))
2361
+ continue;
2362
+ ran = true;
2363
+ if (imp.schemaVersion !== e.schemaVersion) {
2364
+ // The producer released what the consumer's state asked for. A mismatch here means
2365
+ // this scope's code changed between the two, so it is refused by version (K-39)
2366
+ // exactly as the producer would have refused it.
2367
+ deadLetter.run(e.id, imp.moduleId, at, withheldNote('version', source.vertical));
2368
+ result.deadLettered += 1;
2369
+ continue;
2370
+ }
2371
+ const { hops: _hops, ...fact } = e;
2372
+ const event = structuredClone({ ...fact, source });
2373
+ // What the handler emits was emitted BECAUSE of the producer's event (#1237). The id
2374
+ // resolves through `_substrat_imports`, which the row above has just written.
2375
+ rt.causedBy = e.id;
2376
+ rt.db.exec('BEGIN IMMEDIATE');
2377
+ try {
2378
+ // As the producer's principal, admitted above: the handler's checks are real ones,
2379
+ // against the grants this vertical's `peers` gave it, and its emits carry the actor
2380
+ // `{ vertical, scope }` and the authorization they passed (K-34).
2381
+ await imp.handler(this.operationContext(rt, subject), event);
2382
+ rt.db
2383
+ .prepare(`INSERT INTO _substrat_deliveries (event_id, consumer_module, delivered_at, invocation_id)
2384
+ VALUES (?, ?, ?, NULL)`)
2385
+ .run(e.id, imp.moduleId, new Date().toISOString());
2386
+ rt.db.exec('COMMIT');
2387
+ result.delivered += 1;
2388
+ }
2389
+ catch (err) {
2390
+ rt.db.exec('ROLLBACK');
2391
+ // Dead-letter (v0), the consumer's rule: one failure is terminal, and the events
2392
+ // behind it still arrive.
2393
+ deadLetter.run(e.id, imp.moduleId, new Date().toISOString(), String(err));
2394
+ result.deadLettered += 1;
2395
+ }
2396
+ finally {
2397
+ rt.causedBy = null;
2398
+ }
2399
+ }
2400
+ if (!ran)
2401
+ result.duplicates += 1;
2402
+ }
2403
+ // LAST, in this scope's store: a crash before here redelivers the batch, and the journal
2404
+ // rows above absorb it.
2405
+ rt.db.prepare(IMPORT_CURSOR_ADVANCE_SQL).run(source.scopeId, source.vertical, batch.next, at);
2406
+ result.cursor = batch.next;
2407
+ // What the handlers emitted reaches this scope's own consumers and executors in the same
2408
+ // tail, under the ordinary cascade cap.
2409
+ await this.dispatch(rt, null);
2410
+ await this.dispatchExecutors(rt, null);
2411
+ return result;
2412
+ });
2413
+ }
2204
2414
  /**
2205
2415
  * The lifecycle gate the capability doors share (#1672): the scope must exist in this
2206
2416
  * tenant, the tenant must be active, the scope must be active — K-3's fail-closed pair
@@ -2212,7 +2422,11 @@ export class SqliteScopeHost {
2212
2422
  .prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
2213
2423
  .get(scopeId);
2214
2424
  if (!scope || scope.tenant_id !== tenantId) {
2215
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
2425
+ // TYPED (#1714 review): K-3's pair check is the confinement the capability and peer
2426
+ // doors rest on, so its refusal carries a code rather than being one more untyped
2427
+ // throw a test could pass on by accident. `not_found`, in the same words either way:
2428
+ // a scope of another tenant reads exactly as one that does not exist.
2429
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2216
2430
  }
2217
2431
  const tenant = this.directory
2218
2432
  .prepare('SELECT status FROM tenants WHERE tenant_id = ?')
@@ -2277,6 +2491,55 @@ export class SqliteScopeHost {
2277
2491
  const hash = await capabilityTokenHash(sessionToken);
2278
2492
  return this.buildStub(tenantId, scopeId, rt, { kind: 'capability-session', hash }, options);
2279
2493
  }
2494
+ /**
2495
+ * The capability's attachment door (#1686) — `getCapabilityScope`'s gate, then the
2496
+ * attachment surface with the session's hash as its authority. `buildAttachments`
2497
+ * re-resolves that hash on every call, reads are the checker's as `{ capability }`, and
2498
+ * writes are refused.
2499
+ */
2500
+ async getCapabilityAttachments(sessionToken, tenantId, scopeId) {
2501
+ if (!plausibleSessionToken(sessionToken)) {
2502
+ throw substratError('unauthenticated', 'not a capability session token');
2503
+ }
2504
+ const rt = await this.openActiveScope(tenantId, scopeId);
2505
+ const hash = await capabilityTokenHash(sessionToken);
2506
+ const scopeRow = this.directory
2507
+ .prepare('SELECT vertical FROM scopes WHERE scope_id = ?')
2508
+ .get(scopeId);
2509
+ const store = this.attachmentStore(tenantId, scopeRow?.vertical ?? null);
2510
+ return this.buildAttachments(rt, { kind: 'capability-session', hash }, store);
2511
+ }
2512
+ /** Every registered module's `peers`, as one declaration per peer vertical (#1706). */
2513
+ peerDeclarations() {
2514
+ return collectPeers(this.modules.values());
2515
+ }
2516
+ /**
2517
+ * The peer door (#1706) — mirror of the connection, system and capability doors. The caller is
2518
+ * the local broker's word (the platform's, on the pure host); the pair and lifecycle gate run
2519
+ * here, and `admitPeer` runs inside the scope's actor task on every invoke (`buildStub`).
2520
+ */
2521
+ async getVerticalScope(caller, tenantId, scopeId, options) {
2522
+ const parsed = verticalCaller.parse(caller);
2523
+ const rt = await this.openActiveScope(tenantId, scopeId);
2524
+ return this.buildStub(tenantId, scopeId, rt, { kind: 'vertical-peer', caller: parsed }, options);
2525
+ }
2526
+ /**
2527
+ * Does peer `vertical` hold each key at this scope's node right now (#1706) — the checker's own
2528
+ * `covers`, inside the scope's actor task so no invoke's open transaction is read half-done.
2529
+ * The subject's `scope` is the target scope only to satisfy the type: `covers` records nothing,
2530
+ * and the tuple ref it walks is the slug alone.
2531
+ */
2532
+ async peerCovers(tenantId, scopeId, vertical, permissions) {
2533
+ const rt = await this.openActiveScope(tenantId, scopeId);
2534
+ const subject = { kind: 'vertical', id: verticalSlugOf.parse(vertical), scope: scopeId };
2535
+ const coverage = await rt.actor.enqueue(() => this.checker.covers(subject, [...permissions], { tenantId, scopeId }));
2536
+ const missing = new Set(coverage.covered ? [] : coverage.missing);
2537
+ return permissions.map((permission) => ({ permission, held: !missing.has(permission) }));
2538
+ }
2539
+ /** #1705: what this deployment imports — the sweep's reason to call no scope when it is empty. */
2540
+ registeredImports() {
2541
+ return this.crossVertical.consumes();
2542
+ }
2280
2543
  registeredSchedules() {
2281
2544
  const out = [];
2282
2545
  for (const mod of this.modules.values()) {
@@ -2581,12 +2844,17 @@ export class SqliteScopeHost {
2581
2844
  // actor task — so nothing can revoke between this read and the transaction below.
2582
2845
  // Refuses a stale session, a revoked or expired capability and an operation off its
2583
2846
  // allowlist before anything opens; none of those is a K-35 denial (no key checked).
2847
+ // #1706: the peer door's admission, on the same terms — every invoke, inside the
2848
+ // actor task. An undeclared peer, a switched-off one, or an operation off its
2849
+ // allowlist is refused `forbidden` before anything opens; no key was checked.
2584
2850
  const subject = authority.kind === 'capability-session'
2585
2851
  ? {
2586
2852
  kind: 'capability',
2587
2853
  id: resolveCapabilitySession(spineSql(rt.db), authority.hash, this.clock(), operation),
2588
2854
  }
2589
- : authority;
2855
+ : authority.kind === 'vertical-peer'
2856
+ ? admitPeer(switchSqlOf(rt.db), this.peerDeclarations(), authority.caller, operation)
2857
+ : authority;
2590
2858
  // #1672: the secrets this invocation mints — withheld from its idempotency recording,
2591
2859
  // and what the tripwire on its writes looks for.
2592
2860
  const minted = [];
@@ -2970,10 +3238,10 @@ export class SqliteScopeHost {
2970
3238
  (id, module_id, job, instance, payload, status, cursor, counters, attempts,
2971
3239
  last_error, started_at, updated_at, next_attempt_at, ended_at)
2972
3240
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
2973
- const patchRun = db.prepare(`UPDATE _substrat_job_runs
2974
- SET status = ?, cursor = ?, counters = ?, attempts = ?, last_error = ?,
2975
- updated_at = ?, next_attempt_at = ?, ended_at = ?
2976
- WHERE id = ?`);
3241
+ // A compare-and-set on `running` (#1632): a pass that outlived an erasure must not
3242
+ // write its cursor back over the redaction. The kernel's statement, so both adapters
3243
+ // hold the same line.
3244
+ const patchRun = db.prepare(JOB_RUN_PATCH_SQL);
2977
3245
  const patchArgs = (id, p) => [p.status, p.cursor, p.counters, p.attempts, p.lastError, p.updatedAt, p.nextAttemptAt, p.endedAt, id];
2978
3246
  // `db.transaction` on better-sqlite3 runs its body SYNCHRONOUSLY inside a real
2979
3247
  // SQLite transaction, so nothing — not another turn of the event loop, not
@@ -3032,12 +3300,8 @@ export class SqliteScopeHost {
3032
3300
  .prepare('SELECT step, result, attempts, last_error FROM _substrat_job_steps WHERE run_id = ? AND step = ?')
3033
3301
  .get(runId, name) ?? null),
3034
3302
  recordStep: (runId, name, result, attempts, lastError, at) => turn(() => {
3035
- db.prepare(`INSERT INTO _substrat_job_steps (run_id, step, result, attempts, last_error, recorded_at)
3036
- VALUES (?, ?, ?, ?, ?, ?)
3037
- ON CONFLICT (run_id, step) DO UPDATE SET result = excluded.result,
3038
- attempts = excluded.attempts,
3039
- last_error = excluded.last_error,
3040
- recorded_at = excluded.recorded_at`).run(runId, name, result, attempts, lastError, at);
3303
+ // Only while the run is still `running` (#1632) — see `JOB_STEP_RECORD_SQL`.
3304
+ db.prepare(JOB_STEP_RECORD_SQL).run(runId, name, result, attempts, lastError, at, runId);
3041
3305
  }),
3042
3306
  };
3043
3307
  }
@@ -3665,6 +3929,7 @@ export class SqliteScopeHost {
3665
3929
  admissionNote: r.admission_note,
3666
3930
  origin: r.origin_json ? JSON.parse(r.origin_json) : null,
3667
3931
  outbound: outboundOfManifestJson(r.manifest_json),
3932
+ calls: callsOfManifestJson(r.manifest_json),
3668
3933
  createdAt: r.created_at,
3669
3934
  });
3670
3935
  const readVersion = (id) => {
@@ -3876,6 +4141,169 @@ export class SqliteScopeHost {
3876
4141
  permissions: outcome.permissions,
3877
4142
  };
3878
4143
  };
4144
+ /**
4145
+ * #1706: move one PEER's kill switch on one scope — see `HostAdmin.revokeFromPeer`. The
4146
+ * schedule switch's body with the subject swapped (`switchPeer` → `switchSubjectGrants`):
4147
+ * audit first, one turn on the scope actor, outcome after, `not_found` when nothing was held.
4148
+ */
4149
+ const switchPeerAt = async (actor, raw, to) => {
4150
+ const input = peerSwitch.parse(raw);
4151
+ const { tenantId, scopeId } = input.node;
4152
+ const scope = this.directory
4153
+ .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
4154
+ .get(scopeId);
4155
+ if (!scope || scope.tenant_id !== tenantId) {
4156
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4157
+ }
4158
+ const operationId = ulid();
4159
+ const action = to === 'off' ? 'revokeFromPeer' : 'restoreToPeer';
4160
+ const target = { tenantId, scopeId };
4161
+ const base = { operationId, vertical: input.vertical, calls: to };
4162
+ this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
4163
+ let outcome;
4164
+ try {
4165
+ const rt = this.runtime(tenantId, scopeId);
4166
+ outcome = await rt.actor.turn(() => rt.db.transaction(() => switchPeer(switchSqlOf(rt.db), {
4167
+ vertical: input.vertical,
4168
+ scopeId,
4169
+ to,
4170
+ at: new Date().toISOString(),
4171
+ }))());
4172
+ }
4173
+ catch (err) {
4174
+ try {
4175
+ this.recordAdmin(actor, action, target, null, {
4176
+ ...base,
4177
+ phase: 'failed',
4178
+ error: err instanceof Error ? err.message : String(err),
4179
+ });
4180
+ }
4181
+ catch {
4182
+ // Best effort: the original error is what the caller must see.
4183
+ }
4184
+ throw err;
4185
+ }
4186
+ this.recordAdmin(actor, action, target, null, {
4187
+ ...base,
4188
+ phase: outcome.held ? 'applied' : 'refused',
4189
+ changed: outcome.changed,
4190
+ permissions: outcome.permissions,
4191
+ });
4192
+ if (!outcome.held) {
4193
+ throw substratError('not_found', `scope ${scopeId} holds no grant for peer '${input.vertical}' — nothing to switch ${to} ` +
4194
+ `(check the slug: it is the calling vertical's registry id, as the target's \`peers\` names it)`);
4195
+ }
4196
+ return {
4197
+ operationId,
4198
+ vertical: input.vertical,
4199
+ calls: to,
4200
+ changed: outcome.changed,
4201
+ permissions: outcome.permissions,
4202
+ };
4203
+ };
4204
+ /**
4205
+ * The status read's admin-log join (#1674): the `intent` row of the `revokeFromSystem`
4206
+ * still in force for each OFF module — actor, reason, when. Paired to its `applied` row
4207
+ * by `operationId`, so a refused or failed attempt (only possible on a module this
4208
+ * scope has never switched off before, per `switchSystemSchedules`'s `held` check, so
4209
+ * never the explanation for a module that is currently off) is never read as one.
4210
+ */
4211
+ const lastSwitchedOffBy = (tenantId, scopeId,
4212
+ /** The revoking action, and the payload field naming its subject. */
4213
+ action, key, subjects) => {
4214
+ const moduleIds = subjects;
4215
+ const rows = this.directory
4216
+ .prepare(`SELECT actor, after, at FROM _substrat_admin_log
4217
+ WHERE tenant_id = ? AND scope_id = ? AND action = ?
4218
+ ORDER BY id DESC`)
4219
+ .all(tenantId, scopeId, action);
4220
+ const appliedOps = new Set();
4221
+ for (const row of rows) {
4222
+ if (!row.after)
4223
+ continue;
4224
+ const payload = JSON.parse(row.after);
4225
+ if (payload.phase === 'applied' && payload.operationId)
4226
+ appliedOps.add(payload.operationId);
4227
+ }
4228
+ const result = new Map();
4229
+ for (const row of rows) {
4230
+ if (result.size === moduleIds.size)
4231
+ break;
4232
+ if (!row.after)
4233
+ continue;
4234
+ const payload = JSON.parse(row.after);
4235
+ const subject = payload[key];
4236
+ if (payload.phase !== 'intent' || !payload.operationId || typeof subject !== 'string')
4237
+ continue;
4238
+ if (!moduleIds.has(subject) || result.has(subject))
4239
+ continue;
4240
+ if (!appliedOps.has(String(payload.operationId)) || typeof payload.reason !== 'string')
4241
+ continue;
4242
+ result.set(subject, {
4243
+ actor: row.actor,
4244
+ reason: payload.reason,
4245
+ at: row.at,
4246
+ });
4247
+ }
4248
+ return result;
4249
+ };
4250
+ /** #1674's join, bound to the schedule switch — the shape every caller here used. */
4251
+ const lastSwitchedOff = (tenantId, scopeId, moduleIds) => lastSwitchedOffBy(tenantId, scopeId, 'revokeFromSystem', 'moduleId', moduleIds);
4252
+ /**
4253
+ * The status read (#1674): every module this scope holds or has held system authority
4254
+ * for, and where each stands — the SAME predicate `switchSystem` gates its writes on
4255
+ * (`systemGrantsStatus`, over the scope's own storage), so the read and the runner
4256
+ * cannot disagree.
4257
+ */
4258
+ const systemGrantsStatusOf = async (actor, node) => {
4259
+ const { tenantId, scopeId } = node;
4260
+ const scope = this.directory
4261
+ .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
4262
+ .get(scopeId);
4263
+ if (!scope || scope.tenant_id !== tenantId) {
4264
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4265
+ }
4266
+ const rt = this.runtime(tenantId, scopeId);
4267
+ const states = await rt.actor.turn(() => systemGrantsStatus(switchSqlOf(rt.db), new Date().toISOString()));
4268
+ const offModules = new Set(states.filter((s) => s.schedules === 'off').map((s) => s.moduleId));
4269
+ const explanations = offModules.size > 0 ? lastSwitchedOff(tenantId, scopeId, offModules) : new Map();
4270
+ const result = states.map((s) => ({
4271
+ moduleId: s.moduleId,
4272
+ schedules: s.schedules,
4273
+ switchedOff: explanations.get(s.moduleId) ?? null,
4274
+ }));
4275
+ // K-24: reading the switch's position and any live incident reason is itself
4276
+ // access-logged, the same as every other HostAdmin read.
4277
+ this.recordAccess(actor, 'systemGrantsStatus', { tenantId, scopeId }, null, result.length);
4278
+ return result;
4279
+ };
4280
+ /**
4281
+ * The peer switch's status read (#1706) — `systemGrantsStatusOf` with the subject
4282
+ * swapped: the kernel's `peerGrantsStatus` over the scope's own storage, which is the
4283
+ * SAME predicate `admitPeer` refuses a call on, then the admin log's explanation for
4284
+ * each peer that is off. The join belongs on this side because the admin log is the
4285
+ * control plane's store; the position alone is all a vertical's own deployment can say.
4286
+ */
4287
+ const peerGrantsStatusOf = async (actor, node) => {
4288
+ const { tenantId, scopeId } = node;
4289
+ const scope = this.directory
4290
+ .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
4291
+ .get(scopeId);
4292
+ if (!scope || scope.tenant_id !== tenantId) {
4293
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4294
+ }
4295
+ const rt = this.runtime(tenantId, scopeId);
4296
+ const states = await rt.actor.turn(() => peerGrantsStatus(switchSqlOf(rt.db), new Date().toISOString()));
4297
+ const offPeers = new Set(states.filter((p) => p.calls === 'off').map((p) => p.vertical));
4298
+ const explanations = offPeers.size > 0 ? lastSwitchedOffBy(tenantId, scopeId, 'revokeFromPeer', 'vertical', offPeers) : new Map();
4299
+ const result = states.map((p) => ({
4300
+ vertical: verticalSlugOf.parse(p.vertical),
4301
+ calls: p.calls,
4302
+ switchedOff: explanations.get(p.vertical) ?? null,
4303
+ }));
4304
+ this.recordAccess(actor, 'peerGrantsStatus', { tenantId, scopeId }, null, result.length);
4305
+ return result;
4306
+ };
3879
4307
  return {
3880
4308
  // #603: fixed at construction — a host built without a box can never store a
3881
4309
  // credential, and saying so is what lets a transport answer 503 instead of 500.
@@ -4066,8 +4494,14 @@ export class SqliteScopeHost {
4066
4494
  // #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
4067
4495
  // whole rule, shared with the Cloudflare adapter; this is the directory check, the
4068
4496
  // transaction and the audit row around it.
4497
+ revokeFromPeer: async (actor, raw) => switchPeerAt(actor, raw, 'off'),
4498
+ restoreToPeer: async (actor, raw) => switchPeerAt(actor, raw, 'on'),
4069
4499
  revokeFromSystem: async (actor, raw) => switchSystem(actor, raw, 'off'),
4070
4500
  restoreToSystem: async (actor, raw) => switchSystem(actor, raw, 'on'),
4501
+ // #1674: the switch's status read — same gate, the admin log to explain an `off`
4502
+ // entry. Nothing to delegate here: the pure adapter's scope storage IS the store.
4503
+ systemGrantsStatus: systemGrantsStatusOf,
4504
+ peerGrantsStatus: peerGrantsStatusOf,
4071
4505
  // #1672 — the platform's two capability verbs, both a turn on the scope actor
4072
4506
  // (#1678: every scope-level admin write takes one, so none lands mid-invoke inside a
4073
4507
  // stranger's transaction) and both in the admin log without the secret or its hash.
@@ -4238,6 +4672,21 @@ export class SqliteScopeHost {
4238
4672
  this.recordAccess(actor, 'listHostnames', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
4239
4673
  return rows.map(mapHostname);
4240
4674
  },
4675
+ // #1706: the directory's answer to "vertical Y in tenant T", by the kernel's one rule.
4676
+ resolveVerticalInstance: async (tenantId, vertical) => {
4677
+ const rows = this.directory
4678
+ .prepare(`SELECT scope_id, tenant_id, vertical, status, kind, forked_from FROM scopes
4679
+ WHERE tenant_id = ? AND vertical = ?`)
4680
+ .all(tenantId, vertical);
4681
+ return resolveVerticalInstanceFrom(rows.map((r) => ({
4682
+ id: r.scope_id,
4683
+ tenantId: r.tenant_id,
4684
+ vertical: r.vertical,
4685
+ status: r.status,
4686
+ kind: r.kind ?? '',
4687
+ forkedFrom: r.forked_from ?? null,
4688
+ })), tenantId, vertical);
4689
+ },
4241
4690
  resolveHostname: async (raw) => {
4242
4691
  // The router's per-request read. No actor, not logged — same carve-out as
4243
4692
  // resolveIdentity (K-24): this is a machine path, not a staff read.
@@ -4826,6 +5275,26 @@ export class SqliteScopeHost {
4826
5275
  }
4827
5276
  return drained;
4828
5277
  },
5278
+ readExportedEvents: async (actor, tenantId, scopeId, raw) => {
5279
+ // #1705: the producer's half. Parsed here, since HostAdmin is public and the control
5280
+ // plane is not the only caller: a limit or cursor nobody validated must not reach SQL.
5281
+ const input = exportReadInput.parse(raw);
5282
+ this.assertScopeReachable(tenantId, scopeId);
5283
+ const batch = await this.readExports(tenantId, scopeId, input);
5284
+ this.recordAccess(actor, 'readExportedEvents', { tenantId, scopeId }, { consumer: input.consumer, after: input.after, limit: input.limit }, batch.events.length);
5285
+ return batch;
5286
+ },
5287
+ importState: async (actor, tenantId, scopeId) => {
5288
+ this.assertScopeReachable(tenantId, scopeId);
5289
+ const consumes = this.crossVertical.consumes();
5290
+ // A deployment that imports nothing answers without opening the scope. The sweep
5291
+ // asks this of every active scope on every pass that has the phase on.
5292
+ if (consumes.length === 0)
5293
+ return { consumes: [], cursors: [] };
5294
+ const cursors = this.importCursors(this.scopeReadDbFor(tenantId, scopeId));
5295
+ this.recordAccess(actor, 'importState', { tenantId, scopeId }, null, cursors.length);
5296
+ return { consumes, cursors };
5297
+ },
4829
5298
  redrainEvents: async (actor, tenantId, scopeId, input) => {
4830
5299
  // Directory check first, on mark's reasoning: "nothing to reopen" and "you may not
4831
5300
  // address this scope" are different answers.
@@ -5493,7 +5962,7 @@ export class SqliteScopeHost {
5493
5962
  // Both scope-side redactions in ONE turn on the scope actor (#1678): issued while an
5494
5963
  // invoke held its transaction open, they joined it, and its rollback put the
5495
5964
  // person's PII back after this verb had destroyed the key and receipted the erasure.
5496
- const { redacted, intentsRedacted } = await this.runtime(tenantId, scopeId).actor.turn(() => ({
5965
+ const { redacted, intentsRedacted, jobRunsRedacted } = await this.runtime(tenantId, scopeId).actor.turn(() => ({
5497
5966
  redacted: db
5498
5967
  .prepare(`UPDATE _substrat_outbox SET payload = NULL
5499
5968
  WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`)
@@ -5505,12 +5974,27 @@ export class SqliteScopeHost {
5505
5974
  // idempotence as the outbox half: a re-run finds the tombstones and changes
5506
5975
  // nothing.
5507
5976
  intentsRedacted: this.redactSubjectIntents(db, subjectId, at),
5977
+ // A failed delivery's error text about one of those events (#1632) — keyed by
5978
+ // event id, so the outbox predicate names it. Not a copy; not counted.
5979
+ deliveries: db
5980
+ .prepare(DELIVERY_ERROR_REDACTION_SQL)
5981
+ .run(REDACTED_DELIVERY_NOTE, REDACTED_DELIVERY_NOTE, subjectId),
5982
+ // The job-run tables (#1632): a run, its cursor or a step's memo can hold a copy
5983
+ // of the same event. The kernel's walk, so the DO runs the identical SQL.
5984
+ jobRunsRedacted: redactSubjectJobRuns((sql, params) => {
5985
+ const stmt = db.prepare(sql);
5986
+ if (stmt.reader)
5987
+ return stmt.all(...params);
5988
+ stmt.run(...params);
5989
+ return [];
5990
+ }, subjectId, at),
5508
5991
  }));
5509
5992
  const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
5510
5993
  const receipt = subjectShredReceipt.parse({
5511
5994
  subjectId,
5512
5995
  eventsRedacted: redacted.changes,
5513
5996
  intentsRedacted,
5997
+ jobRunsRedacted,
5514
5998
  keyDestroyed: existed,
5515
5999
  tombstoned: true,
5516
6000
  });
@@ -5520,7 +6004,7 @@ export class SqliteScopeHost {
5520
6004
  this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
5521
6005
  // BOTH counts: the access log's number is "how much evidence this destroyed", and
5522
6006
  // an intent payload is a whole event's worth of it.
5523
- this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes + intentsRedacted);
6007
+ this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes + intentsRedacted + jobRunsRedacted);
5524
6008
  return receipt;
5525
6009
  },
5526
6010
  // -- impersonation (K-42, #868) ----------------------------------------
@@ -5984,14 +6468,26 @@ export class SqliteScopeHost {
5984
6468
  status = CASE WHEN status = 'error' THEN 'active' ELSE status END
5985
6469
  WHERE id = ?`)
5986
6470
  .run(now, id);
6471
+ }
6472
+ else {
6473
+ this.directory
6474
+ .prepare(`UPDATE _substrat_connections
6475
+ SET last_error = ?, last_error_at = ?,
6476
+ status = CASE WHEN status = 'revoked' THEN status ELSE 'error' END
6477
+ WHERE id = ?`)
6478
+ .run(outcome.error.slice(0, 2000), now, id);
6479
+ }
6480
+ // #1691: the line is settled — now the data point, off the row's own identity
6481
+ // (never the caller's input), fire-and-forget. With the no-op default there is
6482
+ // nothing to hand it, so the self-host path skips the read entirely.
6483
+ if (this.connectorCalls === noopConnectorCallRecorder)
5987
6484
  return;
6485
+ const row = this.directory
6486
+ .prepare('SELECT tenant_id, vertical, provider FROM _substrat_connections WHERE id = ?')
6487
+ .get(id);
6488
+ if (row) {
6489
+ recordConnectorCall(this.connectorCalls, connectorCallRecord({ tenantId: row.tenant_id, vertical: row.vertical, provider: row.provider }, outcome));
5988
6490
  }
5989
- this.directory
5990
- .prepare(`UPDATE _substrat_connections
5991
- SET last_error = ?, last_error_at = ?,
5992
- status = CASE WHEN status = 'revoked' THEN status ELSE 'error' END
5993
- WHERE id = ?`)
5994
- .run(outcome.error.slice(0, 2000), now, id);
5995
6491
  },
5996
6492
  putConnectorState: async (id, key, value) => {
5997
6493
  this.directory
@@ -6773,8 +7269,8 @@ export class SqliteScopeHost {
6773
7269
  * row's `last_used_at` are the same value — a second clock read could disagree.
6774
7270
  */
6775
7271
  at = this.clock()) {
6776
- // For a connection, system or capability subject this carries THAT id so the type
6777
- // holds — it is not a person, and the event actor below says what it is instead.
7272
+ // For a connection, system, capability or peer-vertical subject this carries THAT id so
7273
+ // the type holds — it is not a person, and the event actor below says what it is instead.
6778
7274
  const principal = subject.id;
6779
7275
  const checker = this.checker;
6780
7276
  const relations = this.relations;