@substrat-run/adapter-sqlite 0.137.0 → 0.139.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,13 +1,13 @@
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, delegatedReadParams, 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, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, ROUTE_SCOPE_HOSTNAMES_MAX, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, 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, ownerTransferAudit, copyMarkAudit, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
5
- import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, COPY_ORIGIN_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, readCapabilityPage, redactSecrets, redactSecretText, moduleLog, consoleLogSink, asyncInvocationId, asyncLinePass, consoleInvocationLineSink, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, guardSpine, guardSqlLimits, DO_SQL_LIMITS, tooManyResultColumns, tooManyTableColumns, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, assertRowLimit, assertRowOffset, 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, redactSubjectScopeText, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, 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, tenantSystemSwitchedOffMessage, SYSTEM_SWITCHES_BACKFILL_SQL, SYSTEM_SWITCHES_DDL, forgetSystemSwitchesOf, listSystemSwitchRecords, recordSystemSwitchedOff, recordSystemSwitchedOn, restoreSystemSwitchRecord, scopesSwitchedOffFor, tenantHoldsSystemGrant, switchedOffModulesOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, systemSwitchRecordsOf, systemSwitchesTableExists, withRecorded, VERSION_MIGRATIONS_DDL, splitManifestMigrations, splitVersionMigrationsBatch, versionMigrationsOf, writeVersionMigrations, denialListQuery, refusalListQuery, mapRefusalRow, 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, ATTACHMENT_TEXT_DDL, assertAttachmentExtractors, assertJobRegistrable, attachmentRecordOfRow, attachmentTextJob, enqueueAttachmentText, isAttachmentTextRun, reconcileAttachmentText, recordAttachmentText, searchAttachments, 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, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, exportedSinceQuery, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, readLifecycleFlow, domainEventOf, readUndrainedOutbox, undrainedEventsOf, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, unknownRoleError, } from '@substrat-run/kernel';
6
- import { attributedHost } from '@substrat-run/kernel';
4
+ import { accessLogEntry, adminLogEntry, delegatedReadParams, operationSeriesCount, 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, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, ROUTE_SCOPE_HOSTNAMES_MAX, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, 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, SCOPE_GATE_REASONS, substratError, assertReplayableDump, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, copyMarkAudit, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
5
+ import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, COPY_ORIGIN_DDL, ENTITY_STATE_MOVES_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, searchStateWhere, uncheckedView, addStatePlans, entityStateMigrations, entityStateTriggerDdl, statefulTablesOf, assertEntityStateIntact, stateListIndexNames, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, readCapabilityPage, redactSecrets, redactSecretText, moduleLog, consoleLogSink, asyncInvocationId, asyncLinePass, consoleInvocationLineSink, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, guardSpine, guardSqlLimits, DO_SQL_LIMITS, tooManyResultColumns, tooManyTableColumns, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, createFindingRule, findingOfOpsFailure, findingOfSweepRun, pruneFindings, listFindingRules, listFindings, observeFinding, revokeFindingRule, setFindingStatus, assertRowLimit, assertRowOffset, 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, redactSubjectScopeText, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_STEP_RECORD_SQL, JOB_LEASE_EXPIRED_NOTE, assertLeaseMs, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, applyScopeRoleChange, changeScopeRole, revokeScopeRoles, scopeRoleHolders, admitPeer, collectPeers, peerSeats, resolveVerticalInstanceFrom, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, PEER_SWITCHES_DDL, SWITCH_OWED_DDL, SWITCH_FENCES_DDL, recordWriteSuperseded, switchFencesOf, switchSupersededMessage, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, moveSwitch, reassertActionOf, reassertEntry, reassertOffRow, recordSwitchedOff, recordSwitchedOn, restoreSwitchRecord, scopesSwitchedOffFor, switchActionOf, switchAuditSubject, switchNotFoundMessage, switchRecordsOf, switchedOffOf, switchesBackfillSqlOf, switchesTableExists, tenantHeldOf, tenantHoldsGrant, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, VERSION_MIGRATIONS_DDL, splitManifestMigrations, splitVersionMigrationsBatch, versionMigrationsOf, writeVersionMigrations, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, assertSessionUsable, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, impersonationStampOf, mapImpersonationRow, newImpersonationSession, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, memberAddedAudit, orgChangeBound, tenantCoverage, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexDdl, listIndexMigrations, listIndexPlans, listQuery, cursorOf, moduleMigrations, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, attachmentRecordOfRow, attachmentTextJob, enqueueAttachmentText, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, queueAttachmentTextBackfill, startAttachmentTextBackfill, attachmentTextBackfillJob, reconcileAttachmentText, recordAttachmentText, searchAttachments, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, emptyImportResult, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, exportedSinceQuery, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, readLifecycleFlow, readOperationSeries, domainEventOf, readUndrainedOutbox, undrainedEventsOf, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, unknownRoleError, } from '@substrat-run/kernel';
6
+ import { attributedView } from '@substrat-run/kernel';
7
7
  import { INERT_SCOPE_REASON, isPrimaryScopeRow } from '@substrat-run/kernel';
8
8
  import { LEGACY_SCOPE_ROWS_BACKFILL, assertNoSpineReference, assertSpineTablesBuilt, capabilitiesForLoad, dumpRowsInsert, isSpineTable, loadDirectoryDump, repointScopeGrants, settleCopiedWork, emittedHere, spineColumnAdditions } from '@substrat-run/kernel';
9
9
  import { ScopeActor } from './actor.js';
10
- import { createTupleChecker } from './checker.js';
10
+ import { createTupleChecker, directoryTenantReader } from './checker.js';
11
11
  /**
12
12
  * A fault in the database itself (full, corrupt, I/O), as opposed to one statement failing.
13
13
  * The #1764 backfill logs and opens past a failed batch, but never past one of these.
@@ -213,6 +213,9 @@ const KERNEL_DDL = `
213
213
  -- #1686: where a copied scope's data came from, and the event id its own events start above.
214
214
  -- Shared with the other adapter from @substrat-run/kernel; the column comments are there.
215
215
  ${COPY_ORIGIN_DDL}
216
+ -- #119: the kernel's authorization for one archive/trash move, read by the derived update
217
+ -- trigger. Shared with the other adapter from @substrat-run/kernel; the comments are there.
218
+ ${ENTITY_STATE_MOVES_DDL}
216
219
  -- #383 / #1232 / #1288: the platform sweep's per-scope gating state, holding two
217
220
  -- families of row that the kind COLUMN — not the spelling of a key — tells apart.
218
221
  -- Spine (kernel-written), never a module migration. Shared with the DO adapter from
@@ -243,6 +246,7 @@ const KERNEL_DDL = `
243
246
  rows_changed INTEGER,
244
247
  PRIMARY KEY (module_id, version)
245
248
  );
249
+ ${SWITCH_FENCES_DDL}
246
250
  CREATE TABLE IF NOT EXISTS _substrat_tuples (
247
251
  subject TEXT NOT NULL,
248
252
  relation TEXT NOT NULL,
@@ -520,6 +524,8 @@ export class SqliteScopeHost {
520
524
  searchPlans = new Map();
521
525
  /** #811: the paged lists modules declare, by entity type. Same one-owner rule as search. */
522
526
  listPlans = new Map();
527
+ /** #119: entity type → its archive/trash plan, from every registered module. */
528
+ statePlans = new Map();
523
529
  /** entityType → the declared attachment gate (#473): read key + write key (default: read). */
524
530
  attachmentTargets = new Map();
525
531
  /** operation name → who binds it: the owning module, its entitlementKey and its declared
@@ -556,10 +562,10 @@ export class SqliteScopeHost {
556
562
  */
557
563
  jobs = new Map();
558
564
  /**
559
- * The event currently being effected by an executor, stamped onto any admin rows
560
- * it writes. Ambient rather than threaded through every HostAdmin signature: it is
561
- * set and cleared immediately around one `await`, and executors run sequentially,
562
- * so there is no window where it belongs to a different event.
565
+ * The event an executor is effecting, stamped onto the admin rows it writes (K-22). Like
566
+ * `onBehalfOf`, only ever set on a VIEW (`causedByView`, or `attributed` with a
567
+ * `causedBy`), never on the host: a field set around the handler's `await` stamped every
568
+ * admin call the host served meanwhile — a staff call included — with that event (#2055).
563
569
  */
564
570
  causedBy = null;
565
571
  /**
@@ -572,6 +578,7 @@ export class SqliteScopeHost {
572
578
  secretBox;
573
579
  /** The parsers attachment text is extracted with (K-43); the host's own, never imported here. */
574
580
  attachmentExtractors;
581
+ attachmentTextBounds;
575
582
  fetchImpl;
576
583
  connectorCalls;
577
584
  clock;
