@substrat-run/adapter-sqlite 0.139.0 → 0.140.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,11 +1,13 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, 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, 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';
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, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, 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, pageOf, countedPageOf, SCOPE_GATE_REASONS, substratError, assertReplayableDump, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, 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, CONTINUATION_POSITION_CAP, visibleContinuation, searchStateWhere, uncheckedView, addStatePlans, entityStateMigrations, assertNoCallerPurge, isUnreachableParent, heldPurgePass, purgeDueOf, purgeHeldBy, lifecycleRefusal, purgeOnlyKeysOf, purgeReportOf, purgeStillDue, refuseTrashedTarget, registerTrashTargets, runPurgePass, withheldKeysFor, statefulTablesOf, afterMigration, afterRuntimeDdl, derivesAnything, repairDerivedObjects, StateColumnLost, 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, CONNECT_LINKS_DDL, consumeConnectLinkRow, insertConnectLink, listConnectLinks as listConnectLinkRows, readConnectLink, restoreConnectLinkRow, revokeConnectLinkRow, 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, eraseSubjectFromModules, moduleRowsErased, moduleErasurePlan, 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, resolvePeerInstanceFrom, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, PEER_SWITCHES_DDL, PEER_BINDINGS_DDL, SWITCH_OWED_DDL, SWITCH_FENCES_DDL, TABLE_OWNERS_DDL, assertMigrationLeavesLedgerAlone, recordOwnershipSteps, runMigrationStatements, executableSqlStatements, blankSqlComments, recordWriteSuperseded, switchFencesOf, switchSupersededMessage, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, moveSwitch, reassertActionOf, reassertEntry, reassertOffRow, recordSwitchedOff, recordSwitchedOn, restoreSwitchRecord, scopesSwitchedOffFor, switchActionOf, switchAuditSubject, recordAuditOutcome, auditWarningOf, 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, listIndexMigrations, listIndexPlans, listQuery, cursorOf, moduleMigrations, MIGRATION_DIGEST_FENCE_DDL, MIGRATION_DIGEST_MARK_LEGACY, assertJournalDumpCoherent, assertMigrationSql, migrationDivergence, migrationFailedError, migrationSteps, planMigrations, isSearchIndexTable, 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, delegatedRevokeSql, writeExplicitTupleIn, grantEntityShapeIn, shapeTopUpBatch, topUpEntityGrantShapes, settlePlatformRequestIn, } from '@substrat-run/kernel';
7
+ import { ADMIN_LOG_INDEXES_SQL, SETTLE_INTENT_SQL, SETTLE_OUTCOME_SQL, settleOutcomeParamsOf, readAuditedOperations, unknownOutcomeOf, } from '@substrat-run/kernel';
7
8
  import { INERT_SCOPE_REASON, isPrimaryScopeRow } from '@substrat-run/kernel';
8
- import { LEGACY_SCOPE_ROWS_BACKFILL, assertNoSpineReference, assertSpineTablesBuilt, capabilitiesForLoad, dumpRowsInsert, isSpineTable, loadDirectoryDump, repointScopeGrants, settleCopiedWork, emittedHere, spineColumnAdditions } from '@substrat-run/kernel';
9
+ import { GRANT_CHILDREN_INDEX_DDL, grantedEntitiesForContext } from '@substrat-run/kernel';
10
+ import { LEGACY_SCOPE_ROWS_BACKFILL, assertSpineTablesBuilt, capabilitiesForLoad, dumpRowsInsert, isSpineTable, loadDirectoryDump, repointScopeGrants, settleCopiedWork, emittedHere, spineColumnAdditions } from '@substrat-run/kernel';
9
11
  import { ScopeActor } from './actor.js';
10
12
  import { createTupleChecker, directoryTenantReader } from './checker.js';
