@substrat-run/adapter-sqlite 0.118.0 → 0.120.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, redactSubjectJobRuns, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, 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);
@@ -1983,14 +2031,14 @@ export class SqliteScopeHost {
1983
2031
  // Restore never creates a scope (that is importScope) — an unknown target fails closed.
1984
2032
  const existing = await this.admin.getScopeRecord(actor, tenantId, scopeId);
1985
2033
  if (!existing)
1986
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
2034
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
1987
2035
  await this.loadDump(tenantId, scopeId, dump.tables);
1988
2036
  this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
1989
2037
  }
1990
2038
  async snapshotScope(actor, tenantId, scopeId, opts) {
1991
2039
  const source = await this.admin.getScopeRecord(actor, tenantId, scopeId);
1992
2040
  if (!source)
1993
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
2041
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
1994
2042
  const dump = await this.admin.exportScope(actor, tenantId, scopeId);
1995
2043
  const snapshotId = ulid();
1996
2044
  await this.importScope(actor, {
@@ -2015,7 +2063,7 @@ export class SqliteScopeHost {
2015
2063
  // #509 ask (b)). A PRIMARY scope keeps the platform's tombstone-only rule (archive it).
2016
2064
  const rec = await this.admin.getScopeRecord(actor, tenantId, scopeId);
2017
2065
  if (!rec)
2018
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
2066
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2019
2067
  if (!rec.forkedFrom && rec.kind !== 'preview') {
2020
2068
  throw new Error(`scope ${scopeId} is not a fork or preview — only previews may be deleted; ` +
2021
2069
  `archive a primary scope instead`);
@@ -2043,7 +2091,7 @@ export class SqliteScopeHost {
2043
2091
  .prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
2044
2092
  .get(scopeId);
2045
2093
  if (!row || row.tenant_id !== tenantId) {
2046
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
2094
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2047
2095
  }
2048
2096
  // Lifecycle gates (control-plane.md §4.1/§4.2), all the K-3 fail-closed path.
2049
2097
  // The tenant record is mandatory: every scope has a tenant with a status
@@ -2104,7 +2152,7 @@ export class SqliteScopeHost {
2104
2152
  if (!scope || scope.tenant_id !== conn.tenant_id) {
2105
2153
  // Same wording as the principal path: a scope in another tenant is
2106
2154
  // indistinguishable from one that does not exist.
2107
- throw new Error(`unknown scope for connection: ${scopeId}`);
2155
+ throw substratError('not_found', `unknown scope for connection: ${scopeId}`);
2108
2156
  }
2109
2157
  if (scope.vertical !== conn.vertical) {
2110
2158
  throw new Error(`connection ${connectionId} is for vertical '${conn.vertical}' and scope ${scopeId} ` +
@@ -2132,7 +2180,7 @@ export class SqliteScopeHost {
2132
2180
  .prepare('SELECT tenant_id, vertical, status FROM scopes WHERE scope_id = ?')
2133
2181
  .get(scopeId);
2134
2182
  if (!scope || scope.tenant_id !== conn.tenant_id) {
2135
- throw new Error(`unknown scope for connection: ${scopeId}`);
2183
+ throw substratError('not_found', `unknown scope for connection: ${scopeId}`);
2136
2184
  }
2137
2185
  if (scope.vertical !== conn.vertical) {
2138
2186
  throw new Error(`connection ${connectionId} is for vertical '${conn.vertical}' and scope ${scopeId} ` +
@@ -2171,7 +2219,7 @@ export class SqliteScopeHost {
2171
2219
  .prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
2172
2220
  .get(scopeId);
2173
2221
  if (!scope || scope.tenant_id !== tenantId) {
2174
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
2222
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2175
2223
  }
2176
2224
  if (scope.status !== 'active') {
2177
2225
  throw new Error(`scope not active (status: ${scope.status}): ${scopeId}`);
@@ -2192,7 +2240,7 @@ export class SqliteScopeHost {
2192
2240
  .prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
2193
2241
  .get(scopeId);
2194
2242
  if (!scope || scope.tenant_id !== tenantId) {
2195
- throw new Error(`unknown scope: ${scopeId}`);
2243
+ throw substratError('not_found', `unknown scope: ${scopeId}`);
2196
2244
  }
2197
2245
  if (scope.status !== 'active') {
2198
2246
  throw new Error(`scope not active (status: ${scope.status}): ${scopeId}`);
@@ -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 = [];
@@ -3133,7 +3401,7 @@ export class SqliteScopeHost {
3133
3401
  .get(scopeId);
3134
3402
  // K-3: a scope under another tenant is indistinguishable from one that does not exist.
3135
3403
  if (!row || row.tenant_id !== tenantId) {
3136
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
3404
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3137
3405
  }
3138
3406
  // `provisioning` allowed — a scope stuck there on a failed migration is a
3139
3407
  // sweep target. Suspended/archived are deliberate states; not disturbed.
@@ -3426,7 +3694,7 @@ export class SqliteScopeHost {
3426
3694
  .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
3427
3695
  .get(scopeId);
3428
3696
  if (!rec || rec.tenant_id !== tenantId) {
3429
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
3697
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3430
3698
  }
3431
3699
  }
3432
3700
  /**
@@ -3661,6 +3929,7 @@ export class SqliteScopeHost {
3661
3929
  admissionNote: r.admission_note,
3662
3930
  origin: r.origin_json ? JSON.parse(r.origin_json) : null,
3663
3931
  outbound: outboundOfManifestJson(r.manifest_json),
3932
+ calls: callsOfManifestJson(r.manifest_json),
3664
3933
  createdAt: r.created_at,
3665
3934
  });
3666
3935
  const readVersion = (id) => {
@@ -3740,7 +4009,7 @@ export class SqliteScopeHost {
3740
4009
  .prepare('SELECT tenant_id, status, vertical FROM scopes WHERE scope_id = ?')
3741
4010
  .get(scopeId);
3742
4011
  if (!row || row.tenant_id !== tenantId) {
3743
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
4012
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3744
4013
  }
3745
4014
  if (!from.includes(row.status)) {
3746
4015
  throw new Error(`illegal scope transition for ${action}: ${row.status} → ${to} ` +
@@ -3872,6 +4141,169 @@ export class SqliteScopeHost {
3872
4141
  permissions: outcome.permissions,
3873
4142
  };
3874
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
+ };
3875
4307
  return {
3876
4308
  // #603: fixed at construction — a host built without a box can never store a
3877
4309
  // credential, and saying so is what lets a transport answer 503 instead of 500.
@@ -3993,7 +4425,7 @@ export class SqliteScopeHost {
3993
4425
  .prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
3994
4426
  .get(grant.node.scopeId);
3995
4427
  if (!scope || scope.tenant_id !== grant.node.tenantId) {
3996
- throw new Error(`unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
4428
+ throw substratError('not_found', `unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
3997
4429
  }
3998
4430
  if (scope.vertical !== conn.vertical) {
3999
4431
  throw new Error(`connection ${grant.connectionId} is for vertical '${conn.vertical}' and scope ` +
@@ -4062,8 +4494,14 @@ export class SqliteScopeHost {
4062
4494
  // #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
4063
4495
  // whole rule, shared with the Cloudflare adapter; this is the directory check, the
4064
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'),
4065
4499
  revokeFromSystem: async (actor, raw) => switchSystem(actor, raw, 'off'),
4066
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,
4067
4505
  // #1672 — the platform's two capability verbs, both a turn on the scope actor
4068
4506
  // (#1678: every scope-level admin write takes one, so none lands mid-invoke inside a
4069
4507
  // stranger's transaction) and both in the admin log without the secret or its hash.
@@ -4119,7 +4557,7 @@ export class SqliteScopeHost {
4119
4557
  .prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
4120
4558
  .get(parsed.scopeId);
4121
4559
  if (!scope || scope.tenant_id !== parsed.tenantId) {
4122
- throw new Error(`unknown scope ${parsed.scopeId} in tenant ${parsed.tenantId}`);
4560
+ throw substratError('not_found', `unknown scope ${parsed.scopeId} in tenant ${parsed.tenantId}`);
4123
4561
  }
4124
4562
  const existing = this.directory
4125
4563
  .prepare(`SELECT h.scope_id AS scope_id, s.status AS scope_status
@@ -4234,9 +4672,26 @@ export class SqliteScopeHost {
4234
4672
  this.recordAccess(actor, 'listHostnames', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
4235
4673
  return rows.map(mapHostname);
4236
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
+ },
4237
4690
  resolveHostname: async (raw) => {
4238
4691
  // The router's per-request read. No actor, not logged — same carve-out as
4239
4692
  // resolveIdentity (K-24): this is a machine path, not a staff read.
4693
+ // Like readRoute in Cloudflare, require an active binding, correctly paired
4694
+ // active scope and active owning tenant. Refusal leaves the binding intact.
4240
4695
  const hostname = raw.toLowerCase();
4241
4696
  // Join the scope's dispatch script in the same read (orchestration.md §5.4).
4242
4697
  // A scope on the stable serving script (#286) routes THERE; falls back to the
@@ -4251,11 +4706,13 @@ export class SqliteScopeHost {
4251
4706
  THEN json_extract(sv.manifest_json, '$.outbound')
4252
4707
  ELSE json_extract(vv.manifest_json, '$.outbound') END AS outbound_json
4253
4708
  FROM hostnames h
4254
- LEFT JOIN scopes s ON s.scope_id = h.scope_id
4709
+ JOIN scopes s ON s.scope_id = h.scope_id AND s.tenant_id = h.tenant_id
4710
+ JOIN tenants t ON t.tenant_id = s.tenant_id
4255
4711
  LEFT JOIN vertical_versions vv ON vv.id = s.vertical_version_id
4256
4712
  LEFT JOIN verticals vr ON vr.slug = s.vertical
4257
4713
  LEFT JOIN vertical_versions sv ON sv.id = vr.serving_version_id
4258
- WHERE h.hostname = ? AND h.status = 'active'`)
4714
+ WHERE h.hostname = ? AND h.status = 'active'
4715
+ AND s.status = 'active' AND t.status = 'active'`)
4259
4716
  .get(hostname);
4260
4717
  if (!r)
4261
4718
  return undefined;
@@ -4567,8 +5024,8 @@ export class SqliteScopeHost {
4567
5024
  // promote) is a complete deploy and a rollback promote reaches the running
4568
5025
  // app. D-30's lockstep concern is a SHARED vertical's many tenants, which a
4569
5026
  // private vertical cannot have — this fires for no one else. Snapshots and
4570
- // forks (forked_from set) keep their frontier untouched, and a rebind that
4571
- // crosses a migration digest snapshots first (fork-before-promote, §4).
5027
+ // forks (forked_from set) and all previews keep their frontier untouched. A rebind
5028
+ // that crosses a migration digest snapshots first (fork-before-promote, §4).
4572
5029
  //
4573
5030
  // EXCEPTION (#321): a DISPATCH-BACKED vertical (its version has a
4574
5031
  // `deploymentRef`) serves in place off a stable script. Rebinding a legacy
@@ -4583,7 +5040,7 @@ export class SqliteScopeHost {
4583
5040
  if (owning && owning.ownerTenant !== null && !owning.listed) {
4584
5041
  const bound = this.directory
4585
5042
  .prepare(`SELECT scope_id, tenant_id, vertical_version_id FROM scopes
4586
- WHERE vertical = ? AND tenant_id = ? AND status = 'active' AND forked_from IS NULL`)
5043
+ WHERE vertical = ? AND tenant_id = ? AND status = 'active' AND forked_from IS NULL AND kind != 'preview'`)
4587
5044
  .all(verticalSlug, owning.ownerTenant);
4588
5045
  for (const s of bound) {
4589
5046
  if (s.vertical_version_id === versionId)
@@ -4659,7 +5116,7 @@ export class SqliteScopeHost {
4659
5116
  .prepare('SELECT tenant_id, kind, vertical_version_id FROM scopes WHERE scope_id = ?')
4660
5117
  .get(scopeId);
4661
5118
  if (!scope || scope.tenant_id !== tenantId) {
4662
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
5119
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
4663
5120
  }
4664
5121
  // The refusal this registry exists for. Without it, "a push lands pending"
4665
5122
  // is a convention, and D-30's argument is that we cannot afford conventions
@@ -4698,7 +5155,7 @@ export class SqliteScopeHost {
4698
5155
  .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
4699
5156
  .get(scopeId);
4700
5157
  if (!scope || scope.tenant_id !== tenantId) {
4701
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
5158
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
4702
5159
  }
4703
5160
  this.directory
4704
5161
  .prepare('UPDATE scopes SET provisioned_version_id = ? WHERE scope_id = ?')
@@ -4749,7 +5206,7 @@ export class SqliteScopeHost {
4749
5206
  .prepare('SELECT tenant_id, serving_ref FROM scopes WHERE scope_id = ?')
4750
5207
  .get(scopeId);
4751
5208
  if (!scope || scope.tenant_id !== tenantId) {
4752
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
5209
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
4753
5210
  }
4754
5211
  this.directory
4755
5212
  .prepare('UPDATE scopes SET serving_ref = ? WHERE scope_id = ?')
@@ -4761,7 +5218,7 @@ export class SqliteScopeHost {
4761
5218
  .prepare('SELECT tenant_id, expires_at FROM scopes WHERE scope_id = ?')
4762
5219
  .get(scopeId);
4763
5220
  if (!scope || scope.tenant_id !== tenantId) {
4764
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
5221
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
4765
5222
  }
4766
5223
  this.directory
4767
5224
  .prepare('UPDATE scopes SET expires_at = ? WHERE scope_id = ?')
@@ -4822,6 +5279,26 @@ export class SqliteScopeHost {
4822
5279
  }
4823
5280
  return drained;
4824
5281
  },
5282
+ readExportedEvents: async (actor, tenantId, scopeId, raw) => {
5283
+ // #1705: the producer's half. Parsed here, since HostAdmin is public and the control
5284
+ // plane is not the only caller: a limit or cursor nobody validated must not reach SQL.
5285
+ const input = exportReadInput.parse(raw);
5286
+ this.assertScopeReachable(tenantId, scopeId);
5287
+ const batch = await this.readExports(tenantId, scopeId, input);
5288
+ this.recordAccess(actor, 'readExportedEvents', { tenantId, scopeId }, { consumer: input.consumer, after: input.after, limit: input.limit }, batch.events.length);
5289
+ return batch;
5290
+ },
5291
+ importState: async (actor, tenantId, scopeId) => {
5292
+ this.assertScopeReachable(tenantId, scopeId);
5293
+ const consumes = this.crossVertical.consumes();
5294
+ // A deployment that imports nothing answers without opening the scope. The sweep
5295
+ // asks this of every active scope on every pass that has the phase on.
5296
+ if (consumes.length === 0)
5297
+ return { consumes: [], cursors: [] };
5298
+ const cursors = this.importCursors(this.scopeReadDbFor(tenantId, scopeId));
5299
+ this.recordAccess(actor, 'importState', { tenantId, scopeId }, null, cursors.length);
5300
+ return { consumes, cursors };
5301
+ },
4825
5302
  redrainEvents: async (actor, tenantId, scopeId, input) => {
4826
5303
  // Directory check first, on mark's reasoning: "nothing to reopen" and "you may not
4827
5304
  // address this scope" are different answers.
@@ -4957,7 +5434,7 @@ export class SqliteScopeHost {
4957
5434
  .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
4958
5435
  .get(scopeId);
4959
5436
  if (!scope || scope.tenant_id !== tenantId) {
4960
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
5437
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
4961
5438
  }
4962
5439
  // No PITR on plain SQLite — an empty list, not an error: there is simply
4963
5440
  // nothing to offer. The backup/restore path (#278) is this host's rewind.
@@ -5417,7 +5894,7 @@ export class SqliteScopeHost {
5417
5894
  .prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
5418
5895
  .get(scopeId);
5419
5896
  if (!rec || rec.tenant_id !== tenantId) {
5420
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
5897
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5421
5898
  }
5422
5899
  if (rec.status !== 'archived') {
5423
5900
  throw new Error(`scope ${scopeId} is ${rec.status}, not archived — only an archived scope may be reaped`);
@@ -5995,14 +6472,26 @@ export class SqliteScopeHost {
5995
6472
  status = CASE WHEN status = 'error' THEN 'active' ELSE status END
5996
6473
  WHERE id = ?`)
5997
6474
  .run(now, id);
6475
+ }
6476
+ else {
6477
+ this.directory
6478
+ .prepare(`UPDATE _substrat_connections
6479
+ SET last_error = ?, last_error_at = ?,
6480
+ status = CASE WHEN status = 'revoked' THEN status ELSE 'error' END
6481
+ WHERE id = ?`)
6482
+ .run(outcome.error.slice(0, 2000), now, id);
6483
+ }
6484
+ // #1691: the line is settled — now the data point, off the row's own identity
6485
+ // (never the caller's input), fire-and-forget. With the no-op default there is
6486
+ // nothing to hand it, so the self-host path skips the read entirely.
6487
+ if (this.connectorCalls === noopConnectorCallRecorder)
5998
6488
  return;
6489
+ const row = this.directory
6490
+ .prepare('SELECT tenant_id, vertical, provider FROM _substrat_connections WHERE id = ?')
6491
+ .get(id);
6492
+ if (row) {
6493
+ recordConnectorCall(this.connectorCalls, connectorCallRecord({ tenantId: row.tenant_id, vertical: row.vertical, provider: row.provider }, outcome));
5999
6494
  }
6000
- this.directory
6001
- .prepare(`UPDATE _substrat_connections
6002
- SET last_error = ?, last_error_at = ?,
6003
- status = CASE WHEN status = 'revoked' THEN status ELSE 'error' END
6004
- WHERE id = ?`)
6005
- .run(outcome.error.slice(0, 2000), now, id);
6006
6495
  },
6007
6496
  putConnectorState: async (id, key, value) => {
6008
6497
  this.directory
@@ -6784,8 +7273,8 @@ export class SqliteScopeHost {
6784
7273
  * row's `last_used_at` are the same value — a second clock read could disagree.
6785
7274
  */
6786
7275
  at = this.clock()) {
6787
- // For a connection, system or capability subject this carries THAT id so the type
6788
- // holds — it is not a person, and the event actor below says what it is instead.
7276
+ // For a connection, system, capability or peer-vertical subject this carries THAT id so
7277
+ // the type holds — it is not a person, and the event actor below says what it is instead.
6789
7278
  const principal = subject.id;
6790
7279
  const checker = this.checker;
6791
7280
  const relations = this.relations;
@@ -7245,7 +7734,7 @@ export class SqliteScopeHost {
7245
7734
  assertScopeReachable(tenantId, scopeId) {
7246
7735
  const r = this.directory.prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?').get(scopeId);
7247
7736
  if (!r || r.tenant_id !== tenantId)
7248
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
7737
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
7249
7738
  if (r.status === 'reaped') {
7250
7739
  throw new Error(`scope ${scopeId} is reaped — its storage is gone and cannot be read`);
7251
7740
  }