@substrat-run/adapter-sqlite 0.119.0 → 0.121.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, 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';
4
+ import { accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, exportReadInput, importBatch, importCursorMove, exportsOfManifestJson, importsOfManifestJson, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchRecord, 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, guardSqlLimits, 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, SYSTEM_SWITCHES_BACKFILL_SQL, SYSTEM_SWITCHES_DDL, forgetSystemSwitchesOf, listSystemSwitchRecords, recordSystemSwitchedOff, recordSystemSwitchedOn, restoreSystemSwitchRecord, switchedOffModulesOf, systemSwitchRecordsOf, systemSwitchesTableExists, withRecorded, 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, moduleMigrations, 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, OUTBOX_MARK_SQL, exportedSinceQuery, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, 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. */
@@ -512,7 +512,7 @@ export class SqliteScopeHost {
512
512
  mkdirSync(this.dir, { recursive: true });
513
513
  this.directory = new Database(join(this.dir, '_directory.sqlite'));
514
514
  this.directory.pragma('journal_mode = WAL');
515
- this.applyDirectorySchema();
515
+ this.ensureDirectorySchema();
516
516
  this.loadRoles();
517
517
  this.checker =
518
518
  options.checker ??
@@ -968,6 +968,7 @@ export class SqliteScopeHost {
968
968
  PRIMARY KEY (scope_id, subject_id)
969
969
  );
970
970
  ${IMPERSONATION_DDL}
971
+ ${SYSTEM_SWITCHES_DDL}
971
972
  CREATE TABLE IF NOT EXISTS _substrat_admin_log (
972
973
  id TEXT PRIMARY KEY,
973
974
  actor TEXT NOT NULL,
@@ -1101,6 +1102,25 @@ export class SqliteScopeHost {
1101
1102
  `);
1102
1103
  this.ensureDirectoryColumns();
1103
1104
  }
1105
+ /**
1106
+ * `applyDirectorySchema`, plus the backfill of the schedule switch's record (#1674) from
1107
+ * the admin log. Whether the table exists is asked BEFORE the DDL creates it, so the
1108
+ * backfill runs only on an application that creates the table: once on a directory that
1109
+ * never had it, and again after a directory restore whose dump predates it (from that
1110
+ * dump's own log). A directory that already holds the table is never backfilled over.
1111
+ *
1112
+ * One transaction (Copilot review, #1674): the table and its backfill commit together, so
1113
+ * a backfill that fails leaves no table behind, and the next start tries again rather than
1114
+ * reading an empty table as already migrated.
1115
+ */
1116
+ ensureDirectorySchema() {
1117
+ this.directory.transaction(() => {
1118
+ const switchRecordIsNew = !systemSwitchesTableExists(switchSqlOf(this.directory));
1119
+ this.applyDirectorySchema();
1120
+ if (switchRecordIsNew)
1121
+ this.directory.exec(SYSTEM_SWITCHES_BACKFILL_SQL);
1122
+ })();
1123
+ }
1104
1124
  registerExecutor(id, eventType, handler, retry) {
1105
1125
  if (this.executors.has(id))
1106
1126
  throw new Error(`executor '${id}' is already registered`);
@@ -1303,7 +1323,9 @@ export class SqliteScopeHost {
1303
1323
  this.crossVertical.register(manifest, registration.imports);
1304
1324
  this.modules.set(manifest.id, {
1305
1325
  id: manifest.id,
1306
- migrations: [...migrations, ...searchMigrations, ...listMigrations],
1326
+ // The order the kernel writes once (#1677): authored, then search, then list indexes.
1327
+ // The two derived sets are still computed above, for the duplicate-version refusals.
1328
+ migrations: moduleMigrations(registration),
1307
1329
  consumers,
1308
1330
  schedules: manifest.schedules ?? [],
1309
1331
  freshness: manifest.freshness ?? [],
@@ -1484,12 +1506,38 @@ export class SqliteScopeHost {
1484
1506
  const seat = seatScopeTuple(peer.subject, peer.relation, peer.object, null);
1485
1507
  rt.db.prepare(seat.sql).run(...seat.params);
1486
1508
  }
1509
+ // #1674: re-assert the recorded OFF positions after the seat, in the same turn
1510
+ // (`system-switch-record.ts`).
1511
+ this.reassertSwitchesInTurn(rt, actor, input.tenantId, input.scopeId);
1487
1512
  });
1488
1513
  // Audit a real provision only; an idempotent re-provision changed nothing.
1489
1514
  if (!existing) {
1490
1515
  this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
1491
1516
  }
1492
1517
  }
1518
+ /**
1519
+ * `HostAdmin.reassertSystemSwitches`' body (#1674), run inside a turn the caller already
1520
+ * holds on the scope's actor: every module the directory records OFF on the scope is
1521
+ * switched off again. Already off answers `changed: false` and writes nothing, and a
1522
+ * module the scope does not hold is left alone. Audited only when something moved.
1523
+ */
1524
+ reassertSwitchesInTurn(rt, actor, tenantId, scopeId) {
1525
+ const at = new Date().toISOString();
1526
+ return switchedOffModulesOf(switchSqlOf(this.directory), tenantId, scopeId).map((moduleId) => {
1527
+ const outcome = rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), { moduleId, scopeId, to: 'off', at }))();
1528
+ if (outcome.changed) {
1529
+ this.recordAdmin(actor, 'reassertSystemSwitch', { tenantId, scopeId }, null, {
1530
+ operationId: ulid(),
1531
+ moduleId,
1532
+ schedules: 'off',
1533
+ phase: 'applied',
1534
+ changed: true,
1535
+ permissions: outcome.permissions,
1536
+ });
1537
+ }
1538
+ return { moduleId, held: outcome.held, changed: outcome.changed };
1539
+ });
1540
+ }
1493
1541
  async provisionTenantStore(actor, input) {
1494
1542
  // Fail closed on an unknown/inactive tenant, exactly as provisionScope does: a store
1495
1543
  // for a tenant that does not exist is the same "FK string" hole (§4.1). A store is
@@ -2031,14 +2079,14 @@ export class SqliteScopeHost {
2031
2079
  // Restore never creates a scope (that is importScope) — an unknown target fails closed.
2032
2080
  const existing = await this.admin.getScopeRecord(actor, tenantId, scopeId);
2033
2081
  if (!existing)
2034
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
2082
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2035
2083
  await this.loadDump(tenantId, scopeId, dump.tables);
2036
2084
  this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
2037
2085
  }
2038
2086
  async snapshotScope(actor, tenantId, scopeId, opts) {
2039
2087
  const source = await this.admin.getScopeRecord(actor, tenantId, scopeId);
2040
2088
  if (!source)
2041
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
2089
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2042
2090
  const dump = await this.admin.exportScope(actor, tenantId, scopeId);
2043
2091
  const snapshotId = ulid();
2044
2092
  await this.importScope(actor, {
@@ -2063,7 +2111,7 @@ export class SqliteScopeHost {
2063
2111
  // #509 ask (b)). A PRIMARY scope keeps the platform's tombstone-only rule (archive it).
2064
2112
  const rec = await this.admin.getScopeRecord(actor, tenantId, scopeId);
2065
2113
  if (!rec)
2066
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
2114
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2067
2115
  if (!rec.forkedFrom && rec.kind !== 'preview') {
2068
2116
  throw new Error(`scope ${scopeId} is not a fork or preview — only previews may be deleted; ` +
2069
2117
  `archive a primary scope instead`);
@@ -2078,6 +2126,7 @@ export class SqliteScopeHost {
2078
2126
  // orphaned bytes with no record (the §9 hazard).
2079
2127
  this.directory.prepare('DELETE FROM hostnames WHERE scope_id = ?').run(scopeId);
2080
2128
  rmSync(join(this.dir, `${tenantId}__${scopeId}.sqlite`), { force: true });
2129
+ forgetSystemSwitchesOf(switchSqlOf(this.directory), scopeId);
2081
2130
  this.directory.prepare('DELETE FROM scopes WHERE scope_id = ?').run(scopeId);
2082
2131
  this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
2083
2132
  forkedFrom: rec.forkedFrom,
@@ -2091,7 +2140,7 @@ export class SqliteScopeHost {
2091
2140
  .prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
2092
2141
  .get(scopeId);
2093
2142
  if (!row || row.tenant_id !== tenantId) {
2094
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
2143
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2095
2144
  }
2096
2145
  // Lifecycle gates (control-plane.md §4.1/§4.2), all the K-3 fail-closed path.
2097
2146
  // The tenant record is mandatory: every scope has a tenant with a status
@@ -2152,7 +2201,7 @@ export class SqliteScopeHost {
2152
2201
  if (!scope || scope.tenant_id !== conn.tenant_id) {
2153
2202
  // Same wording as the principal path: a scope in another tenant is
2154
2203
  // indistinguishable from one that does not exist.
2155
- throw new Error(`unknown scope for connection: ${scopeId}`);
2204
+ throw substratError('not_found', `unknown scope for connection: ${scopeId}`);
2156
2205
  }
2157
2206
  if (scope.vertical !== conn.vertical) {
2158
2207
  throw new Error(`connection ${connectionId} is for vertical '${conn.vertical}' and scope ${scopeId} ` +
@@ -2180,7 +2229,7 @@ export class SqliteScopeHost {
2180
2229
  .prepare('SELECT tenant_id, vertical, status FROM scopes WHERE scope_id = ?')
2181
2230
  .get(scopeId);
2182
2231
  if (!scope || scope.tenant_id !== conn.tenant_id) {
2183
- throw new Error(`unknown scope for connection: ${scopeId}`);
2232
+ throw substratError('not_found', `unknown scope for connection: ${scopeId}`);
2184
2233
  }
2185
2234
  if (scope.vertical !== conn.vertical) {
2186
2235
  throw new Error(`connection ${connectionId} is for vertical '${conn.vertical}' and scope ${scopeId} ` +
@@ -2219,7 +2268,7 @@ export class SqliteScopeHost {
2219
2268
  .prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
2220
2269
  .get(scopeId);
2221
2270
  if (!scope || scope.tenant_id !== tenantId) {
2222
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
2271
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2223
2272
  }
2224
2273
  if (scope.status !== 'active') {
2225
2274
  throw new Error(`scope not active (status: ${scope.status}): ${scopeId}`);
@@ -2240,7 +2289,7 @@ export class SqliteScopeHost {
2240
2289
  .prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
2241
2290
  .get(scopeId);
2242
2291
  if (!scope || scope.tenant_id !== tenantId) {
2243
- throw new Error(`unknown scope: ${scopeId}`);
2292
+ throw substratError('not_found', `unknown scope: ${scopeId}`);
2244
2293
  }
2245
2294
  if (scope.status !== 'active') {
2246
2295
  throw new Error(`scope not active (status: ${scope.status}): ${scopeId}`);
@@ -2411,6 +2460,34 @@ export class SqliteScopeHost {
2411
2460
  return result;
2412
2461
  });
2413
2462
  }
2463
+ /**
2464
+ * A stored version's manifest, unaudited: the platform reading its own code metadata (#1705).
2465
+ * `undefined` when the registry has no such version (under `slug`, when one is named).
2466
+ */
2467
+ manifestJsonOf(versionId, slug) {
2468
+ const row = (slug === undefined
2469
+ ? this.directory.prepare('SELECT manifest_json FROM vertical_versions WHERE id = ?').get(versionId)
2470
+ : this.directory
2471
+ .prepare('SELECT manifest_json FROM vertical_versions WHERE id = ? AND vertical_slug = ?')
2472
+ .get(versionId, slug));
2473
+ return row ? row.manifest_json : undefined;
2474
+ }
2475
+ /** #1705 PR 3: the promote gate's question, over two stored versions (`exportBreaksOf`). */
2476
+ exportBreaksBetween(actor, producer, outgoingId, incomingId) {
2477
+ return exportBreaksOf({
2478
+ admin: this.admin,
2479
+ actor,
2480
+ producer,
2481
+ outgoing: exportsOfManifestJson(this.manifestJsonOf(outgoingId)),
2482
+ incoming: exportsOfManifestJson(this.manifestJsonOf(incomingId)),
2483
+ readImports: async (slug, versionId) => {
2484
+ const manifest = this.manifestJsonOf(versionId, slug);
2485
+ if (manifest === undefined)
2486
+ throw substratError('not_found', `unknown version ${versionId} for vertical '${slug}'`);
2487
+ return importsOfManifestJson(manifest);
2488
+ },
2489
+ });
2490
+ }
2414
2491
  /**
2415
2492
  * The lifecycle gate the capability doors share (#1672): the scope must exist in this
2416
2493
  * tenant, the tenant must be active, the scope must be active — K-3's fail-closed pair
@@ -2826,6 +2903,11 @@ export class SqliteScopeHost {
2826
2903
  // harness only after the enqueue task resolves — i.e. after COMMIT — so a
2827
2904
  // rolled-back intent never signals.
2828
2905
  const signals = { platformRequests: 0 };
2906
+ // #1705 PR 2: exported-type rows this invoke (and its consumers' tail) committed, for
2907
+ // `onExportedEvents`. Counted only in a deployment that exports something, and never
2908
+ // for a read-only session, which commits nothing.
2909
+ const exportTypes = session?.mode === 'read-only' ? [] : this.crossVertical.exportTypes();
2910
+ let exported = 0;
2829
2911
  // #129: read inside the transaction, reported after it commits — a tag
2830
2912
  // handed out for writes that were rolled back is a tag nobody may hold.
2831
2913
  let committedVersion;
@@ -2839,6 +2921,9 @@ export class SqliteScopeHost {
2839
2921
  // `finally` so a later consumer carries none rather than the last caller's,
2840
2922
  // which is the leak `causedBy` had to be moved off the host to avoid.
2841
2923
  rt.invocationId = invokeOptions?.invocationId ?? null;
2924
+ // The outbox's insertion mark, inside the actor task so nothing else writes between it
2925
+ // and this invoke (`OUTBOX_MARK_SQL` says why rowid and not the id).
2926
+ const exportMark = exportTypes.length > 0 ? Number(rt.db.prepare(OUTBOX_MARK_SQL).get().mark) : null;
2842
2927
  try {
2843
2928
  // #1672: the capability door's session, re-resolved on EVERY invoke and inside the
2844
2929
  // actor task — so nothing can revoke between this read and the transaction below.
@@ -2986,6 +3071,10 @@ export class SqliteScopeHost {
2986
3071
  await this.dispatch(rt, rt.invocationId);
2987
3072
  await this.dispatchExecutors(rt, rt.invocationId);
2988
3073
  }
3074
+ if (exportMark !== null && !replayed) {
3075
+ const q = exportedSinceQuery(exportTypes, exportMark);
3076
+ exported = Number(rt.db.prepare(q.sql).get(...q.params).n);
3077
+ }
2989
3078
  return structuredClone(result);
2990
3079
  }
2991
3080
  finally {
@@ -2999,6 +3088,8 @@ export class SqliteScopeHost {
2999
3088
  });
3000
3089
  if (signals.platformRequests > 0)
3001
3090
  options?.onPlatformRequests?.(signals.platformRequests);
3091
+ if (exported > 0)
3092
+ options?.onExportedEvents?.(exported);
3002
3093
  if (committedVersion !== undefined)
3003
3094
  invokeOptions?.onEntityVersion?.(committedVersion);
3004
3095
  if (replayed)
@@ -3401,7 +3492,7 @@ export class SqliteScopeHost {
3401
3492
  .get(scopeId);
3402
3493
  // K-3: a scope under another tenant is indistinguishable from one that does not exist.
3403
3494
  if (!row || row.tenant_id !== tenantId) {
3404
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
3495
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3405
3496
  }
3406
3497
  // `provisioning` allowed — a scope stuck there on a failed migration is a
3407
3498
  // sweep target. Suspended/archived are deliberate states; not disturbed.
@@ -3694,7 +3785,7 @@ export class SqliteScopeHost {
3694
3785
  .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
3695
3786
  .get(scopeId);
3696
3787
  if (!rec || rec.tenant_id !== tenantId) {
3697
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
3788
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3698
3789
  }
3699
3790
  }
3700
3791
  /**
@@ -4009,33 +4100,40 @@ export class SqliteScopeHost {
4009
4100
  .prepare('SELECT tenant_id, status, vertical FROM scopes WHERE scope_id = ?')
4010
4101
  .get(scopeId);
4011
4102
  if (!row || row.tenant_id !== tenantId) {
4012
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
4103
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4013
4104
  }
4014
4105
  if (!from.includes(row.status)) {
4015
4106
  throw new Error(`illegal scope transition for ${action}: ${row.status} → ${to} ` +
4016
4107
  `(allowed from: ${from.join('|')})`);
4017
4108
  }
4018
- // Stamp/clear archived_at so the reap sweep can age scopes (§4.4). Entering
4019
- // `archived` records when; `unarchive` (→ active, a restore) clears it so a later
4020
- // re-archive dates from the new event; `reaped` keeps it as terminal history.
4021
- if (to === 'archived') {
4022
- this.directory
4023
- .prepare('UPDATE scopes SET status = ?, archived_at = ? WHERE scope_id = ?')
4024
- .run(to, new Date().toISOString(), scopeId);
4025
- }
4026
- else if (to === 'active') {
4027
- this.directory
4028
- .prepare('UPDATE scopes SET status = ?, archived_at = NULL WHERE scope_id = ?')
4029
- .run(to, scopeId);
4030
- }
4031
- else {
4032
- this.directory.prepare('UPDATE scopes SET status = ? WHERE scope_id = ?').run(to, scopeId);
4033
- }
4034
- // The audit target carries the scope's vertical (control-plane.md §4.4:
4035
- // "vertical stays null until §4.2 lifecycle actions that name one"). It is
4036
- // read from the scope rather than passed in, so the trail cannot disagree
4037
- // with the directory about which deployment the action touched.
4038
- this.recordAdmin(actor, action, { tenantId, scopeId, vertical: row.vertical }, { status: row.status }, { status: to, ...afterExtra });
4109
+ // One directory transaction: the status, the audit row and — on a reap — the scope's
4110
+ // switch records (#1674). Reaped is terminal, so a cleanup that failed after the flip
4111
+ // could never be retried; together, a failure leaves the scope where it was.
4112
+ this.directory.transaction(() => {
4113
+ // Stamp/clear archived_at so the reap sweep can age scopes (§4.4). Entering
4114
+ // `archived` records when; `unarchive` (→ active, a restore) clears it so a later
4115
+ // re-archive dates from the new event; `reaped` keeps it as terminal history.
4116
+ if (to === 'archived') {
4117
+ this.directory
4118
+ .prepare('UPDATE scopes SET status = ?, archived_at = ? WHERE scope_id = ?')
4119
+ .run(to, new Date().toISOString(), scopeId);
4120
+ }
4121
+ else if (to === 'active') {
4122
+ this.directory
4123
+ .prepare('UPDATE scopes SET status = ?, archived_at = NULL WHERE scope_id = ?')
4124
+ .run(to, scopeId);
4125
+ }
4126
+ else {
4127
+ this.directory.prepare('UPDATE scopes SET status = ? WHERE scope_id = ?').run(to, scopeId);
4128
+ }
4129
+ if (to === 'reaped')
4130
+ forgetSystemSwitchesOf(switchSqlOf(this.directory), scopeId);
4131
+ // The audit target carries the scope's vertical (control-plane.md §4.4:
4132
+ // "vertical stays null until §4.2 lifecycle actions that name one"). It is
4133
+ // read from the scope rather than passed in, so the trail cannot disagree
4134
+ // with the directory about which deployment the action touched.
4135
+ this.recordAdmin(actor, action, { tenantId, scopeId, vertical: row.vertical }, { status: row.status }, { status: to, ...afterExtra });
4136
+ })();
4039
4137
  };
4040
4138
  const writeTenantTuple = (tenantId, subject, relation, object, expiresAt) => this.directory
4041
4139
  .prepare(`INSERT OR REPLACE INTO _substrat_tenant_tuples
@@ -4095,6 +4193,44 @@ export class SqliteScopeHost {
4095
4193
  const target = { tenantId, scopeId };
4096
4194
  const base = { operationId, moduleId: input.moduleId, schedules: to };
4097
4195
  this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
4196
+ // The directory's record (#1674): ON before the scope moves, OFF after it held — see
4197
+ // `recordSystemSwitchedOn` for why that order is the safe one.
4198
+ const at = new Date().toISOString();
4199
+ const directorySql = switchSqlOf(this.directory);
4200
+ const record = { tenantId, scopeId, moduleId: input.moduleId, actor, reason: input.reason, operationId, at };
4201
+ const errorOf = (err) => (err instanceof Error ? err.message : String(err));
4202
+ let prior = null;
4203
+ if (to === 'on') {
4204
+ try {
4205
+ prior = recordSystemSwitchedOn(directorySql, record);
4206
+ }
4207
+ catch (err) {
4208
+ // Nothing has moved: fail the call here, audited — the Cloudflare adapter's posture.
4209
+ try {
4210
+ this.recordAdmin(actor, action, target, null, { ...base, phase: 'failed', error: errorOf(err) });
4211
+ }
4212
+ catch {
4213
+ // Best effort: the original error is what the caller must see.
4214
+ }
4215
+ throw err;
4216
+ }
4217
+ }
4218
+ /** A record write after the scope moved (review #5): retried once, never swallowed. */
4219
+ const recordWrite = (write) => {
4220
+ try {
4221
+ write();
4222
+ return null;
4223
+ }
4224
+ catch {
4225
+ try {
4226
+ write();
4227
+ return null;
4228
+ }
4229
+ catch (err) {
4230
+ return errorOf(err);
4231
+ }
4232
+ }
4233
+ };
4098
4234
  let outcome;
4099
4235
  try {
4100
4236
  // A turn on the scope actor (#1666 review), exactly as the job store (#1577): `invoke`
@@ -4103,19 +4239,16 @@ export class SqliteScopeHost {
4103
4239
  // with it — after this verb had already audited `applied`. `turn` is re-entrant, so a
4104
4240
  // caller already inside one of this scope's tasks joins it rather than deadlocking.
4105
4241
  const rt = this.runtime(tenantId, scopeId);
4106
- outcome = await rt.actor.turn(() => rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), {
4107
- moduleId: input.moduleId,
4108
- scopeId,
4109
- to,
4110
- at: new Date().toISOString(),
4111
- }))());
4242
+ outcome = await rt.actor.turn(() => rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), { moduleId: input.moduleId, scopeId, to, at }))());
4112
4243
  }