11
13
  /**
@@ -244,9 +246,15 @@ const KERNEL_DDL = `
244
246
  applied_at TEXT NOT NULL,
245
247
  duration_ms INTEGER,
246
248
  rows_changed INTEGER,
249
+ -- #2066: SHA-256 of the SQL that ran (kernel migrationDigest). NULL on a row written
250
+ -- before the column: unrecorded, accepted, never backfilled.
251
+ sql_digest TEXT,
247
252
  PRIMARY KEY (module_id, version)
248
253
  );
254
+ -- #2066: no new journal row without its digest (the kernel's comment says why).
255
+ ${MIGRATION_DIGEST_FENCE_DDL}
249
256
  ${SWITCH_FENCES_DDL}
257
+ ${TABLE_OWNERS_DDL}
250
258
  CREATE TABLE IF NOT EXISTS _substrat_tuples (
251
259
  subject TEXT NOT NULL,
252
260
  relation TEXT NOT NULL,
@@ -258,6 +266,7 @@ const KERNEL_DDL = `
258
266
  revoked_at TEXT,
259
267
  PRIMARY KEY (subject, relation, object)
260
268
  );
269
+ ${GRANT_CHILDREN_INDEX_DDL};
261
270
  CREATE TABLE IF NOT EXISTS _substrat_deliveries (
262
271
  event_id TEXT NOT NULL,
263
272
  consumer_module TEXT NOT NULL,
@@ -470,6 +479,66 @@ const admitByDelivery = (rt, eventId, row) => {
470
479
  `the attachments of the entity its event names, and no others`);
471
480
  }
472
481
  };
482
+ /**
483
+ * #119: this thread's claim on each host directory it has open, by real path — the OS lock and
484
+ * how many live hosts on this thread share it. Several hosts on ONE thread may share a directory
485
+ * (that is how the pure adapter models several verticals on one platform directory, #1705): the
486
+ * guarantees that read the directory and a scope's file together do so with no `await` in between,
487
+ * and one thread's JS runs one task at a time, so nothing it runs can interleave there. A writer on
488
+ * another thread or in ANOTHER process could. This map is module state, so each worker thread has
489
+ * its own: a second thread opens its own `_host.lock` connection, and SQLite refuses it exactly as
490
+ * it refuses another process.
491
+ */
492
+ const PROCESS_HOST_DIRS = new Map();
493
+ /**
494
+ * #119: claim a host directory for this process, for one live `SqliteScopeHost`. The first host in
495
+ * the process takes an exclusive lock on `_host.lock` (a SQLite file: `locking_mode = EXCLUSIVE`
496
+ * plus a write holds SQLite's file lock until the connection closes); later hosts in the process
497
+ * share it, and the last `release()` drops it. The operating system drops it when the process
498
+ * exits, however it exits, so it never goes stale. No busy timeout: another process's claim is
499
+ * refused at once — `conflict`, reason `host_dir_in_use`, naming the directory — never queued.
500
+ */
501
+ function claimHostDir(dir) {
502
+ const key = realpathSync(dir);
503
+ let claim = PROCESS_HOST_DIRS.get(key);
504
+ if (!claim) {
505
+ const lock = new Database(join(key, '_host.lock'), { timeout: 0 });
506
+ try {
507
+ lock.pragma('locking_mode = EXCLUSIVE');
508
+ // No journal file beside the directory: the one write only takes the lock.
509
+ lock.pragma('journal_mode = MEMORY');
510
+ lock.exec('CREATE TABLE IF NOT EXISTS owner (id INTEGER PRIMARY KEY CHECK (id = 1), pid INTEGER NOT NULL, at TEXT NOT NULL)');
511
+ lock.prepare('INSERT OR REPLACE INTO owner (id, pid, at) VALUES (1, ?, ?)').run(process.pid, new Date().toISOString());
512
+ }
513
+ catch (err) {
514
+ lock.close();
515
+ const code = err.code;
516
+ if (code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED') {
517
+ throw substratError('conflict', `host directory already in use by another process: ${key} — one process owns a host directory`, {
518
+ reason: 'host_dir_in_use',
519
+ });
520
+ }
521
+ throw err;
522
+ }
523
+ claim = { lock, hosts: 0 };
524
+ PROCESS_HOST_DIRS.set(key, claim);
525
+ }
526
+ claim.hosts += 1;
527
+ const held = claim;
528
+ let released = false;
529
+ return {
530
+ release: () => {
531
+ if (released)
532
+ return;
533
+ released = true;
534
+ held.hosts -= 1;
535
+ if (held.hosts > 0)
536
+ return;
537
+ PROCESS_HOST_DIRS.delete(key);
538
+ held.lock.close();
539
+ },
540
+ };
541
+ }
473
542
  export class SqliteScopeHost {
474
543
  admin;
475
544
  /**
@@ -499,8 +568,39 @@ export class SqliteScopeHost {
499
568
  */
500
569
  liveReads;
501
570
  dir;
571
+ /** #119: this host's share of its process's claim on `dir` (`claimHostDir`), released by `close()`. */
572
+ owner;
502
573
  checker;
503
574
  directory;
575
+ /** A private directory seam: module SQL and scope/directory exports never reach it. */
576
+ continuationStore(scopeId, writable) {
577
+ const pruneExpired = () => this.directory.prepare('DELETE FROM private_continuation_positions WHERE expires_at <= ?').run(Date.parse(this.clock()));
578
+ return {
579
+ keys: async () => {
580
+ const row = this.directory.prepare('SELECT keyring FROM private_continuation_keys WHERE scope_id = ?').get(scopeId);
581
+ return row ? JSON.parse(row.keyring) : null;
582
+ },
583
+ setKeys: async (keys) => {
584
+ this.directory.prepare(`INSERT INTO private_continuation_keys (scope_id, keyring) VALUES (?, ?)
585
+ ON CONFLICT(scope_id) DO UPDATE SET keyring = excluded.keyring`).run(scopeId, JSON.stringify(keys));
586
+ },
587
+ position: async (id, expiresAt) => {
588
+ if (writable)
589
+ pruneExpired();
590
+ const row = this.directory.prepare(`SELECT ciphertext FROM private_continuation_positions
591
+ WHERE scope_id = ? AND expires_at = ? AND locator = ?`).get(scopeId, expiresAt, id);
592
+ return row ? { sealed: JSON.parse(row.ciphertext), expiresAt } : null;
593
+ },
594
+ setPosition: async (id, position) => {
595
+ pruneExpired();
596
+ this.directory.prepare(`INSERT INTO private_continuation_positions (scope_id, expires_at, locator, ciphertext)
597
+ VALUES (?, ?, ?, ?)`).run(scopeId, position.expiresAt, id, JSON.stringify(position.sealed));
598
+ this.directory.prepare(`DELETE FROM private_continuation_positions WHERE rowid IN (
599
+ SELECT rowid FROM private_continuation_positions WHERE scope_id = ?
600
+ ORDER BY rowid DESC LIMIT -1 OFFSET ?)`).run(scopeId, CONTINUATION_POSITION_CAP);
601
+ },
602
+ };
603
+ }
504
604
  scopes = new Map();
505
605
  scopesById = new Map();
506
606
  /** The runtimes holding an open `reader` (#1624), least recently read first — `READER_CAP`'s LRU. */
@@ -526,6 +626,8 @@ export class SqliteScopeHost {
526
626
  listPlans = new Map();
527
627
  /** #119: entity type → its archive/trash plan, from every registered module. */
528
628
  statePlans = new Map();
629
+ /** #2068: each registered module's erasure, in registration order — what `shredSubject` runs. */
630
+ erasurePlans = [];
529
631
  /** entityType → the declared attachment gate (#473): read key + write key (default: read). */
530
632
  attachmentTargets = new Map();
531
633
  /** operation name → who binds it: the owning module, its entitlementKey and its declared
@@ -544,6 +646,11 @@ export class SqliteScopeHost {
544
646
  * a bare `defineOperation` carries no declaration to read one off.
545
647
  */
546
648
  operationConcurrency = new Map();
649
+ /**
650
+ * #119: name → the entity the operation addresses by id, its declared key and whether it
651
+ * reaches a trashed one — what the host's trash refusal reads. Populated by `registerModule`.
652
+ */
653
+ operationTarget = new Map();
547
654
  /**
548
655
  * #116: the operations that declared `idempotency: false` — a set of refusals,
549
656
  * because that is what the declaration is. Every name NOT in here honours an
@@ -603,24 +710,43 @@ export class SqliteScopeHost {
603
710
  this.versionId = options.versionId ?? null;
604
711
  this.logSink = options.logSink ?? consoleLogSink;
605
712
  this.invocationLineSink = options.invocationLineSink ?? consoleInvocationLineSink;
713
+ // Every option is read above and here, BEFORE the directory is claimed: an option that throws
714
+ // (a getter, a bad value) then fails the constructor with nothing yet held.
715
+ const customChecker = options.checker;
606
716
  this.dir = options.dir;
607
717
  mkdirSync(this.dir, { recursive: true });
608
- this.directory = new Database(join(this.dir, '_directory.sqlite'));
609
- this.directory.pragma('journal_mode = WAL');
610
- this.ensureDirectorySchema();
611
- this.loadRoles();
612
- this.checker =
613
- options.checker ??
614
- createTupleChecker({
615
- directory: this.directory,
616
- scopeDb: (scopeId) => this.scopesById.get(scopeId)?.db,
617
- getRole: (tenantId, key) => this.roles.get(`${tenantId}/${key}`),
618
- // #956: the evaluator judges `expires_at` against the host's clock, so a
619
- // scripted one can actually expire a grant. A caller-supplied `checker`
620
- // keeps its own time source — this only binds the built-in one.
621
- clock: () => this.clock(),
622
- });
623
- this.admin = this.buildAdmin();
718
+ // #119: from the claim to the end of the constructor is ONE guard. A constructor that throws
719
+ // leaves no host for anyone to close, so whatever it opened must be released here, and the
720
+ // only way to keep that true as lines are added is that every line after the claim is inside
721
+ // this `try`. Do not add work after it; add it inside, before `this.owner`.
722
+ const owner = claimHostDir(this.dir);
723
+ let directory;
724
+ try {
725
+ directory = new Database(join(this.dir, '_directory.sqlite'));
726
+ this.directory = directory;
727
+ this.directory.pragma('journal_mode = WAL');
728
+ this.ensureDirectorySchema();
729
+ this.loadRoles();
730
+ this.checker =
731
+ customChecker ??
732
+ createTupleChecker({
733
+ directory: this.directory,
734
+ scopeDb: (scopeId) => this.scopesById.get(scopeId)?.db,
735
+ getRole: (tenantId, key) => this.roles.get(`${tenantId}/${key}`),
736
+ // #956: the evaluator judges `expires_at` against the host's clock, so a
737
+ // scripted one can actually expire a grant. A caller-supplied `checker`
738
+ // keeps its own time source — this only binds the built-in one.
739
+ clock: () => this.clock(),
740
+ });
741
+ this.admin = this.buildAdmin();
742
+ this.owner = owner;
743
+ }
744
+ catch (err) {
745
+ // A host that never finished opening owns nothing: the next one may open the directory.
746
+ directory?.close();
747
+ owner.release();
748
+ throw err;
749
+ }
624
750
  }
625
751
  /**
626
752
  * The directory schema, applied on every open, and inside a restore's transaction onto an
@@ -648,7 +774,7 @@ export class SqliteScopeHost {
648
774
  * recorded answer to #969; `docs/architecture/kernel-design.md` §8 says why.
649
775
  */
650
776
  applyDirectorySchema() {
651
- this.directory.exec(`
777
+ execSqlStatements(this.directory, `
652
778
  -- The tenant registry (control-plane.md §4.1). Before this a tenant was an
653
779
  -- FK string on scope rows; now it is a real record with a lifecycle status.
654
780
  CREATE TABLE IF NOT EXISTS tenants (
@@ -707,6 +833,22 @@ export class SqliteScopeHost {
707
833
  archived_at TEXT,
708
834
  created_at TEXT NOT NULL
709
835
  );
836
+ -- Private host state. Module SQL opens only the scope file, never this directory;
837
+ -- neither table is included in directory exports or scope dumps (#2074).
838
+ CREATE TABLE IF NOT EXISTS private_continuation_keys (
839
+ scope_id TEXT PRIMARY KEY,
840
+ keyring TEXT NOT NULL
841
+ );
842
+ CREATE TABLE IF NOT EXISTS private_continuation_positions (
843
+ scope_id TEXT NOT NULL,
844
+ expires_at INTEGER NOT NULL,
845
+ locator TEXT NOT NULL,
846
+ ciphertext TEXT NOT NULL,
847
+ PRIMARY KEY (scope_id, expires_at, locator)
848
+ );
849
+ CREATE INDEX IF NOT EXISTS private_continuation_positions_expiry
850
+ ON private_continuation_positions (expires_at);
851
+ ${PEER_BINDINGS_DDL}
710
852
  -- The hostname map (K-26). A single environment-wide router resolves against
711
853
  -- this before dispatching to the vertical's worker.
712
854
  --
@@ -1007,6 +1149,9 @@ export class SqliteScopeHost {
1007
1149
  updated_at TEXT NOT NULL,
1008
1150
  PRIMARY KEY (connection_id, state_key)
1009
1151
  );
1152
+ -- A vertical's mailed connect links (connections.md §3.5.4) — kernel-owned DDL, so
1153
+ -- both adapters build the same table.
1154
+ ${CONNECT_LINKS_DDL}
1010
1155
  CREATE TABLE IF NOT EXISTS _substrat_identities (
1011
1156
  provider TEXT NOT NULL,
1012
1157
  external_id TEXT NOT NULL,
@@ -1094,14 +1239,9 @@ export class SqliteScopeHost {
1094
1239
  on_behalf_of TEXT,
1095
1240
  at TEXT NOT NULL
1096
1241
  );
1097
- -- Read-path indexes for the console (control-plane.md §4.5). The admin log
1098
- -- is append-only and only grows, so every filter it offers needs one; the
1099
- -- trailing id column makes each a covering index for the ORDER BY.
1100
- CREATE INDEX IF NOT EXISTS _substrat_admin_log_tenant ON _substrat_admin_log (tenant_id, id);
1101
- CREATE INDEX IF NOT EXISTS _substrat_admin_log_scope ON _substrat_admin_log (scope_id, id);
1102
- CREATE INDEX IF NOT EXISTS _substrat_admin_log_actor ON _substrat_admin_log (actor, id);
1103
- CREATE INDEX IF NOT EXISTS _substrat_admin_log_action ON _substrat_admin_log (action, id);
1104
- CREATE INDEX IF NOT EXISTS _substrat_admin_log_at ON _substrat_admin_log (at);
1242
+ -- Every admin-log index, from the kernel's one list (#2064), which the legacy
1243
+ -- rebuild below runs again after its rename.
1244
+ ${ADMIN_LOG_INDEXES_SQL}
1105
1245
  -- Operational failures (#559): what the platform could NOT do. Unlike the
1106
1246
  -- never-swept admin log above, this is retention-bounded telemetry, pruned
1107
1247
  -- on write (OPS_FAILURE_RETENTION_DAYS). reference carries the upstream
@@ -1402,6 +1542,11 @@ export class SqliteScopeHost {
1402
1542
  if (this.modules.has(manifest.id)) {
1403
1543
  throw new Error(`module already registered: ${manifest.id}`);
1404
1544
  }
1545
+ // #119: refused before anything is recorded, so a module the trash rules refuse leaves nothing.
1546
+ const trashTargets = registerTrashTargets(manifest.id, new Set(Object.keys(registration.operations ?? {})), registration.operationInputs, manifest.entityStates, manifest.schedules);
1547
+ // #2068: refused here, before anything is recorded, when the module claims an erasure it
1548
+ // cannot deliver (a hook with no reach declared, a `custom` entity with no hook).
1549
+ const erasure = moduleErasurePlan(registration);
1405
1550
  const migrations = registration.migrations ?? [];
1406
1551
  const seen = new Set();
1407
1552
  for (const m of migrations) {
@@ -1492,11 +1637,15 @@ export class SqliteScopeHost {
1492
1637
  // The order the kernel writes once (#1677): authored, then search, then list indexes.
1493
1638
  // The two derived sets are still computed above, for the duplicate-version refusals.
1494
1639
  migrations: moduleMigrations(registration),
1640
+ steps: migrationSteps(registration),
1495
1641
  consumers,
1496
1642
  schedules: manifest.schedules ?? [],
1497
1643
  freshness: manifest.freshness ?? [],
1498
1644
  peers: manifest.peers ?? [],
1645
+ purgeOnlyKeys: purgeOnlyKeysOf(manifest.schedules ?? []),
1499
1646
  });
1647
+ if (erasure)
1648
+ this.erasurePlans.push(erasure);
1500
1649
  for (const rel of manifest.entityRelations ?? []) {
1501
1650
  const parents = this.relations.get(rel.entityType) ?? new Set();
1502
1651
  parents.add(rel.parentType);
@@ -1583,6 +1732,9 @@ export class SqliteScopeHost {
1583
1732
  if (declaredOptOuts.includes(name) && this.operations.has(name)) {
1584
1733
  this.operationIdempotencyOptOut.add(name);
1585
1734
  }
1735
+ const target = trashTargets.get(name);
1736
+ if (target && this.operations.has(name))
1737
+ this.operationTarget.set(name, target);
1586
1738
  }
1587
1739
  }
1588
1740
  defineOperation(name, handler) {
@@ -2246,13 +2398,16 @@ export class SqliteScopeHost {
2246
2398
  // A Durable Object's dump carries spine tables this adapter keeps in its directory, or does
2247
2399
  // not keep at all; they are skipped by name, and any other unknown spine table is refused.
2248
2400
  const loadable = replayable.filter((t) => !DO_SCOPE_ONLY_SPINE_TABLES.has(t.name.toLowerCase()));
2401
+ // Comment-blanked (#2068), as the Durable Object replays it: the dump's DDL is
2402
+ // `sqlite_master.sql` verbatim, and `prepare` still compiles exactly one statement.
2249
2403
  for (const t of loadable)
2250
2404
  if (!isSpineTable(t.name))
2251
- db.prepare(t.ddl).run();
2252
- db.exec(KERNEL_DDL);
2405
+ db.prepare(blankSqlComments(t.ddl)).run();
2406
+ execSqlStatements(db, KERNEL_DDL);
2253
2407
  this.ensureSpineColumns(db);
2254
2408
  const columnsOf = (name) => builtColumnsOf(db, name);
2255
2409
  assertSpineTablesBuilt(loadable.map((t) => t.name), columnsOf);
2410
+ assertJournalDumpCoherent(loadable);
2256
2411
  // A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
2257
2412
  // untyped column the checker never reads.
2258
2413
  for (const t of loadable) {
@@ -2268,28 +2423,15 @@ export class SqliteScopeHost {
2268
2423
  for (const row of t.rows)
2269
2424
  stmt.run(...row);
2270
2425
  }
2271
- // Rebuild the derived search indexes over the rows just loaded (#827). The DDL
2272
- // drops and recreates, so this also repairs an index the dump left stale, and
2273
- // the triggers it recreates are what keep the restored scope in step from here.
2274
- // Skipped for a plan whose content table this dump did not carry — a restore
2275
- // must not invent a table for an index to point at.
2276
- const present = new Set(db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((r) => r.name));
2277
- for (const plan of this.searchPlans.values()) {
2278
- if (present.has(plan.table))
2279
- db.exec(searchIndexDdl(plan));
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
- }
2426
+ // #827 / #119 / #811: the search triggers, the archive/trash guard triggers, the purge
2427
+ // sweep's index and the derived list indexes went with the dropped tables, and the search index points at rows that are
2428
+ // gone. Put back, and the search index rebuilt, AFTER the rows — which may legitimately
2429
+ // arrive archived or trashed — as the dump's own journal says they were derived (#2090).
2430
+ // A plan whose table this dump did not carry is skipped: a restore must not invent one.
2431
+ repairDerivedObjects(spineSql(db), (ddl) => execSqlStatements(db, ddl), this.derivedPlans(), {
2432
+ after: 'the restored dump',
2433
+ absentTable: 'skip',
2434
+ });
2293
2435
  // #1575: attachment text is not in a dump, so a load left it as it was. Drop the
2294
2436
  // text of attachments the dump did not bring back, and queue extraction for those
2295
2437
  // it brought back without text — the bytes decide what that run finds.
@@ -2333,10 +2475,11 @@ export class SqliteScopeHost {
2333
2475
  }
2334
2476
  // The frontier came in with the dump — refresh the cached applied-migration set so
2335
2477
  // a later bind/migrate builds on the loaded state, not the previous one.
2336
- rt.appliedMigrations.clear();
2337
- for (const r of db.prepare('SELECT module_id, version FROM _substrat_migrations').all()) {
2338
- rt.appliedMigrations.add(`${r.module_id}@${r.version}`);
2339
- }
2478
+ rt.appliedMigrations = readAppliedMigrations(db);
2479
+ // The directory is a separate database, but the actor serializes the load
2480
+ // and the next invocation. Old locators must be gone before that next turn.
2481
+ this.directory.prepare('DELETE FROM private_continuation_positions WHERE scope_id = ?').run(scopeId);
2482
+ this.directory.prepare('DELETE FROM private_continuation_keys WHERE scope_id = ?').run(scopeId);
2340
2483
  });
2341
2484
  }
2342
2485
  async restoreScope(actor, tenantId, scopeId, dump, opts) {
@@ -2399,8 +2542,13 @@ export class SqliteScopeHost {
2399
2542
  // orphaned bytes with no record (the §9 hazard).
2400
2543
  this.directory.prepare('DELETE FROM hostnames WHERE scope_id = ?').run(scopeId);
2401
2544
  rmSync(join(this.dir, `${tenantId}__${scopeId}.sqlite`), { force: true });
2402
- forgetSwitchesOf(switchSqlOf(this.directory), scopeId);
2403
- this.directory.prepare('DELETE FROM scopes WHERE scope_id = ?').run(scopeId);
2545
+ this.directory.transaction(() => {
2546
+ this.directory.prepare('UPDATE peer_bindings SET invalidated = 1 WHERE target_scope_id = ?').run(scopeId);
2547
+ forgetSwitchesOf(switchSqlOf(this.directory), scopeId);
2548
+ this.directory.prepare('DELETE FROM private_continuation_positions WHERE scope_id = ?').run(scopeId);
2549
+ this.directory.prepare('DELETE FROM private_continuation_keys WHERE scope_id = ?').run(scopeId);
2550
+ this.directory.prepare('DELETE FROM scopes WHERE scope_id = ?').run(scopeId);
2551
+ })();
2404
2552
  this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
2405
2553
  forkedFrom: rec.forkedFrom,
2406
2554
  forkedAt: rec.forkedAt,
@@ -2553,6 +2701,14 @@ export class SqliteScopeHost {
2553
2701
  return this.buildStub(tenantId, scopeId, rt, asPrincipal(record.principal), options, record.id);
2554
2702
  }
2555
2703
  async getSystemScope(moduleId, tenantId, scopeId) {
2704
+ return this.openSystemScope(moduleId, tenantId, scopeId, false);
2705
+ }
2706
+ /**
2707
+ * The system door's stub. `purge` (#119) is set only by `runPurgeSweep`, which never hands the
2708
+ * stub on: it is the one stub whose invokes may use the module's purge-only keys, and only on
2709
+ * the purge operation. Every stub any caller can get is built with it off.
2710
+ */
2711
+ async openSystemScope(moduleId, tenantId, scopeId, purge) {
2556
2712
  // The scheduler's door (#383) — mirror of getConnectorScope. The module must be
2557
2713
  // registered on this host (a schedule can only invoke its own vertical's ops),
2558
2714
  // and the scope must be active. Authority is then an ordinary check against
@@ -2571,7 +2727,7 @@ export class SqliteScopeHost {
2571
2727
  }
2572
2728
  const rt = this.runtime(tenantId, scopeId);
2573
2729
  await this.applyPendingMigrations(rt);
2574
- return this.buildStub(tenantId, scopeId, rt, { kind: 'system', id: moduleId });
2730
+ return this.buildStub(tenantId, scopeId, rt, { kind: 'system', id: moduleId }, undefined, undefined, purge);
2575
2731
  }
2576
2732
  /**
2577
2733
  * #1705: the producer's release, decided by THIS deployment's own declarations. The admin
@@ -3186,19 +3342,34 @@ export class SqliteScopeHost {
3186
3342
  const startedAt = Date.now();
3187
3343
  let emitted;
3188
3344
  let failure;
3345
+ // #119, #2096: more of a purge horizon's lap is due — the schedule stays due, so the next pass continues it.
3346
+ let stillDue = false;
3189
3347
  try {
3190
- const stub = await this.getSystemScope(moduleId, tenantId, scopeId);
3191
- await stub.invoke(schedule.operation, schedule.input, { invocationId, onEmitted: (r) => (emitted = r) });
3348
+ if (schedule.purge) {
3349
+ // #119: a purge horizon's schedule runs its operation once per due entity, never once.
3350
+ const pass = await this.runPurgeSweep(moduleId, tenantId, scopeId, schedule.operation);
3351
+ stillDue = purgeStillDue(pass);
3352
+ const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
3353
+ report.errors.push(...outcome.errors);
3354
+ if (outcome.failure)
3355
+ throw outcome.failure;
3356
+ }
3357
+ else {
3358
+ const stub = await this.getSystemScope(moduleId, tenantId, scopeId);
3359
+ await stub.invoke(schedule.operation, schedule.input, { invocationId, onEmitted: (r) => (emitted = r) });
3360
+ }
3192
3361
  report.fired += 1;
3193
3362
  }
3194
3363
  catch (err) {
3195
3364
  status = 'failed';
3196
3365
  failure = { error: err };
3197
3366
  report.failed += 1;
3198
- report.errors.push({
3199
- operation: schedule.operation,
3200
- error: err instanceof Error ? err.message : String(err),
3201
- });
3367
+ if (!schedule.purge) {
3368
+ report.errors.push({
3369
+ operation: schedule.operation,
3370
+ error: err instanceof Error ? err.message : String(err),
3371
+ });
3372
+ }
3202
3373
  }
3203
3374
  lines.write({
3204
3375
  kind: 'schedule',
@@ -3215,6 +3386,9 @@ export class SqliteScopeHost {
3215
3386
  ...(emitted ? { emitted } : {}),
3216
3387
  versionId: this.versionId,
3217
3388
  });
3389
+ report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
3390
+ if (stillDue)
3391
+ continue;
3218
3392
  // On the scope actor (#1678): the cadence row joined whatever transaction an invoke
3219
3393
  // held open, so its rollback forgot the run and the next pass fired it AGAIN.
3220
3394
  await rt.actor.turn(() => rt.db
@@ -3224,11 +3398,65 @@ export class SqliteScopeHost {
3224
3398
  last_status = excluded.last_status,
3225
3399
  invocation_id = excluded.invocation_id`)
3226
3400
  .run(schedule.operation, new Date(now).toISOString(), status, invocationId));
3227
- report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
3228
3401
  }
3229
3402
  lines.end();
3230
3403
  return report;
3231
3404
  }
3405
+ /**
3406
+ * One pass of a purge horizon's schedule on one scope (#119) — the Durable Object's
3407
+ * `runPurgeSweep`, mirrored. The caller names only which schedule: the module and the entity come
3408
+ * from the registered schedule, the cutoff from this host's clock and the declared horizon, the
3409
+ * ids from the scope's own bin, read in a turn. Then the purge operation is invoked once per id
3410
+ * through a purge stub (`openSystemScope`) that never leaves this method, each in its own
3411
+ * transaction and under the cutoff the host re-checks inside it (`refuseTrashedTarget`).
3412
+ */
3413
+ async runPurgeSweep(moduleId, tenantId, scopeId, operation) {
3414
+ const entityType = this.modules
3415
+ .get(moduleId)
3416
+ ?.schedules.find((s) => s.operation === operation && s.purge)?.purge?.entityType;
3417
+ if (entityType === undefined)
3418
+ throw substratError('not_found', `no purge schedule of ${moduleId} runs ${operation}`);
3419
+ // The pair first, from the directory, before the scope's storage is opened for it.
3420
+ const owner = this.directory.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?').get(scopeId);
3421
+ if (!owner || owner.tenant_id !== tenantId)
3422
+ throw substratError('not_found', `unknown scope: ${scopeId}`);
3423
+ this.directory.prepare('DELETE FROM private_continuation_positions WHERE expires_at <= ?')
3424
+ .run(Date.parse(this.clock()));
3425
+ const rt = this.runtime(tenantId, scopeId);
3426
+ await this.applyPendingMigrations(rt);
3427
+ const now = this.clock();
3428
+ // #119: the gate the schedule run applies, applied by the sweep itself (`purgeHeldBy`), and
3429
+ // again inside each purge's transaction. A held scope selects nothing.
3430
+ const due = await rt.actor.turn(() => {
3431
+ const held = purgeHeldBy(this.purgeGateFacts(rt, moduleId, tenantId, scopeId));
3432
+ return held !== null
3433
+ ? { held }
3434
+ : purgeDueOf(spineSql(rt.db), this.statePlans, this.operationTarget, operation, entityType, now);
3435
+ });
3436
+ if ('held' in due)
3437
+ return heldPurgePass(due.held);
3438
+ const stub = await this.openSystemScope(moduleId, tenantId, scopeId, true);
3439
+ return runPurgePass(operation, due, async (entityId) => {
3440
+ await stub.invoke(operation, { [due.idFrom]: entityId }, { invocationId: ulid() });
3441
+ }, (write) => rt.actor.turn(() => write(spineSql(rt.db))));
3442
+ }
3443
+ /**
3444
+ * #119: the purge gate's facts (`PurgeGateFacts`) — the module's switch from the scope's own
3445
+ * storage, and the lifecycle, the primacy and the tenant from this host's directory, which is
3446
+ * their authority here.
3447
+ */
3448
+ purgeGateFacts(rt, moduleId, tenantId, scopeId) {
3449
+ const row = this.directory
3450
+ .prepare(`SELECT s.tenant_id AS tenant_id, s.status AS scope, t.status AS tenant
3451
+ FROM scopes s LEFT JOIN tenants t ON t.tenant_id = s.tenant_id WHERE s.scope_id = ?`)
3452
+ .get(scopeId);
3453
+ return {
3454
+ switched: systemScheduleState(switchSqlOf(rt.db), moduleId, this.clock()),
3455
+ lifecycle: row ? lifecycleRefusal({ scope: row.scope, tenant: row.tenant ?? 'active' }) : null,
3456
+ copy: !this.isPrimaryInDirectory(scopeId),
3457
+ foreignTenant: row?.tenant_id !== tenantId,
3458
+ };
3459
+ }
3232
3460
  /** The stub body, shared by the principal, connection, system, impersonation and capability doors. */
3233
3461
  buildStub(tenantId, scopeId, rt,
3234
3462
  /**
@@ -3244,7 +3472,9 @@ export class SqliteScopeHost {
3244
3472
  * about them with no override branch anywhere — and this is what every
3245
3473
  * record additionally keeps, plus the bound a read-only session is held to.
3246
3474
  */
3247
- sessionId) {
3475
+ sessionId,
3476
+ /** #119: a purge stub — `openSystemScope`'s, built only by `runPurgeSweep`, never handed on. */
3477
+ purge = false) {
3248
3478
  const operations = this.operations;
3249
3479
  return {
3250
3480
  tenantId,
@@ -3258,6 +3488,15 @@ export class SqliteScopeHost {
3258
3488
  : authority.kind,
3259
3489
  invoke: async (operation, input, invokeOptions) => {
3260
3490
  const handler = operations.get(operation);
3491
+ // #119: purge authority is never an invoke option — only a purge stub, which
3492
+ // `runPurgeSweep` builds and keeps, purges — and an options object naming a cutoff is
3493
+ // refused outright.
3494
+ try {
3495
+ assertNoCallerPurge(invokeOptions);
3496
+ }
3497
+ catch (err) {
3498
+ return Promise.reject(err);
3499
+ }
3261
3500
  // `not_found`, not a bare throw (#113) — the same code `adapter-cloudflare`
3262
3501
  // gives it, so a demo and a hosted vertical answer 404 for the same reason.
3263
3502
  if (!handler)
@@ -3369,7 +3608,15 @@ export class SqliteScopeHost {
3369
3608
  const minted = [];
3370
3609
  // Fresh per operation: the context carries the K-34 authorization accumulator,
3371
3610
  // which must not leak across operations (invokes are serialized per scope).
3372
- const ctx = this.operationContext(rt, subject, undefined, signals, session, operation, minted);
3611
+ // #119: the keys the module's system principal holds only for its purge schedules are
3612
+ // withheld from every call but the purge sweep's own invoke of the purge operation.
3613
+ const target = this.operationTarget.get(operation);
3614
+ // Through the system door only: the sweep's mark on any other subject's call means nothing.
3615
+ const purging = purge && subject.kind === 'system';
3616
+ const withheld = subject.kind === 'system'
3617
+ ? withheldKeysFor(this.modules.get(subject.id)?.purgeOnlyKeys, target, purging)
3618
+ : undefined;
3619
+ const ctx = this.operationContext(rt, subject, undefined, signals, session, operation, minted, undefined, undefined, withheld);
3373
3620
  const clonedInput = structuredClone(input);
3374
3621
  // #893: parse, don't trust — at the scope door, from the operation's own
3375
3622
  // declaration. BEFORE `BEGIN`, so a malformed call never opens a
@@ -3436,11 +3683,28 @@ export class SqliteScopeHost {
3436
3683
  // so the only loss is effort on a path that was already failing.
3437
3684
  const ref = guarded ? this.concurrencyRef(operation, guarded, parsed) : undefined;
3438
3685
  const seen = ref && invokeOptions?.ifMatch !== undefined ? this.versionAt(rt, ref) : undefined;
3686
+ // #119: an operation on a TRASHED entity it did not declare it reaches is refused
3687
+ // here, before the guards and the handler — the declared key first, so a caller
3688
+ // without it meets the same `forbidden` an active entity gives (`entity-trash.ts`).
3689
+ await refuseTrashedTarget({ sql: spineSql(rt.db), plans: this.statePlans, check: ctx.check }, operation, target, parsed, purging ? { now: this.clock(), gate: this.purgeGateFacts(rt, subject.id, tenantId, scopeId) } : undefined);
3439
3690
  // Manifest guards (K-17): pre-conditions, inside the operation's own
3440
3691
  // transaction, before the handler. A throw here blocks the operation
3441
3692
  // and rolls back exactly like a handler throw — fail closed.
3442
3693
  await this.runGuards(operation, ctx, parsed);
3443
3694
  result = await handler(ctx, parsed);
3695
+ // #119: the purge gate's AUTHORITATIVE read. Lifecycle, primacy and tenant live in the
3696
+ // directory, which this transaction does not cover and whose writers (a suspend, a
3697
+ // tenant status, a reclassification) do not take the scope's actor — so a hold could
3698
+ // commit while the handler above was awaiting. From here to `COMMIT` nothing awaits:
3699
+ // better-sqlite3 is synchronous and this process's JS single-threaded, so nothing in
3700
+ // this process — another host on the same directory included — can write between this
3701
+ // read and the commit of the delete; and this process owns the directory
3702
+ // (`claimHostDir`), so no other process can either.
3703
+ if (purging) {
3704
+ const held = purgeHeldBy(this.purgeGateFacts(rt, subject.id, tenantId, scopeId));
3705
+ if (held !== null)
3706
+ throw substratError('conflict', `${operation}: ${held}`, { reason: 'purge_held' });
3707
+ }
3444
3708
  if (ref && invokeOptions?.ifMatch !== undefined) {
3445
3709
  assertIfMatch(ref, invokeOptions.ifMatch, seen ?? null);
3446
3710
  }
@@ -3593,6 +3857,7 @@ export class SqliteScopeHost {
3593
3857
  for (const rt of [...this.scopes.values()])
3594
3858
  this.closeRuntime(rt);
3595
3859
  this.directory.close();
3860
+ this.owner.release();
3596
3861
  }
3597
3862
  // -------------------------------------------------------------------------
3598
3863
  // Event dispatch (testrun spec §9.2.3): at-least-once, kernel-journaled,
@@ -4101,17 +4366,17 @@ export class SqliteScopeHost {
4101
4366
  // Nothing pending FOR THIS HOST → noop, and deliberately no state write: a
4102
4367
  // host that does not run the scope's modules must never clear (or overwrite)
4103
4368
  // a failure recorded by the deployment that does.
4104
- let pending = false;
4105
- for (const mod of this.modules.values()) {
4106
- for (const migration of mod.migrations) {
4107
- if (!rt.appliedMigrations.has(`${mod.id}@${migration.version}`))
4108
- pending = true;
4109
- }
4110
- }
4369
+ // A scope that applied different SQL (#2066) is not a noop: the pass below fails it.
4370
+ const plan = await planMigrations(this.modules.values(), rt.appliedMigrations);
4371
+ const pending = plan.pending.length > 0 || plan.diverged !== undefined;
4372
+ // #2090: with nothing pending, the pass still checks and repairs what the kernel derived —
4373
+ // the one way a scope stripped before that check existed is ever put right.
4111
4374
  if (!pending)
4112
- return { status: 'noop' };
4375
+ rt.derivedRepaired = false;
4113
4376
  try {
4114
- await this.applyPendingMigrations(rt);
4377
+ await this.applyPendingMigrations(rt, plan);
4378
+ if (!pending)
4379
+ return { status: 'noop' };
4115
4380
  return { status: 'migrated', schemaVersion: String(rt.appliedMigrations.size) };
4116
4381
  }
4117
4382
  catch {
@@ -4169,21 +4434,22 @@ export class SqliteScopeHost {
4169
4434
  async settlePlatformRequest(tenantId, scopeId, id, outcome) {
4170
4435
  const rt = this.runtime(tenantId, scopeId);
4171
4436
  await this.applyPendingMigrations(rt);
4172
- await rt.actor.enqueue(() => {
4173
- // Compare-and-set on `pending` (#1600 review). The drain reads pending rows, runs a
4174
- // handler, then settles — and between the read and the settle a subject erasure can
4175
- // redact the row. Settling by `id` alone let that stale pass overwrite the redaction
4176
- // and write a provider's reply, which can quote the person, back into `last_error`.
4177
- // Nothing legitimate is refused by this: `listPlatformRequests` returns only pending
4178
- // rows, so every settle in the tree targets one that was pending when it was read.
4179
- // A settle that finds the row already terminal does nothing, deliberately silently —
4180
- // throwing would make the drain's blanket catch retry a row that is correctly over.
4181
- rt.db
4182
- .prepare(`UPDATE _substrat_platform_requests
4183
- SET status = ?, result = COALESCE(?, result), last_error = ?, last_failure = ?,
4184
- attempts = attempts + 1, settled_at = ?
4185
- WHERE id = ? AND status = 'pending'`)
4186
- .run(outcome.status, outcome.result === undefined ? null : JSON.stringify(outcome.result), outcome.lastError ?? null, outcome.failure == null ? null : JSON.stringify(outcome.failure), outcome.status === 'pending' ? null : new Date().toISOString(), id);
4437
+ await rt.actor.enqueue(async () => {
4438
+ // The kernel's one settle (#2102): compare-and-set on `pending` (#1600 review), and the
4439
+ // outcome event written in the same transaction, only by the settle that moved the row.
4440
+ rt.db.transaction(() => settlePlatformRequestIn(switchSqlOf(rt.db), id, outcome, {
4441
+ tenantId: rt.tenantId,
4442
+ scopeId: rt.scopeId,
4443
+ now: new Date().toISOString(),
4444
+ mintEventId: (ms) => rt.mintEventId(ms),
4445
+ version: this.versionId,
4446
+ }))();
4447
+ // What the event announces reaches this scope's consumers and executors in the same
4448
+ // tail, as an import's events do. A settle that wrote none has nothing to deliver.
4449
+ if (outcome.event) {
4450
+ await this.dispatch(rt, null);
4451
+ await this.dispatchExecutors(rt, null);
4452
+ }
4187
4453
  });
4188
4454
  }
4189
4455
  async dispatch(rt,
@@ -4568,6 +4834,65 @@ export class SqliteScopeHost {
4568
4834
  auditedFindings(actor, run) {
4569
4835
  return this.directory.transaction(() => run(redactionSqlOf(this.directory), (a) => this.recordAdmin(actor, a.action, a.target, a.before, a.after)))();
4570
4836
  }
4837
+ /**
4838
+ * A connect-link move and the audit row it hands back, in ONE directory transaction
4839
+ * (connections.md §3.5.4): a consume whose audit write failed must not leave the link spent
4840
+ * with no connection and no record.
4841
+ */
4842
+ auditedConnectLink(actor, run) {
4843
+ return this.directory.transaction(() => run(redactionSqlOf(this.directory), (a) => this.recordAdmin(actor, a.action, a.target, a.before, a.after)))();
4844
+ }
4845
+ /**
4846
+ * #559: one ops-failure row with its issue and finding, in one transaction (a savepoint when
4847
+ * the caller holds one: #2064's settle writes it beside the `unknown` row it reports).
4848
+ */
4849
+ writeOpsFailure(entry) {
4850
+ const at = new Date().toISOString();
4851
+ const fingerprint = opsFailureFingerprint(entry);
4852
+ // #1748: the evidence, its issue and its finding in ONE transaction — a detector that
4853
+ // throws takes the evidence with it, so the caller's retry observes it rather than finding
4854
+ // the row already there and the observation lost.
4855
+ this.directory.transaction(() => {
4856
+ this.directory
4857
+ .prepare(`INSERT INTO _substrat_ops_failures
4858
+ (id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
4859
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
4860
+ .run(ulid(), entry.actor, entry.operation, entry.stage ?? null, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.status ?? null,
4861
+ // Bounded here, not trusted from the catch site: one runaway upstream
4862
+ // body must not become a runaway directory row (#559).
4863
+ entry.message.slice(0, 2000), entry.reference ?? null, entry.origin ?? null, entry.code ?? null, fingerprint, at);
4864
+ // Prune-on-write (#559): retention lives here, not in a cron — every insert
4865
+ // pays for its own housekeeping, so the table stays bounded even where no
4866
+ // scheduled pass runs (this adapter has none).
4867
+ const horizon = new Date(Date.now() - OPS_FAILURE_RETENTION_DAYS * 86_400_000).toISOString();
4868
+ this.directory.prepare('DELETE FROM _substrat_ops_failures WHERE at < ?').run(horizon);
4869
+ // The issues materialization (#1233): the group's counters live on their
4870
+ // own row, bumped in the same call, because the evidence self-prunes above
4871
+ // and a count must survive its own exemplars. A fresh arrival regresses a
4872
+ // resolved issue; an ignored one stays ignored — that is what ignoring means.
4873
+ this.directory
4874
+ .prepare(`INSERT INTO _substrat_issues
4875
+ (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)
4876
+ VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
4877
+ ON CONFLICT (fingerprint) DO UPDATE SET
4878
+ seen_count = seen_count + 1,
4879
+ last_seen = excluded.last_seen,
4880
+ last_message = excluded.last_message,
4881
+ last_tenant_id = excluded.last_tenant_id,
4882
+ last_owner_kind = excluded.last_owner_kind,
4883
+ last_vertical = COALESCE(excluded.last_vertical, last_vertical),
4884
+ last_version = COALESCE(excluded.last_version, last_version),
4885
+ origin = COALESCE(excluded.origin, origin),
4886
+ status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`)
4887
+ .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);
4888
+ const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
4889
+ this.directory.prepare('DELETE FROM _substrat_issues WHERE last_seen < ?').run(issueHorizon);
4890
+ // #1748: the tenant's Recurring finding, over the tenant's own occurrences only.
4891
+ const finding = findingOfOpsFailure(entry);
4892
+ if (finding)
4893
+ observeFinding(redactionSqlOf(this.directory), finding, at);
4894
+ })();
4895
+ }
4571
4896
  recordAdmin(actor, action, target, before, after,
4572
4897
  /** When the row says it happened; the wall clock unless the write took its time elsewhere (#1184). */
4573
4898
  at = new Date().toISOString()) {
@@ -4813,6 +5138,9 @@ export class SqliteScopeHost {
4813
5138
  else {
4814
5139
  this.directory.prepare('UPDATE scopes SET status = ? WHERE scope_id = ?').run(to, scopeId);
4815
5140
  }
5141
+ if (to === 'archived' || to === 'reaped') {
5142
+ this.directory.prepare('UPDATE peer_bindings SET invalidated = 1 WHERE tenant_id = ? AND target_scope_id = ?').run(tenantId, scopeId);
5143
+ }
4816
5144
  if (to === 'reaped')
4817
5145
  forgetSwitchesOf(switchSqlOf(this.directory), scopeId);
4818
5146
  // The audit target carries the scope's vertical (control-plane.md §4.4:
@@ -4870,10 +5198,7 @@ export class SqliteScopeHost {
4870
5198
  if (!node.scopeId)
4871
5199
  throw new Error('scope tuple requires node.scopeId');
4872
5200
  const rt = this.runtime(node.tenantId, node.scopeId);
4873
- rt.db
4874
- .prepare(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
4875
- VALUES (?, ?, ?, ?)`)
4876
- .run(subject, relation, object, expiresAt ?? null);
5201
+ writeExplicitTupleIn(switchSqlOf(rt.db), subject, relation, object, { kind: 'replace', expiresAt: expiresAt ?? null });
4877
5202
  };
4878
5203
  const writeGrant = (subject, permission, node, entity, expiresAt) => {
4879
5204
  if (entity) {
@@ -4915,12 +5240,19 @@ export class SqliteScopeHost {
4915
5240
  // AUDIT FIRST (#1666 review), exactly as the Cloudflare adapter: the intent row before
4916
5241
  // anything moves, the outcome row after, on every attempt. The directory and the
4917
5242
  // scope's file are separate databases, so a crash between them leaves an intent with
4918
- // no recorded outcome — never a moved switch with no audit row.
5243
+ // no recorded outcome — never a moved switch with no audit row. #2089: every outcome row
5244
+ // goes through the kernel's `recordAuditOutcome`, exactly as the Cloudflare adapter — one
5245
+ // that cannot be written is logged, never swallowed, and the settle closes its intent.
4919
5246
  const operationId = ulid();
4920
5247
  const action = switchActionOf(kind, to);
4921
5248
  const target = { tenantId, scopeId };
4922
5249
  const base = { operationId, ...switchAuditSubject(kind, key, to) };
4923
5250
  this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason });
5251
+ const recordOutcome = (row) => recordAuditOutcome(() => this.recordAdmin(actor, action, target, null, { ...base, ...row }), {
5252
+ flow: `${kind}-switch`,
5253
+ operationId,
5254
+ phase: row.phase,
5255
+ }, (message, fields) => console.error(message, fields));
4924
5256
  // The directory's record (#1674; #2029 for a peer), written BEFORE the scope moves, both
4925
5257
  // ways, and undone if the move throws or holds nothing — see `recordSwitchedOn` and
4926
5258
  // (#1823) `recordSwitchedOff` for why that order is the safe one.
@@ -4934,18 +5266,13 @@ export class SqliteScopeHost {
4934
5266
  }
4935
5267
  catch (err) {
4936
5268
  // Nothing has moved: fail the call here, audited — the Cloudflare adapter's posture.
4937
- try {
4938
- this.recordAdmin(actor, action, target, null, { ...base, phase: 'failed', error: errorOf(err) });
4939
- }
4940
- catch {
4941
- // Best effort: the original error is what the caller must see.
4942
- }
5269
+ await recordOutcome({ phase: 'failed', error: errorOf(err) });
4943
5270
  throw err;
4944
5271
  }
4945
5272
  // #2045: a newer call on this subject has recorded its position already: this one writes
4946
5273
  // nothing, here or in the scope, and says so.
4947
5274
  if (recordWriteSuperseded(prior, record)) {
4948
- this.recordAdmin(actor, action, target, null, { ...base, phase: 'refused', superseded: true });
5275
+ await recordOutcome({ phase: 'refused', superseded: true });
4949
5276
  throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
4950
5277
  }
4951
5278
  /** The record's undo, after the scope's move threw or held nothing: retried once, never swallowed. */
@@ -4984,23 +5311,13 @@ export class SqliteScopeHost {
4984
5311
  }
4985
5312
  catch (err) {
4986
5313
  const recordError = undoRecord();
4987
- try {
4988
- this.recordAdmin(actor, action, target, null, {
4989
- ...base,
4990
- phase: 'failed',
4991
- error: errorOf(err),
4992
- ...(recordError ? { recordError } : {}),
4993
- });
4994
- }
4995
- catch {
4996
- // Best effort: the original error is what the caller must see.
4997
- }
5314
+ await recordOutcome({ phase: 'failed', error: errorOf(err), ...(recordError ? { recordError } : {}) });
4998
5315
  throw err;
4999
5316
  }
5000
5317
  // #2045: the scope has applied a newer call on this subject, so this move wrote nothing. The
5001
5318
  // record is that newer call's too (its write overwrote this one's), so nothing is undone.
5002
5319
  if (outcome.superseded) {
5003
- this.recordAdmin(actor, action, target, null, { ...base, phase: 'refused', superseded: true });
5320
+ await recordOutcome({ phase: 'refused', superseded: true });
5004
5321
  throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
5005
5322
  }
5006
5323
  // A call that held nothing moved nothing (the subject has no authority reaching the
@@ -5008,8 +5325,7 @@ export class SqliteScopeHost {
5008
5325
  // switch the subject off the day it is installed; left `on`, the next reconcile of a
5009
5326
  // wiped scope would leave it running.
5010
5327
  const recordError = outcome.held ? null : undoRecord();
5011
- this.recordAdmin(actor, action, target, null, {
5012
- ...base,
5328
+ const unrecorded = await recordOutcome({
5013
5329
  phase: outcome.held ? 'applied' : 'refused',
5014
5330
  changed: outcome.changed,
5015
5331
  permissions: outcome.permissions,
@@ -5019,30 +5335,32 @@ export class SqliteScopeHost {
5019
5335
  throw substratError('not_found', switchNotFoundMessage(kind, scopeId, key, to) +
5020
5336
  (recordError ? `; and its directory record could not be put back (${recordError})` : ''));
5021
5337
  }
5022
- return { operationId, outcome };
5338
+ return { operationId, outcome, ...(unrecorded !== null ? { auditWarning: auditWarningOf('the switch', unrecorded) } : {}) };
5023
5339
  };
5024
5340
  /** #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`. */
5025
5341
  const switchSystem = async (actor, raw, to) => {
5026
5342
  const input = systemSwitch.parse(raw);
5027
- const { operationId, outcome } = await switchSubject(actor, 'system', input.node, input.moduleId, input.reason, to);
5343
+ const { operationId, outcome, auditWarning } = await switchSubject(actor, 'system', input.node, input.moduleId, input.reason, to);
5028
5344
  return {
5029
5345
  operationId,
5030
5346
  moduleId: input.moduleId,
5031
5347
  schedules: to,
5032
5348
  changed: outcome.changed,
5033
5349
  permissions: outcome.permissions,
5350
+ ...(auditWarning ? { auditWarning } : {}),
5034
5351
  };
5035
5352
  };
5036
5353
  /** #1706: move one PEER's kill switch on one scope — see `HostAdmin.revokeFromPeer`. */
5037
5354
  const switchPeerAt = async (actor, raw, to) => {
5038
5355
  const input = peerSwitch.parse(raw);
5039
- const { operationId, outcome } = await switchSubject(actor, 'peer', input.node, input.vertical, input.reason, to);
5356
+ const { operationId, outcome, auditWarning } = await switchSubject(actor, 'peer', input.node, input.vertical, input.reason, to);
5040
5357
  return {
5041
5358
  operationId,
5042
5359
  vertical: input.vertical,
5043
5360
  calls: to,
5044
5361
  changed: outcome.changed,
5045
5362
  permissions: outcome.permissions,
5363
+ ...(auditWarning ? { auditWarning } : {}),
5046
5364
  };
5047
5365
  };
5048
5366
  /**
@@ -5151,6 +5469,8 @@ export class SqliteScopeHost {
5151
5469
  return result;
5152
5470
  };
5153
5471
  return {
5472
+ // #2069: a view of the view this admin was built over — the person added, its event kept.
5473
+ attributed: (onBehalfOf) => this.attributed(onBehalfOf).admin,
5154
5474
  // #603: fixed at construction — a host built without a box can never store a
5155
5475
  // credential, and saying so is what lets a transport answer 503 instead of 500.
5156
5476
  canStoreSecrets: isSecretBoxConfigured(this.secretBox),
@@ -5245,6 +5565,42 @@ export class SqliteScopeHost {
5245
5565
  await onNode(grant.node, () => writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt));
5246
5566
  this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
5247
5567
  },
5568
+ grantEntityShape: async (actor, grant) => {
5569
+ const { tenantId, scopeId } = grant.node;
5570
+ this.assertScope(tenantId, scopeId);
5571
+ const rt = this.runtime(tenantId, scopeId);
5572
+ // One unit on the scope actor (#1678): the marker and its keys land together or not at all.
5573
+ await rt.actor.turn(() => rt.db.transaction(() => grantEntityShapeIn(switchSqlOf(rt.db), grant.principalId, grant.entity, grant.permissions))());
5574
+ this.recordAdmin(actor, 'grantEntityShape', { tenantId, scopeId }, null, grant);
5575
+ },
5576
+ reconcileEntityGrantShapes: async (actor, node, shapes, opts) => {
5577
+ const limit = shapeTopUpBatch(opts?.batch);
5578
+ const { tenantId, scopeId } = node;
5579
+ this.assertScope(tenantId, scopeId);
5580
+ const rt = this.runtime(tenantId, scopeId);
5581
+ let toppedUp = 0;
5582
+ let retired = 0;
5583
+ // #2071: one bounded transaction per pass, so a large scope never holds one long; a pass
5584
+ // that did not use its whole budget found everything.
5585
+ for (let done = false; !done;) {
5586
+ const pass = await rt.actor.turn(() => rt.db.transaction(() => topUpEntityGrantShapes(switchSqlOf(rt.db), {
5587
+ tenantId,
5588
+ scopeId,
5589
+ shapes,
5590
+ now: new Date().toISOString(),
5591
+ limit,
5592
+ mintEventId: (ms) => rt.mintEventId(ms),
5593
+ version: this.versionId,
5594
+ }))());
5595
+ toppedUp += pass.toppedUp;
5596
+ retired += pass.retired;
5597
+ done = pass.done;
5598
+ }
5599
+ if (toppedUp > 0 || retired > 0) {
5600
+ this.recordAdmin(actor, 'reconcileEntityGrantShapes', { tenantId, scopeId }, null, { shapes, toppedUp, retired });
5601
+ }
5602
+ return { toppedUp, retired };
5603
+ },
5248
5604
  grantToConnection: async (actor, raw) => {
5249
5605
  const grant = connectionGrant.parse(raw);
5250
5606
  const check = () => {
@@ -5569,6 +5925,55 @@ export class SqliteScopeHost {
5569
5925
  forkedFrom: r.forked_from ?? null,
5570
5926
  })), tenantId, vertical);
5571
5927
  },
5928
+ resolvePeerInstance: async (tenantId, callerScopeId, vertical) => {
5929
+ const binding = this.directory.prepare('SELECT target_scope_id, invalidated FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?').get(tenantId, callerScopeId, vertical);
5930
+ const rows = this.directory.prepare(`SELECT scope_id, tenant_id, vertical, status, kind, forked_from FROM scopes
5931
+ WHERE tenant_id = ? AND vertical = ?`).all(tenantId, vertical);
5932
+ return resolvePeerInstanceFrom(rows.map((r) => ({
5933
+ id: r.scope_id,
5934
+ tenantId: r.tenant_id,
5935
+ vertical: r.vertical,
5936
+ status: r.status,
5937
+ kind: r.kind ?? '',
5938
+ forkedFrom: r.forked_from,
5939
+ })), tenantId, vertical, binding?.target_scope_id ?? null, binding?.invalidated === 1);
5940
+ },
5941
+ peerBinding: async (actor, tenantId, callerScopeId, vertical) => {
5942
+ const row = this.directory.prepare('SELECT target_scope_id, invalidated FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?').get(tenantId, callerScopeId, vertical);
5943
+ this.recordAccess(actor, 'peerBinding', { tenantId, scopeId: callerScopeId }, { vertical }, row ? 1 : 0);
5944
+ return row ? { tenantId, callerScopeId, vertical, targetScopeId: row.target_scope_id, invalidated: row.invalidated === 1 } : undefined;
5945
+ },
5946
+ peerBindings: async (actor, tenantId, callerScopeId) => {
5947
+ const rows = this.directory.prepare('SELECT vertical, target_scope_id, invalidated FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? ORDER BY vertical').all(tenantId, callerScopeId);
5948
+ this.recordAccess(actor, 'peerBindings', { tenantId, scopeId: callerScopeId }, null, rows.length);
5949
+ return rows.map((r) => ({ tenantId, callerScopeId, vertical: r.vertical, targetScopeId: r.target_scope_id, invalidated: r.invalidated === 1 }));
5950
+ },
5951
+ setPeerBinding: async (_actor, tenantId, callerScopeId, vertical, targetScopeId) => this.directory.transaction(() => {
5952
+ const caller = this.directory.prepare('SELECT tenant_id, status, kind, forked_from FROM scopes WHERE scope_id = ?').get(callerScopeId);
5953
+ if (!caller || caller.tenant_id !== tenantId || caller.status !== 'active' || !isPrimaryScopeRow(caller)) {
5954
+ throw substratError('not_found', 'calling app is not a live primary scope in this tenant');
5955
+ }
5956
+ if (targetScopeId !== null) {
5957
+ const target = this.directory.prepare('SELECT tenant_id, vertical, status, kind, forked_from FROM scopes WHERE scope_id = ?').get(targetScopeId);
5958
+ if (!target || target.tenant_id !== tenantId || target.vertical !== vertical || target.status !== 'active' || !isPrimaryScopeRow(target)) {
5959
+ throw substratError('not_found', 'target is not a live primary instance of this vertical in this tenant');
5960
+ }
5961
+ }
5962
+ const key = [tenantId, callerScopeId, vertical];
5963
+ const before = this.directory.prepare('SELECT target_scope_id, invalidated FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?').get(...key);
5964
+ const previous = before?.target_scope_id;
5965
+ if ((previous ?? null) === targetScopeId && before?.invalidated !== 1)
5966
+ return { changed: false, previous: previous ?? null };
5967
+ if (targetScopeId === null) {
5968
+ this.directory.prepare('DELETE FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?').run(...key);
5969
+ }
5970
+ else {
5971
+ this.directory.prepare(`INSERT INTO peer_bindings (tenant_id, caller_scope_id, vertical, target_scope_id)
5972
+ VALUES (?, ?, ?, ?) ON CONFLICT (tenant_id, caller_scope_id, vertical)
5973
+ DO UPDATE SET target_scope_id = excluded.target_scope_id, invalidated = 0`).run(...key, targetScopeId);
5974
+ }
5975
+ return { changed: true, previous: previous ?? null };
5976
+ })(),
5572
5977
  resolveHostname: async (raw) => {
5573
5978
  // The router's per-request read. No actor, not logged — same carve-out as
5574
5979
  // resolveIdentity (K-24): this is a machine path, not a staff read.
@@ -5649,6 +6054,9 @@ export class SqliteScopeHost {
5649
6054
  installSpec.provisions = parsed.provisions;
5650
6055
  if (parsed.sendsEmail)
5651
6056
  installSpec.sendsEmail = parsed.sendsEmail;
6057
+ // Where the scope lifecycle is held (#1713) — read by the lifecycle delivery's targets.
6058
+ if (parsed.lifecycle)
6059
+ installSpec.lifecycle = parsed.lifecycle;
5652
6060
  if (parsed.surfaces)
5653
6061
  installSpec.surfaces = parsed.surfaces;
5654
6062
  const installSpecJson = Object.keys(installSpec).length ? JSON.stringify(installSpec) : null;
@@ -6258,10 +6666,20 @@ export class SqliteScopeHost {
6258
6666
  this.assertScopeReachable(tenantId, scopeId);
6259
6667
  const rec = this.directory.prepare('SELECT vertical FROM scopes WHERE scope_id = ?').get(scopeId);
6260
6668
  const source = await importCursorSourceOf((t, v) => this.admin.resolveVerticalInstance(t, v), { tenantId, scopeId, vertical: rec.vertical }, move.from);
6669
+ // #2114: the replay's id is also its audit `operationId`, so the scheduled settle pairs
6670
+ // and closes the lever's rows exactly as it does the kill switches'.
6261
6671
  const replayId = ulid();
6262
6672
  const target = { tenantId, scopeId };
6263
- const base = { replayId, mode: move.mode, from: move.from, source: source.scopeId };
6673
+ const base = { operationId: replayId, replayId, mode: move.mode, from: move.from, source: source.scopeId };
6264
6674
  this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, phase: 'intent', reason: move.reason });
6675
+ // #2114: every outcome row goes through the kernel's `recordAuditOutcome`, exactly as the
6676
+ // Cloudflare adapter and the kill switches (#2089): logged when it cannot be written, never
6677
+ // swallowed, and never in place of the move's own error or result.
6678
+ const recordOutcome = (row) => recordAuditOutcome(() => this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, ...row }), {
6679
+ flow: 'import-cursor',
6680
+ operationId: replayId,
6681
+ phase: row.phase,
6682
+ }, (message, fields) => console.error(message, fields));
6265
6683
  let moved;
6266
6684
  try {
6267
6685
  const rt = this.runtime(tenantId, scopeId);
@@ -6271,26 +6689,16 @@ export class SqliteScopeHost {
6271
6689
  moved = await rt.actor.turn(() => rt.db.transaction(() => moveImportCursor(switchSqlOf(rt.db), { move, source, replayId, now, imports: this.crossVertical.consumes() }))());
6272
6690
  }
6273
6691
  catch (err) {
6274
- try {
6275
- this.recordAdmin(actor, 'moveImportCursor', target, null, {
6276
- ...base,
6277
- phase: 'failed',
6278
- error: err instanceof Error ? err.message : String(err),
6279
- });
6280
- }
6281
- catch {
6282
- // Best effort: the original error is what the caller must see.
6283
- }
6692
+ await recordOutcome({ phase: 'failed', error: err instanceof Error ? err.message : String(err) });
6284
6693
  throw err;
6285
6694
  }
6286
- this.recordAdmin(actor, 'moveImportCursor', target, null, {
6287
- ...base,
6695
+ const unrecorded = await recordOutcome({
6288
6696
  phase: 'applied',
6289
6697
  previous: moved.previous,
6290
6698
  cursor: moved.cursor,
6291
6699
  archived: moved.archived,
6292
6700
  });
6293
- return moved;
6701
+ return unrecorded === null ? moved : { ...moved, auditWarning: auditWarningOf('the move', unrecorded) };
6294
6702
  },
6295
6703
  redrainEvents: async (actor, tenantId, scopeId, input) => {
6296
6704
  // Directory check first, on mark's reasoning: "nothing to reopen" and "you may not
@@ -6870,7 +7278,8 @@ export class SqliteScopeHost {
6870
7278
  WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND sql IS NOT NULL
6871
7279
  ORDER BY name`)
6872
7280
  .all();
6873
- const tables = defs.map(({ name, sql }) => {
7281
+ const privateTables = new Set(['private_continuation_keys', 'private_continuation_positions']);
7282
+ const tables = defs.filter(({ name }) => !privateTables.has(name)).map(({ name, sql }) => {
6874
7283
  const stmt = this.directory.prepare(`SELECT * FROM "${name}"`).raw(true);
6875
7284
  const rows = stmt.all();
6876
7285
  const columns = stmt.columns().map((c) => c.name);
@@ -6888,7 +7297,11 @@ export class SqliteScopeHost {
6888
7297
  // as identifiers, and its `ddl`, never executed here since #1912, is still held to one
6889
7298
  // CREATE TABLE. Judged as a whole before the first DROP, so a refused dump leaves the
6890
7299
  // directory untouched.
6891
- assertReplayableDump(dump.tables);
7300
+ // Older backup producers and local migration fixtures may include these
7301
+ // tables. Ignore them entirely: neither their DDL nor their rows may set
7302
+ // a restored key epoch or make an old hidden locator valid again.
7303
+ const publicTables = dump.tables.filter((table) => !['private_continuation_keys', 'private_continuation_positions'].includes(table.name.toLowerCase()));
7304
+ assertReplayableDump(publicTables);
6892
7305
  // One transaction, foreign keys deferred to commit: the dump is ordered by table NAME
6893
7306
  // (which says nothing about references — a `tenants` row can name another as
6894
7307
  // `provisioned_by_tenant`), and the DROPs themselves delete rows a populated child would
@@ -6911,7 +7324,7 @@ export class SqliteScopeHost {
6911
7324
  this.buildDirectorySchema();
6912
7325
  // Refuses a table this code does not build, adds unknown columns bare, loads the rows
6913
7326
  // by name, then the legacy scope and #1674 backfills, all inside this transaction.
6914
- loadDirectoryDump(dump.tables, {
7327
+ loadDirectoryDump(publicTables, {
6915
7328
  columnsOf: (name) => builtColumnsOf(this.directory, name),
6916
7329
  exec: (sql) => this.directory.exec(sql),
6917
7330
  now: () => this.clock(),
@@ -7028,6 +7441,10 @@ export class SqliteScopeHost {
7028
7441
  // and transaction facts remain". A consumer's timeline still shows that something
7029
7442
  // happened, to what, and when; it no longer shows who or what was said.
7030
7443
  const db = this.scopeDbFor(tenantId, scopeId);
7444
+ // #2068: the module half reaches the scope's own tables, so they have to exist — the
7445
+ // same migrations an invoke would apply first.
7446
+ const rt = this.runtime(tenantId, scopeId);
7447
+ await this.applyPendingMigrations(rt);
7031
7448
  // One instant for the whole erasure, read before the first write: the intent
7032
7449
  // tombstones below and the key's own tombstone should not disagree about when a
7033
7450
  // person was erased.
@@ -7035,8 +7452,24 @@ export class SqliteScopeHost {
7035
7452
  // Both scope-side redactions in ONE turn on the scope actor (#1678): issued while an
7036
7453
  // invoke held its transaction open, they joined it, and its rollback put the
7037
7454
  // person's PII back after this verb had destroyed the key and receipted the erasure.
7455
+ //
7456
+ // And in ONE transaction (#2068): a module's `onSubjectErased` hook that throws rolls
7457
+ // the whole scope side back, the spine redaction with it, and the erasure throws
7458
+ // before the key below is touched — nothing receipts an erasure that did not happen.
7038
7459
  const scopeSql = redactionSqlOf(db);
7039
- const { redacted, intentsRedacted, jobRunsRedacted, text } = await this.runtime(tenantId, scopeId).actor.turn(() => ({
7460
+ const { redacted, intentsRedacted, jobRunsRedacted, text, vertical } = await rt.actor.turn(() => db.transaction(() => ({
7461
+ // The module half first (#2068): the declared entities, then each hook, with the
7462
+ // search indexes over them under FTS5 secure-delete. The spine half follows in the same
7463
+ // transaction, so the order between them decides nothing but the reading order.
7464
+ vertical: eraseSubjectFromModules({
7465
+ sql: spineSql(db),
7466
+ plans: this.erasurePlans,
7467
+ searchPlans: this.searchPlans.values(),
7468
+ statefulTables: statefulTablesOf(this.statePlans),
7469
+ subjectId,
7470
+ at,
7471
+ migrationSqlOf: (moduleId, version) => this.modules.get(moduleId)?.migrations.find((m) => m.version === version)?.sql,
7472
+ }),
7040
7473
  redacted: db
7041
7474
  .prepare(`UPDATE _substrat_outbox SET payload = NULL
7042
7475
  WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`)
@@ -7059,7 +7492,7 @@ export class SqliteScopeHost {
7059
7492
  // The free-text copies (#1632), and the tombstoned intents the directory half
7060
7493
  // follows. Last, so those ids include every intent tombstoned above.
7061
7494
  text: redactSubjectScopeText(scopeSql, subjectId, at),
7062
- }));
7495
+ }))());
7063
7496
  const { idempotencyResults, intentIds } = text;
7064
7497
  // The directory's failure text (#1632) — a drain failure quoting one of those
7065
7498
  // intents, an issue's exemplar, a sweep record's error. Before the key, for the
@@ -7071,6 +7504,7 @@ export class SqliteScopeHost {
7071
7504
  eventsRedacted: redacted.changes,
7072
7505
  intentsRedacted,
7073
7506
  jobRunsRedacted,
7507
+ ...vertical,
7074
7508
  keyDestroyed: existed,
7075
7509
  tombstoned: true,
7076
7510
  });
@@ -7080,7 +7514,7 @@ export class SqliteScopeHost {
7080
7514
  this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
7081
7515
  // BOTH counts: the access log's number is "how much evidence this destroyed", and
7082
7516
  // an intent payload is a whole event's worth of it.
7083
- this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes + intentsRedacted + jobRunsRedacted + idempotencyResults);
7517
+ this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes + intentsRedacted + jobRunsRedacted + idempotencyResults + moduleRowsErased(vertical));
7084
7518
  return receipt;
7085
7519
  },
7086
7520
  // -- impersonation (K-42, #868) ----------------------------------------
@@ -7151,6 +7585,7 @@ export class SqliteScopeHost {
7151
7585
  '_substrat_system_switches', // #1674: the schedule switch's record, per scope
7152
7586
  '_substrat_membership_fences', // #1184: the latest removal, per principal
7153
7587
  '_substrat_peer_switches', // #2029: the peer switch's record, per scope
7588
+ 'peer_bindings', // #1720: a tenant's explicit caller-to-target choices
7154
7589
  '_substrat_switch_owed', // #2045: subjects owed a re-assert, per scope
7155
7590
  '_substrat_findings', // #1748: the tenant's findings
7156
7591
  '_substrat_finding_rules', // #1748: the tenant's suppress rules
@@ -7469,7 +7904,7 @@ export class SqliteScopeHost {
7469
7904
  .prepare(`UPDATE _substrat_connections
7470
7905
  SET status = 'active', expires_at = ?, last_error = NULL, last_error_at = NULL
7471
7906
  WHERE id = ?`)
7472
- .run(expiresAt ?? row.expires_at, id);
7907
+ .run(expiresAt === undefined ? row.expires_at : expiresAt, id);
7473
7908
  // The event, never the token. "Rotated at T" is the auditable fact — plus WHO
7474
7909
  // authorized it when the rotation was a tenant admin's act (§3.5.1's attribution,
7475
7910
  // rotate-side): the principal, never laundered into the actor column.
@@ -7477,7 +7912,7 @@ export class SqliteScopeHost {
7477
7912
  id,
7478
7913
  provider: row.provider,
7479
7914
  rotatedAt: now,
7480
- expiresAt: expiresAt ?? row.expires_at,
7915
+ expiresAt: expiresAt === undefined ? row.expires_at : expiresAt,
7481
7916
  ...(opts?.rotatedBy ? { rotatedBy: opts.rotatedBy } : {}),
7482
7917
  });
7483
7918
  },
@@ -7508,6 +7943,51 @@ export class SqliteScopeHost {
7508
7943
  .run(now, id);
7509
7944
  this.recordAdmin(actor, 'revokeConnection', { tenantId: row.tenant_id, vertical: row.vertical }, { status: row.status }, { id, provider: row.provider, status: 'revoked', revokedAt: now });
7510
7945
  },
7946
+ // -- a vertical's mailed connect links (connections.md §3.5.4) --------------
7947
+ // The statements are the kernel's (`connect-links.ts`), shared with the hosted
7948
+ // adapter; what is here is the clock, the parse at the edge, and the transaction:
7949
+ // each move commits with the audit row it hands back, or not at all.
7950
+ mintConnectLink: async (actor, raw) => {
7951
+ const input = mintConnectLinkInput.parse(raw);
7952
+ return this.auditedConnectLink(actor, (sql, audit) => insertConnectLink(sql, {
7953
+ id: ulid(),
7954
+ tenantId: input.tenantId,
7955
+ scopeId: input.scopeId,
7956
+ vertical: input.vertical,
7957
+ provider: input.provider,
7958
+ createdBy: input.createdBy,
7959
+ subjectRef: input.subjectRef ?? null,
7960
+ returnUrl: input.returnUrl ?? null,
7961
+ createdAt: this.clock(),
7962
+ expiresAt: input.expiresAt,
7963
+ }, audit));
7964
+ },
7965
+ getConnectLink: async (actor, raw) => {
7966
+ const key = connectLinkKey.parse(raw);
7967
+ const link = readConnectLink(redactionSqlOf(this.directory), key);
7968
+ this.recordAccess(actor, 'getConnectLink', key, { id: key.id }, link ? 1 : 0);
7969
+ return link;
7970
+ },
7971
+ listConnectLinks: async (actor, raw) => {
7972
+ const filter = connectLinkFilter.parse(raw);
7973
+ const links = listConnectLinkRows(redactionSqlOf(this.directory), filter, this.clock());
7974
+ this.recordAccess(actor, 'listConnectLinks', filter, filter, links.length);
7975
+ return links;
7976
+ },
7977
+ revokeConnectLink: async (actor, raw) => {
7978
+ const key = connectLinkKey.parse(raw);
7979
+ // Idempotent, and a no-op is not audited (the statement hands back no row for one).
7980
+ return this.auditedConnectLink(actor, (sql, audit) => revokeConnectLinkRow(sql, key, audit))?.link;
7981
+ },
7982
+ consumeConnectLink: async (actor, raw) => {
7983
+ const input = consumeConnectLinkInput.parse(raw);
7984
+ return this.auditedConnectLink(actor, (sql, audit) => consumeConnectLinkRow(sql, input, this.clock(), audit));
7985
+ },
7986
+ restoreConnectLink: async (actor, raw) => {
7987
+ const key = connectLinkKey.parse(raw);
7988
+ const link = this.auditedConnectLink(actor, (sql, audit) => restoreConnectLinkRow(sql, key, this.clock(), audit));
7989
+ return link !== undefined;
7990
+ },
7511
7991
  openConnection: async (tenantId, vertical, provider, externalAccountRef) => {
7512
7992
  const rows = this.directory
7513
7993
  .prepare(`SELECT * FROM _substrat_connections
@@ -7660,6 +8140,10 @@ export class SqliteScopeHost {
7660
8140
  const { tenantId, scopeId, ...after } = memberChangeAudit.parse(entry);
7661
8141
  this.recordAdmin(actor, 'manageScopeMember', { tenantId, scopeId }, null, after);
7662
8142
  },
8143
+ recordPeerBindingChange: async (actor, entry) => {
8144
+ const { tenantId, scopeId, ...after } = peerBindingAudit.parse(entry);
8145
+ this.recordAdmin(actor, 'setPeerBinding', { tenantId, scopeId }, null, after);
8146
+ },
7663
8147
  /** #2005: one change to a scope's copy marker, written around the vertical's own change. */
7664
8148
  recordCopyMark: async (actor, entry) => {
7665
8149
  const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
@@ -7800,52 +8284,24 @@ export class SqliteScopeHost {
7800
8284
  }));
7801
8285
  },
7802
8286
  recordOpsFailure: async (entry) => {
7803
- const at = new Date().toISOString();
7804
- const fingerprint = opsFailureFingerprint(entry);
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
- })();
8287
+ this.writeOpsFailure(entry);
8288
+ },
8289
+ /** #2064: an audited operation's rows, by the operation-id index, in bounded batches. */
8290
+ auditedOperations: async (actor, refs) => {
8291
+ const rows = readAuditedOperations((sql, params) => this.directory.prepare(sql).all(...params), refs);
8292
+ this.recordAccess(actor, 'auditedOperations', {}, { operations: refs.length }, rows.length);
8293
+ return rows;
7848
8294
  },
8295
+ /** #2064: settle an intent with no outcome, in one transaction — see `audit-outcome.ts`. */
8296
+ settleUnrecordedOutcome: async (actor, input) => this.directory.transaction(() => {
8297
+ const row = this.directory.prepare(SETTLE_INTENT_SQL).get(input.intentId);
8298
+ const outcome = unknownOutcomeOf(row, input.intentId, input.error);
8299
+ if (this.directory.prepare(SETTLE_OUTCOME_SQL).get(...settleOutcomeParamsOf(outcome.operation)))
8300
+ return false;
8301
+ this.recordAdmin(actor, outcome.action, outcome.target, null, outcome.after);
8302
+ this.writeOpsFailure({ ...outcome.failure, actor });
8303
+ return true;
8304
+ })(),
7849
8305
  listOpsFailures: async (actor, filter) => {
7850
8306
  const where = [];
7851
8307
  const params = [];
@@ -8131,7 +8587,16 @@ export class SqliteScopeHost {
8131
8587
  const existing = db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name.toLowerCase() === column.toLowerCase());
8132
8588
  if (existing)
8133
8589
  return false;
8134
- db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
8590
+ try {
8591
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
8592
+ }
8593
+ catch (err) {
8594
+ // A second opener of the same file can add it between the PRAGMA and here (#2066 review):
8595
+ // the column is there, which is all this wanted. The DO's ALTER pass tolerates it the same way.
8596
+ if (!/duplicate column name/i.test(err.message))
8597
+ throw err;
8598
+ return false;
8599
+ }
8135
8600
  return true;
8136
8601
  }
8137
8602
  /**
@@ -8166,7 +8631,7 @@ export class SqliteScopeHost {
8166
8631
  // behind an empty table `CREATE TABLE IF NOT EXISTS` put back. `db.transaction`
8167
8632
  // nests as a SAVEPOINT, which is what makes this safe on the `loadDump` path too,
8168
8633
  // where the whole replay is already inside one.
8169
- db.transaction(() => db.exec(script))();
8634
+ db.transaction(() => execSqlStatements(db, script))();
8170
8635
  }
8171
8636
  /**
8172
8637
  * #1573: run a create-copy-drop-rename against the directory as ONE transaction.
@@ -8186,7 +8651,7 @@ export class SqliteScopeHost {
8186
8651
  * capture one.
8187
8652
  */
8188
8653
  rebuildAtomically(script) {
8189
- this.directory.transaction(() => this.directory.exec(script))();
8654
+ this.directory.transaction(() => execSqlStatements(this.directory, script))();
8190
8655
  }
8191
8656
  /**
8192
8657
  * Rebuild `_substrat_identities` when it still carries the pre-K-22 global key.
@@ -8233,7 +8698,8 @@ export class SqliteScopeHost {
8233
8698
  * constraint in place, so this is the same create-copy-drop-rename the identity key
8234
8699
  * uses, detected the same way — from `sqlite_master.sql`, which works on DO SQLite
8235
8700
  * too. Rows are copied verbatim: the log stays append-only in content, this only
8236
- * widens what a future row may say.
8701
+ * widens what a future row may say. The DROP takes every index on the table with it, so
8702
+ * the same transaction rebuilds them from the kernel's list (#2064).
8237
8703
  */
8238
8704
  ensureAdminLogTenantNullable() {
8239
8705
  const row = this.directory
@@ -8260,6 +8726,7 @@ export class SqliteScopeHost {
8260
8726
  FROM _substrat_admin_log;
8261
8727
  DROP TABLE _substrat_admin_log;
8262
8728
  ALTER TABLE _substrat_admin_log_new RENAME TO _substrat_admin_log;
8729
+ ${ADMIN_LOG_INDEXES_SQL}
8263
8730
  `);
8264
8731
  }
8265
8732
  /**
@@ -8460,7 +8927,12 @@ export class SqliteScopeHost {
8460
8927
  */
8461
8928
  at = this.clock(),
8462
8929
  /** #2055: the delivery a consumer's or an import's context runs for; absent otherwise. */
8463
- delivery) {
8930
+ delivery,
8931
+ /**
8932
+ * #119: keys this context's checks refuse whatever the subject holds — a module's
8933
+ * purge-only keys, outside the purge sweep's own invoke (`purgeOnlyKeysOf`).
8934
+ */
8935
+ withheld) {
8464
8936
  // For a connection, system, capability or peer-vertical subject this carries THAT id so
8465
8937
  // the type holds — it is not a person, and the event actor below says what it is instead.
8466
8938
  const principal = subject.id;
@@ -8527,6 +8999,9 @@ export class SqliteScopeHost {
8527
8999
  // #1642: parsed before the override actor's early return, which never reaches
8528
9000
  // the checker — a cast key would otherwise become that path's proof relation.
8529
9001
  const permission = assertPermissionKey(unparsed);
9002
+ if (withheld?.has(permission)) {
9003
+ return { allowed: false, checked: permission, node: { tenantId: rt.tenantId, scopeId: rt.scopeId } };
9004
+ }
8530
9005
  if (overrideActor) {
8531
9006
  return {
8532
9007
  allowed: true,
@@ -8602,10 +9077,12 @@ export class SqliteScopeHost {
8602
9077
  tenantId: rt.tenantId,
8603
9078
  scopeId: rt.scopeId,
8604
9079
  principal,
9080
+ pageContinuation: (list, query) => visibleContinuation(this.continuationStore(rt.scopeId, impersonation?.mode !== 'read-only' && this.isPrimaryInDirectory(rt.scopeId)), { scopeId: rt.scopeId, principal: `${subject.kind}:${subject.id}`, operation: operation ?? 'kernel', list, query }, () => Date.parse(this.clock()), () => ctxRef.log.info('legacy filtered-list cursor accepted', { list }), impersonation?.mode !== 'read-only' && this.isPrimaryInDirectory(rt.scopeId)),
8605
9081
  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()))
9082
+ // #119 / #2090: after runtime DDL, inside this operation's transaction, what the kernel
9083
+ // derived onto any table is repaired — and a lost state column fails the operation.
9084
+ derivesAnything(this.derivedPlans())
9085
+ ? () => afterRuntimeDdl(spineSql(rt.db), (ddl) => execSqlStatements(rt.db, ddl), this.derivedPlans())
8609
9086
  : undefined)), minted),
8610
9087
  now: () => at,
8611
9088
  // #1746/#1747: the host stamps who and where; module code supplies only the template
@@ -8665,6 +9142,10 @@ export class SqliteScopeHost {
8665
9142
  return entityVersionOf(rt.db.prepare(q.sql).all(...q.params));
8666
9143
  },
8667
9144
  check: runCheck,
9145
+ grantedEntities: async (unparsed, entityType, options) => {
9146
+ const permission = assertPermissionKey(unparsed);
9147
+ return grantedEntitiesForContext(checker, subject, permission, { tenantId: rt.tenantId, scopeId: rt.scopeId }, entityType, options, runCheck, withheld, Boolean(overrideActor));
9148
+ },
8668
9149
  canAssign: runCanAssign,
8669
9150
  /**
8670
9151
  * #827. The plan is registration state, the index is scope state: an entity
@@ -8703,16 +9184,14 @@ export class SqliteScopeHost {
8703
9184
  view: uncheckedView('ctx.page', entityType, params.view),
8704
9185
  });
8705
9186
  const rows = rt.db.prepare(q.sql).all(...q.params);
8706
- // A FULL page may have more; a short one is the end of the walk. Same rule
8707
- // as `pageOf`, applied here because the cursor is a COLUMN's value (plus
8708
- // the tie-break) rather than a field the caller could name.
8709
- const last = rows.length >= limit ? rows[rows.length - 1] : undefined;
8710
- const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn, q.order, q.view);
8711
- const page = { entries: rows, nextCursor };
9187
+ // `pageOf`'s rule — a FULL page may have more, a short one is the end — with the
9188
+ // cursor minted from the row's COLUMN (plus the tie-break), and each row's own
9189
+ // cursor when asked (#2073: `pageVisible` may stop at a visible row mid-page).
9190
+ const mint = (row) => cursorOf(row, q.sortColumn, plan.idColumn, q.order, q.view);
8712
9191
  if (!params.total)
8713
- return page;
9192
+ return pageOf(rows, limit, mint, params.rowCursors);
8714
9193
  const counted = rt.db.prepare(q.countSql).all(...q.countParams);
8715
- return { ...page, total: counted[0]?.n ?? 0 };
9194
+ return countedPageOf(rows, limit, mint, counted[0]?.n ?? 0, params.rowCursors);
8716
9195
  },
8717
9196
  /**
8718
9197
  * Narrow a permission this caller already holds onto one entity (#K-sharing).
@@ -8741,10 +9220,8 @@ export class SqliteScopeHost {
8741
9220
  throw new PermissionDenied(`cannot grant '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
8742
9221
  'the caller does not hold it there (a grant delegates, it never elevates)');
8743
9222
  }
8744
- rt.db
8745
- .prepare(`INSERT OR IGNORE INTO _substrat_tuples (subject, relation, object)
8746
- VALUES (?, ?, ?)`)
8747
- .run(`principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
9223
+ // #2071: an explicit grant, so it clears a tombstone `revoke` left.
9224
+ writeExplicitTupleIn(switchSqlOf(rt.db), `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`, { kind: 'delegated' });
8748
9225
  },
8749
9226
  /**
8750
9227
  * Withdraw a grant this caller could have made. Same guardrails, same reason — but NOT
@@ -8758,9 +9235,10 @@ export class SqliteScopeHost {
8758
9235
  throw new PermissionDenied(`cannot revoke '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
8759
9236
  'the caller does not hold it there');
8760
9237
  }
8761
- rt.db
8762
- .prepare(`DELETE FROM _substrat_tuples WHERE subject = ? AND relation = ? AND object = ?`)
8763
- .run(`principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
9238
+ // K-21 (#2071): a tombstone, never a delete — a declared shape's top-up must be able
9239
+ // to tell a key taken back from one never held.
9240
+ const r = delegatedRevokeSql(principal, permission, `${entity.entityType}:${entity.entityId}`, at);
9241
+ rt.db.prepare(r.sql).run(...r.params);
8764
9242
  },
8765
9243
  atomic: createAtomic(runSub, { passed, signals }),
8766
9244
  // #1672: mint / revoke / list, written once in the kernel. The raw spine seam (this
@@ -8785,6 +9263,7 @@ export class SqliteScopeHost {
8785
9263
  now: at,
8786
9264
  emit: (event) => writeEvent(event, 'kernel'),
8787
9265
  assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
9266
+ isUnreachableParent: (entity) => isUnreachableParent(spineSql(rt.db), statePlans, entity),
8788
9267
  }),
8789
9268
  // #119: archive and trash, written once in the kernel. The raw seam, because the guarded
8790
9269
  // `ctx.sql` refuses the very columns these write; the operation's own check, so the
@@ -8803,6 +9282,7 @@ export class SqliteScopeHost {
8803
9282
  searchPlans,
8804
9283
  statePlans,
8805
9284
  check: runCheck,
9285
+ continuation: (entityType, params) => ctxRef.pageContinuation(`trash:${entityType}`, { ...params, view: 'trashed' }),
8806
9286
  }),
8807
9287
  // #304: the request-time entitlement read. The pure adapter is single-process, so the
8808
9288
  // directory is local — no projection needed; it reads `_substrat_entitlements` straight,
@@ -8856,56 +9336,109 @@ export class SqliteScopeHost {
8856
9336
  kernelEmitters.set(ctxRef, (event) => writeEvent(event, 'kernel'));
8857
9337
  return ctxRef;
8858
9338
  }
8859
- async applyPendingMigrations(rt) {
8860
- const pending = [];
8861
- for (const mod of this.modules.values()) {
8862
- for (const migration of mod.migrations) {
8863
- if (!rt.appliedMigrations.has(`${mod.id}@${migration.version}`)) {
8864
- pending.push({ moduleId: mod.id, migration });
8865
- }
9339
+ /** Every registered module's derivation plans — what `repairDerivedObjects` and its checks read. */
9340
+ derivedPlans() {
9341
+ return { state: this.statePlans, lists: this.listPlans, search: this.searchPlans };
9342
+ }
9343
+ /**
9344
+ * #2090: a pass with nothing pending, once per runtime — check and repair what the kernel
9345
+ * derived onto the scope's tables, in a transaction of its own. Its migrations are journaled
9346
+ * and will never run again, so this is what reaches a scope stripped before the migration
9347
+ * pass checked. A lost state column fails the scope closed like a failed migration, named by
9348
+ * the journaled migration that added the column; every later call tries again and fails again.
9349
+ */
9350
+ async repairDerived(rt) {
9351
+ let failure;
9352
+ await rt.actor.enqueue(() => {
9353
+ rt.db.exec('BEGIN IMMEDIATE');
9354
+ try {
9355
+ repairDerivedObjects(spineSql(rt.db), (ddl) => execSqlStatements(rt.db, ddl), this.derivedPlans(), {
9356
+ after: 'an applied migration',
9357
+ });
9358
+ rt.db.exec('COMMIT');
8866
9359
  }
9360
+ catch (err) {
9361
+ rt.db.exec('ROLLBACK');
9362
+ failure = { version: err instanceof StateColumnLost ? err.migration : 'kernel@derived-objects', error: err.message };
9363
+ }
9364
+ });
9365
+ if (failure) {
9366
+ this.recordMigrationState(rt, failure);
9367
+ throw migrationFailedError(failure.version, failure.error);
9368
+ }
9369
+ rt.derivedRepaired = true;
9370
+ }
9371
+ async applyPendingMigrations(rt, plan) {
9372
+ const { pending, diverged } = plan ?? (await planMigrations(this.modules.values(), rt.appliedMigrations));
9373
+ if (diverged) {
9374
+ this.recordMigrationState(rt, diverged);
9375
+ throw migrationFailedError(diverged.version, diverged.error);
8867
9376
  }
8868
9377
  // Nothing pending → nothing to record. A scope provisioned before any module
8869
- // registers legitimately sits at schema_version '0'.
8870
- if (pending.length === 0)
9378
+ // registers legitimately sits at schema_version '0'. Still, once per runtime, what the
9379
+ // kernel derived onto the tables is checked and repaired (#2090).
9380
+ if (pending.length === 0) {
9381
+ if (!rt.derivedRepaired)
9382
+ await this.repairDerived(rt);
8871
9383
  return;
9384
+ }
8872
9385
  // The failing `module@version` and its cause, captured structurally rather than
8873
9386
  // re-parsed out of the thrown message — the directory record has to name both.
8874
9387
  let failure;
8875
9388
  try {
8876
9389
  await rt.actor.enqueue(() => {
8877
- for (const { moduleId, migration } of pending) {
9390
+ for (const [i, { moduleId, migration, digest, authored }] of pending.entries()) {
8878
9391
  const key = `${moduleId}@${migration.version}`;
8879
9392
  if (rt.appliedMigrations.has(key))
8880
9393
  continue;
8881
9394
  rt.db.exec('BEGIN IMMEDIATE');
9395
+ let recorded = digest;
8882
9396
  try {
8883
9397
  const already = rt.db
8884
- .prepare('SELECT 1 FROM _substrat_migrations WHERE module_id = ? AND version = ?')
9398
+ .prepare('SELECT sql_digest FROM _substrat_migrations WHERE module_id = ? AND version = ?')
8885
9399
  .get(moduleId, migration.version);
8886
- if (!already) {
9400
+ if (already) {
9401
+ // Applied since this pass read the journal: held to the same digest rule.
9402
+ const error = migrationDivergence(already.sql_digest, digest, authored);
9403
+ if (error)
9404
+ throw new Error(error);
9405
+ recorded = already.sql_digest;
9406
+ }
9407
+ else {
8887
9408
  const started = performance.now();
8888
9409
  const before = rt.db.prepare('SELECT total_changes() AS n').get().n;
8889
- // #1898: a migration runs on the scope's own handle, not `ctx.sql`, so the
8890
- // spine guard's REFERENCES rule is applied here.
8891
- assertNoSpineReference(migration.sql, `migration ${key}`);
8892
- rt.db.exec(migration.sql);
9410
+ // #1898, #2066: a migration runs on the scope's own handle, not `ctx.sql`, so the
9411
+ // spine rules a migration is held to are applied here.
9412
+ assertMigrationSql(migration.sql, { key, digest, authored });
9413
+ // #2068: the ownership ledger is the kernel's — no migration may name it, not even to
9414
+ // read it (the spine rules above allow reads).
9415
+ assertMigrationLeavesLedgerAlone(migration.sql, `migration ${key}`);
9416
+ // #2068: one statement at a time, keeping the table set either side of each — the
9417
+ // same splitter the Durable Object runs, so both hosts execute identical statements.
9418
+ const steps = runMigrationStatements(spineSql(rt.db), migration.sql, (stmt) => rt.db.exec(stmt));
8893
9419
  assertTablesWithinColumnLimit(rt.db);
9420
+ // #2090: the state columns this migration must have left, and after the last of the
9421
+ // pass, the triggers and indexes a create-copy-rename rebuild dropped.
9422
+ const last = i === pending.length - 1;
9423
+ afterMigration(spineSql(rt.db), (ddl) => execSqlStatements(rt.db, ddl), this.derivedPlans(), key, last);
8894
9424
  const after = rt.db.prepare('SELECT total_changes() AS n').get().n;
9425
+ // After the count: `rows_changed` is the migration's own, never the kernel's bookkeeping.
9426
+ recordOwnershipSteps(spineSql(rt.db), moduleId, steps, this.clock());
8895
9427
  rt.db
8896
- .prepare('INSERT INTO _substrat_migrations (module_id, version, applied_at, duration_ms, rows_changed) VALUES (?, ?, ?, ?, ?)')
8897
- .run(moduleId, migration.version, this.clock(), Math.max(0, Math.round(performance.now() - started)), after - before);
9428
+ .prepare('INSERT INTO _substrat_migrations (module_id, version, applied_at, duration_ms, rows_changed, sql_digest) VALUES (?, ?, ?, ?, ?, ?)')
9429
+ .run(moduleId, migration.version, this.clock(), Math.max(0, Math.round(performance.now() - started)), after - before, digest);
8898
9430
  }
8899
9431
  rt.db.exec('COMMIT');
8900
9432
  }
8901
9433
  catch (err) {
8902
9434
  rt.db.exec('ROLLBACK');
8903
9435
  failure = { version: key, error: err.message };
8904
- throw new Error(`migration failed for ${key} — scope fails closed: ${err.message}`);
9436
+ throw migrationFailedError(key, err.message);
8905
9437
  }
8906
- rt.appliedMigrations.add(key);
9438
+ rt.appliedMigrations.set(key, recorded);
8907
9439
  }
8908
9440
  });
9441
+ rt.derivedRepaired = true;
8909
9442
  }
8910
9443
  finally {
8911
9444
  // `finally`, not the success path: a scope that failed closed is exactly the
@@ -9077,9 +9610,14 @@ export class SqliteScopeHost {
9077
9610
  // #1763: old journal rows keep NULL, meaning their cost was never recorded.
9078
9611
  this.ensureColumn(db, '_substrat_migrations', 'duration_ms', 'duration_ms INTEGER');
9079
9612
  this.ensureColumn(db, '_substrat_migrations', 'rows_changed', 'rows_changed INTEGER');
9613
+ // #2066: the rows already there get the legacy mark, never a digest — what they ran was never
9614
+ // measured. KERNEL_DDL's fence keeps any other NULL out, so the mark finds only those.
9615
+ this.ensureColumn(db, '_substrat_migrations', 'sql_digest', 'sql_digest TEXT');
9616
+ db.exec(MIGRATION_DIGEST_MARK_LEGACY);
9080
9617
  // KERNEL_DDL is all IF NOT EXISTS, so a scope DB created before K-21 keeps the
9081
9618
  // old shape — ALTER the tombstone in.
9082
9619
  this.ensureColumn(db, '_substrat_tuples', 'revoked_at', 'revoked_at TEXT');
9620
+ db.exec(GRANT_CHILDREN_INDEX_DDL);
9083
9621
  // Executor retry state (#100), same reasoning: scopes provisioned before it
9084
9622
  // already have the table. Defaults read as "terminal", which is exactly right
9085
9623
  // for the rows already there — every one of them is a completed delivery or a
@@ -9136,6 +9674,9 @@ export class SqliteScopeHost {
9136
9674
  // from the kernel's (now widened) DDL when it runs — so by the time this ALTER
9137
9675
  // executes, the table always already has `kind` in its key, never `invocation_id`.
9138
9676
  this.ensureColumn(db, '_substrat_schedule_state', 'invocation_id', 'invocation_id TEXT');
9677
+ // #2096: a purge horizon's lap in progress, on a scope DB built before the column. NULL is the
9678
+ // start of a lap — the honest reading of every row already there. After the #1288 rebuild, as above.
9679
+ this.ensureColumn(db, '_substrat_schedule_state', 'purge_cursor', 'purge_cursor TEXT');
9139
9680
  this.ensureColumn(db, '_substrat_job_runs', 'subject_id', 'subject_id TEXT');
9140
9681
  // #2034: the lease, on a scope DB built before it. NULL = nobody holds the run.
9141
9682
  this.ensureColumn(db, '_substrat_job_runs', 'lease_owner', 'lease_owner TEXT');
@@ -9171,9 +9712,9 @@ export class SqliteScopeHost {
9171
9712
  // WAL is also what the read connection rests on (#1624): a reader sees the last
9172
9713
  // committed snapshot and never blocks, or is blocked by, the writer.
9173
9714
  db.pragma('journal_mode = WAL');
9174
- db.exec(KERNEL_DDL);
9715
+ execSqlStatements(db, KERNEL_DDL);
9175
9716
  this.ensureSpineColumns(db);
9176
- const appliedMigrations = new Set(db.prepare('SELECT module_id, version FROM _substrat_migrations').all().map((r) => `${r.module_id}@${r.version}`));
9717
+ const appliedMigrations = readAppliedMigrations(db);
9177
9718
  // #1335: the floor this scope's ids have to clear is the highest id already in
9178
9719
  // its outbox, not wherever the wall clock happens to be. Read once, here, because
9179
9720
  // this is the one place a scope's runtime is built — a reopened host, a revived
@@ -9191,6 +9732,7 @@ export class SqliteScopeHost {
9191
9732
  reader: null,
9192
9733
  actor: new ScopeActor(),
9193
9734
  appliedMigrations,
9735
+ derivedRepaired: false,
9194
9736
  mintEventId,
9195
9737
  invocationId: null,
9196
9738
  };
@@ -9199,6 +9741,11 @@ export class SqliteScopeHost {
9199
9741
  return created;
9200
9742
  }
9201
9743
  }
9744
+ /** A scope's migration journal: `module@version` → the SQL digest it recorded (#2066). */
9745
+ function readAppliedMigrations(db) {
9746
+ const rows = db.prepare('SELECT module_id, version, sql_digest FROM _substrat_migrations').all();
9747
+ return new Map(rows.map((r) => [`${r.module_id}@${r.version}`, r.sql_digest]));
9748
+ }
9202
9749
  /** The platform spine (`_substrat_*`) and SQLite internals — the UI groups these apart. */
9203
9750
  function isSystemTable(name) {
9204
9751
  return name.startsWith('_substrat') || name.startsWith('sqlite_');
@@ -9228,6 +9775,15 @@ function redactionSqlOf(db) {
9228
9775
  * `_substrat_*`" a mechanism rather than a lint rule (#954) — the kernel's own
9229
9776
  * spine writes use `rt.db` directly and never pass through here.
9230
9777
  */
9778
+ /**
9779
+ * Run a multi-statement DDL blob one statement at a time, as its comment-blanked text (#2068) —
9780
+ * the same statements and the same text the Durable Object executes, so both hosts store the
9781
+ * same DDL in `sqlite_master` and a later `ALTER TABLE … DROP COLUMN` rewrites the same thing.
9782
+ */
9783
+ function execSqlStatements(db, sql) {
9784
+ for (const statement of executableSqlStatements(sql))
9785
+ db.exec(statement);
9786
+ }
9231
9787
  /**
9232
9788
  * The kernel's OWN spine access (#1672) — the same seam as `scopedSql` without `guardSpine`,
9233
9789
  * because these are the kernel's writes to `_substrat_capabilities`, which module code may