@substrat-run/adapter-sqlite 0.145.0 → 0.147.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
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync,
2
2
  import { dirname, join } from 'node:path';
3
3
  import Database from 'better-sqlite3';
4
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, assertNoBindingShapeBreak, importsOfManifestJson, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, isSubstratError, 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, principalBecomeCapabilityInput, 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, CAPABILITY_BECOME_MINT_OPERATION, becomeMintCheck, mintBecomeCapabilityAsPrincipal, revokeBecomeCapabilityAsPrincipal, readBecomeLinkStates, ENTITY_UNBIND_OPERATION, allowedFor, unbindEntityHolderBounded, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, CONTINUATION_POSITION_CAP, visibleContinuation, searchStateWhere, uncheckedView, addStatePlans, addUpdatedStampPlans, watchUpdatedStamps, entityStateMigrations, assertNoCallerPurge, isUnreachableParent, heldPurgePass, purgeDueOf, purgeHeldBy, lifecycleRefusal, purgeOnlyKeysOf, purgeReportOf, purgeStillDue, refuseTrashedTarget, assertBoundOperations, 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, SCOPE_STORAGE_DDL, forgetScopeStorage, listScopeStorageAttemptRows, listScopeStorageRows, pruneScopeStorageRows, recordScopeStorageRows, 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, auditedCapabilityRevoke, 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, scopeMoveOf, runningVersionOf, isPrimaryScope, PROVISIONED_VERSIONS_SQL, installsReconciledOf, strandJudgmentsOf, provisionedVersionsParams, boundShapesColumn, boundShapesOfColumn, installReconciled, 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';
5
+ import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, COPY_ORIGIN_DDL, ENTITY_STATE_MOVES_DDL, CAPABILITY_EXCHANGE_OPERATION, CAPABILITY_BECOME_MINT_OPERATION, becomeMintCheck, mintBecomeCapabilityAsPrincipal, revokeBecomeCapabilityAsPrincipal, readBecomeLinkStates, ENTITY_UNBIND_OPERATION, allowedFor, unbindEntityHolderBounded, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, CONTINUATION_POSITION_CAP, visibleContinuation, searchStateWhere, uncheckedView, addStatePlans, addUpdatedStampPlans, watchUpdatedStamps, entityStateMigrations, assertNoCallerPurge, isUnreachableParent, heldPurgePass, purgeDueOf, purgeHeldBy, lifecycleRefusal, purgeOnlyKeysOf, purgeReportOf, purgeStillDue, refuseTrashedTarget, assertBoundOperations, 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, SCOPE_STORAGE_DDL, forgetScopeStorage, listScopeStorageAttemptRows, listScopeStorageRows, listScopeStoreStorageRows, pruneScopeStorageRows, recordScopeStorageRows, 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, peerCallerAt, collectPeers, peerSeats, resolveVerticalInstanceFrom, resolvePeerInstanceFrom, peerVerticalSpellings, samePeerVertical, 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, auditedCapabilityRevoke, 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, scopeMoveOf, runningVersionOf, isPrimaryScope, PROVISIONED_VERSIONS_SQL, installsReconciledOf, strandJudgmentsOf, provisionedVersionsParams, boundShapesColumn, boundShapesOfColumn, installReconciled, 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, callReservedStoreOperation, callScopeStore, observeStoreCall, replayStoreErasures, scopeStoreLifecycleOf, scopeStoreRunnersOf, } from '@substrat-run/kernel';
6
6
  import { attributedView, delegatedRevokeSql, writeExplicitTupleIn, grantEntityShapeIn, shapeTopUpBatch, topUpEntityGrantShapes, settlePlatformRequestIn, } from '@substrat-run/kernel';
7
7
  import { ADMIN_LOG_INDEXES_SQL, SETTLE_INTENT_SQL, SETTLE_OUTCOME_SQL, settleOutcomeParamsOf, readAuditedOperations, unknownOutcomeOf, } from '@substrat-run/kernel';
8
8
  import { INERT_SCOPE_REASON, isPrimaryScopeRow } from '@substrat-run/kernel';