4113
4244
  catch (err) {
4245
+ const recordError = recordWrite(() => restoreSystemSwitchRecord(directorySql, record, prior));
4114
4246
  try {
4115
4247
  this.recordAdmin(actor, action, target, null, {
4116
4248
  ...base,
4117
4249
  phase: 'failed',
4118
- error: err instanceof Error ? err.message : String(err),
4250
+ error: errorOf(err),
4251
+ ...(recordError ? { recordError } : {}),
4119
4252
  });
4120
4253
  }
4121
4254
  catch {
@@ -4123,15 +4256,30 @@ export class SqliteScopeHost {
4123
4256
  }
4124
4257
  throw err;
4125
4258
  }
4259
+ // OFF is recorded once the scope held it. A refused ON moved nothing, so its record
4260
+ // write is undone: left `on`, the next reconcile of a wiped scope would leave the
4261
+ // module running.
4262
+ const recordError = to === 'off' && outcome.held
4263
+ ? recordWrite(() => recordSystemSwitchedOff(directorySql, record))
4264
+ : to === 'on' && !outcome.held
4265
+ ? recordWrite(() => restoreSystemSwitchRecord(directorySql, record, prior))
4266
+ : null;
4126
4267
  this.recordAdmin(actor, action, target, null, {
4127
4268
  ...base,
4128
4269
  phase: outcome.held ? 'applied' : 'refused',
4129
4270
  changed: outcome.changed,
4130
4271
  permissions: outcome.permissions,
4272
+ ...(recordError ? { recordError } : {}),
4131
4273
  });
4132
4274
  if (!outcome.held) {
4133
4275
  throw substratError('not_found', `scope ${scopeId} holds no system grant for module '${input.moduleId}' — nothing to switch ${to} ` +
4134
- `(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')`);
4276
+ `(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')` +
4277
+ (recordError ? `; and its directory record could not be put back (${recordError})` : ''));
4278
+ }
4279
+ if (recordError) {
4280
+ throw substratError('unavailable', `module '${input.moduleId}' is switched ${to} on scope ${scopeId}, but the directory's record of it ` +
4281
+ `could not be written (${recordError}) — repeat the call to record it; a wipe of this scope would ` +
4282
+ `otherwise lose the switch`);
4135
4283
  }