@@ -589,6 +596,7 @@ export class SqliteScopeHost {
589
596
  this.secretBox = options.secretBox ?? unconfiguredSecretBox;
590
597
  assertAttachmentExtractors(options.attachmentExtractors ?? []);
591
598
  this.attachmentExtractors = options.attachmentExtractors ?? [];
599
+ this.attachmentTextBounds = resolveAttachmentTextBounds(options.attachmentTextBounds);
592
600
  this.fetchImpl = options.fetch ?? globalFetch;
593
601
  this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
594
602
  this.clock = options.clock ?? (() => instant.parse(new Date().toISOString()));
@@ -1063,6 +1071,9 @@ export class SqliteScopeHost {
1063
1071
  );
1064
1072
  ${IMPERSONATION_DDL}
1065
1073
  ${SYSTEM_SWITCHES_DDL}
1074
+ ${MEMBERSHIP_FENCES_DDL}
1075
+ ${PEER_SWITCHES_DDL}
1076
+ ${SWITCH_OWED_DDL}
1066
1077
  CREATE TABLE IF NOT EXISTS _substrat_admin_log (
1067
1078
  id TEXT PRIMARY KEY,
1068
1079
  actor TEXT NOT NULL,
@@ -1142,6 +1153,8 @@ export class SqliteScopeHost {
1142
1153
  resolved_at TEXT
1143
1154
  );
1144
1155
  CREATE INDEX IF NOT EXISTS _substrat_issues_seen ON _substrat_issues (last_seen);
1156
+ -- #1748: tenant findings and their suppress rules — kernel-owned DDL (findings.ts).
1157
+ ${FINDINGS_DDL}
1145
1158
  -- The durable sweep record (#1232): one row per unit outcome per pass —
1146
1159
  -- a connection swept/skipped/failed, a schedule fired/skipped/failed. What
1147
1160
  -- makes "when was this last swept" answerable after the log line rolls off.
@@ -1237,10 +1250,16 @@ export class SqliteScopeHost {
1237
1250
  */
1238
1251
  ensureDirectorySchema() {
1239
1252
  this.directory.transaction(() => {
1240
- const switchRecordIsNew = !systemSwitchesTableExists(switchSqlOf(this.directory));
1253
+ // Each kill switch's record (#1674, #2029) is backfilled on the pass that creates its table.
1254
+ const newRecords = SWITCH_KINDS.filter((kind) => !switchesTableExists(switchSqlOf(this.directory), kind));
1255
+ const fencesAreNew = !membershipFencesTableExists(switchSqlOf(this.directory));
1241
1256
  this.buildDirectorySchema();
1242
- if (switchRecordIsNew)
1243
- this.directory.exec(SYSTEM_SWITCHES_BACKFILL_SQL);
1257
+ for (const kind of newRecords)
1258
+ this.directory.exec(switchesBackfillSqlOf(kind));
1259
+ // #1184: the removal fence, for everyone removed before it existed — the same once-only
1260
+ // gate — stamped now, by the host clock (`membershipFencesBackfillSql` says why).
1261
+ if (fencesAreNew)
1262
+ this.directory.exec(membershipFencesBackfillSql(this.clock()));
1244
1263
  })();
1245
1264
  this.splitVersionMigrations();
1246
1265
  }
@@ -1448,14 +1467,17 @@ export class SqliteScopeHost {
1448
1467
  // #811: the list indexes this module's `lists` declare — same placement and
1449
1468
  // the same reason as the search indexes above: appended AFTER the module's
1450
1469
  // own migrations, so `CREATE INDEX` names a table that exists.
1451
- const listMigrations = listIndexMigrations(manifest.id, manifest.lists);
1452
- for (const m of listMigrations) {
1470
+ // #119: the archive/trash columns, and the plans the verbs and the reads consult.
1471
+ const stateMigrations = entityStateMigrations(manifest.id, manifest.entityStates);
1472
+ const listMigrations = listIndexMigrations(manifest.id, manifest.lists, manifest.entityStates);
1473
+ for (const m of [...stateMigrations, ...listMigrations]) {
1453
1474
  if (seen.has(m.version)) {
1454
1475
  throw new Error(`duplicate migration version in ${manifest.id}: ${m.version}`);
1455
1476
  }
1456
1477
  seen.add(m.version);
1457
1478
  }
1458
- for (const plan of listIndexPlans(manifest.id, manifest.lists)) {
1479
+ addStatePlans(this.statePlans, manifest.id, manifest.entityStates, manifest.permissions);
1480
+ for (const plan of listIndexPlans(manifest.id, manifest.lists, manifest.entityStates)) {
1459
1481
  const existing = this.listPlans.get(plan.entityType);
1460
1482
  if (existing) {
1461
1483
  throw new Error(`list: '${plan.entityType}' declares a paged list in both '${existing.moduleId}' and ` +
@@ -1578,10 +1600,10 @@ export class SqliteScopeHost {
1578
1600
  .prepare('SELECT status FROM tenants WHERE tenant_id = ?')
1579
1601
  .get(input.tenantId);
1580
1602
  if (!tenantRow) {
1581
- throw new Error(`cannot provision scope under unknown tenant: ${input.tenantId}`);
1603
+ throw substratError('conflict', `cannot provision scope under unknown tenant: ${input.tenantId}`);
1582
1604
  }
1583
1605
  if (tenantRow.status !== 'active') {
1584
- throw new Error(`cannot provision scope under non-active tenant (status: ${tenantRow.status}): ${input.tenantId}`);
1606
+ throw substratError('conflict', `cannot provision scope under non-active tenant (status: ${tenantRow.status}): ${input.tenantId}`);
1585
1607
  }
1586
1608
  const record = resolveScopeRecord(input);
1587
1609
  // Idempotency is on the scope_id (§3.3: provisioning is idempotent and
@@ -1600,7 +1622,7 @@ export class SqliteScopeHost {
1600
1622
  .prepare("SELECT scope_id FROM scopes WHERE tenant_id = ? AND slug = ? AND status NOT IN ('archived', 'reaped')")
1601
1623
  .get(input.tenantId, record.slug);
1602
1624
  if (slugOwner) {
1603
- throw new Error(`scope slug '${record.slug}' already taken under tenant ${input.tenantId} ` +
1625
+ throw substratError('conflict', `scope slug '${record.slug}' already taken under tenant ${input.tenantId} ` +
1604
1626
  `by ${slugOwner.scope_id} (slugs are unique within a tenant)`);
1605
1627
  }
1606
1628
  this.directory
@@ -1666,51 +1688,52 @@ export class SqliteScopeHost {
1666
1688
  }
1667
1689
  /**
1668
1690
  * `HostAdmin.reassertSystemSwitches`' body (#1674), run inside a turn the caller already
1669
- * holds on the scope's actor: every module the directory records OFF on the scope is
1670
- * switched off again. Already off answers `changed: false` and writes nothing, and a
1671
- * module with no authority reaching the scope (no grant here, no tenant-level grant) is left
1672
- * alone. Audited only when something moved.
1691
+ * holds on the scope's actor: every module, and (#2029) every peer, the directory records OFF
1692
+ * on the scope is switched off again. Already off answers `changed: false` and writes nothing,
1693
+ * and a subject with no authority reaching the scope (no grant here, no tenant-level grant) is
1694
+ * left alone. Audited only when something moved, under each kind's re-assert action.
1673
1695
  *
1674
1696
  * `audit` receives each row. By default it writes to the admin log at once. A caller running
1675
1697
  * this inside a scope transaction that may still roll back (a restore's replay) collects
1676
1698
  * the rows and writes them once the transaction commits. The admin log is another
1677
1699
  * database, and a row must not outlive a move that was rolled back.
1678
1700
  */
1679
- reassertSwitchesInTurn(rt, actor, tenantId, scopeId, opts, audit = (after) => this.recordAdmin(actor, 'reassertSystemSwitch', { tenantId, scopeId }, null, after)) {
1701
+ reassertSwitchesInTurn(rt, actor, tenantId, scopeId, opts, audit = (action, after) => this.recordAdmin(actor, action, { tenantId, scopeId }, null, after)) {
1680
1702
  const at = new Date().toISOString();
1681
- // #1742 review: a move the deployment made from a stale list — the module was restored
1682
- // ON after the list was read — is undone first, so the operator's ON stands.
1683
- const recorded = systemSwitchRecordsOf(switchSqlOf(this.directory), tenantId, scopeId);
1684
- // One synchronous turn: no switch can land between this read and the moves below.
1685
- const reverted = new Set(staleCarryReverts(recorded, opts?.appliedInUnit));
1686
- for (const moduleId of reverted) {
1687
- const outcome = rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), { moduleId, scopeId, to: 'on', at }))();
1688
- if (outcome.changed)
1689
- audit({ operationId: ulid(), ...staleCarryRevertRow(moduleId, outcome) });
1690
- }
1691
- const recordedOff = switchedOffModulesOf(switchSqlOf(this.directory), tenantId, scopeId);
1692
- // #1742: what a deployment already switched off inside its own unit, audited here — the
1693
- // switch below answers `changed: false` for it and would write no row.
1694
- // A move the revert above undid is not credited as an in-unit OFF.
1695
- const applied = opts?.appliedInUnit?.filter((a) => !reverted.has(a.moduleId));
1696
- for (const row of inUnitMovesToAudit(recordedOff, applied))
1697
- audit({ operationId: ulid(), ...row });
1698
- return recordedOff.map((moduleId) => {
1699
- // #1823: a module whose only authority here is a tenant-level grant is held too.
1700
- const tenantHeld = tenantHoldsSystemGrant(switchSqlOf(this.directory), tenantId, moduleId, this.clock());
1701
- const outcome = rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), { moduleId, scopeId, to: 'off', at, tenantHeld }))();
1702
- if (outcome.changed) {
1703
- audit({
1704
- operationId: ulid(),
1705
- moduleId,
1706
- schedules: 'off',
1707
- phase: 'applied',
1708
- changed: true,
1709
- permissions: outcome.permissions,
1710
- });
1703
+ const directorySql = switchSqlOf(this.directory);
1704
+ const results = [];
1705
+ for (const kind of SWITCH_KINDS) {
1706
+ const action = reassertActionOf(kind);
1707
+ // #2045: every move carries the fence of the call the record holds, so a scope moved since by
1708
+ // a newer call stays as that call put it.
1709
+ const fences = switchFencesOf(directorySql, kind, tenantId, scopeId);
1710
+ const move = (key, to, tenantHeld) => rt.db.transaction(() => moveSwitch(switchSqlOf(rt.db), kind, { key, scopeId, to, at, tenantHeld, fence: fences.get(key) }))();
1711
+ // #1742 review: a move the deployment made from a stale list — the subject was restored
1712
+ // ON after the list was read — is undone first, so the operator's ON stands.
1713
+ const recorded = switchRecordsOf(directorySql, kind, tenantId, scopeId);
1714
+ // One synchronous turn: no switch can land between this read and the moves below.
1715
+ const reverted = new Set(staleCarryReverts(kind, recorded, opts?.appliedInUnit));
1716
+ for (const key of reverted) {
1717
+ const outcome = move(key, 'on');
1718
+ if (outcome.changed)
1719
+ audit(action, { operationId: ulid(), ...staleCarryRevertRow(kind, key, outcome) });
1711
1720
  }
1712
- return { moduleId, held: outcome.held, changed: outcome.changed };
1713
- });
1721
+ const recordedOff = switchedOffOf(directorySql, kind, tenantId, scopeId);
1722
+ // #1742: what a deployment already switched off inside its own unit, audited here — the
1723
+ // switch below answers `changed: false` for it and would write no row. A move the revert
1724
+ // above undid is not credited as an in-unit OFF.
1725
+ for (const row of inUnitMovesToAudit(kind, recordedOff, opts?.appliedInUnit, reverted)) {
1726
+ audit(action, { operationId: ulid(), ...row });
1727
+ }
1728
+ for (const key of recordedOff) {
1729
+ // #1823 (#2030): a subject whose only authority here is a tenant-level grant is held too.
1730
+ const outcome = move(key, 'off', tenantHoldsGrant(directorySql, kind, tenantId, key, this.clock()));
1731
+ if (outcome.changed)
1732
+ audit(action, { operationId: ulid(), ...reassertOffRow(kind, key, outcome.permissions) });
1733
+ results.push(reassertEntry(kind, key, outcome));
1734
+ }
1735
+ }
1736
+ return results;
1714
1737
  }
1715
1738
  async provisionTenantStore(actor, input) {
1716
1739
  // Fail closed on an unknown/inactive tenant, exactly as provisionScope does: a store
@@ -1720,10 +1743,10 @@ export class SqliteScopeHost {
1720
1743
  .prepare('SELECT status FROM tenants WHERE tenant_id = ?')
1721
1744
  .get(input.tenantId);
1722
1745
  if (!tenantRow) {
1723
- throw new Error(`cannot provision tenant store under unknown tenant: ${input.tenantId}`);
1746
+ throw substratError('not_found', `cannot provision tenant store under unknown tenant: ${input.tenantId}`);
1724
1747
  }
1725
1748
  if (tenantRow.status !== 'active') {
1726
- throw new Error(`cannot provision tenant store under non-active tenant (status: ${tenantRow.status}): ${input.tenantId}`);
1749
+ throw substratError('conflict', `cannot provision tenant store under non-active tenant (status: ${tenantRow.status}): ${input.tenantId}`);
1727
1750
  }
1728
1751
  // Idempotent on (tenant, vertical, binding): a retried provision re-resolves the SAME
1729
1752
  // store rather than minting a second database (the K-31 ready-gate retries the whole
@@ -1791,10 +1814,10 @@ export class SqliteScopeHost {
1791
1814
  .prepare('SELECT status FROM tenants WHERE tenant_id = ?')
1792
1815
  .get(input.tenantId);
1793
1816
  if (!tenantRow) {
1794
- throw new Error(`cannot provision blob store under unknown tenant: ${input.tenantId}`);
1817
+ throw substratError('not_found', `cannot provision blob store under unknown tenant: ${input.tenantId}`);
1795
1818
  }
1796
1819
  if (tenantRow.status !== 'active') {
1797
- throw new Error(`cannot provision blob store under non-active tenant (status: ${tenantRow.status}): ${input.tenantId}`);
1820
+ throw substratError('conflict', `cannot provision blob store under non-active tenant (status: ${tenantRow.status}): ${input.tenantId}`);
1798
1821
  }
1799
1822
  const existing = this.directory
1800
1823
  .prepare('SELECT ref FROM blob_stores WHERE tenant_id = ? AND vertical = ? AND binding = ?')
@@ -2255,6 +2278,18 @@ export class SqliteScopeHost {
2255
2278
  if (present.has(plan.table))
2256
2279
  db.exec(searchIndexDdl(plan));
2257
2280
  }
2281
+ // #119: the guard triggers went with the dropped table. Put back AFTER the rows, which
2282
+ // may legitimately arrive archived or trashed.
2283
+ for (const plan of this.statePlans.values()) {
2284
+ if (present.has(plan.table))
2285
+ db.exec(entityStateTriggerDdl(plan));
2286
+ }
2287
+ // #811 / #119: the derived list indexes went with it too, and a load never put them back —
2288
+ // an archivable entity's partial indexes are part of what the kernel checks after DDL.
2289
+ for (const plan of this.listPlans.values()) {
2290
+ if (present.has(plan.table))
2291
+ db.exec(listIndexDdl(plan));
2292
+ }
2258
2293
  // #1575: attachment text is not in a dump, so a load left it as it was. Drop the
2259
2294
  // text of attachments the dump did not bring back, and queue extraction for those
2260
2295
  // it brought back without text — the bytes decide what that run finds.
@@ -2316,9 +2351,9 @@ export class SqliteScopeHost {
2316
2351
  const rows = [];
2317
2352
  // #1869: the re-point's source is the separate hint when given; the dump is otherwise as sent.
2318
2353
  const source = { tables: dump.tables, scopeId: opts?.sourceScopeId ?? dump.scopeId };
2319
- await this.loadDump(tenantId, scopeId, source, (rt) => this.reassertSwitchesInTurn(rt, actor, tenantId, scopeId, undefined, (after) => rows.push(after)));
2320
- for (const after of rows)
2321
- this.recordAdmin(actor, 'reassertSystemSwitch', { tenantId, scopeId }, null, after);
2354
+ await this.loadDump(tenantId, scopeId, source, (rt) => this.reassertSwitchesInTurn(rt, actor, tenantId, scopeId, undefined, (action, after) => rows.push([action, after])));
2355
+ for (const [action, after] of rows)
2356
+ this.recordAdmin(actor, action, { tenantId, scopeId }, null, after);
2322
2357
  this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
2323
2358
  }
2324
2359
  async snapshotScope(actor, tenantId, scopeId, opts) {
@@ -2351,7 +2386,7 @@ export class SqliteScopeHost {
2351
2386
  if (!rec)
2352
2387
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2353
2388
  if (!rec.forkedFrom && rec.kind !== 'preview') {
2354
- throw new Error(`scope ${scopeId} is not a fork or preview — only previews may be deleted; ` +
2389
+ throw substratError('forbidden', `scope ${scopeId} is not a fork or preview — only previews may be deleted; ` +
2355
2390
  `archive a primary scope instead`);
2356
2391
  }
2357
2392
  // Close and evict the runtime handle before touching the file.
@@ -2364,7 +2399,7 @@ export class SqliteScopeHost {
2364
2399
  // orphaned bytes with no record (the §9 hazard).
2365
2400
  this.directory.prepare('DELETE FROM hostnames WHERE scope_id = ?').run(scopeId);
2366
2401
  rmSync(join(this.dir, `${tenantId}__${scopeId}.sqlite`), { force: true });
2367
- forgetSystemSwitchesOf(switchSqlOf(this.directory), scopeId);
2402
+ forgetSwitchesOf(switchSqlOf(this.directory), scopeId);
2368
2403
  this.directory.prepare('DELETE FROM scopes WHERE scope_id = ?').run(scopeId);
2369
2404
  this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
2370
2405
  forkedFrom: rec.forkedFrom,
@@ -2389,10 +2424,12 @@ export class SqliteScopeHost {
2389
2424
  .prepare('SELECT status FROM tenants WHERE tenant_id = ?')
2390
2425
  .get(tenantId);
2391
2426
  if (!tenantRow) {
2392
- throw new Error(`scope has no tenant record: (${tenantId}, ${scopeId})`);
2427
+ throw substratError('not_found', `scope has no tenant record: (${tenantId}, ${scopeId})`, {
2428
+ reason: SCOPE_GATE_REASONS.unrecorded,
2429
+ });
2393
2430
  }
2394
2431
  if (tenantRow.status !== 'active') {
2395
- throw new Error(`tenant not active (status: ${tenantRow.status}): ${tenantId}`);
2432
+ throw substratError('conflict', `tenant not active (status: ${tenantRow.status}): ${tenantId}`, { reason: SCOPE_GATE_REASONS.notActive });
2396
2433
  }
2397
2434
  // `provisioning` is handled BELOW rather than here, because a scope that never
2398
2435
  // finished setting up should still retry its migrations when touched — that lazy
@@ -2401,7 +2438,7 @@ export class SqliteScopeHost {
2401
2438
  // deliberate states, and running migrations for them would be work on behalf of a
2402
2439
  // request that is going to be refused anyway.
2403
2440
  if (row.status !== 'active' && row.status !== 'provisioning') {
2404
- throw new Error(`scope not active (status: ${row.status}): ${scopeId}`);
2441
+ throw substratError('conflict', `scope not active (status: ${row.status}): ${scopeId}`, { reason: SCOPE_GATE_REASONS.notActive });
2405
2442
  }
2406
2443
  const rt = this.runtime(tenantId, scopeId);
2407
2444
  await this.applyPendingMigrations(rt);
@@ -2410,7 +2447,7 @@ export class SqliteScopeHost {
2410
2447
  // the scope exists on the vertical's side (K-31). Refused, but only after the
2411
2448
  // retry above has had its chance — and if THAT is what failed, it threw with
2412
2449
  // the migration's own message, which is the one an operator needs.
2413
- throw new Error(`scope not active (status: ${row.status}): ${scopeId}`);
2450
+ throw substratError('conflict', `scope not active (status: ${row.status}): ${scopeId}`, { reason: SCOPE_GATE_REASONS.notActive });
2414
2451
  }
2415
2452
  return this.buildStub(tenantId, scopeId, rt, asPrincipal(principal), options);
2416
2453
  }
@@ -2446,7 +2483,7 @@ export class SqliteScopeHost {
2446
2483
  `runs '${scope.vertical ?? 'none'}'`);
2447
2484
  }
2448
2485
  if (scope.status !== 'active') {
2449
- throw new Error(`scope not active (status: ${scope.status}): ${scopeId}`);
2486
+ throw substratError('conflict', `scope not active (status: ${scope.status}): ${scopeId}`, { reason: SCOPE_GATE_REASONS.notActive });
2450
2487
  }
2451
2488
  const rt = this.runtime(conn.tenant_id, scopeId);
2452
2489
  await this.applyPendingMigrations(rt);
@@ -2474,7 +2511,7 @@ export class SqliteScopeHost {
2474
2511
  `runs '${scope.vertical ?? 'none'}'`);
2475
2512
  }
2476
2513
  if (scope.status !== 'active') {
2477
- throw new Error(`scope not active (status: ${scope.status}): ${scopeId}`);
2514
+ throw substratError('conflict', `scope not active (status: ${scope.status}): ${scopeId}`, { reason: SCOPE_GATE_REASONS.notActive });
2478
2515
  }
2479
2516
  const rt = this.runtime(conn.tenant_id, scopeId);
2480
2517
  await this.applyPendingMigrations(rt);
@@ -2509,7 +2546,7 @@ export class SqliteScopeHost {
2509
2546
  throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2510
2547
  }
2511
2548
  if (scope.status !== 'active') {
2512
- throw new Error(`scope not active (status: ${scope.status}): ${scopeId}`);
2549
+ throw substratError('conflict', `scope not active (status: ${scope.status}): ${scopeId}`, { reason: SCOPE_GATE_REASONS.notActive });
2513
2550
  }
2514
2551
  const rt = this.runtime(tenantId, scopeId);
2515
2552
  await this.applyPendingMigrations(rt);
@@ -2521,7 +2558,7 @@ export class SqliteScopeHost {
2521
2558
  // and the scope must be active. Authority is then an ordinary check against
2522
2559
  // `system:<moduleId>` grants inside the stub; nothing here is a person.
2523
2560
  if (!this.modules.has(moduleId)) {
2524
- throw new Error(`module not registered on this host: ${moduleId}`);
2561
+ throw substratError('not_found', `module not registered on this host: ${moduleId}`);
2525
2562
  }
2526
2563
  const scope = this.directory
2527
2564
  .prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
@@ -2530,7 +2567,7 @@ export class SqliteScopeHost {
2530
2567
  throw substratError('not_found', `unknown scope: ${scopeId}`);
2531
2568
  }
2532
2569
  if (scope.status !== 'active') {
2533
- throw new Error(`scope not active (status: ${scope.status}): ${scopeId}`);
2570
+ throw substratError('conflict', `scope not active (status: ${scope.status}): ${scopeId}`, { reason: SCOPE_GATE_REASONS.notActive });
2534
2571
  }
2535
2572
  const rt = this.runtime(tenantId, scopeId);
2536
2573
  await this.applyPendingMigrations(rt);
@@ -2600,15 +2637,11 @@ export class SqliteScopeHost {
2600
2637
  const source = batch.source;
2601
2638
  const rt = await this.openActiveScope(tenantId, scopeId);
2602
2639
  return rt.actor.enqueue(async () => {
2603
- const result = {
2604
- delivered: 0,
2605
- deadLettered: 0,
2606
- duplicates: 0,
2607
- withheld: 0,
2608
- cursor: batch.after,
2609
- stale: false,
2610
- paused: null,
2611
- };
2640
+ const result = emptyImportResult(batch);
2641
+ // #2004: a copy consumes no other vertical's events, at the door as in the sweep
2642
+ // (`scope-copy.ts`). A pause: nothing runs and the watermark stays.
2643
+ if (!this.isPrimaryInDirectory(scopeId))
2644
+ return { ...result, paused: { reason: INERT_SCOPE_REASON } };
2612
2645
  // #1706's door, for a delivery (`operation: null`): the producer must be a declared peer
2613
2646
  // of this vertical with its switch on. Admitted inside the actor task, before anything
2614
2647
  // runs, so a switch pulled between two passes refuses the second. A refusal is a PAUSE:
@@ -2676,16 +2709,14 @@ export class SqliteScopeHost {
2676
2709
  const { hops: _hops, ...fact } = e;
2677
2710
  const event = structuredClone({ ...fact, source });
2678
2711
  const unit = unitOf(imp.moduleId, e.id, e.type);
2679
- // What the handler emits was emitted BECAUSE of the producer's event (#1237). The id
2680
- // resolves through `_substrat_imports`, which the row above has just written.
2681
- rt.causedBy = e.id;
2682
- rt.unitInvocationId = unit.invocationId;
2683
2712
  rt.db.exec('BEGIN IMMEDIATE');
2684
2713
  try {
2685
2714
  // As the producer's principal, admitted above: the handler's checks are real ones,
2686
2715
  // against the grants this vertical's `peers` gave it, and its emits carry the actor
2687
- // `{ vertical, scope }` and the authorization they passed (K-34).
2688
- await imp.handler(this.operationContext(rt, subject), event);
2716
+ // `{ vertical, scope }` and the authorization they passed (K-34). What it emits was
2717
+ // emitted BECAUSE of the producer's event (#1237); the id resolves through
2718
+ // `_substrat_imports`, which the row above has just written.
2719
+ await imp.handler(this.deliveryContext(rt, subject, undefined, { causedBy: e.id, invocationId: unit.invocationId }), event);
2689
2720
  rt.db
2690
2721
  .prepare(`INSERT INTO _substrat_deliveries (event_id, consumer_module, delivered_at, invocation_id)
2691
2722
  VALUES (?, ?, ?, NULL)`)
@@ -2702,10 +2733,6 @@ export class SqliteScopeHost {
2702
2733
  result.deadLettered += 1;
2703
2734
  lines.write({ ...unit, outcome: 'dead-lettered', error: err });
2704
2735
  }
2705
- finally {
2706
- rt.causedBy = null;
2707
- rt.unitInvocationId = null;
2708
- }
2709
2736
  }
2710
2737
  if (!ran)
2711
2738
  result.duplicates += 1;
@@ -2797,10 +2824,10 @@ export class SqliteScopeHost {
2797
2824
  .prepare('SELECT status FROM tenants WHERE tenant_id = ?')
2798
2825
  .get(tenantId);
2799
2826
  if (!tenant || tenant.status !== 'active') {
2800
- throw new Error(`tenant not active (status: ${tenant?.status ?? 'missing'}): ${tenantId}`);
2827
+ throw substratError('conflict', `tenant not active (status: ${tenant?.status ?? 'missing'}): ${tenantId}`, { reason: SCOPE_GATE_REASONS.notActive });
2801
2828
  }
2802
2829
  if (scope.status !== 'active') {
2803
- throw new Error(`scope not active (status: ${scope.status}): ${scopeId}`);
2830
+ throw substratError('conflict', `scope not active (status: ${scope.status}): ${scopeId}`, { reason: SCOPE_GATE_REASONS.notActive });
2804
2831
  }
2805
2832
  const rt = this.runtime(tenantId, scopeId);
2806
2833
  await this.applyPendingMigrations(rt);
@@ -2916,20 +2943,44 @@ export class SqliteScopeHost {
2916
2943
  const target = principalId.parse(assignee);
2917
2944
  return rt.actor.turn(async () => {
2918
2945
  const bound = await this.assignmentBound(subject, tenantId, scopeId, roleKey);
2919
- if (bound.covered) {
2920
- rt.db.prepare(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
2921
- VALUES (?, ?, ?, NULL)`).run(`principal:${target}`, `role:${roleKey}`, `scope:${scopeId}`);
2922
- }
2946
+ if (bound.covered)
2947
+ applyScopeRoleChange(switchSqlOf(rt.db), scopeId, target, { revoke: [], grant: roleKey }, this.clock());
2923
2948
  return bound;
2924
2949
  });
2925
2950
  }
2951
+ async listScopeRoleHolders(tenantId, scopeId, principal) {
2952
+ const rt = await this.openActiveScope(tenantId, scopeId);
2953
+ const who = principal === undefined ? undefined : principalId.parse(principal);
2954
+ return rt.actor.enqueue(() => scopeRoleHolders(switchSqlOf(rt.db), scopeId, this.clock(), who));
2955
+ }
2956
+ async changeScopeRoleBounded(tenantId, scopeId, caller, principal, from, to) {
2957
+ const rt = await this.openActiveScope(tenantId, scopeId);
2958
+ const target = principalId.parse(principal);
2959
+ const answer = await rt.actor.turn(() => changeScopeRole(switchSqlOf(rt.db), scopeId, target, from, to, this.clock(), this.roleBound(caller, tenantId, scopeId), (run) => rt.db.transaction(run)()));
2960
+ if (answer === 'not-held')
2961
+ throw substratError('conflict', `${target} does not hold '${from}' at this scope`);
2962
+ if (answer === 'unknown-to')
2963
+ throw unknownRoleError(to);
2964
+ return answer;
2965
+ }
2966
+ async revokeScopeRolesBounded(tenantId, scopeId, caller, principal) {
2967
+ const rt = await this.openActiveScope(tenantId, scopeId);
2968
+ return rt.actor.turn(() => revokeScopeRoles(switchSqlOf(rt.db), scopeId, principalId.parse(principal), this.clock(), this.roleBound(caller, tenantId, scopeId), (run) => rt.db.transaction(run)()));
2969
+ }
2970
+ /** The caller's bound per role at the scope, `null` for a role this tenant does not define (#1150). */
2971
+ roleBound(caller, tenantId, scopeId) {
2972
+ const subject = asPrincipal(principalId.parse(caller));
2973
+ return async (roleKey) => this.roles.has(`${tenantId}/${roleKey}`) ? this.assignmentBound(subject, tenantId, scopeId, roleKey) : null;
2974
+ }
2926
2975
  /**
2927
2976
  * §5.1's assignment bound, resolved against the SAME role table the checker expands
2928
2977
  * — not a vertical's compile-time `ROLES` array. A tenant's projected role is what
2929
2978
  * assignment would actually confer, and that is what has to be bounded. The one
2930
2979
  * resolution both `ctx.canAssign` and the host's `canAssign` answer from.
2931
2980
  */
2932
- async assignmentBound(subject, tenantId, scopeId, roleKey) {
2981
+ async assignmentBound(subject, tenantId,
2982
+ /** Null for the tenant node — the executor's `level: 'tenant'` (#1184). */
2983
+ scopeId, roleKey) {
2933
2984
  const role = this.roles.get(`${tenantId}/${roleKey}`);
2934
2985
  if (!role) {
2935
2986
  throw unknownRoleError(roleKey);
@@ -3280,6 +3331,8 @@ export class SqliteScopeHost {
3280
3331
  let replayed = false;
3281
3332
  // #1746: what this call itself emitted, for `onEmitted`. Set only after a commit.
3282
3333
  let emittedReport;
3334
+ // #1184: what this call's executors did inline, for `onExecutorOutcomes`.
3335
+ const executorOutcomes = invokeOptions?.onExecutorOutcomes ? [] : undefined;
3283
3336
  const invoked = await rt.actor.enqueue(async () => {
3284
3337
  // #1237: the invocation this call belongs to, for the duration of it. Set
3285
3338
  // INSIDE the actor task — the actor serializes invoke and dispatch alike, so
@@ -3449,7 +3502,7 @@ export class SqliteScopeHost {
3449
3502
  // #1525: still inside the actor task that set it, so these deliveries are
3450
3503
  // this call's own work — the same tail its consumers' emits are stamped in.
3451
3504
  await this.dispatch(rt, rt.invocationId);
3452
- await this.dispatchExecutors(rt, rt.invocationId);
3505
+ await this.dispatchExecutors(rt, rt.invocationId, executorOutcomes);
3453
3506
  }
3454
3507
  if (exportMark !== null && !replayed) {
3455
3508
  const q = exportedSinceQuery(exportTypes, exportMark);
@@ -3476,6 +3529,8 @@ export class SqliteScopeHost {
3476
3529
  invokeOptions?.onIdempotentReplay?.();
3477
3530
  if (emittedReport)
3478
3531
  invokeOptions?.onEmitted?.(emittedReport);
3532
+ if (executorOutcomes && !replayed && session?.mode !== 'read-only')
3533
+ invokeOptions?.onExecutorOutcomes?.(executorOutcomes);
3479
3534
  return invoked;
3480
3535
  },
3481
3536
  };
@@ -3564,6 +3619,17 @@ export class SqliteScopeHost {
3564
3619
  * At-least-once still requires idempotent handlers. Retry is the backstop, not a
3565
3620
  * substitute.
3566
3621
  */
3622
+ /**
3623
+ * #1184: the scope reads an executor's handler may make, built over `rt` directly. The
3624
+ * handler runs INSIDE this scope's actor task, so a host read that re-enqueued — the
3625
+ * public `canAssign`, `admin.entityHistory` — would wait on the task holding it.
3626
+ */
3627
+ executorScope(rt) {
3628
+ return {
3629
+ history: async (entity, page) => readHistory({ sql: scopedSql(rt.db) }, entity, page),
3630
+ covers: (principal, roleKey, level) => this.assignmentBound(asPrincipal(principalId.parse(principal)), rt.tenantId, level === 'scope' ? rt.scopeId : null, roleKey),
3631
+ };
3632
+ }
3567
3633
  async dispatchExecutors(rt,
3568
3634
  /**
3569
3635
  * #1525: the call this drain pass is running in, or null — PASSED, not read off
@@ -3572,7 +3638,9 @@ export class SqliteScopeHost {
3572
3638
  * and journal over RPC, so the id has to travel as an argument on that side, and
3573
3639
  * a recorded fact whose correctness argument differs per adapter drifts.
3574
3640
  */
3575
- invocationId) {
3641
+ invocationId,
3642
+ /** #1184: what each attempt did, for the emitting call's `onExecutorOutcomes`. */
3643
+ outcomes) {
3576
3644
  const report = {
3577
3645
  attempted: 0,
3578
3646
  delivered: 0,
@@ -3583,15 +3651,16 @@ export class SqliteScopeHost {
3583
3651
  return report;
3584
3652
  const lines = asyncLinePass(this.invocationLineSink);
3585
3653
  try {
3586
- await this.dispatchExecutorsPass(rt, invocationId, report, lines);
3654
+ await this.dispatchExecutorsPass(rt, invocationId, report, lines, outcomes);
3587
3655
  }
3588
3656
  finally {
3589
3657
  lines.end();
3590
3658
  }
3591
3659
  return report;
3592
3660
  }
3593
- async dispatchExecutorsPass(rt, invocationId, report, lines) {
3661
+ async dispatchExecutorsPass(rt, invocationId, report, lines, outcomes) {
3594
3662
  const now = new Date().toISOString();
3663
+ const scope = this.executorScope(rt);
3595
3664
  // #2005: a non-primary scope (a fork, a snapshot, a preview of either kind) causes no
3596
3665
  // outbound effects. Every executor is host code acting with platform authority — a
3597
3666
  // connector with the tenant's credential, a plain executor with `HostAdmin` — so its
@@ -3643,26 +3712,44 @@ export class SqliteScopeHost {
3643
3712
  lines.write({ ...unit, attempt: attempts, outcome: 'dead-lettered' });
3644
3713
  continue;
3645
3714
  }
3715
+ const outcomeOf = (outcome, error) => {
3716
+ outcomes?.push(executorOutcomeOf(id, event, outcome, error));
3717
+ };
3646
3718
  if (isInert()) {
3647
3719
  const { attempts } = this.recordExecutorDelivery(rt, row.id, deliveryId, INERT_SCOPE_REASON, TERMINAL_RETRY, invocationId);
3648
3720
  report.inert = (report.inert ?? 0) + 1;
3721
+ outcomeOf('inert', INERT_SCOPE_REASON);
3649
3722
  // Its own outcome, never `delivered`: no handler ran (#2005).
3650
3723
  lines.write({ ...unit, attempt: attempts, outcome: 'inert' });
3651
3724
  continue;
3652
3725
  }
3653
- this.causedBy = event.id;
3726
+ // #2055: the handler writes through a view bound to its event, never through the host.
3727
+ const caused = this.causedByView(event.id);
3654
3728
  try {
3729
+ let result;
3655
3730
  if (executor.kind === 'connector') {
3656
3731
  // `true`: dispatchExecutors is only ever reached from inside
3657
3732
  // `rt.actor.enqueue` (invoke's post-commit tail, or drainDue).
3658
- await executor.handler(this.connectorContext(rt, executor.timeoutMs, true, event.id), event);
3733
+ await executor.handler(caused.connectorContext(rt, executor.timeoutMs, true, event.id), event);
3659
3734
  }
3660
3735
  else {
3661
- await executor.handler(this.admin, event);
3736
+ result = await executor.handler(caused.admin, event, scope);
3737
+ }
3738
+ // #1184: a refusal is the handler's own terminal decision — journaled with its
3739
+ // reason, never retried, and listed by `executorDeadLetters` beside an exhausted one.
3740
+ const refused = isDeliveryRefusal(result) ? result : null;
3741
+ const text = refused && refusalJournalText(refused);
3742
+ const { attempts } = this.recordExecutorDelivery(rt, row.id, deliveryId, text, refused ? TERMINAL_RETRY : executor.retry, invocationId);
3743
+ if (refused) {
3744
+ report.deadLettered += 1;
3745
+ outcomeOf('refused', refused.reason);
3746
+ lines.write({ ...unit, attempt: attempts, outcome: 'dead-lettered', error: text });
3747
+ }
3748
+ else {
3749
+ report.delivered += 1;
3750
+ outcomeOf('delivered');
3751
+ lines.write({ ...unit, attempt: attempts, outcome: 'delivered' });
3662
3752
  }
3663
- const { attempts } = this.recordExecutorDelivery(rt, row.id, deliveryId, null, executor.retry, invocationId);
3664
- report.delivered += 1;
3665
- lines.write({ ...unit, attempt: attempts, outcome: 'delivered' });
3666
3753
  }
3667
3754
  catch (err) {
3668
3755
  const { dead, attempts } = this.recordExecutorDelivery(rt, row.id, deliveryId, err instanceof Error ? (err.stack ?? err.message) : String(err), executor.retry, invocationId);
@@ -3670,11 +3757,9 @@ export class SqliteScopeHost {
3670
3757
  report.deadLettered += 1;
3671
3758
  else
3672
3759
  report.retrying += 1;
3760
+ outcomeOf(dead ? 'dead-lettered' : 'retrying', err);
3673
3761
  lines.write({ ...unit, attempt: attempts, outcome: dead ? 'dead-lettered' : 'retrying', error: err });
3674
3762
  }
3675
- finally {
3676
- this.causedBy = null;
3677
- }
3678
3763
  }
3679
3764
  }
3680
3765
  }
@@ -3727,13 +3812,14 @@ export class SqliteScopeHost {
3727
3812
  // emitting operation's own tail made.
3728
3813
  return rt.actor.enqueue(() => this.dispatchExecutors(rt, null));
3729
3814
  }
3730
- registerJob(moduleId, name, handler, retry) {
3815
+ registerJob(moduleId, name, handler, retry, options) {
3731
3816
  // #1575: the kernel's own jobs are dispatched before this registry is read.
3732
3817
  assertJobRegistrable(moduleId, name);
3818
+ assertLeaseMs(options?.leaseMs);
3733
3819
  const key = `${moduleId}/${name}`;
3734
3820
  if (this.jobs.has(key))
3735
3821
  throw new Error(`job '${key}' is already registered`);
3736
- this.jobs.set(key, { handler, retry });
3822
+ this.jobs.set(key, { handler, retry, leaseMs: options?.leaseMs });
3737
3823
  }
3738
3824
  /**
3739
3825
  * D-14's in-process driver: the run store is direct SQL on the scope db.
@@ -3766,11 +3852,24 @@ export class SqliteScopeHost {
3766
3852
  (id, module_id, job, instance, payload, status, cursor, counters, attempts,
3767
3853
  last_error, started_at, updated_at, next_attempt_at, ended_at, subject_id)
3768
3854
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
3769
- // A compare-and-set on `running` (#1632): a pass that outlived an erasure must not
3770
- // write its cursor back over the redaction. The kernel's statement, so both adapters
3771
- // hold the same line.
3855
+ // A compare-and-set on `running` (#1632) and on the pass's lease (#2034): a pass that
3856
+ // outlived an erasure, or lost its run to a takeover, must not write back over it. The
3857
+ // kernel's statement, so both adapters hold the same line.
3772
3858
  const patchRun = db.prepare(JOB_RUN_PATCH_SQL);
3773
- const patchArgs = (id, p) => [p.status, p.cursor, p.counters, p.attempts, p.lastError, p.updatedAt, p.nextAttemptAt, p.endedAt, id];
3859
+ const patchArgs = (id, p, owner) => [p.status, p.cursor, p.counters, p.attempts, p.lastError, p.updatedAt, p.nextAttemptAt, p.endedAt, id, owner];
3860
+ const beganAt = db.prepare('SELECT lease_began_at FROM _substrat_job_runs WHERE id = ?');
3861
+ const beginRun = db.prepare(JOB_RUN_BEGIN_SQL);
3862
+ const missRun = db.prepare(JOB_RUN_MISS_SQL);
3863
+ const missSettle = db.prepare(JOB_RUN_MISS_SETTLE_SQL);
3864
+ // #2042 r4: every lease time is this host's clock as the statement runs, inside its turn —
3865
+ // never a time the drive computed, which a skew or a wait in the queue would make wrong.
3866
+ const now = () => this.clock();
3867
+ const plus = (at, ms) => new Date(Date.parse(at) + ms).toISOString();
3868
+ const claimRun = db.prepare(JOB_RUN_CLAIM_SQL);
3869
+ const renewRun = db.prepare(JOB_RUN_RENEW_SQL);
3870
+ const recordStepRow = db.prepare(JOB_STEP_RECORD_SQL);
3871
+ const dropSteps = db.prepare('DELETE FROM _substrat_job_steps WHERE run_id = ?');
3872
+ const stepRow = db.prepare('SELECT step, result, attempts, last_error FROM _substrat_job_steps WHERE run_id = ? AND step = ?');
3774
3873
  // `db.transaction` on better-sqlite3 runs its body SYNCHRONOUSLY inside a real
3775
3874
  // SQLite transaction, so nothing — not another turn of the event loop, not
3776
3875
  // another caller of this host — can interleave between the read and the write.
@@ -3782,9 +3881,37 @@ export class SqliteScopeHost {
3782
3881
  insertRun.run(r.id, r.module_id, r.job, r.instance, r.payload, r.status, r.cursor, r.counters, r.attempts, r.last_error, r.started_at, r.updated_at, r.next_attempt_at, r.ended_at, r.subject_id ?? null);
3783
3882
  return r;
3784
3883
  });
3785
- const commitPassTx = db.transaction((id, p) => {
3786
- patchRun.run(...patchArgs(id, p));
3787
- db.prepare('DELETE FROM _substrat_job_steps WHERE run_id = ?').run(id);
3884
+ const commitPassTx = db.transaction((id, p, owner) => {
3885
+ // #2034: the ledger goes only with a patch that applied — a stale holder's commit must
3886
+ // not empty the ledger of the pass that took the run over.
3887
+ if (patchRun.run(...patchArgs(id, p, owner)).changes === 0)
3888
+ return false;
3889
+ dropSteps.run(id);
3890
+ return true;
3891
+ });
3892
+ // #2034: the claim, and whether it took over a lease, read in the same transaction.
3893
+ const claimTx = db.transaction((id, owner, leaseMs) => {
3894
+ // #2042 r2, r4: a takeover is charged only when the lease it takes had BEGUN its pass.
3895
+ const before = beganAt.get(id);
3896
+ const at = now();
3897
+ const claimed = row(claimRun.get(owner, plus(at, leaseMs), at, JOB_LEASE_EXPIRED_NOTE, id, at));
3898
+ return claimed && { run: claimed, takeover: (before?.lease_began_at ?? null) !== null };
3899
+ });
3900
+ // #2042 r3, r4: an admission miss — the relative count, then the backoff or failure it calls for.
3901
+ const missTx = db.transaction((id, owner, note) => {
3902
+ const at = now();
3903
+ const counted = missRun.get(at, id, owner);
3904
+ if (!counted)
3905
+ return null;
3906
+ const o = admissionMissOutcome(counted.admission_misses, at, note);
3907
+ missSettle.run(o.status, o.nextAttemptAt, o.endedAt, o.lastError, id, counted.admission_misses);
3908
+ return { misses: counted.admission_misses, failed: o.status === 'failed' };
3909
+ });
3910
+ const recordStepTx = db.transaction((runId, name, result, attempts, lastError, at, owner, leaseMs) => {
3911
+ if (renewRun.run(plus(now(), leaseMs), runId, owner).changes === 0)
3912
+ return false;
3913
+ recordStepRow.run(runId, name, result, attempts, lastError, at, runId, owner);
3914
+ return true;
3788
3915
  });
3789
3916
  // Every store operation takes its own short turn on the scope actor, so no
3790
3917
  // statement here can land inside an `invoke`'s open transaction. `turn` is the
@@ -3792,7 +3919,13 @@ export class SqliteScopeHost {
3792
3919
  const turn = (fn) => rt.actor.enqueue(fn);
3793
3920
  return {
3794
3921
  startOrJoin: (key, r) => turn(() => startOrJoinTx(key, r)),
3795
- get: (id) => turn(() => row(db.prepare('SELECT * FROM _substrat_job_runs WHERE id = ?').get(id))),
3922
+ claim: (id, owner, leaseMs) => turn(() => claimTx(id, owner, leaseMs)),
3923
+ // #2042 r4: BEGIN, the commitment point, judged by this host's clock as it runs.
3924
+ begin: (id, owner, marginMs) => turn(() => {
3925
+ const at = now();
3926
+ return beginRun.run(at, id, owner, plus(at, marginMs)).changes > 0;
3927
+ }),
3928
+ miss: (id, owner, note) => turn(() => missTx(id, owner, note)),
3796
3929
  // #1834: the drive's one snapshot — keys only, in the order runs became due.
3797
3930
  dueKeys: (now, max) => turn(() => db
3798
3931
  .prepare(`SELECT id, module_id, job FROM _substrat_job_runs
@@ -3820,17 +3953,18 @@ export class SqliteScopeHost {
3820
3953
  ORDER BY id DESC LIMIT ?`)
3821
3954
  .all(...params);
3822
3955
  }),
3823
- patch: (id, p) => turn(() => {
3824
- patchRun.run(...patchArgs(id, p));
3825
- }),
3826
- commitPass: (id, p) => turn(() => commitPassTx(id, p)),
3827
- step: (runId, name) => turn(() => db
3828
- .prepare('SELECT step, result, attempts, last_error FROM _substrat_job_steps WHERE run_id = ? AND step = ?')
3829
- .get(runId, name) ?? null),
3830
- recordStep: (runId, name, result, attempts, lastError, at) => turn(() => {
3831
- // Only while the run is still `running` (#1632) — see `JOB_STEP_RECORD_SQL`.
3832
- db.prepare(JOB_STEP_RECORD_SQL).run(runId, name, result, attempts, lastError, at, runId);
3956
+ patch: (id, p, owner) => turn(() => patchRun.run(...patchArgs(id, p, owner)).changes > 0),
3957
+ commitPass: (id, p, owner) => turn(() => commitPassTx(id, p, owner)),
3958
+ // #2034: a step boundary renews the lease; a pass that lost it reads nothing.
3959
+ beginStep: (runId, name, owner, leaseMs) => turn(() => {
3960
+ if (renewRun.run(plus(now(), leaseMs), runId, owner).changes === 0)
3961
+ return { held: false, row: null };
3962
+ return { held: true, row: stepRow.get(runId, name) ?? null };
3833
3963
  }),
3964
+ recordStep: (runId, name, result, attempts, lastError, at, owner, leaseMs) =>
3965
+ // Only while the run is still `running` (#1632) and this pass holds it (#2034), renewing
3966
+ // the lease in the same transaction — see `JOB_STEP_RECORD_SQL`.
3967
+ turn(() => recordStepTx(runId, name, result, attempts, lastError, at, owner, leaseMs)),
3834
3968
  };
3835
3969
  }
3836
3970
  /**
@@ -3852,7 +3986,7 @@ export class SqliteScopeHost {
3852
3986
  write: (attachmentId, outcome) => rt.actor.enqueue(() => recordAttachmentText(spineSql(rt.db), attachmentId, outcome, this.clock())),
3853
3987
  },
3854
3988
  // K-43: the host's parsers, handed in — this adapter imports none.
3855
- this.attachmentExtractors);
3989
+ this.attachmentExtractors, this.attachmentTextBounds);
3856
3990
  }
3857
3991
  async startJobRun(tenantId, scopeId, input) {
3858
3992
  const rt = this.runtime(tenantId, scopeId);
@@ -3862,12 +3996,19 @@ export class SqliteScopeHost {
3862
3996
  async runDueJobs(tenantId, scopeId, options) {
3863
3997
  const rt = this.runtime(tenantId, scopeId);
3864
3998
  await this.applyPendingMigrations(rt);
3865
- // #1575: the kernel's extraction job is this host's own, bound to this scope — no
3866
- // deployment registers it, and none can shadow it.
3867
- const attachmentText = { handler: this.attachmentTextHandler(rt) };
3999
+ // #1575: the kernel's extraction jobs are this host's own, bound to this scope — no
4000
+ // deployment registers them, and none can shadow them. The backfill starts here, on
4001
+ // the drive, so a scope's first request never pays for attachments that predate it.
4002
+ await rt.actor.enqueue(() => startAttachmentTextBackfill(spineSql(rt.db), ulid(), this.clock()));
4003
+ const kernelJobs = {
4004
+ [ATTACHMENT_TEXT_JOB]: this.attachmentTextHandler(rt),
4005
+ [ATTACHMENT_TEXT_BACKFILL_JOB]: attachmentTextBackfillJob({
4006
+ queueBatch: (after) => rt.actor.enqueue(() => rt.db.transaction(() => queueAttachmentTextBackfill(spineSql(rt.db), after, ulid, this.clock()))()),
4007
+ }),
4008
+ };
3868
4009
  return runDueJobRuns({
3869
4010
  store: this.jobStore(rt),
3870
- handlerFor: (run) => isAttachmentTextRun(run) ? attachmentText : this.jobs.get(`${run.module_id}/${run.job}`),
4011
+ handlerFor: (run) => kernelJobFor(run, kernelJobs) ?? this.jobs.get(`${run.module_id}/${run.job}`),
3871
4012
  now: this.clock,
3872
4013
  openScope: (run) => this.getSystemScope(run.module_id, tenantId, scopeId),
3873
4014
  maxPasses: options?.maxPasses,
@@ -3887,15 +4028,10 @@ export class SqliteScopeHost {
3887
4028
  // Cloudflare one does; the context build is the same one `dispatchExecutors` hands
3888
4029
  // an in-process connector.
3889
4030
  const rt = this.runtime(tenantId, scopeId);
3890
- this.causedBy = event.id;
3891
- try {
3892
- // `false`: this path deliberately does NOT enqueue, so nothing is held and
3893
- // the connection's reads take an ordinary serialized turn.
3894
- await handler(this.connectorContext(rt, options?.timeoutMs ?? 30_000, false, event.id), event);
3895
- }
3896
- finally {
3897
- this.causedBy = null;
3898
- }
4031
+ // `false`: this path deliberately does NOT enqueue, so nothing is held and
4032
+ // the connection's reads take an ordinary serialized turn. Built on a view bound to
4033
+ // the event, so its admin rows carry it and nothing else the host serves does (#2055).
4034
+ await handler(this.causedByView(event.id).connectorContext(rt, options?.timeoutMs ?? 30_000, false, event.id), event);
3899
4035
  }
3900
4036
  /**
3901
4037
  * The scope's own answer to "what may this connection do here" (#726 gap 1) — read
@@ -3959,7 +4095,7 @@ export class SqliteScopeHost {
3959
4095
  // `provisioning` allowed — a scope stuck there on a failed migration is a
3960
4096
  // sweep target. Suspended/archived are deliberate states; not disturbed.
3961
4097
  if (row.status !== 'active' && row.status !== 'provisioning') {
3962
- throw new Error(`scope not migratable (status: ${row.status}): ${scopeId}`);
4098
+ throw substratError('conflict', `scope not migratable (status: ${row.status}): ${scopeId}`);
3963
4099
  }
3964
4100
  const rt = this.runtime(tenantId, scopeId);
3965
4101
  // Nothing pending FOR THIS HOST → noop, and deliberately no state write: a
@@ -4115,16 +4251,12 @@ export class SqliteScopeHost {
4115
4251
  lines.write({ ...unit, outcome: 'dead-lettered' });
4116
4252
  continue;
4117
4253
  }
4118
- const ctx = this.operationContext(rt, asPrincipal(this.systemPrincipal), {
4119
- system: mod.id,
4254
+ // #1237: anything this consumer emits was emitted BECAUSE of this event — where a
4255
+ // backwards walk used to stop. #1901: its `ctx.log` lines join this delivery's line.
4256
+ const ctx = this.deliveryContext(rt, asPrincipal(this.systemPrincipal), { system: mod.id }, {
4257
+ causedBy: event.id,
4258
+ invocationId: unit.invocationId,
4120
4259
  });
4121
- // #1237: anything this consumer emits was emitted BECAUSE of this event.
4122
- // `dispatchExecutors` already did this for the admin log; the module
4123
- // consumers never did, which is exactly where a backwards walk stopped.
4124
- // On the runtime rather than the host — see `ScopeRuntime.causedBy`.
4125
- rt.causedBy = event.id;
4126
- // #1901: the handler's `ctx.log` lines join this delivery's line.
4127
- rt.unitInvocationId = unit.invocationId;
4128
4260
  rt.db.exec('BEGIN IMMEDIATE');
4129
4261
  try {
4130
4262
  await consumer.handler(ctx, event);
@@ -4148,14 +4280,6 @@ export class SqliteScopeHost {
4148
4280
  .run(event.id, mod.id, new Date().toISOString(), String(err), invocationId);
4149
4281
  lines.write({ ...unit, outcome: 'dead-lettered', error: err });
4150
4282
  }
4151
- finally {
4152
- rt.unitInvocationId = null;
4153
- // Cleared on BOTH paths. Left set, the id would leak onto every later
4154
- // emit in this scope — an operation's own event stamped as caused by
4155
- // whatever happened to be delivered last, which is worse than no cause
4156
- // at all, because it reads as a recorded fact.
4157
- rt.causedBy = null;
4158
- }
4159
4283
  }
4160
4284
  }
4161
4285
  }
@@ -4436,14 +4560,26 @@ export class SqliteScopeHost {
4436
4560
  throw new Error(`connection not found: ${id}`);
4437
4561
  return row;
4438
4562
  }
4439
- recordAdmin(actor, action, target, before, after) {
4563
+ /**
4564
+ * A findings mutation (#1748) and the audit rows it hands back, in ONE directory transaction:
4565
+ * an audit write that fails rolls the mutation back, so no verdict, rule or stale resolution
4566
+ * stands without its row.
4567
+ */
4568
+ auditedFindings(actor, run) {
4569
+ return this.directory.transaction(() => run(redactionSqlOf(this.directory), (a) => this.recordAdmin(actor, a.action, a.target, a.before, a.after)))();
4570
+ }
4571
+ recordAdmin(actor, action, target, before, after,
4572
+ /** When the row says it happened; the wall clock unless the write took its time elsewhere (#1184). */
4573
+ at = new Date().toISOString()) {
4440
4574
  this.directory
4441
4575
  .prepare(`INSERT INTO _substrat_admin_log
4442
4576
  (id, actor, action, tenant_id, scope_id, vertical, before, after, caused_by, on_behalf_of, at)
4443
4577
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
4444
- .run(ulid(), actor, action, target.tenantId ?? null, target.scopeId ?? null, target.vertical ?? null, before == null ? null : JSON.stringify(before), after == null ? null : JSON.stringify(after), this.causedBy,
4578
+ .run(ulid(), actor, action, target.tenantId ?? null, target.scopeId ?? null, target.vertical ?? null, before == null ? null : JSON.stringify(before), after == null ? null : JSON.stringify(after),
4579
+ // #2055: like `onBehalfOf`, set only on a view, never on the host itself.
4580
+ this.causedBy,
4445
4581
  // #977: set only on an attributed view (`attributed`), never on the host itself.
4446
- this.onBehalfOf === null ? null : JSON.stringify(this.onBehalfOf), new Date().toISOString());
4582
+ this.onBehalfOf === null ? null : JSON.stringify(this.onBehalfOf), at);
4447
4583
  }
4448
4584
  tenantHoldsEntitlement(tenantId, key) {
4449
4585
  // An expired grant fails closed, exactly as if revoked (#33) — evaluated
@@ -4464,8 +4600,12 @@ export class SqliteScopeHost {
4464
4600
  .all(tenantId).map((r) => ({ key: r.entitlement_key, expired: r.expires_at !== null && r.expires_at <= now }));
4465
4601
  }
4466
4602
  /** #977: this host, with every admin row it writes naming who the actor acted for. */
4467
- attributed(onBehalfOf) {
4468
- return attributedHost(this, onBehalfOf, this.buildAdmin);
4603
+ attributed(onBehalfOf, options) {
4604
+ return attributedView(this, { onBehalfOf, causedBy: options?.causedBy }, this.buildAdmin);
4605
+ }
4606
+ /** #2055: this host, with every admin row it writes naming `eventId` as its cause. */
4607
+ causedByView(eventId) {
4608
+ return attributedView(this, { causedBy: eventId }, this.buildAdmin);
4469
4609
  }
4470
4610
  buildAdmin() {
4471
4611
  // #977: an admin whose audit rows say on whose behalf it acts. Built over a VIEW of this
@@ -4503,12 +4643,12 @@ export class SqliteScopeHost {
4503
4643
  const requirePoolServes = (provider, tenant) => {
4504
4644
  const pool = readPool(provider);
4505
4645
  if (!pool) {
4506
- throw new Error(`identity pool '${provider}' is not registered — a pool must declare its ` +
4646
+ throw substratError('conflict', `identity pool '${provider}' is not registered — a pool must declare its ` +
4507
4647
  `topology before it may link (central vs tenant-bound decides whether the ` +
4508
4648
  `same externalId in two tenants is one person or two)`);
4509
4649
  }
4510
4650
  if (pool.topology === 'tenant-bound' && pool.tenantId !== tenant) {
4511
- throw new Error(`identity pool '${provider}' is bound to tenant ${pool.tenantId} and cannot link into ${tenant}`);
4651
+ throw substratError('conflict', `identity pool '${provider}' is bound to tenant ${pool.tenantId} and cannot link into ${tenant}`);
4512
4652
  }
4513
4653
  };
4514
4654
  const mapVertical = (r) => verticalSchema.parse({
@@ -4578,7 +4718,7 @@ export class SqliteScopeHost {
4578
4718
  */
4579
4719
  const requireOrg = (tenant, id) => {
4580
4720
  if (!readOrg(tenant, id)) {
4581
- throw new Error(`unknown org ${id} in tenant ${tenant}`);
4721
+ throw substratError('not_found', `unknown org ${id} in tenant ${tenant}`);
4582
4722
  }
4583
4723
  };
4584
4724
  // The directory row → the `scope` contract. Parsed, not cast: the columns are
@@ -4650,7 +4790,7 @@ export class SqliteScopeHost {
4650
4790
  throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4651
4791
  }
4652
4792
  if (!from.includes(row.status)) {
4653
- throw new Error(`illegal scope transition for ${action}: ${row.status} → ${to} ` +
4793
+ throw substratError('conflict', `illegal scope transition for ${action}: ${row.status} → ${to} ` +
4654
4794
  `(allowed from: ${from.join('|')})`);
4655
4795
  }
4656
4796
  // One directory transaction: the status, the audit row and — on a reap — the scope's
@@ -4674,7 +4814,7 @@ export class SqliteScopeHost {
4674
4814
  this.directory.prepare('UPDATE scopes SET status = ? WHERE scope_id = ?').run(to, scopeId);
4675
4815
  }
4676
4816
  if (to === 'reaped')
4677
- forgetSystemSwitchesOf(switchSqlOf(this.directory), scopeId);
4817
+ forgetSwitchesOf(switchSqlOf(this.directory), scopeId);
4678
4818
  // The audit target carries the scope's vertical (control-plane.md §4.4:
4679
4819
  // "vertical stays null until §4.2 lifecycle actions that name one"). It is
4680
4820
  // read from the scope rather than passed in, so the trail cannot disagree
@@ -4687,6 +4827,42 @@ export class SqliteScopeHost {
4687
4827
  (tenant_id, subject, relation, object, expires_at)
4688
4828
  VALUES (?, ?, ?, ?, ?)`)
4689
4829
  .run(tenantId, subject, relation, object, expiresAt ?? null);
4830
+ // #1184: a tenant-level removal, for inside a directory transaction — the tombstone
4831
+ // (K-21), the removal fence, raised even when nothing was held so an add still on its way
4832
+ // cannot land after this, and the audit row (`audit`), which K-21 writes only when
4833
+ // something changed. The shape of the Durable-Object adapter's `revokeAndFence`.
4834
+ //
4835
+ // All three are stamped by the HOST clock, the one that stamps a scope event's `occurredAt`:
4836
+ // the fence is compared with an add's `occurredAt`, and two clocks would let a host clock
4837
+ // running ahead of the wall push a removal under the skew window (Codex round 3).
4838
+ let revokeStmt;
4839
+ let fenceStmt;
4840
+ const revokeAndFence = (tenantId, principal, relation, object, audit) => {
4841
+ const now = this.clock();
4842
+ const changed = (revokeStmt ??= this.directory.prepare(`UPDATE _substrat_tenant_tuples SET revoked_at = ?
4843
+ WHERE tenant_id = ? AND subject = ? AND relation = ? AND object = ? AND revoked_at IS NULL`)).run(now, tenantId, `principal:${principal}`, relation, object).changes > 0;
4844
+ (fenceStmt ??= this.directory.prepare(RAISE_MEMBERSHIP_FENCE_SQL)).run(tenantId, principal, now);
4845
+ if (changed)
4846
+ audit(now);
4847
+ return changed;
4848
+ };
4849
+ const revokeTenantRoleAndFence = (actor, assignment) => {
4850
+ const { tenantId } = assignment.node;
4851
+ return revokeAndFence(tenantId, assignment.principalId, `role:${assignment.roleKey}`, `tenant:${tenantId}`, (at) => this.recordAdmin(actor, 'unassignRole', { tenantId, scopeId: null }, assignment, null, at));
4852
+ };
4853
+ // An org membership written or tombstoned with its audit row — `addMember` and `removeMember`,
4854
+ // and the membership executor's unit (#2047), one spelling each. Leaving raises the fence too.
4855
+ const joinOrg = (actor, tenantId, principal, orgId, expiresAt) => {
4856
+ writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${orgId}`, expiresAt);
4857
+ this.recordAdmin(actor, 'addMember', { tenantId }, null, memberAddedAudit(principal, orgId, expiresAt));
4858
+ };
4859
+ const leaveOrgAndFence = (actor, tenantId, principal, orgId) => revokeAndFence(tenantId, principal, 'member', `org:${orgId}`, (at) => this.recordAdmin(actor, 'removeMember', { tenantId }, { principal, orgId }, null, at));
4860
+ // The tenant-level reads the checker makes, synchronous, for the bound inside a unit.
4861
+ const directoryReader = directoryTenantReader({
4862
+ directory: this.directory,
4863
+ getRole: (tenantId, key) => this.roles.get(`${tenantId}/${key}`),
4864
+ clock: () => this.clock(),
4865
+ });
4690
4866
  // The EXPLICIT grant: `INSERT OR REPLACE` clears a tombstone, because a re-grant must
4691
4867
  // grant. Provisioning seats with `seatScopeTuple` instead, which never un-revokes
4692
4868
  // (#1659) — keep the two apart.
@@ -4722,10 +4898,14 @@ export class SqliteScopeHost {
4722
4898
  const onNode = (node, body) => node.scopeId
4723
4899
  ? this.runtime(node.tenantId, node.scopeId).actor.turn(body)
4724
4900
  : Promise.resolve().then(body);
4725
- /** #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`. */
4726
- const switchSystem = async (actor, raw, to) => {
4727
- const input = systemSwitch.parse(raw);
4728
- const { tenantId, scopeId } = input.node;
4901
+ /**
4902
+ * One kill switch moved on one scope — the ONE body `revokeFromSystem` / `restoreToSystem`
4903
+ * (#1666, a module's `system:` authority) and `revokeFromPeer` / `restoreToPeer` (#1706, a
4904
+ * peer vertical's) share, so the two levers cannot disagree about the record, the audit or
4905
+ * the undo. `kind` says which; `key` is the module id or the peer's slug.
4906
+ */
4907
+ const switchSubject = async (actor, kind, node, key, reason, to) => {
4908
+ const { tenantId, scopeId } = node;
4729
4909
  const scope = this.directory
4730
4910
  .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
4731
4911
  .get(scopeId);
@@ -4737,20 +4917,20 @@ export class SqliteScopeHost {
4737
4917
  // scope's file are separate databases, so a crash between them leaves an intent with
4738
4918
  // no recorded outcome — never a moved switch with no audit row.
4739
4919
  const operationId = ulid();
4740
- const action = to === 'off' ? 'revokeFromSystem' : 'restoreToSystem';
4920
+ const action = switchActionOf(kind, to);
4741
4921
  const target = { tenantId, scopeId };
4742
- const base = { operationId, moduleId: input.moduleId, schedules: to };
4743
- this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
4744
- // The directory's record (#1674), written BEFORE the scope moves, both ways, and undone
4745
- // if the move throws or holds nothing — see `recordSystemSwitchedOn` and (#1823)
4746
- // `recordSystemSwitchedOff` for why that order is the safe one.
4922
+ const base = { operationId, ...switchAuditSubject(kind, key, to) };
4923
+ this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason });
4924
+ // The directory's record (#1674; #2029 for a peer), written BEFORE the scope moves, both
4925
+ // ways, and undone if the move throws or holds nothing — see `recordSwitchedOn` and
4926
+ // (#1823) `recordSwitchedOff` for why that order is the safe one.
4747
4927
  const at = new Date().toISOString();
4748
4928
  const directorySql = switchSqlOf(this.directory);
4749
- const record = { tenantId, scopeId, moduleId: input.moduleId, actor, reason: input.reason, operationId, at };
4929
+ const record = { kind, tenantId, scopeId, key, actor, reason, operationId, at };
4750
4930
  const errorOf = (err) => (err instanceof Error ? err.message : String(err));
4751
4931
  let prior;
4752
4932
  try {
4753
- prior = to === 'on' ? recordSystemSwitchedOn(directorySql, record) : recordSystemSwitchedOff(directorySql, record);
4933
+ prior = to === 'on' ? recordSwitchedOn(directorySql, record) : recordSwitchedOff(directorySql, record);
4754
4934
  }
4755
4935
  catch (err) {
4756
4936
  // Nothing has moved: fail the call here, audited — the Cloudflare adapter's posture.
@@ -4762,9 +4942,15 @@ export class SqliteScopeHost {
4762
4942
  }
4763
4943
  throw err;
4764
4944
  }
4945
+ // #2045: a newer call on this subject has recorded its position already: this one writes
4946
+ // nothing, here or in the scope, and says so.
4947
+ if (recordWriteSuperseded(prior, record)) {
4948
+ this.recordAdmin(actor, action, target, null, { ...base, phase: 'refused', superseded: true });
4949
+ throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
4950
+ }
4765
4951
  /** The record's undo, after the scope's move threw or held nothing: retried once, never swallowed. */
4766
4952
  const undoRecord = () => {
4767
- const undo = () => restoreSystemSwitchRecord(directorySql, record, prior);
4953
+ const undo = () => restoreSwitchRecord(directorySql, record, prior);
4768
4954
  try {
4769
4955
  undo();
4770
4956
  return null;
@@ -4787,12 +4973,13 @@ export class SqliteScopeHost {
4787
4973
  // with it — after this verb had already audited `applied`. `turn` is re-entrant, so a
4788
4974
  // caller already inside one of this scope's tasks joins it rather than deadlocking.
4789
4975
  //
4790
- // #1823: read in the same turn, so no tenant grant moves between the read and the switch.
4791
- // The directory is this process's own database, so the read is synchronous.
4976
+ // #1823 (#2030 for a peer): read in the same turn, so no tenant grant moves between the
4977
+ // read and the switch. The directory is this process's own database, so the read is
4978
+ // synchronous.
4792
4979
  const rt = this.runtime(tenantId, scopeId);
4793
4980
  outcome = await rt.actor.turn(() => {
4794
- const tenantHeld = tenantHoldsSystemGrant(directorySql, tenantId, input.moduleId, this.clock());
4795
- return rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), { moduleId: input.moduleId, scopeId, to, at, tenantHeld }))();
4981
+ const tenantHeld = tenantHoldsGrant(directorySql, kind, tenantId, key, this.clock());
4982
+ return rt.db.transaction(() => moveSwitch(switchSqlOf(rt.db), kind, { key, scopeId, to, at, tenantHeld, fence: operationId }))();
4796
4983
  });
4797
4984
  }
4798
4985
  catch (err) {
@@ -4810,10 +4997,16 @@ export class SqliteScopeHost {
4810
4997
  }
4811
4998
  throw err;
4812
4999
  }
4813
- // A call that held nothing moved nothing (the module has no authority reaching the scope,
4814
- // its own or the tenant's), so its record write is undone: left `off`, it
4815
- // would switch the module off the day it is installed; left `on`, the next reconcile of
4816
- // a wiped scope would leave the module running.
5000
+ // #2045: the scope has applied a newer call on this subject, so this move wrote nothing. The
5001
+ // record is that newer call's too (its write overwrote this one's), so nothing is undone.
5002
+ if (outcome.superseded) {
5003
+ this.recordAdmin(actor, action, target, null, { ...base, phase: 'refused', superseded: true });
5004
+ throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
5005
+ }
5006
+ // A call that held nothing moved nothing (the subject has no authority reaching the
5007
+ // scope, its own or the tenant's), so its record write is undone: left `off`, it would
5008
+ // switch the subject off the day it is installed; left `on`, the next reconcile of a
5009
+ // wiped scope would leave it running.
4817
5010
  const recordError = outcome.held ? null : undoRecord();
4818
5011
  this.recordAdmin(actor, action, target, null, {
4819
5012
  ...base,
@@ -4823,10 +5016,15 @@ export class SqliteScopeHost {
4823
5016
  ...(recordError ? { recordError } : {}),
4824
5017
  });
4825
5018
  if (!outcome.held) {
4826
- throw substratError('not_found', `scope ${scopeId} holds no system grant for module '${input.moduleId}' — nothing to switch ${to} ` +
4827
- `(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')` +
5019
+ throw substratError('not_found', switchNotFoundMessage(kind, scopeId, key, to) +
4828
5020
  (recordError ? `; and its directory record could not be put back (${recordError})` : ''));
4829
5021
  }
5022
+ return { operationId, outcome };
5023
+ };
5024
+ /** #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`. */
5025
+ const switchSystem = async (actor, raw, to) => {
5026
+ const input = systemSwitch.parse(raw);
5027
+ const { operationId, outcome } = await switchSubject(actor, 'system', input.node, input.moduleId, input.reason, to);
4830
5028
  return {
4831
5029
  operationId,
4832
5030
  moduleId: input.moduleId,
@@ -4835,58 +5033,10 @@ export class SqliteScopeHost {
4835
5033
  permissions: outcome.permissions,
4836
5034
  };
4837
5035
  };
4838
- /**
4839
- * #1706: move one PEER's kill switch on one scope — see `HostAdmin.revokeFromPeer`. The
4840
- * schedule switch's body with the subject swapped (`switchPeer` → `switchSubjectGrants`):
4841
- * audit first, one turn on the scope actor, outcome after, `not_found` when nothing was held.
4842
- */
5036
+ /** #1706: move one PEER's kill switch on one scope — see `HostAdmin.revokeFromPeer`. */
4843
5037
  const switchPeerAt = async (actor, raw, to) => {
4844
5038
  const input = peerSwitch.parse(raw);
4845
- const { tenantId, scopeId } = input.node;
4846
- const scope = this.directory
4847
- .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
4848
- .get(scopeId);
4849
- if (!scope || scope.tenant_id !== tenantId) {
4850
- throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4851
- }
4852
- const operationId = ulid();
4853
- const action = to === 'off' ? 'revokeFromPeer' : 'restoreToPeer';
4854
- const target = { tenantId, scopeId };
4855
- const base = { operationId, vertical: input.vertical, calls: to };
4856
- this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
4857
- let outcome;
4858
- try {
4859
- const rt = this.runtime(tenantId, scopeId);
4860
- outcome = await rt.actor.turn(() => rt.db.transaction(() => switchPeer(switchSqlOf(rt.db), {
4861
- vertical: input.vertical,
4862
- scopeId,
4863
- to,
4864
- at: new Date().toISOString(),
4865
- }))());
4866
- }
4867
- catch (err) {
4868
- try {
4869
- this.recordAdmin(actor, action, target, null, {
4870
- ...base,
4871
- phase: 'failed',
4872
- error: err instanceof Error ? err.message : String(err),
4873
- });
4874
- }
4875
- catch {
4876
- // Best effort: the original error is what the caller must see.
4877
- }
4878
- throw err;
4879
- }
4880
- this.recordAdmin(actor, action, target, null, {
4881
- ...base,
4882
- phase: outcome.held ? 'applied' : 'refused',
4883
- changed: outcome.changed,
4884
- permissions: outcome.permissions,
4885
- });
4886
- if (!outcome.held) {
4887
- throw substratError('not_found', `scope ${scopeId} holds no grant for peer '${input.vertical}' — nothing to switch ${to} ` +
4888
- `(check the slug: it is the calling vertical's registry id, as the target's \`peers\` names it)`);
4889
- }
5039
+ const { operationId, outcome } = await switchSubject(actor, 'peer', input.node, input.vertical, input.reason, to);
4890
5040
  return {
4891
5041
  operationId,
4892
5042
  vertical: input.vertical,
@@ -4961,7 +5111,7 @@ export class SqliteScopeHost {
4961
5111
  const states = await rt.actor.turn(() => systemGrantsStatus(switchSqlOf(rt.db), new Date().toISOString()));
4962
5112
  const offModules = new Set(states.filter((s) => s.schedules === 'off').map((s) => s.moduleId));
4963
5113
  const explanations = offModules.size > 0 ? lastSwitchedOff(tenantId, scopeId, offModules) : new Map();
4964
- const recorded = systemSwitchRecordsOf(switchSqlOf(this.directory), tenantId, scopeId);
5114
+ const recorded = switchRecordsOf(switchSqlOf(this.directory), 'system', tenantId, scopeId);
4965
5115
  const result = withRecorded(states, recorded).map((s) => ({
4966
5116
  moduleId: s.moduleId,
4967
5117
  schedules: s.schedules,
@@ -5070,29 +5220,22 @@ export class SqliteScopeHost {
5070
5220
  // Tombstone (K-21), never DELETE — the checker skips revoked_at rows, so the
5071
5221
  // assignment stops resolving while staying visible to audit. Guarded on
5072
5222
  // `revoked_at IS NULL` so a repeat unassign is a silent no-op.
5073
- const subject = `principal:${assignment.principalId}`;
5074
- const relation = `role:${assignment.roleKey}`;
5075
- const now = new Date().toISOString();
5076
- let changes;
5077
- if (assignment.node.scopeId) {
5078
- // On the scope actor (#1678): a revoke that joined a stranger's transaction and
5079
- // rolled back with it left the role LIVE after the caller was told it was gone.
5080
- const scopeNode = assignment.node.scopeId;
5081
- const rt = this.runtime(assignment.node.tenantId, scopeNode);
5082
- changes = await rt.actor.turn(() => rt.db
5083
- .prepare(`UPDATE _substrat_tuples SET revoked_at = ?
5084
- WHERE subject = ? AND relation = ? AND object = ? AND revoked_at IS NULL`)
5085
- .run(now, subject, relation, `scope:${scopeNode}`).changes);
5086
- }
5087
- else {
5088
- changes = this.directory
5089
- .prepare(`UPDATE _substrat_tenant_tuples SET revoked_at = ?
5090
- WHERE tenant_id = ? AND subject = ? AND relation = ? AND object = ? AND revoked_at IS NULL`)
5091
- .run(now, assignment.node.tenantId, subject, relation, `tenant:${assignment.node.tenantId}`).changes;
5223
+ const scopeNode = assignment.node.scopeId;
5224
+ if (!scopeNode) {
5225
+ // One directory transaction (#1184): the revoke, the removal fence and the audit row.
5226
+ this.directory.transaction(() => revokeTenantRoleAndFence(actor, assignment))();
5227
+ return;
5092
5228
  }
5229
+ // On the scope actor (#1678): a revoke that joined a stranger's transaction and
5230
+ // rolled back with it left the role LIVE after the caller was told it was gone.
5231
+ const rt = this.runtime(assignment.node.tenantId, scopeNode);
5232
+ const changes = await rt.actor.turn(() => rt.db
5233
+ .prepare(`UPDATE _substrat_tuples SET revoked_at = ?
5234
+ WHERE subject = ? AND relation = ? AND object = ? AND revoked_at IS NULL`)
5235
+ .run(new Date().toISOString(), `principal:${assignment.principalId}`, `role:${assignment.roleKey}`, `scope:${scopeNode}`).changes);
5093
5236
  if (changes === 0)
5094
5237
  return; // never assigned, or already revoked — idempotent, unaudited
5095
- this.recordAdmin(actor, 'unassignRole', { tenantId: assignment.node.tenantId, scopeId: assignment.node.scopeId }, assignment, null);
5238
+ this.recordAdmin(actor, 'unassignRole', { tenantId: assignment.node.tenantId, scopeId: scopeNode }, assignment, null);
5096
5239
  },
5097
5240
  grant: async (actor, raw) => {
5098
5241
  // Parsed like its `grantToConnection`/`grantToSystem` siblings, not taken on
@@ -5213,10 +5356,19 @@ export class SqliteScopeHost {
5213
5356
  },
5214
5357
  tenantHeldSystemModules: async (actor, tenantId, moduleIds) => {
5215
5358
  const now = this.clock();
5216
- const held = moduleIds.filter((m) => tenantHoldsSystemGrant(switchSqlOf(this.directory), tenantId, m, now));
5359
+ const held = tenantHeldOf(switchSqlOf(this.directory), 'system', tenantId, moduleIds, now);
5217
5360
  this.recordAccess(actor, 'tenantHeldSystemModules', { tenantId }, { moduleIds: [...moduleIds] }, held.length);
5218
5361
  return held;
5219
5362
  },
5363
+ peerSwitchCarry: async (actor, node) => {
5364
+ const directorySql = switchSqlOf(this.directory);
5365
+ const switchedOffPeers = switchedOffOf(directorySql, 'peer', node.tenantId, node.scopeId);
5366
+ const tenantHeldPeers = tenantHeldOf(directorySql, 'peer', node.tenantId, switchedOffPeers, this.clock());
5367
+ const all = switchFencesOf(directorySql, 'peer', node.tenantId, node.scopeId);
5368
+ const fences = Object.fromEntries(switchedOffPeers.map((v) => [v, all.get(v)]));
5369
+ this.recordAccess(actor, 'peerSwitchCarry', node, null, switchedOffPeers.length);
5370
+ return { switchedOffPeers, tenantHeldPeers, fences };
5371
+ },
5220
5372
  reassertSystemSwitches: async (actor, node, opts) => {
5221
5373
  this.assertScope(node.tenantId, node.scopeId);
5222
5374
  const rt = this.runtime(node.tenantId, node.scopeId);
@@ -5300,7 +5452,7 @@ export class SqliteScopeHost {
5300
5452
  // rebinding it would move another tenant's traffic. Exception: the holder is
5301
5453
  // ARCHIVED or REAPED (a deleted app, storage since wiped) — it released the
5302
5454
  // name, so the rebind reclaims it.
5303
- throw new Error(`hostname '${parsed.hostname}' is already bound to another scope`);
5455
+ throw substratError('conflict', `hostname '${parsed.hostname}' is already bound to another scope`);
5304
5456
  }
5305
5457
  // Exactly one canonical per (scope, surface): "which one do certs and
5306
5458
  // redirects use" has to have one answer, so a new canonical demotes the old.
@@ -5326,7 +5478,7 @@ export class SqliteScopeHost {
5326
5478
  .prepare('SELECT tenant_id, scope_id, status FROM hostnames WHERE hostname = ?')
5327
5479
  .get(hostname);
5328
5480
  if (!row)
5329
- throw new Error(`unknown hostname '${hostname}'`);
5481
+ throw substratError('not_found', `unknown hostname '${hostname}'`);
5330
5482
  if (row.status === status)
5331
5483
  return; // idempotent, and a no-op is not audited
5332
5484
  this.directory
@@ -5340,7 +5492,7 @@ export class SqliteScopeHost {
5340
5492
  .prepare('SELECT tenant_id, scope_id, status, custom_hostname_id, validation_records FROM hostnames WHERE hostname = ?')
5341
5493
  .get(hostname);
5342
5494
  if (!row)
5343
- throw new Error(`unknown hostname '${hostname}'`);
5495
+ throw substratError('not_found', `unknown hostname '${hostname}'`);
5344
5496
  const recordsJson = fields.validationRecords.length
5345
5497
  ? JSON.stringify(fields.validationRecords)
5346
5498
  : null;
@@ -5529,7 +5681,7 @@ export class SqliteScopeHost {
5529
5681
  if (existing.ownerTenant !== parsed.ownerTenant) {
5530
5682
  throw substratError('conflict', `vertical '${parsed.slug}' is owned by ${existing.ownerTenant ?? 'the platform'}, not ${parsed.ownerTenant ?? 'the platform'}`);
5531
5683
  }
5532
- throw new Error(`vertical '${parsed.slug}' is already registered as ${existing.source}`);
5684
+ throw substratError('conflict', `vertical '${parsed.slug}' is already registered as ${existing.source}`);
5533
5685
  }
5534
5686
  this.directory
5535
5687
  .prepare('INSERT INTO verticals (slug, name, source, owner_tenant, env_spec, install_spec, listed, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
@@ -5762,11 +5914,11 @@ export class SqliteScopeHost {
5762
5914
  // gate is about change, not about existence.
5763
5915
  if (outgoing) {
5764
5916
  if (outgoing.permissionDigest !== incoming.permissionDigest && !ack.permissionChange) {
5765
- throw new Error(`promotion changes the permission surface (${outgoing.permissionDigest} → ` +
5917
+ throw substratError('conflict', `promotion changes the permission surface (${outgoing.permissionDigest} → ` +
5766
5918
  `${incoming.permissionDigest}) — acknowledge it explicitly to promote`);
5767
5919
  }
5768
5920
  if (outgoing.migrationDigest !== incoming.migrationDigest && !ack.migrationChange) {
5769
- throw new Error(`promotion changes migrations (${outgoing.migrationDigest} → ` +
5921
+ throw substratError('conflict', `promotion changes migrations (${outgoing.migrationDigest} → ` +
5770
5922
  `${incoming.migrationDigest}) — acknowledge it explicitly to promote`);
5771
5923
  }
5772
5924
  // #1705 PR 3: an export an installed consumer imports, dropped or re-versioned.
@@ -6264,6 +6416,13 @@ export class SqliteScopeHost {
6264
6416
  this.recordAccess(actor, 'lifecycleFlow', { tenantId, scopeId }, delegatedReadParams.lifecycleFlow(input), flow.observation.events);
6265
6417
  return flow;
6266
6418
  },
6419
+ operationSeries: async (actor, tenantId, scopeId, input) => {
6420
+ // #1750: business volumes per bucket, for Pulse.
6421
+ const db = this.scopeReadDbFor(tenantId, scopeId);
6422
+ const series = readOperationSeries({ sql: scopedSql(db) }, input);
6423
+ this.recordAccess(actor, 'operationSeries', { tenantId, scopeId }, delegatedReadParams.operationSeries(input), operationSeriesCount(series));
6424
+ return series;
6425
+ },
6267
6426
  scopeAppliedMigrations: async (actor, tenantId, scopeId) => {
6268
6427
  const db = this.scopeReadDbFor(tenantId, scopeId);
6269
6428
  const rows = db
@@ -6315,7 +6474,7 @@ export class SqliteScopeHost {
6315
6474
  .prepare('SELECT org_id FROM orgs WHERE tenant_id = ? AND slug = ?')
6316
6475
  .get(parsed.tenantId, parsed.slug);
6317
6476
  if (slugOwner) {
6318
- throw new Error(`org slug '${parsed.slug}' already taken by ${slugOwner.org_id} (slugs are unique per tenant)`);
6477
+ throw substratError('conflict', `org slug '${parsed.slug}' already taken by ${slugOwner.org_id} (slugs are unique per tenant)`);
6319
6478
  }
6320
6479
  this.directory
6321
6480
  .prepare('INSERT INTO orgs (org_id, tenant_id, slug, name, created_at) VALUES (?, ?, ?, ?, ?)')
@@ -6334,32 +6493,66 @@ export class SqliteScopeHost {
6334
6493
  this.recordAccess(actor, 'getOrg', { tenantId }, { orgId }, o ? 1 : 0);
6335
6494
  return o;
6336
6495
  },
6337
- addMember: async (actor, tenantId, principal, orgId) => {
6496
+ addMember: async (actor, tenantId, principal, orgId, opts) => {
6338
6497
  requireOrg(tenantId, orgId);
6339
6498
  // INSERT OR REPLACE, so re-adding a revoked member clears the tombstone —
6340
6499
  // they are a member again. The add/revoke history is not lost: it lives in
6341
6500
  // the append-only admin log, which is where "what happened" belongs. The
6342
6501
  // tuple carries "what is true now" plus enough to explain a live proof.
6343
- writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${orgId}`);
6344
- this.recordAdmin(actor, 'addMember', { tenantId }, null, { principal, orgId });
6502
+ joinOrg(actor, tenantId, principal, orgId, opts?.expiresAt ?? null);
6503
+ },
6504
+ applyMembership: async (actor, change) => {
6505
+ // ONE directory transaction, synchronous throughout (#1184): the fence (an add's), the
6506
+ // bound and the write. Nothing else on this host lands between them, and a failure
6507
+ // leaves none.
6508
+ const { tenantId, principal, roleKey, boundedBy } = change;
6509
+ const assignment = { principalId: principal, roleKey, node: { tenantId, scopeId: null } };
6510
+ return this.directory.transaction(() => {
6511
+ if (change.op === 'add') {
6512
+ const fence = this.directory
6513
+ .prepare(MEMBERSHIP_FENCE_SINCE_SQL)
6514
+ .get(tenantId, principal, change.unlessRemovedSince);
6515
+ if (fence)
6516
+ return { applied: false, removedAt: fence.removed_at };
6517
+ }
6518
+ const role = directoryReader.getRole(tenantId, roleKey);
6519
+ if (!role)
6520
+ return { applied: false, unknownRole: roleKey };
6521
+ const bound = tenantCoverage(directoryReader, tenantId, boundedBy, role.permissions);
6522
+ if (!bound.covered)
6523
+ return { applied: false, missing: bound.missing };
6524
+ // #2047: the org, bounded by `boundedBy`'s own live membership of it.
6525
+ const { orgId } = change;
6526
+ const org = orgId ? { orgId, ...orgChangeBound(directoryReader, tenantId, { ...change, orgId }) } : undefined;
6527
+ if (org) {
6528
+ if (!readOrg(tenantId, org.orgId))
6529
+ return { applied: false, unknownOrg: org.orgId };
6530
+ if (!org.bounded)
6531
+ return { applied: false, notMember: org.orgId };
6532
+ }
6533
+ if (change.op === 'remove') {
6534
+ const roleTaken = revokeTenantRoleAndFence(actor, assignment);
6535
+ const orgLeft = !!org && leaveOrgAndFence(actor, tenantId, principal, org.orgId);
6536
+ return { applied: true, changed: roleTaken || orgLeft };
6537
+ }
6538
+ writeTenantTuple(tenantId, `principal:${principal}`, `role:${roleKey}`, `tenant:${tenantId}`);
6539
+ this.recordAdmin(actor, 'assignRole', { tenantId, scopeId: null }, null, assignment);
6540
+ if (org?.bounded)
6541
+ joinOrg(actor, tenantId, principal, org.orgId, org.expiresAt);
6542
+ return { applied: true };
6543
+ })();
6345
6544
  },
6346
6545
  removeMember: async (actor, tenantId, principal, orgId) => {
6347
6546
  requireOrg(tenantId, orgId);
6348
- // Tombstone (K-21), never DELETE. Guarded on `revoked_at IS NULL` so a
6349
- // repeat revoke neither moves the timestamp nor writes a second audit row.
6350
- const info = this.directory
6351
- .prepare(`UPDATE _substrat_tenant_tuples SET revoked_at = ?
6352
- WHERE tenant_id = ? AND subject = ? AND relation = 'member' AND object = ?
6353
- AND revoked_at IS NULL`)
6354
- .run(new Date().toISOString(), tenantId, `principal:${principal}`, `org:${orgId}`);
6355
- if (info.changes === 0)
6356
- return; // never a member, or already revoked
6357
- this.recordAdmin(actor, 'removeMember', { tenantId }, { principal, orgId }, null);
6547
+ // Tombstone (K-21), never DELETE: a repeat revoke neither moves the timestamp nor writes
6548
+ // a second audit row. One transaction with the removal fence (#1184), which a no-op
6549
+ // raises too.
6550
+ this.directory.transaction(() => leaveOrgAndFence(actor, tenantId, principal, orgId))();
6358
6551
  },
6359
6552
  listMembers: async (actor, tenantId, orgId, options) => {
6360
6553
  requireOrg(tenantId, orgId);
6361
6554
  const rows = this.directory
6362
- .prepare(`SELECT subject, revoked_at FROM _substrat_tenant_tuples
6555
+ .prepare(`SELECT subject, revoked_at, expires_at FROM _substrat_tenant_tuples
6363
6556
  WHERE tenant_id = ? AND relation = 'member' AND object = ?
6364
6557
  ${options?.includeRevoked ? '' : 'AND revoked_at IS NULL'}
6365
6558
  ORDER BY subject`)
@@ -6369,6 +6562,7 @@ export class SqliteScopeHost {
6369
6562
  principal: r.subject.slice('principal:'.length),
6370
6563
  orgId,
6371
6564
  revokedAt: r.revoked_at,
6565
+ expiresAt: r.expires_at,
6372
6566
  }));
6373
6567
  },
6374
6568
  createTenant: async (actor, input) => {
@@ -6385,7 +6579,7 @@ export class SqliteScopeHost {
6385
6579
  .prepare('SELECT tenant_id FROM tenants WHERE slug = ?')
6386
6580
  .get(parsed.slug);
6387
6581
  if (slugOwner) {
6388
- throw new Error(`tenant slug '${parsed.slug}' already taken by ${slugOwner.tenant_id} (slugs are unique)`);
6582
+ throw substratError('conflict', `tenant slug '${parsed.slug}' already taken by ${slugOwner.tenant_id} (slugs are unique)`);
6389
6583
  }
6390
6584
  this.directory
6391
6585
  .prepare(`INSERT INTO tenants (tenant_id, slug, name, status, created_at, provisioned_by_tenant)
@@ -6396,12 +6590,12 @@ export class SqliteScopeHost {
6396
6590
  setTenantStatus: async (actor, tenantId, status) => {
6397
6591
  const before = readTenant(tenantId);
6398
6592
  if (!before)
6399
- throw new Error(`unknown tenant: ${tenantId}`);
6593
+ throw substratError('not_found', `unknown tenant: ${tenantId}`);
6400
6594
  // `reaped` is terminal and destroys data — it is unreachable here and only
6401
6595
  // ever set by reapTenant, so a plain status flip cannot forge a tombstone
6402
6596
  // over live data (§4.8, the tenant analogue of reapScope's archived-only gate).
6403
6597
  if (status === 'reaped') {
6404
- throw new Error(`tenant ${tenantId} cannot be set to 'reaped' via setTenantStatus — reap goes through reapTenant (control-plane.md §4.8)`);
6598
+ throw substratError('validation_failed', `tenant ${tenantId} cannot be set to 'reaped' via setTenantStatus — reap goes through reapTenant (control-plane.md §4.8)`);
6405
6599
  }
6406
6600
  // Stamp/clear deleting_at so the grace-window sweep can age tenants (§4.8):
6407
6601
  // entering `deleting` stamps, leaving it (an un-delete) clears — exactly the
@@ -6421,7 +6615,7 @@ export class SqliteScopeHost {
6421
6615
  setTenantName: async (actor, tenantId, name) => {
6422
6616
  const before = readTenant(tenantId);
6423
6617
  if (!before)
6424
- throw new Error(`unknown tenant: ${tenantId}`);
6618
+ throw substratError('not_found', `unknown tenant: ${tenantId}`);
6425
6619
  if (before.name === name)
6426
6620
  return; // no-op is not audited — nothing changed
6427
6621
  this.directory.prepare('UPDATE tenants SET name = ? WHERE tenant_id = ?').run(name, tenantId);
@@ -6553,7 +6747,7 @@ export class SqliteScopeHost {
6553
6747
  // the query, so the quoted identifier below carries no user input.
6554
6748
  const known = new Set(db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((r) => r.name));
6555
6749
  if (!known.has(input.table))
6556
- throw new Error(`unknown table '${input.table}'`);
6750
+ throw substratError('not_found', `unknown table '${input.table}'`);
6557
6751
  // The ceiling clamps; a bound SQLite would misread (NaN, non-finite, fractional,
6558
6752
  // negative) is refused instead of reaching LIMIT / OFFSET (#1632).
6559
6753
  const limit = Math.min(assertRowLimit('limit', input.limit ?? SCOPE_TABLE_PAGE_DEFAULT), SCOPE_TABLE_PAGE_MAX);
@@ -6574,7 +6768,7 @@ export class SqliteScopeHost {
6574
6768
  // itself rejects multi-statement strings and bad SQL with the driver's message.
6575
6769
  const stmt = db.prepare(sql);
6576
6770
  if (!stmt.readonly)
6577
- throw new Error('read-only console: statement is not read-only');
6771
+ throw substratError('validation_failed', 'read-only console: statement is not read-only');
6578
6772
  if (!stmt.reader)
6579
6773
  return { columns: [], rows: [], truncated: false };
6580
6774
  stmt.raw(true);
@@ -6720,6 +6914,7 @@ export class SqliteScopeHost {
6720
6914
  loadDirectoryDump(dump.tables, {
6721
6915
  columnsOf: (name) => builtColumnsOf(this.directory, name),
6722
6916
  exec: (sql) => this.directory.exec(sql),
6917
+ now: () => this.clock(),
6723
6918
  insert: (sql, rows) => {
6724
6919
  const insert = this.directory.prepare(sql);
6725
6920
  for (const row of rows)
@@ -6771,7 +6966,7 @@ export class SqliteScopeHost {
6771
6966
  throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
6772
6967
  }
6773
6968
  if (rec.status !== 'archived') {
6774
- throw new Error(`scope ${scopeId} is ${rec.status}, not archived — only an archived scope may be reaped`);
6969
+ throw substratError('conflict', `scope ${scopeId} is ${rec.status}, not archived — only an archived scope may be reaped`);
6775
6970
  }
6776
6971
  // A serving scope always holds ≥1 bound hostname; a truly-dead one has been
6777
6972
  // unbound. The dashboard delete path unbinds AT archive, but a bare console
@@ -6787,7 +6982,7 @@ export class SqliteScopeHost {
6787
6982
  .prepare('SELECT hostname FROM hostnames WHERE scope_id = ? LIMIT 1')
6788
6983
  .get(scopeId);
6789
6984
  if (bound) {
6790
- throw new Error(`scope ${scopeId} still resolves hostname '${bound.hostname}' — ` +
6985
+ throw substratError('conflict', `scope ${scopeId} still resolves hostname '${bound.hostname}' — ` +
6791
6986
  `unbind it before reaping (reap wipes storage and cannot be undone)`);
6792
6987
  }
6793
6988
  // The reader goes with the writer (#1624): an open handle on a removed file keeps
@@ -6938,9 +7133,9 @@ export class SqliteScopeHost {
6938
7133
  // reaped — an illegal source fails closed, like reapScope's archived-only gate.
6939
7134
  const before = readTenant(tenantId);
6940
7135
  if (!before)
6941
- throw new Error(`unknown tenant: ${tenantId}`);
7136
+ throw substratError('not_found', `unknown tenant: ${tenantId}`);
6942
7137
  if (before.status !== 'deleting') {
6943
- throw new Error(`tenant ${tenantId} is ${before.status}, not deleting — only a deleting tenant may be reaped`);
7138
+ throw substratError('conflict', `tenant ${tenantId} is ${before.status}, not deleting — only a deleting tenant may be reaped`);
6944
7139
  }
6945
7140
  // Clear the tenant's directory-side rows. Idempotent (set-to-empty), so a crash
6946
7141
  // mid-reap converges on retry. KEPT as the tombstone: the `tenants` row itself
@@ -6954,6 +7149,11 @@ export class SqliteScopeHost {
6954
7149
  '_substrat_entitlements', // per-tenant SKU flags
6955
7150
  'orgs', // K-22 org records
6956
7151
  '_substrat_system_switches', // #1674: the schedule switch's record, per scope
7152
+ '_substrat_membership_fences', // #1184: the latest removal, per principal
7153
+ '_substrat_peer_switches', // #2029: the peer switch's record, per scope
7154
+ '_substrat_switch_owed', // #2045: subjects owed a re-assert, per scope
7155
+ '_substrat_findings', // #1748: the tenant's findings
7156
+ '_substrat_finding_rules', // #1748: the tenant's suppress rules
6957
7157
  ];
6958
7158
  const clear = this.directory.transaction(() => {
6959
7159
  for (const table of tables) {
@@ -7072,7 +7272,7 @@ export class SqliteScopeHost {
7072
7272
  if (existing.topology === parsed.topology && existing.tenantId === parsed.tenantId) {
7073
7273
  return;
7074
7274
  }
7075
- throw new Error(`identity pool '${parsed.provider}' is already registered as ${existing.topology}` +
7275
+ throw substratError('conflict', `identity pool '${parsed.provider}' is already registered as ${existing.topology}` +
7076
7276
  `${existing.tenantId ? ` for tenant ${existing.tenantId}` : ''}`);
7077
7277
  }
7078
7278
  this.directory
@@ -7091,9 +7291,9 @@ export class SqliteScopeHost {
7091
7291
  listIdentityTenants: async (actor, provider, externalId) => {
7092
7292
  const pool = readPool(provider);
7093
7293
  if (!pool)
7094
- throw new Error(`identity pool '${provider}' is not registered`);
7294
+ throw substratError('not_found', `identity pool '${provider}' is not registered`);
7095
7295
  if (pool.topology !== 'central') {
7096
- throw new Error(`identity pool '${provider}' is tenant-bound — enumerating tenants is only ` +
7296
+ throw substratError('forbidden', `identity pool '${provider}' is tenant-bound — enumerating tenants is only ` +
7097
7297
  `meaningful on a central pool, where the same externalId is the same person`);
7098
7298
  }
7099
7299
  const tenants = this.directory
@@ -7107,9 +7307,9 @@ export class SqliteScopeHost {
7107
7307
  listIdentityMemberships: async (actor, provider, externalId) => {
7108
7308
  const pool = readPool(provider);
7109
7309
  if (!pool)
7110
- throw new Error(`identity pool '${provider}' is not registered`);
7310
+ throw substratError('not_found', `identity pool '${provider}' is not registered`);
7111
7311
  if (pool.topology !== 'central') {
7112
- throw new Error(`identity pool '${provider}' is tenant-bound — enumerating tenants is only ` +
7312
+ throw substratError('forbidden', `identity pool '${provider}' is tenant-bound — enumerating tenants is only ` +
7113
7313
  `meaningful on a central pool, where the same externalId is the same person`);
7114
7314
  }
7115
7315
  const rows = this.directory
@@ -7408,7 +7608,7 @@ export class SqliteScopeHost {
7408
7608
  if (existing) {
7409
7609
  if (existing.principal_id === parsed.principal)
7410
7610
  return; // idempotent, unaudited
7411
- throw new Error(`identity ${parsed.provider}:${parsed.externalId} in tenant ${parsed.tenantId} ` +
7611
+ throw substratError('conflict', `identity ${parsed.provider}:${parsed.externalId} in tenant ${parsed.tenantId} ` +
7412
7612
  `is already bound to ${existing.principal_id}`);
7413
7613
  }
7414
7614
  this.directory
@@ -7455,6 +7655,11 @@ export class SqliteScopeHost {
7455
7655
  const { tenantId, scopeId, ...after } = ownerTransferAudit.parse(entry);
7456
7656
  this.recordAdmin(actor, 'transferOwner', { tenantId, scopeId }, null, after);
7457
7657
  },
7658
+ /** #1150: one phase of a dashboard member change, written around the vertical's own. */
7659
+ recordMemberChange: async (actor, entry) => {
7660
+ const { tenantId, scopeId, ...after } = memberChangeAudit.parse(entry);
7661
+ this.recordAdmin(actor, 'manageScopeMember', { tenantId, scopeId }, null, after);
7662
+ },
7458
7663
  /** #2005: one change to a scope's copy marker, written around the vertical's own change. */
7459
7664
  recordCopyMark: async (actor, entry) => {
7460
7665
  const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
@@ -7597,40 +7802,49 @@ export class SqliteScopeHost {
7597
7802
  recordOpsFailure: async (entry) => {
7598
7803
  const at = new Date().toISOString();
7599
7804
  const fingerprint = opsFailureFingerprint(entry);
7600
- this.directory
7601
- .prepare(`INSERT INTO _substrat_ops_failures
7602
- (id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
7603
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
7604
- .run(ulid(), entry.actor, entry.operation, entry.stage ?? null, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.status ?? null,
7605
- // Bounded here, not trusted from the catch site: one runaway upstream
7606
- // body must not become a runaway directory row (#559).
7607
- entry.message.slice(0, 2000), entry.reference ?? null, entry.origin ?? null, entry.code ?? null, fingerprint, at);
7608
- // Prune-on-write (#559): retention lives here, not in a cron — every insert
7609
- // pays for its own housekeeping, so the table stays bounded even where no
7610
- // scheduled pass runs (this adapter has none).
7611
- const horizon = new Date(Date.now() - OPS_FAILURE_RETENTION_DAYS * 86_400_000).toISOString();
7612
- this.directory.prepare('DELETE FROM _substrat_ops_failures WHERE at < ?').run(horizon);
7613
- // The issues materialization (#1233): the group's counters live on their
7614
- // own row, bumped in the same call, because the evidence self-prunes above
7615
- // and a count must survive its own exemplars. A fresh arrival regresses a
7616
- // resolved issue; an ignored one stays ignored — that is what ignoring means.
7617
- this.directory
7618
- .prepare(`INSERT INTO _substrat_issues
7619
- (fingerprint, operation, stage, origin, code, status, seen_count, first_seen, last_seen, last_message, last_tenant_id, last_owner_kind, last_vertical, last_version, resolved_version, resolved_at)
7620
- VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
7621
- ON CONFLICT (fingerprint) DO UPDATE SET
7622
- seen_count = seen_count + 1,
7623
- last_seen = excluded.last_seen,
7624
- last_message = excluded.last_message,
7625
- last_tenant_id = excluded.last_tenant_id,
7626
- last_owner_kind = excluded.last_owner_kind,
7627
- last_vertical = COALESCE(excluded.last_vertical, last_vertical),
7628
- last_version = COALESCE(excluded.last_version, last_version),
7629
- origin = COALESCE(excluded.origin, origin),
7630
- status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`)
7631
- .run(fingerprint, entry.operation, entry.stage ?? null, entry.origin ?? null, entry.code ?? null, at, at, entry.message.slice(0, 2000), entry.tenantId ?? null, issueExemplarOwner(entry.tenantId ?? null), entry.vertical ?? null, entry.version ?? null);
7632
- const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
7633
- this.directory.prepare('DELETE FROM _substrat_issues WHERE last_seen < ?').run(issueHorizon);
7805
+ // #1748: the evidence, its issue and its finding in ONE transaction — a detector that
7806
+ // throws takes the evidence with it, so the caller's retry observes it rather than finding
7807
+ // the row already there and the observation lost.
7808
+ this.directory.transaction(() => {
7809
+ this.directory
7810
+ .prepare(`INSERT INTO _substrat_ops_failures
7811
+ (id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
7812
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
7813
+ .run(ulid(), entry.actor, entry.operation, entry.stage ?? null, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.status ?? null,
7814
+ // Bounded here, not trusted from the catch site: one runaway upstream
7815
+ // body must not become a runaway directory row (#559).
7816
+ entry.message.slice(0, 2000), entry.reference ?? null, entry.origin ?? null, entry.code ?? null, fingerprint, at);
7817
+ // Prune-on-write (#559): retention lives here, not in a cron — every insert
7818
+ // pays for its own housekeeping, so the table stays bounded even where no
7819
+ // scheduled pass runs (this adapter has none).
7820
+ const horizon = new Date(Date.now() - OPS_FAILURE_RETENTION_DAYS * 86_400_000).toISOString();
7821
+ this.directory.prepare('DELETE FROM _substrat_ops_failures WHERE at < ?').run(horizon);
7822
+ // The issues materialization (#1233): the group's counters live on their
7823
+ // own row, bumped in the same call, because the evidence self-prunes above
7824
+ // and a count must survive its own exemplars. A fresh arrival regresses a
7825
+ // resolved issue; an ignored one stays ignored — that is what ignoring means.
7826
+ this.directory
7827
+ .prepare(`INSERT INTO _substrat_issues
7828
+ (fingerprint, operation, stage, origin, code, status, seen_count, first_seen, last_seen, last_message, last_tenant_id, last_owner_kind, last_vertical, last_version, resolved_version, resolved_at)
7829
+ VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
7830
+ ON CONFLICT (fingerprint) DO UPDATE SET
7831
+ seen_count = seen_count + 1,
7832
+ last_seen = excluded.last_seen,
7833
+ last_message = excluded.last_message,
7834
+ last_tenant_id = excluded.last_tenant_id,
7835
+ last_owner_kind = excluded.last_owner_kind,
7836
+ last_vertical = COALESCE(excluded.last_vertical, last_vertical),
7837
+ last_version = COALESCE(excluded.last_version, last_version),
7838
+ origin = COALESCE(excluded.origin, origin),
7839
+ status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`)
7840
+ .run(fingerprint, entry.operation, entry.stage ?? null, entry.origin ?? null, entry.code ?? null, at, at, entry.message.slice(0, 2000), entry.tenantId ?? null, issueExemplarOwner(entry.tenantId ?? null), entry.vertical ?? null, entry.version ?? null);
7841
+ const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
7842
+ this.directory.prepare('DELETE FROM _substrat_issues WHERE last_seen < ?').run(issueHorizon);
7843
+ // #1748: the tenant's Recurring finding, over the tenant's own occurrences only.
7844
+ const finding = findingOfOpsFailure(entry);
7845
+ if (finding)
7846
+ observeFinding(redactionSqlOf(this.directory), finding, at);
7847
+ })();
7634
7848
  },
7635
7849
  listOpsFailures: async (actor, filter) => {
7636
7850
  const where = [];
@@ -7712,19 +7926,30 @@ export class SqliteScopeHost {
7712
7926
  }));
7713
7927
  },
7714
7928
  recordSweepRun: async (entry) => {
7715
- this.directory
7716
- .prepare(`INSERT OR IGNORE INTO _substrat_sweep_runs
7717
- (id, kind, unit, outcome, tenant_id, scope_id, vertical, version, operation,
7718
- connection_id, error, elapsed_ms, request_id, event_type, observed_at, platform_requests, at)
7719
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
7720
- .run(ulid(), entry.kind, entry.unit, entry.outcome, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.operation ?? null, entry.connectionId ?? null,
7721
- // Bounded here, not trusted from the sweep: one runaway provider body
7722
- // must not become a runaway directory row (the #559 rule).
7723
- entry.error == null ? null : entry.error.slice(0, 2000), entry.elapsedMs ?? null, entry.requestId ?? null, entry.eventType ?? null, entry.observedAt ?? null, entry.platformRequests == null ? null : JSON.stringify(entry.platformRequests), entry.at ?? new Date().toISOString());
7724
- // Prune-on-write, like ops failures and for the same reason: the table stays
7725
- // bounded even on a deployment whose scheduled pass is broken.
7726
- const horizon = new Date(Date.now() - SWEEP_RUN_RETENTION_DAYS * 86_400_000).toISOString();
7727
- this.directory.prepare('DELETE FROM _substrat_sweep_runs WHERE at < ?').run(horizon);
7929
+ const at = entry.at ?? new Date().toISOString();
7930
+ // #1748: the row and its finding in ONE transaction. A replayed drain the unique index
7931
+ // ignores observes nothing, which is right only because the first arrival's observation
7932
+ // committed with it, or did not commit at all.
7933
+ this.directory.transaction(() => {
7934
+ const { changes } = this.directory
7935
+ .prepare(`INSERT OR IGNORE INTO _substrat_sweep_runs
7936
+ (id, kind, unit, outcome, tenant_id, scope_id, vertical, version, operation,
7937
+ connection_id, error, elapsed_ms, request_id, event_type, observed_at, platform_requests, at)
7938
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
7939
+ .run(ulid(), entry.kind, entry.unit, entry.outcome, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.operation ?? null, entry.connectionId ?? null,
7940
+ // Bounded here, not trusted from the sweep: one runaway provider body
7941
+ // must not become a runaway directory row (the #559 rule).
7942
+ entry.error == null ? null : entry.error.slice(0, 2000), entry.elapsedMs ?? null, entry.requestId ?? null, entry.eventType ?? null, entry.observedAt ?? null, entry.platformRequests == null ? null : JSON.stringify(entry.platformRequests), at);
7943
+ // Prune-on-write, like ops failures and for the same reason: the table stays
7944
+ // bounded even on a deployment whose scheduled pass is broken.
7945
+ const horizon = new Date(Date.now() - SWEEP_RUN_RETENTION_DAYS * 86_400_000).toISOString();
7946
+ this.directory.prepare('DELETE FROM _substrat_sweep_runs WHERE at < ?').run(horizon);
7947
+ // #1748: a failed schedule (Invariant) or a stale freshness verdict (Drift). Only when the
7948
+ // row was written: a replayed drain the unique index ignored is not a second occurrence.
7949
+ const finding = changes > 0 ? findingOfSweepRun(entry) : null;
7950
+ if (finding)
7951
+ observeFinding(redactionSqlOf(this.directory), finding, at);
7952
+ })();
7728
7953
  },
7729
7954
  listSweepRuns: async (actor, filter) => {
7730
7955
  const where = [];
@@ -7794,6 +8019,8 @@ export class SqliteScopeHost {
7794
8019
  }
7795
8020
  return pruned;
7796
8021
  },
8022
+ // #1748: one transaction, so a stale resolution never lands without its audit row.
8023
+ pruneFindings: async (actor, limit) => this.auditedFindings(actor, (sql, audit) => pruneFindings(sql, Date.now(), limit, audit)),
7797
8024
  listIssues: async (actor, filter) => {
7798
8025
  const where = [];
7799
8026
  const params = [];
@@ -7838,6 +8065,25 @@ export class SqliteScopeHost {
7838
8065
  this.recordAdmin(actor, 'setIssueStatus', { tenantId: null, vertical: after.lastVertical }, { fingerprint, status: existing.status }, { fingerprint, status: after.status });
7839
8066
  return after;
7840
8067
  },
8068
+ listFindings: async (actor, filter) => {
8069
+ const rows = listFindings(redactionSqlOf(this.directory), filter);
8070
+ this.recordAccess(actor, 'listFindings', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
8071
+ return rows;
8072
+ },
8073
+ // #1748: every findings mutation runs in ONE transaction with its audit row (`audited`).
8074
+ setFindingStatus: async (actor, tenantId, id, status) => this.auditedFindings(actor, (sql, audit) => setFindingStatus(sql, tenantId, id, status, new Date().toISOString(), audit))
8075
+ ?.after,
8076
+ createFindingRule: async (actor, tenantId, input) => {
8077
+ const { rule, suppressed } = this.auditedFindings(actor, (sql, audit) => createFindingRule(sql, tenantId, input, actor, new Date().toISOString(), audit));
8078
+ return { rule, suppressed: suppressed.length };
8079
+ },
8080
+ revokeFindingRule: async (actor, tenantId, ruleId) => this.auditedFindings(actor, (sql, audit) => revokeFindingRule(sql, tenantId, ruleId, new Date().toISOString(), audit))
8081
+ ?.after,
8082
+ listFindingRules: async (actor, tenantId, filter) => {
8083
+ const rows = listFindingRules(redactionSqlOf(this.directory), tenantId, filter?.active ? new Date().toISOString() : undefined, filter?.limit);
8084
+ this.recordAccess(actor, 'listFindingRules', { tenantId }, filter, rows.length);
8085
+ return rows;
8086
+ },
7841
8087
  recordModelUsage: async (input) => {
7842
8088
  const l = input.line;
7843
8089
  // INSERT OR IGNORE on the UNIQUE request id: the drain may replay a settled-then-
@@ -8179,6 +8425,10 @@ export class SqliteScopeHost {
8179
8425
  * can never disagree about who acted (#97). `overrideActor` remains for the
8180
8426
  * system-actor path, where the acting module is the honest answer.
8181
8427
  */
8428
+ /** The context a consumer or an import runs in: no operation, and the delivery it is for (#2055). */
8429
+ deliveryContext(rt, subject, overrideActor, delivery) {
8430
+ return this.operationContext(rt, subject, overrideActor, undefined, undefined, undefined, [], undefined, delivery);
8431
+ }
8182
8432
  operationContext(rt, subject, overrideActor,
8183
8433
  /** #458: per-invoke tally of `ctx.requestPlatform` calls; absent for consumer dispatch. */
8184
8434
  signals,
@@ -8208,7 +8458,9 @@ export class SqliteScopeHost {
8208
8458
  * instant its row write used, so the `capability.exercised` event's `occurredAt` and the
8209
8459
  * row's `last_used_at` are the same value — a second clock read could disagree.
8210
8460
  */
8211
- at = this.clock()) {
8461
+ at = this.clock(),
8462
+ /** #2055: the delivery a consumer's or an import's context runs for; absent otherwise. */
8463
+ delivery) {
8212
8464
  // For a connection, system, capability or peer-vertical subject this carries THAT id so
8213
8465
  // the type holds — it is not a person, and the event actor below says what it is instead.
8214
8466
  const principal = subject.id;
@@ -8216,6 +8468,7 @@ export class SqliteScopeHost {
8216
8468
  const relations = this.relations;
8217
8469
  const searchPlans = this.searchPlans;
8218
8470
  const listPlans = this.listPlans;
8471
+ const statePlans = this.statePlans;
8219
8472
  // K-34: the checks that passed in THIS operation. The context is created per invoke
8220
8473
  // (see buildStub), so this accumulates one operation's authorizations; `emit` snapshots
8221
8474
  // whatever has passed up to that point. A system/override actor is unconditionally
@@ -8337,11 +8590,10 @@ export class SqliteScopeHost {
8337
8590
  // about the process, so it never rides `DomainEvent` for module code to
8338
8591
  // branch on; it exists for the observability joins the column serves.
8339
8592
  this.versionId,
8340
- // #1237: whatever delivery is in flight FOR THIS SCOPE, if any — read off
8341
- // the runtime, not the host, because the host serves every scope at once
8342
- // and this context outlives an `await`. A fact about the surrounding
8343
- // dispatch, never envelope data module code could set or branch on.
8344
- rt.causedBy,
8593
+ // #1237: the delivery this context was built for, if any — passed in, never read
8594
+ // off the runtime or the host (#2055). A fact about the surrounding dispatch,
8595
+ // never envelope data module code could set or branch on.
8596
+ delivery?.causedBy ?? null,
8345
8597
  // #1237: a fact about the surrounding CALL, like the version above — never
8346
8598
  // envelope data module code could set or branch on.
8347
8599
  rt.invocationId, full.payload === undefined ? null : JSON.stringify(full.payload));
@@ -8350,7 +8602,11 @@ export class SqliteScopeHost {
8350
8602
  tenantId: rt.tenantId,
8351
8603
  scopeId: rt.scopeId,
8352
8604
  principal,
8353
- sql: guardSecrets(guardSqlLimits(scopedSql(rt.db, true)), minted),
8605
+ sql: guardSecrets(guardSqlLimits(scopedSql(rt.db, true, statefulTablesOf(statePlans),
8606
+ // #119: after runtime DDL, the stateful tables must still carry what the kernel derived.
8607
+ statePlans.size
8608
+ ? () => assertEntityStateIntact(spineSql(rt.db), statePlans, stateListIndexNames(listPlans.values()))
8609
+ : undefined)), minted),
8354
8610
  now: () => at,
8355
8611
  // #1746/#1747: the host stamps who and where; module code supplies only the template
8356
8612
  // and its fields. The invocation id is read per call — it is set for the duration of
@@ -8359,7 +8615,7 @@ export class SqliteScopeHost {
8359
8615
  tenantId: rt.tenantId,
8360
8616
  scopeId: rt.scopeId,
8361
8617
  operation: operation ?? null,
8362
- invocationId: () => rt.invocationId ?? rt.unitInvocationId,
8618
+ invocationId: () => rt.invocationId ?? delivery?.invocationId ?? null,
8363
8619
  // A consumer runs under the system override, so it logs as `system`.
8364
8620
  principalKind: overrideActor ? 'system' : subject.kind,
8365
8621
  // The string-safe redaction: `redactSecrets` parses its serialization back, and a
@@ -8420,7 +8676,9 @@ export class SqliteScopeHost {
8420
8676
  const plan = searchPlans.get(entityType);
8421
8677
  if (!plan)
8422
8678
  throw new NotSearchable(entityType);
8423
- const q = searchQuery(plan, searchMatchExpression(term, plan.tokenizer), searchLimit(options?.limit));
8679
+ const q = searchQuery(plan, searchMatchExpression(term, plan.tokenizer), searchLimit(options?.limit),
8680
+ // #119: active rows unless the archive is asked for.
8681
+ searchStateWhere(statePlans, entityType, options?.view));
8424
8682
  return rt.db.prepare(q.sql).all(...q.params).map((row) => ({ entityType, id: row.id, rank: row.rank }));
8425
8683
  },
8426
8684
  /**
@@ -8441,13 +8699,15 @@ export class SqliteScopeHost {
8441
8699
  order: params.order,
8442
8700
  cursor: params.cursor,
8443
8701
  filters: params.filters,
8702
+ // #119: the bin is the checked reader's, never this one's.
8703
+ view: uncheckedView('ctx.page', entityType, params.view),
8444
8704
  });
8445
8705
  const rows = rt.db.prepare(q.sql).all(...q.params);
8446
8706
  // A FULL page may have more; a short one is the end of the walk. Same rule
8447
8707
  // as `pageOf`, applied here because the cursor is a COLUMN's value (plus
8448
8708
  // the tie-break) rather than a field the caller could name.
8449
8709
  const last = rows.length >= limit ? rows[rows.length - 1] : undefined;
8450
- const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn, q.order);
8710
+ const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn, q.order, q.view);
8451
8711
  const page = { entries: rows, nextCursor };
8452
8712
  if (!params.total)
8453
8713
  return page;
@@ -8526,6 +8786,24 @@ export class SqliteScopeHost {
8526
8786
  emit: (event) => writeEvent(event, 'kernel'),
8527
8787
  assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
8528
8788
  }),
8789
+ // #119: archive and trash, written once in the kernel. The raw seam, because the guarded
8790
+ // `ctx.sql` refuses the very columns these write; the operation's own check, so the
8791
+ // declared key's pass is one of its authorizations.
8792
+ ...createEntityStateVerbs({
8793
+ sql: spineSql(rt.db),
8794
+ plans: statePlans,
8795
+ now: at,
8796
+ check: runCheck,
8797
+ emit: (event) => writeEvent(event, 'kernel'),
8798
+ assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
8799
+ }),
8800
+ ...createTrashedReads({
8801
+ query: (q, params) => rt.db.prepare(q).all(...params),
8802
+ listPlans,
8803
+ searchPlans,
8804
+ statePlans,
8805
+ check: runCheck,
8806
+ }),
8529
8807
  // #304: the request-time entitlement read. The pure adapter is single-process, so the
8530
8808
  // directory is local — no projection needed; it reads `_substrat_entitlements` straight,
8531
8809
  // with the same expiry-at-read filter as the gate (#33), and the SAME contract the DO
@@ -8689,7 +8967,7 @@ export class SqliteScopeHost {
8689
8967
  if (!r || r.tenant_id !== tenantId)
8690
8968
  throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
8691
8969
  if (r.status === 'reaped') {
8692
- throw new Error(`scope ${scopeId} is reaped — its storage is gone and cannot be read`);
8970
+ throw substratError('conflict', `scope ${scopeId} is reaped — its storage is gone and cannot be read`);
8693
8971
  }
8694
8972
  }
8695
8973
  /**
@@ -8859,6 +9137,13 @@ export class SqliteScopeHost {
8859
9137
  // executes, the table always already has `kind` in its key, never `invocation_id`.
8860
9138
  this.ensureColumn(db, '_substrat_schedule_state', 'invocation_id', 'invocation_id TEXT');
8861
9139
  this.ensureColumn(db, '_substrat_job_runs', 'subject_id', 'subject_id TEXT');
9140
+ // #2034: the lease, on a scope DB built before it. NULL = nobody holds the run.
9141
+ this.ensureColumn(db, '_substrat_job_runs', 'lease_owner', 'lease_owner TEXT');
9142
+ // #2042 r2, r4: whether the holder BEGAN its pass. NULL = it has not, which is right for any lease
9143
+ // already there: a takeover of it costs nothing.
9144
+ this.ensureColumn(db, '_substrat_job_runs', 'lease_began_at', 'lease_began_at TEXT');
9145
+ // #2042 r3: consecutive admission misses. NULL = none, right for every run already there.
9146
+ this.ensureColumn(db, '_substrat_job_runs', 'admission_misses', 'admission_misses INTEGER');
8862
9147
  // #2009: the copy classification on a scope DB built before it (NULL reads as a copy; see
8863
9148
  // `COPY_ORIGIN_DDL`).
8864
9149
  this.ensureColumn(db, '_substrat_copy_origin', 'is_copy', 'is_copy INTEGER');
@@ -8907,9 +9192,7 @@ export class SqliteScopeHost {
8907
9192
  actor: new ScopeActor(),
8908
9193
  appliedMigrations,
8909
9194
  mintEventId,
8910
- causedBy: null,
8911
9195
  invocationId: null,
8912
- unitInvocationId: null,
8913
9196
  };
8914
9197
  this.scopes.set(key, created);
8915
9198
  this.scopesById.set(scopeId, created);
@@ -8974,7 +9257,11 @@ function assertTablesWithinColumnLimit(db) {
8974
9257
  if (wide)
8975
9258
  throw new Error(tooManyTableColumns(wide.name, wide.width));
8976
9259
  }
8977
- function scopedSql(db, judgeWidth = false) {
9260
+ function scopedSql(db, judgeWidth = false,
9261
+ /** #119: the module tables carrying archive/trash columns — `guardSpine` refuses positional writes to them. */
9262
+ statefulTables,
9263
+ /** #119: the kernel's integrity check, run after any runtime DDL — see `guardSpine`. */
9264
+ afterDdl) {
8978
9265
  // #1811: a Durable Object refuses a result set over its column cap, `exec` of a SELECT included.
8979
9266
  // The driver knows the width, so it is read off the prepared statement rather than parsed out
8980
9267
  // of the text (a `SELECT *` is as wide as the tables under it). Module SQL only, like the
@@ -9001,7 +9288,7 @@ function scopedSql(db, judgeWidth = false) {
9001
9288
  assertTablesWithinColumnLimit(db);
9002
9289
  return { changes: info.changes };
9003
9290
  },
9004
- });
9291
+ }, statefulTables, afterDdl);
9005
9292
  }
9006
9293
  const schemaVersion = (db) => db.pragma('schema_version', { simple: true });
9007
9294
  //# sourceMappingURL=index.js.map