@@ -620,6 +620,9 @@ export class SqliteScopeHost {
620
620
  /** predicate name → the module-contributed implementation. Names are global. */
621
621
  predicates = new Map();
622
622
  /** operation names whose default binding some manifest withdrew (K-17). */
623
+ scopeStoreRunners;
624
+ scopeStoreVertical;
625
+ scopeStoreLifecycle;
623
626
  withdrawn = new Map(); // operation → withdrawing module
624
627
  relations = new Map();
625
628
  /** entityType → its derived FTS5 index (#827). One entity type, one index. */
@@ -712,6 +715,9 @@ export class SqliteScopeHost {
712
715
  this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
713
716
  this.clock = options.clock ?? (() => instant.parse(new Date().toISOString()));
714
717
  this.versionId = options.versionId ?? null;
718
+ this.scopeStoreVertical = options.scopeStores?.vertical;
719
+ this.scopeStoreRunners = scopeStoreRunnersOf(options.scopeStores);
720
+ this.scopeStoreLifecycle = scopeStoreLifecycleOf(this.scopeStoreRunners);
715
721
  this.logSink = options.logSink ?? consoleLogSink;
716
722
  this.invocationLineSink = options.invocationLineSink ?? consoleInvocationLineSink;
717
723
  // Every option is read above and here, BEFORE the directory is claimed: an option that throws
@@ -2358,6 +2364,7 @@ export class SqliteScopeHost {
2358
2364
  };
2359
2365
  }
2360
2366
  async importScope(actor, input, dump) {
2367
+ this.refuseUnusableScopeStores(dump.stores, input.vertical ?? null);
2361
2368
  // Create the destination scope (directory row + storage). Its migration step
2362
2369
  // creates some tables; the dump then replaces them wholesale, so the end state
2363
2370
  // is the dump, not whatever the local module set would have built. Provenance is
@@ -2370,6 +2377,7 @@ export class SqliteScopeHost {
2370
2377
  });
2371
2378
  // A fork: its callers (snapshotScope) hand it a dump the platform exported.
2372
2379
  await this.loadDump(input.tenantId, input.scopeId, { ...dump, exact: true });
2380
+ await this.importScopeStores(input.tenantId, input.scopeId, dump.stores);
2373
2381
  await this.admin.activateScope(actor, input.tenantId, input.scopeId);
2374
2382
  this.recordAdmin(actor, 'importScope', { tenantId: input.tenantId, scopeId: input.scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
2375
2383
  }
@@ -2522,6 +2530,7 @@ export class SqliteScopeHost {
2522
2530
  const existing = await this.admin.getScopeRecord(actor, tenantId, scopeId);
2523
2531
  if (!existing)
2524
2532
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2533
+ this.refuseUnusableScopeStores(dump.stores, existing.vertical);
2525
2534
  // #1742: a dump from before a switch was pulled brings the module's grants back live, so
2526
2535
  // the directory's recorded-off modules go back off in the load's own turn, as a
2527
2536
  // provision's seat does — no schedule pass can run in between.
@@ -2530,9 +2539,17 @@ export class SqliteScopeHost {
2530
2539
  // #1869: the re-point's source is the separate hint when given; the dump is otherwise as sent.
2531
2540
  const source = { tables: dump.tables, scopeId: opts?.sourceScopeId ?? dump.scopeId };
2532
2541
  await this.loadDump(tenantId, scopeId, source, (rt) => this.reassertSwitchesInTurn(rt, actor, tenantId, scopeId, undefined, (action, after) => rows.push([action, after])));
2542
+ await this.importScopeStores(tenantId, scopeId, dump.stores);
2543
+ const replay = await this.replayShreddedStoreErasures(actor, tenantId, scopeId, (opts?.sourceScopeId ?? dump.scopeId));
2533
2544
  for (const [action, after] of rows)
2534
2545
  this.recordAdmin(actor, action, { tenantId, scopeId }, null, after);
2535
- this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
2546
+ this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, {
2547
+ sourceScopeId: dump.scopeId,
2548
+ tables: dump.tables.length,
2549
+ capturedAt: dump.capturedAt,
2550
+ ...replay,
2551
+ });
2552
+ return replay;
2536
2553
  }
2537
2554
  async snapshotScope(actor, tenantId, scopeId, opts) {
2538
2555
  const source = await this.admin.getScopeRecord(actor, tenantId, scopeId);
@@ -2554,6 +2571,7 @@ export class SqliteScopeHost {
2554
2571
  if (source.verticalVersionId) {
2555
2572
  await this.admin.bindScopeVersion(actor, tenantId, snapshotId, source.verticalVersionId);
2556
2573
  }
2574
+ await this.replayShreddedStoreErasures(actor, tenantId, snapshotId, scopeId);
2557
2575
  return snapshotId;
2558
2576
  }
2559
2577
  async deleteSnapshot(actor, tenantId, scopeId) {
@@ -2567,6 +2585,7 @@ export class SqliteScopeHost {
2567
2585
  throw substratError('forbidden', `scope ${scopeId} is not a fork or preview — only previews may be deleted; ` +
2568
2586
  `archive a primary scope instead`);
2569
2587
  }
2588
+ await this.wipeScopeStores(tenantId, scopeId);
2570
2589
  // Close and evict the runtime handle before touching the file.
2571
2590
  const rt = this.scopes.get(`${tenantId}/${scopeId}`);
2572
2591
  if (rt)
@@ -2635,6 +2654,195 @@ export class SqliteScopeHost {
2635
2654
  }
2636
2655
  return this.buildStub(tenantId, scopeId, rt, asPrincipal(principal), options);
2637
2656
  }
2657
+ async getScopeStore(principal, tenantId, scopeId, binding, options) {
2658
+ // The directory check is the same one `getScope` makes. With no runners
2659
+ // configured, the binding is refused after that check.
2660
+ await this.getScope(principal, tenantId, scopeId);
2661
+ if (this.scopeStoreRunners.size === 0) {
2662
+ throw substratError('unavailable', `no scope-store runner is configured for binding '${binding}'`);
2663
+ }
2664
+ const vertical = this.scopeStoreVerticalOf(scopeId);
2665
+ const runner = this.scopeStoreRunners.get(binding);
2666
+ if (!runner) {
2667
+ throw substratError('not_found', `no scope store is bound for '${binding}'`);
2668
+ }
2669
+ await this.scopeStoreLifecycle.start();
2670
+ return {
2671
+ tenantId,
2672
+ scopeId,
2673
+ subjectKind: 'principal',
2674
+ invoke: (operation, input, ...extra) => observeStoreCall(binding, operation, () => callScopeStore((request) => runner.invoke({ tenantId, vertical, scopeId, binding }, request), operation, input, ...extra), options?.onStoreCall),
2675
+ };
2676
+ }
2677
+ /** The scope's vertical when it has one. No vertical means this host has no store to touch. */
2678
+ scopeStoreVerticalIfPresent(scopeId) {
2679
+ const row = this.directory.prepare('SELECT vertical FROM scopes WHERE scope_id = ?').get(scopeId);
2680
+ const vertical = row?.vertical;
2681
+ if (!vertical)
2682
+ return undefined;
2683
+ if (this.scopeStoreVertical !== undefined && vertical !== this.scopeStoreVertical) {
2684
+ throw substratError('forbidden', `this host serves vertical '${this.scopeStoreVertical}', and scope ${scopeId} runs '${vertical}'`);
2685
+ }
2686
+ return vertical;
2687
+ }
2688
+ /** The scope's vertical, refused when this host is limited to another one. */
2689
+ scopeStoreVerticalOf(scopeId) {
2690
+ const vertical = this.scopeStoreVerticalIfPresent(scopeId);
2691
+ if (!vertical) {
2692
+ throw substratError('not_found', `scope ${scopeId} has no vertical, so it has no scope store`);
2693
+ }
2694
+ return vertical;
2695
+ }
2696
+ /**
2697
+ * One reserved call on a declared binding. The vertical's stub never reaches
2698
+ * this; `callReservedStoreOperation` refuses a name that is not reserved.
2699
+ */
2700
+ async invokeReservedStore(address, operation, input) {
2701
+ const runner = this.scopeStoreRunners.get(address.binding);
2702
+ if (!runner)
2703
+ throw substratError('not_found', `no scope store is bound for '${address.binding}'`);
2704
+ await this.scopeStoreLifecycle.start();
2705
+ return callReservedStoreOperation((request) => runner.invoke(address, request), operation, input);
2706
+ }
2707
+ /**
2708
+ * Each declared binding's `substrat.size`. No runners means an empty list,
2709
+ * and the scope is not opened to discover that.
2710
+ */
2711
+ async readScopeStoreSizes(tenantId, scopeId) {
2712
+ const bindings = [...this.scopeStoreRunners.keys()];
2713
+ if (bindings.length === 0)
2714
+ return [];
2715
+ this.scopeReadDbFor(tenantId, scopeId);
2716
+ const vertical = this.scopeStoreVerticalOf(scopeId);
2717
+ const sizes = [];
2718
+ for (const binding of bindings) {
2719
+ const { bytes } = await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.size');
2720
+ sizes.push({ binding, bytes });
2721
+ }
2722
+ return sizes;
2723
+ }
2724
+ /** The vertical whose stores this scope has on this host, or nothing when it has none. */
2725
+ storeVerticalOf(scopeId) {
2726
+ if (this.scopeStoreRunners.size === 0)
2727
+ return undefined;
2728
+ const row = this.directory.prepare('SELECT vertical FROM scopes WHERE scope_id = ?').get(scopeId);
2729
+ const vertical = row?.vertical ?? undefined;
2730
+ if (!vertical)
2731
+ return undefined;
2732
+ if (this.scopeStoreVertical !== undefined && vertical !== this.scopeStoreVertical)
2733
+ return undefined;
2734
+ return vertical;
2735
+ }
2736
+ /**
2737
+ * `substrat.erase` on each declared binding. A store error throws, so the caller
2738
+ * can leave the subject key untouched. Fail closed (#2242): an empty answer means
2739
+ * this host KNOWS it holds nothing for the scope. It always can tell — the
2740
+ * directory names the scope's vertical, and the runners are this host's own — so
2741
+ * unlike a Cloudflare host with no control plane it has no "cannot tell" case.
2742
+ */
2743
+ async eraseScopeStores(tenantId, scopeId, subjectId) {
2744
+ const bindings = [...this.scopeStoreRunners.keys()];
2745
+ // Shred calls this for every scope. No vertical names no store object, and a
2746
+ // vertical this host is not pinned to is one its runners never served: both hold
2747
+ // nothing here, and the key is still destroyed.
2748
+ const vertical = this.storeVerticalOf(scopeId);
2749
+ if (!vertical)
2750
+ return [];
2751
+ const erasures = [];
2752
+ for (const binding of bindings) {
2753
+ const { erased } = await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.erase', { subjectId });
2754
+ erasures.push({ binding, erased });
2755
+ }
2756
+ return erasures;
2757
+ }
2758
+ /**
2759
+ * A dump's stores this host cannot load are refused before any write (#2211, L8): a
2760
+ * binding that is not bound, a store with no kind or a kind other than the bound runner's,
2761
+ * any store at all for a destination with no vertical (no store object can be named for
2762
+ * it, so loading would drop them), or a destination on a vertical other than the one this
2763
+ * host is pinned to.
2764
+ */
2765
+ refuseUnusableScopeStores(stores, vertical) {
2766
+ const listed = stores ?? [];
2767
+ if (listed.length === 0)
2768
+ return;
2769
+ if (vertical === null) {
2770
+ throw substratError('conflict', 'dump carries scope stores, and the destination scope has no vertical to hold them');
2771
+ }
2772
+ if (this.scopeStoreVertical !== undefined && vertical !== this.scopeStoreVertical) {
2773
+ throw substratError('forbidden', `this host serves vertical '${this.scopeStoreVertical}', and the destination scope runs '${vertical}'`);
2774
+ }
2775
+ for (const store of listed) {
2776
+ const runner = this.scopeStoreRunners.get(store.binding);
2777
+ if (!runner) {
2778
+ throw substratError('not_found', `dump names scope store '${store.binding}', which is not bound`);
2779
+ }
2780
+ if (!store.kind) {
2781
+ throw substratError('validation_failed', `dump's scope store '${store.binding}' names no kind`);
2782
+ }
2783
+ if (store.kind !== runner.kind) {
2784
+ throw substratError('conflict', `dump's scope store '${store.binding}' is kind '${store.kind}', and this host binds kind '${runner.kind}'`);
2785
+ }
2786
+ }
2787
+ }
2788
+ async exportScopeStores(tenantId, scopeId) {
2789
+ const bindings = [...this.scopeStoreRunners.keys()];
2790
+ if (bindings.length === 0)
2791
+ return [];
2792
+ const vertical = this.scopeStoreVerticalIfPresent(scopeId);
2793
+ if (!vertical)
2794
+ return [];
2795
+ const stores = [];
2796
+ for (const binding of bindings) {
2797
+ const runner = this.scopeStoreRunners.get(binding);
2798
+ if (!runner)
2799
+ throw substratError('not_found', `no scope store is bound for '${binding}'`);
2800
+ const exported = await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.export');
2801
+ stores.push({ binding, kind: runner.kind, tables: exported.tables });
2802
+ }
2803
+ return stores;
2804
+ }
2805
+ async importScopeStores(tenantId, scopeId, stores) {
2806
+ const listed = stores ?? [];
2807
+ if (listed.length === 0)
2808
+ return;
2809
+ const vertical = this.scopeStoreVerticalIfPresent(scopeId);
2810
+ if (!vertical) {
2811
+ throw substratError('conflict', `dump carries scope stores, and scope ${scopeId} has no vertical to hold them`);
2812
+ }
2813
+ for (const store of listed) {
2814
+ await this.invokeReservedStore({ tenantId, vertical, scopeId, binding: store.binding }, 'substrat.import', { tables: store.tables });
2815
+ }
2816
+ }
2817
+ /**
2818
+ * `substrat.erase` again for every tombstoned subject (#2207), on the target's and the
2819
+ * source's marks (`replayStoreErasures`). A target this host names no store for (no
2820
+ * runner, or no vertical a store is kept for) has nothing to replay into: nothing is read.
2821
+ */
2822
+ async replayShreddedStoreErasures(actor, tenantId, target, source) {
2823
+ if (!this.storeVerticalOf(target))
2824
+ return undefined;
2825
+ return replayStoreErasures({
2826
+ admin: this.admin,
2827
+ actor,
2828
+ tenantId,
2829
+ target,
2830
+ source,
2831
+ erase: async (subjectId) => (await this.eraseScopeStores(tenantId, target, subjectId)).length,
2832
+ });
2833
+ }
2834
+ /** `substrat.wipe` on each declared binding, before the scope's own storage goes. */
2835
+ async wipeScopeStores(tenantId, scopeId) {
2836
+ const bindings = [...this.scopeStoreRunners.keys()];
2837
+ if (bindings.length === 0)
2838
+ return;
2839
+ const vertical = this.scopeStoreVerticalIfPresent(scopeId);
2840
+ if (!vertical)
2841
+ return;
2842
+ for (const binding of bindings) {
2843
+ await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.wipe');
2844
+ }
2845
+ }
2638
2846
  /**
2639
2847
  * A scope stub whose authority is a CONNECTION (#97).
2640
2848
  *
@@ -3067,6 +3275,32 @@ export class SqliteScopeHost {
3067
3275
  await this.applyPendingMigrations(rt);
3068
3276
  return rt;
3069
3277
  }
3278
+ /**
3279
+ * The tenant a peer name is read in (#2252): the directory's own slug for that tenant, the one
3280
+ * prefix a push pinned to it registers under. Never parsed out of the name being matched.
3281
+ */
3282
+ peerTenant(tenantId) {
3283
+ const row = this.directory.prepare('SELECT slug FROM tenants WHERE tenant_id = ?').get(tenantId);
3284
+ return { id: tenantId, slug: row?.slug ?? null };
3285
+ }
3286
+ /**
3287
+ * Every scope of the tenant registered under a spelling of the peer name `vertical` in
3288
+ * `tenant` (`peerVerticalSpellings`), for the kernel's resolvers to choose among (#2252).
3289
+ */
3290
+ peerCandidates(tenant, vertical) {
3291
+ const rows = this.directory
3292
+ .prepare(`SELECT scope_id, tenant_id, vertical, status, kind, forked_from FROM scopes
3293
+ WHERE tenant_id = ? AND vertical IN (SELECT value FROM json_each(?))`)
3294
+ .all(tenant.id, JSON.stringify(peerVerticalSpellings(vertical, tenant.slug)));
3295
+ return rows.map((r) => ({
3296
+ id: r.scope_id,
3297
+ tenantId: r.tenant_id,
3298
+ vertical: r.vertical,
3299
+ status: r.status,
3300
+ kind: r.kind ?? '',
3301
+ forkedFrom: r.forked_from ?? null,
3302
+ }));
3303
+ }
3070
3304
  /** The directory's current hold, read again when background work is about to run. */
3071
3305
  scopeWorkRefusal(tenantId, scopeId, allowProvisioning = false) {
3072
3306
  const row = this.directory
@@ -3161,7 +3395,8 @@ export class SqliteScopeHost {
3161
3395
  * here, and `admitPeer` runs inside the scope's actor task on every invoke (`buildStub`).
3162
3396
  */
3163
3397
  async getVerticalScope(caller, tenantId, scopeId, options) {
3164
- const parsed = verticalCaller.parse(caller);
3398
+ // #2252: the caller as this vertical's `peers` names it, as on the hosted door.
3399
+ const parsed = peerCallerAt(this.peerDeclarations(), verticalCaller.parse(caller));
3165
3400
  const rt = await this.openActiveScope(tenantId, scopeId);
3166
3401
  return this.buildStub(tenantId, scopeId, rt, { kind: 'vertical-peer', caller: parsed }, options);
3167
3402
  }
@@ -4045,10 +4280,15 @@ export class SqliteScopeHost {
4045
4280
  }
4046
4281
  }
4047
4282
  async close() {
4048
- for (const rt of [...this.scopes.values()])
4049
- this.closeRuntime(rt);
4050
- this.directory.close();
4051
- this.owner.release();
4283
+ try {
4284
+ await this.scopeStoreLifecycle.stop();
4285
+ }
4286
+ finally {
4287
+ for (const rt of [...this.scopes.values()])
4288
+ this.closeRuntime(rt);
4289
+ this.directory.close();
4290
+ this.owner.release();
4291
+ }
4052
4292
  }
4053
4293
  // -------------------------------------------------------------------------
4054
4294
  // Event dispatch (testrun spec §9.2.3): at-least-once, kernel-journaled,
@@ -6130,32 +6370,16 @@ export class SqliteScopeHost {
6130
6370
  return rows.map(mapHostname);
6131
6371
  },
6132
6372
  // #1706: the directory's answer to "vertical Y in tenant T", by the kernel's one rule.
6373
+ // Names match EXACTLY: this is the cross-vertical edges' read (the import cursor's
6374
+ // producer). A peer call's tenant-pinned spelling is `resolvePeerInstance`'s (#2252).
6133
6375
  resolveVerticalInstance: async (tenantId, vertical) => {
6134
- const rows = this.directory
6135
- .prepare(`SELECT scope_id, tenant_id, vertical, status, kind, forked_from FROM scopes
6136
- WHERE tenant_id = ? AND vertical = ?`)
6137
- .all(tenantId, vertical);
6138
- return resolveVerticalInstanceFrom(rows.map((r) => ({
6139
- id: r.scope_id,
6140
- tenantId: r.tenant_id,
6141
- vertical: r.vertical,
6142
- status: r.status,
6143
- kind: r.kind ?? '',
6144
- forkedFrom: r.forked_from ?? null,
6145
- })), tenantId, vertical);
6376
+ const tenant = { id: tenantId, slug: null };
6377
+ return resolveVerticalInstanceFrom(this.peerCandidates(tenant, vertical), tenant, vertical);
6146
6378
  },
6147
6379
  resolvePeerInstance: async (tenantId, callerScopeId, vertical) => {
6148
6380
  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);
6149
- const rows = this.directory.prepare(`SELECT scope_id, tenant_id, vertical, status, kind, forked_from FROM scopes
6150
- WHERE tenant_id = ? AND vertical = ?`).all(tenantId, vertical);
6151
- return resolvePeerInstanceFrom(rows.map((r) => ({
6152
- id: r.scope_id,
6153
- tenantId: r.tenant_id,
6154
- vertical: r.vertical,
6155
- status: r.status,
6156
- kind: r.kind ?? '',
6157
- forkedFrom: r.forked_from,
6158
- })), tenantId, vertical, binding?.target_scope_id ?? null, binding?.invalidated === 1);
6381
+ const tenant = this.peerTenant(tenantId);
6382
+ return resolvePeerInstanceFrom(this.peerCandidates(tenant, vertical), tenant, vertical, binding?.target_scope_id ?? null, binding?.invalidated === 1);
6159
6383
  },
6160
6384
  peerBinding: async (actor, tenantId, callerScopeId, vertical) => {
6161
6385
  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);
@@ -6174,7 +6398,7 @@ export class SqliteScopeHost {
6174
6398
  }
6175
6399
  if (targetScopeId !== null) {
6176
6400
  const target = this.directory.prepare('SELECT tenant_id, vertical, status, kind, forked_from FROM scopes WHERE scope_id = ?').get(targetScopeId);
6177
- if (!target || target.tenant_id !== tenantId || target.vertical !== vertical || target.status !== 'active' || !isPrimaryScopeRow(target)) {
6401
+ if (!target || target.tenant_id !== tenantId || !samePeerVertical(target.vertical, vertical, this.peerTenant(tenantId).slug) || target.status !== 'active' || !isPrimaryScopeRow(target)) {
6178
6402
  throw substratError('not_found', 'target is not a live primary instance of this vertical in this tenant');
6179
6403
  }
6180
6404
  }
@@ -7216,6 +7440,11 @@ export class SqliteScopeHost {
7216
7440
  this.recordAccess(actor, 'scopeDatabaseSize', { tenantId, scopeId }, null, 1);
7217
7441
  return bytes;
7218
7442
  },
7443
+ scopeStoreSizes: async (actor, tenantId, scopeId) => {
7444
+ const sizes = await this.readScopeStoreSizes(tenantId, scopeId);
7445
+ this.recordAccess(actor, 'scopeStoreSizes', { tenantId, scopeId }, null, sizes.length);
7446
+ return sizes;
7447
+ },
7219
7448
  rewindScope: async () => {
7220
7449
  throw new Error('point-in-time rewind is not available on this host (PITR is a Durable-Object-plane mechanism) — use the backup restore path');
7221
7450
  },
@@ -7611,7 +7840,8 @@ export class SqliteScopeHost {
7611
7840
  return { name, ddl: sql, columns, rows };
7612
7841
  });
7613
7842
  this.recordAccess(actor, 'exportScope', { tenantId, scopeId }, null, tables.length);
7614
- return { tenantId, scopeId, capturedAt: new Date().toISOString(), tables };
7843
+ const stores = await this.exportScopeStores(tenantId, scopeId);
7844
+ return { tenantId, scopeId, capturedAt: new Date().toISOString(), tables, stores };
7615
7845
  },
7616
7846
  // -- directory disaster recovery (#40) ----------------------------------
7617
7847
  // The self-hosted half of the same story. A self-host has no DO point-in-time
@@ -7751,6 +7981,7 @@ export class SqliteScopeHost {
7751
7981
  const rt = this.scopes.get(`${tenantId}/${scopeId}`);
7752
7982
  if (rt)
7753
7983
  this.closeRuntime(rt);
7984
+ await this.wipeScopeStores(tenantId, scopeId);
7754
7985
  rmSync(join(this.dir, `${tenantId}__${scopeId}.sqlite`), { force: true });
7755
7986
  // The recoverable copy the caller stored first (#493), named in the audit entry so
7756
7987
  // the trail answers "was there a backup" without correlating two timestamps.
@@ -7778,7 +8009,19 @@ export class SqliteScopeHost {
7778
8009
  },
7779
8010
  // `stamp` is the erasure epoch an orchestrated erasure's claim compared (#1722). A direct shred
7780
8011
  // claims nothing and records the epoch it ran under: the backfill orders erasures by them.
7781
- shredSubject: async (actor, tenantId, scopeId, subjectId, stamp) => {
8012
+ listShreddedSubjects: async (actor, tenantId, scopeId, cursor) => {
8013
+ this.assertScope(tenantId, scopeId);
8014
+ const rows = this.directory
8015
+ .prepare(`SELECT subject_id FROM _substrat_subject_keys
8016
+ WHERE scope_id = ? AND tenant_id = ? AND shredded_at IS NOT NULL AND subject_id > ?
8017
+ ORDER BY subject_id LIMIT 101`)
8018
+ .all(scopeId, tenantId, cursor ?? '');
8019
+ const subjects = rows.slice(0, 100).map((row) => row.subject_id);
8020
+ const nextCursor = rows.length > 100 ? (subjects[99] ?? null) : null;
8021
+ this.recordAccess(actor, 'listShreddedSubjects', { tenantId, scopeId }, null, subjects.length);
8022
+ return { subjects, nextCursor };
8023
+ },
8024
+ shredSubject: async (actor, tenantId, scopeId, subjectId, stamp, storeErasures) => {
7782
8025
  this.assertScope(tenantId, scopeId);
7783
8026
  const before = stamp ?? {
7784
8027
  erasureEpoch: this.directory.prepare(COPY_BACKFILL_SCOPE_SQL).get(tenantId, scopeId)
@@ -7853,6 +8096,11 @@ export class SqliteScopeHost {
7853
8096
  // intents, an issue's exemplar, a sweep record's error. Before the key, for the
7854
8097
  // ordering reason above.
7855
8098
  redactSubjectDirectoryText(redactionSqlOf(this.directory), { tenantId, scopeId, subjectId, intentIds });
8099
+ // Scope stores after the scope-side redaction and before the key (#2068). A store
8100
+ // that errors throws here, so the key below is untouched and a retry converges.
8101
+ // An orchestrated caller passes the serving script's result and this host does
8102
+ // not erase again.
8103
+ const erasures = storeErasures ?? (await this.eraseScopeStores(tenantId, scopeId, subjectId));
7856
8104
  const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
7857
8105
  const receipt = subjectShredReceipt.parse({
7858
8106
  subjectId,
@@ -7860,6 +8108,7 @@ export class SqliteScopeHost {
7860
8108
  intentsRedacted,
7861
8109
  jobRunsRedacted,
7862
8110
  ...vertical,
8111
+ storeErasures: erasures,
7863
8112
  keyDestroyed: existed,
7864
8113
  tombstoned: true,
7865
8114
  });
@@ -7880,7 +8129,7 @@ export class SqliteScopeHost {
7880
8129
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
7881
8130
  return row.erasure_epoch;
7882
8131
  },
7883
- finalizeSubjectShred: async (actor, tenantId, scopeId, subjectId, redactions, expected) => {
8132
+ finalizeSubjectShred: async (actor, tenantId, scopeId, subjectId, redactions, expected, storeErasures) => {
7884
8133
  if (redactions.length === 0)
7885
8134
  throw substratError('conflict', 'subject erasure has no confirmed scope redaction');
7886
8135
  const claimed = this.directory.prepare(`UPDATE scopes SET erasure_epoch = COALESCE(erasure_epoch, 0) + 1
@@ -7896,7 +8145,7 @@ export class SqliteScopeHost {
7896
8145
  // The pure adapter has one co-located scope store. Its existing atomic shred is
7897
8146
  // the final local redaction and key destruction, after remote copies confirmed.
7898
8147
  const shred = this.admin.shredSubject;
7899
- return shred(actor, tenantId, scopeId, subjectId, { erasureEpoch: expected.epoch, path: 'orchestrated' });
8148
+ return shred(actor, tenantId, scopeId, subjectId, { erasureEpoch: expected.epoch, path: 'orchestrated' }, storeErasures);
7900
8149
  },
7901
8150
  // -- impersonation (K-42, #868) ----------------------------------------
7902
8151
  beginImpersonation: async (actor, input) => {
@@ -7972,6 +8221,7 @@ export class SqliteScopeHost {
7972
8221
  '_substrat_finding_rules', // #1748: the tenant's suppress rules
7973
8222
  '_substrat_scope_storage', // #1524: storage samples of scopes whose storage is gone
7974
8223
  '_substrat_scope_storage_attempts', // #1524: and the phase's last try at each
8224
+ '_substrat_scope_store_storage', // #2212: scope-store samples, gone with the tenant
7975
8225
  ];
7976
8226
  const clear = this.directory.transaction(() => {
7977
8227
  for (const table of tables) {
@@ -8080,6 +8330,7 @@ export class SqliteScopeHost {
8080
8330
  // #1524: the stored gauge's latest sample per non-reaped scope. A directory read only.
8081
8331
  storage: listScopeStorageRows(redactionSqlOf(this.directory), { tenantId: only, latest: true }),
8082
8332
  storageAttempts: listScopeStorageAttemptRows(redactionSqlOf(this.directory), only),
8333
+ storeStorage: listScopeStoreStorageRows(redactionSqlOf(this.directory), only),
8083
8334
  });
8084
8335
  // The count that matters for K-24 is how many TENANTS this reading covered —
8085
8336
  // "read the meter for one tenant" and "metered the whole fleet" are different
@@ -9507,6 +9758,8 @@ export class SqliteScopeHost {
9507
9758
  // envelope data module code could set or branch on.
9508
9759
  rt.invocationId, full.payload === undefined ? null : JSON.stringify(full.payload));
9509
9760
  };
9761
+ // #2192: the call's invocation id, one reader for `ctx.log` and `ctx.requestId`.
9762
+ const requestIdNow = () => rt.invocationId ?? delivery?.invocationId ?? null;
9510
9763
  const ctxRef = {
9511
9764
  tenantId: rt.tenantId,
9512
9765
  scopeId: rt.scopeId,
@@ -9526,13 +9779,17 @@ export class SqliteScopeHost {
9526
9779
  tenantId: rt.tenantId,
9527
9780
  scopeId: rt.scopeId,
9528
9781
  operation: operation ?? null,
9529
- invocationId: () => rt.invocationId ?? delivery?.invocationId ?? null,
9782
+ invocationId: requestIdNow,
9530
9783
  // A consumer runs under the system override, so it logs as `system`.
9531
9784
  principalKind: overrideActor ? 'system' : subject.kind,
9532
9785
  // The string-safe redaction: `redactSecrets` parses its serialization back, and a
9533
9786
  // log's text is not JSON.
9534
9787
  redact: (text) => redactSecretText(text, minted),
9535
9788
  }, this.logSink),
9789
+ // #2192: the same id the log lines carry.
9790
+ get requestId() {
9791
+ return requestIdNow();
9792
+ },
9536
9793
  emit: (event) => writeEvent(event, 'module'),
9537
9794
  requestPlatform: (request) => {
9538
9795
  assertImpersonationWrites(impersonation, 'ctx.requestPlatform');