4136
4284
  return {
4137
4285
  operationId,
@@ -4267,10 +4415,12 @@ export class SqliteScopeHost {
4267
4415
  const states = await rt.actor.turn(() => systemGrantsStatus(switchSqlOf(rt.db), new Date().toISOString()));
4268
4416
  const offModules = new Set(states.filter((s) => s.schedules === 'off').map((s) => s.moduleId));
4269
4417
  const explanations = offModules.size > 0 ? lastSwitchedOff(tenantId, scopeId, offModules) : new Map();
4270
- const result = states.map((s) => ({
4418
+ const recorded = systemSwitchRecordsOf(switchSqlOf(this.directory), tenantId, scopeId);
4419
+ const result = withRecorded(states, recorded).map((s) => ({
4271
4420
  moduleId: s.moduleId,
4272
4421
  schedules: s.schedules,
4273
4422
  switchedOff: explanations.get(s.moduleId) ?? null,
4423
+ recorded: s.recorded,
4274
4424
  }));
4275
4425
  // K-24: reading the switch's position and any live incident reason is itself
4276
4426
  // access-logged, the same as every other HostAdmin read.
@@ -4425,7 +4575,7 @@ export class SqliteScopeHost {
4425
4575
  .prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
4426
4576
  .get(grant.node.scopeId);
4427
4577
  if (!scope || scope.tenant_id !== grant.node.tenantId) {
4428
- throw new Error(`unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
4578
+ throw substratError('not_found', `unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
4429
4579
  }
4430
4580
  if (scope.vertical !== conn.vertical) {
4431
4581
  throw new Error(`connection ${grant.connectionId} is for vertical '${conn.vertical}' and scope ` +
@@ -4501,6 +4651,18 @@ export class SqliteScopeHost {
4501
4651
  // #1674: the switch's status read — same gate, the admin log to explain an `off`
4502
4652
  // entry. Nothing to delegate here: the pure adapter's scope storage IS the store.
4503
4653
  systemGrantsStatus: systemGrantsStatusOf,
4654
+ // #1674: the switch's directory record — the fleet read, and the re-assert (whose body
4655
+ // is `reassertSwitchesInTurn`; this is the scope check and the turn around it).
4656
+ listSystemSwitches: async (actor, filter) => {
4657
+ const rows = listSystemSwitchRecords(switchSqlOf(this.directory), filter);
4658
+ this.recordAccess(actor, 'listSystemSwitches', { tenantId: filter?.tenantId ?? null }, filter ?? null, rows.length);
4659
+ return rows.map((r) => systemSwitchRecord.parse(r));
4660
+ },
4661
+ reassertSystemSwitches: async (actor, node) => {
4662
+ this.assertScope(node.tenantId, node.scopeId);
4663
+ const rt = this.runtime(node.tenantId, node.scopeId);
4664
+ return rt.actor.turn(() => this.reassertSwitchesInTurn(rt, actor, node.tenantId, node.scopeId));
4665
+ },
4504
4666
  peerGrantsStatus: peerGrantsStatusOf,
4505
4667
  // #1672 — the platform's two capability verbs, both a turn on the scope actor
4506
4668
  // (#1678: every scope-level admin write takes one, so none lands mid-invoke inside a
@@ -4557,7 +4719,7 @@ export class SqliteScopeHost {
4557
4719
  .prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
4558
4720
  .get(parsed.scopeId);
4559
4721
  if (!scope || scope.tenant_id !== parsed.tenantId) {
4560
- throw new Error(`unknown scope ${parsed.scopeId} in tenant ${parsed.tenantId}`);
4722
+ throw substratError('not_found', `unknown scope ${parsed.scopeId} in tenant ${parsed.tenantId}`);
4561
4723
  }
4562
4724
  const existing = this.directory
4563
4725
  .prepare(`SELECT h.scope_id AS scope_id, s.status AS scope_status
@@ -4690,6 +4852,8 @@ export class SqliteScopeHost {
4690
4852
  resolveHostname: async (raw) => {
4691
4853
  // The router's per-request read. No actor, not logged — same carve-out as
4692
4854
  // resolveIdentity (K-24): this is a machine path, not a staff read.
4855
+ // Like readRoute in Cloudflare, require an active binding, correctly paired
4856
+ // active scope and active owning tenant. Refusal leaves the binding intact.
4693
4857
  const hostname = raw.toLowerCase();
4694
4858
  // Join the scope's dispatch script in the same read (orchestration.md §5.4).
4695
4859
  // A scope on the stable serving script (#286) routes THERE; falls back to the
@@ -4704,11 +4868,13 @@ export class SqliteScopeHost {
4704
4868
  THEN json_extract(sv.manifest_json, '$.outbound')
4705
4869
  ELSE json_extract(vv.manifest_json, '$.outbound') END AS outbound_json
4706
4870
  FROM hostnames h
4707
- LEFT JOIN scopes s ON s.scope_id = h.scope_id
4871
+ JOIN scopes s ON s.scope_id = h.scope_id AND s.tenant_id = h.tenant_id
4872
+ JOIN tenants t ON t.tenant_id = s.tenant_id
4708
4873
  LEFT JOIN vertical_versions vv ON vv.id = s.vertical_version_id
4709
4874
  LEFT JOIN verticals vr ON vr.slug = s.vertical
4710
4875
  LEFT JOIN vertical_versions sv ON sv.id = vr.serving_version_id
4711
- WHERE h.hostname = ? AND h.status = 'active'`)
4876
+ WHERE h.hostname = ? AND h.status = 'active'
4877
+ AND s.status = 'active' AND t.status = 'active'`)
4712
4878
  .get(hostname);
4713
4879
  if (!r)
4714
4880
  return undefined;
@@ -4974,6 +5140,18 @@ export class SqliteScopeHost {
4974
5140
  note,
4975
5141
  });
4976
5142
  },
5143
+ promotionImpact: async (actor, verticalSlug, channel, versionId) => {
5144
+ const incoming = readVersion(versionId);
5145
+ if (!incoming || incoming.verticalSlug !== verticalSlug) {
5146
+ throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
5147
+ }
5148
+ const current = this.directory
5149
+ .prepare('SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = ?')
5150
+ .get(verticalSlug, channel);
5151
+ const breaks = current ? await this.exportBreaksBetween(actor, verticalSlug, current.version_id, versionId) : [];
5152
+ this.recordAccess(actor, 'promotionImpact', { tenantId: null }, { verticalSlug, channel, versionId }, breaks.length);
5153
+ return breaks;
5154
+ },
4977
5155
  promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
4978
5156
  const incoming = readVersion(versionId);
4979
5157
  if (!incoming)
@@ -5001,6 +5179,12 @@ export class SqliteScopeHost {
5001
5179
  throw new Error(`promotion changes migrations (${outgoing.migrationDigest} → ` +
5002
5180
  `${incoming.migrationDigest}) — acknowledge it explicitly to promote`);
5003
5181
  }
5182
+ // #1705 PR 3: an export an installed consumer imports, dropped or re-versioned.
5183
+ if (!ack.exportBreak) {
5184
+ const breaks = await this.exportBreaksBetween(actor, verticalSlug, outgoing.id, incoming.id);
5185
+ if (breaks.length > 0)
5186
+ throw substratError('precondition_failed', exportBreakRefusal(breaks));
5187
+ }
5004
5188
  }
5005
5189
  const promotedAt = new Date().toISOString();
5006
5190
  this.directory
@@ -5020,8 +5204,8 @@ export class SqliteScopeHost {
5020
5204
  // promote) is a complete deploy and a rollback promote reaches the running
5021
5205
  // app. D-30's lockstep concern is a SHARED vertical's many tenants, which a
5022
5206
  // private vertical cannot have — this fires for no one else. Snapshots and
5023
- // forks (forked_from set) keep their frontier untouched, and a rebind that
5024
- // crosses a migration digest snapshots first (fork-before-promote, §4).
5207
+ // forks (forked_from set) and all previews keep their frontier untouched. A rebind
5208
+ // that crosses a migration digest snapshots first (fork-before-promote, §4).
5025
5209
  //
5026
5210
  // EXCEPTION (#321): a DISPATCH-BACKED vertical (its version has a
5027
5211
  // `deploymentRef`) serves in place off a stable script. Rebinding a legacy
@@ -5036,7 +5220,7 @@ export class SqliteScopeHost {
5036
5220
  if (owning && owning.ownerTenant !== null && !owning.listed) {
5037
5221
  const bound = this.directory
5038
5222
  .prepare(`SELECT scope_id, tenant_id, vertical_version_id FROM scopes
5039
- WHERE vertical = ? AND tenant_id = ? AND status = 'active' AND forked_from IS NULL`)
5223
+ WHERE vertical = ? AND tenant_id = ? AND status = 'active' AND forked_from IS NULL AND kind != 'preview'`)
5040
5224
  .all(verticalSlug, owning.ownerTenant);
5041
5225
  for (const s of bound) {
5042
5226
  if (s.vertical_version_id === versionId)
@@ -5112,7 +5296,7 @@ export class SqliteScopeHost {
5112
5296
  .prepare('SELECT tenant_id, kind, vertical_version_id FROM scopes WHERE scope_id = ?')
5113
5297
  .get(scopeId);
5114
5298
  if (!scope || scope.tenant_id !== tenantId) {
5115
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
5299
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
5116
5300
  }
5117
5301
  // The refusal this registry exists for. Without it, "a push lands pending"
5118
5302
  // is a convention, and D-30's argument is that we cannot afford conventions
@@ -5151,7 +5335,7 @@ export class SqliteScopeHost {
5151
5335
  .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
5152
5336
  .get(scopeId);
5153
5337
  if (!scope || scope.tenant_id !== tenantId) {
5154
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
5338
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
5155
5339
  }
5156
5340
  this.directory
5157
5341
  .prepare('UPDATE scopes SET provisioned_version_id = ? WHERE scope_id = ?')
@@ -5202,7 +5386,7 @@ export class SqliteScopeHost {
5202
5386
  .prepare('SELECT tenant_id, serving_ref FROM scopes WHERE scope_id = ?')
5203
5387
  .get(scopeId);
5204
5388
  if (!scope || scope.tenant_id !== tenantId) {
5205
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
5389
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
5206
5390
  }
5207
5391
  this.directory
5208
5392
  .prepare('UPDATE scopes SET serving_ref = ? WHERE scope_id = ?')
@@ -5214,7 +5398,7 @@ export class SqliteScopeHost {
5214
5398
  .prepare('SELECT tenant_id, expires_at FROM scopes WHERE scope_id = ?')
5215
5399
  .get(scopeId);
5216
5400
  if (!scope || scope.tenant_id !== tenantId) {
5217
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
5401
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
5218
5402
  }
5219
5403
  this.directory
5220
5404
  .prepare('UPDATE scopes SET expires_at = ? WHERE scope_id = ?')
@@ -5295,6 +5479,47 @@ export class SqliteScopeHost {
5295
5479
  this.recordAccess(actor, 'importState', { tenantId, scopeId }, null, cursors.length);
5296
5480
  return { consumes, cursors };
5297
5481
  },
5482
+ moveImportCursor: async (actor, tenantId, scopeId, raw) => {
5483
+ // #1705 PR 3: the replay lever. Parsed at the door: the acknowledgement literal is part
5484
+ // of the input, and a move without it never reaches the store.
5485
+ const move = importCursorMove.parse(raw);
5486
+ this.assertScopeReachable(tenantId, scopeId);
5487
+ const rec = this.directory.prepare('SELECT vertical FROM scopes WHERE scope_id = ?').get(scopeId);
5488
+ const source = await importCursorSourceOf((t, v) => this.admin.resolveVerticalInstance(t, v), { tenantId, scopeId, vertical: rec.vertical }, move.from);
5489
+ const replayId = ulid();
5490
+ const target = { tenantId, scopeId };
5491
+ const base = { replayId, mode: move.mode, from: move.from, source: source.scopeId };
5492
+ this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, phase: 'intent', reason: move.reason });
5493
+ let moved;
5494
+ try {
5495
+ const rt = this.runtime(tenantId, scopeId);
5496
+ const now = Date.parse(this.clock());
5497
+ // One turn on the scope actor, which is where `deliverToPeer` runs: a delivery on this
5498
+ // edge is either wholly before the move or refused by its compare-and-set after it.
5499
+ moved = await rt.actor.turn(() => rt.db.transaction(() => moveImportCursor(switchSqlOf(rt.db), { move, source, replayId, now, imports: this.crossVertical.consumes() }))());
5500
+ }
5501
+ catch (err) {
5502
+ try {
5503
+ this.recordAdmin(actor, 'moveImportCursor', target, null, {
5504
+ ...base,
5505
+ phase: 'failed',
5506
+ error: err instanceof Error ? err.message : String(err),
5507
+ });
5508
+ }
5509
+ catch {
5510
+ // Best effort: the original error is what the caller must see.
5511
+ }
5512
+ throw err;
5513
+ }
5514
+ this.recordAdmin(actor, 'moveImportCursor', target, null, {
5515
+ ...base,
5516
+ phase: 'applied',
5517
+ previous: moved.previous,
5518
+ cursor: moved.cursor,
5519
+ archived: moved.archived,
5520
+ });
5521
+ return moved;
5522
+ },
5298
5523
  redrainEvents: async (actor, tenantId, scopeId, input) => {
5299
5524
  // Directory check first, on mark's reasoning: "nothing to reopen" and "you may not
5300
5525
  // address this scope" are different answers.
@@ -5430,7 +5655,7 @@ export class SqliteScopeHost {
5430
5655
  .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
5431
5656
  .get(scopeId);
5432
5657
  if (!scope || scope.tenant_id !== tenantId) {
5433
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
5658
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
5434
5659
  }
5435
5660
  // No PITR on plain SQLite — an empty list, not an error: there is simply
5436
5661
  // nothing to offer. The backup/restore path (#278) is this host's rewind.
@@ -5850,7 +6075,7 @@ export class SqliteScopeHost {
5850
6075
  })();
5851
6076
  // Carry a copy taken before a directory migration forward to the running
5852
6077
  // code's shape — the same assertion a cold start makes.
5853
- this.applyDirectorySchema();
6078
+ this.ensureDirectorySchema();
5854
6079
  // Roles are held in memory (`loadRoles`), so a restore that only rewrote the
5855
6080
  // table would leave every permission check reading the PRE-restore roles until
5856
6081
  // the process restarted — the exact silent-divergence a recovery must not have.
@@ -5890,7 +6115,7 @@ export class SqliteScopeHost {
5890
6115
  .prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
5891
6116
  .get(scopeId);
5892
6117
  if (!rec || rec.tenant_id !== tenantId) {
5893
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
6118
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5894
6119
  }
5895
6120
  if (rec.status !== 'archived') {
5896
6121
  throw new Error(`scope ${scopeId} is ${rec.status}, not archived — only an archived scope may be reaped`);
@@ -6072,6 +6297,7 @@ export class SqliteScopeHost {
6072
6297
  '_substrat_roles', // operator-defined roles
6073
6298
  '_substrat_entitlements', // per-tenant SKU flags
6074
6299
  'orgs', // K-22 org records
6300
+ '_substrat_system_switches', // #1674: the schedule switch's record, per scope
6075
6301
  ];
6076
6302
  const clear = this.directory.transaction(() => {
6077
6303
  for (const table of tables) {
@@ -7375,7 +7601,7 @@ export class SqliteScopeHost {
7375
7601
  tenantId: rt.tenantId,
7376
7602
  scopeId: rt.scopeId,
7377
7603
  principal,
7378
- sql: guardSecrets(scopedSql(rt.db), minted),
7604
+ sql: guardSecrets(guardSqlLimits(scopedSql(rt.db)), minted),
7379
7605
  now: () => at,
7380
7606
  emit: (event) => {
7381
7607
  assertImpersonationWrites(impersonation, 'ctx.emit');
@@ -7730,7 +7956,7 @@ export class SqliteScopeHost {
7730
7956
  assertScopeReachable(tenantId, scopeId) {
7731
7957
  const r = this.directory.prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?').get(scopeId);
7732
7958
  if (!r || r.tenant_id !== tenantId)
7733
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
7959
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
7734
7960
  if (r.status === 'reaped') {
7735
7961
  throw new Error(`scope ${scopeId} is reaped — its storage is gone and cannot be read`);
7736
7962
  }