@substrat-run/adapter-sqlite 0.148.0 → 0.150.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,16 +1,17 @@
1
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, 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, connectionBindingAudit, copyMarkAudit, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
5
- import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, UPLOAD_LINK_DDL, createAttachmentVerbs, inspectUploadLink, redeemUploadLinkInTransaction, 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, VERTICAL_SCOPE_STORES_DDL, forgetVerticalScopeStores, indexVersionScopeStores, kindDeclarationsOf, verticalScopeStoresOf, STORE_KIND_RELEASES_DDL, beginStoreKindRolloutIn, finishStoreKindRolloutIn, holdStoreKindIn, recordStoreKindReleaseIn, releaseStoreKindHoldIn, storeKindReleaseIn, storeKindStateIn, 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';
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, connectionBindingAudit, copyMarkAudit, orphanSweepAudit, redrainEventsInput, REDRAIN_BATCH, errorCodeOf, } from '@substrat-run/contracts';
5
+ import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, UPLOAD_LINK_DDL, createAttachmentVerbs, inspectUploadLink, redeemUploadLinkInTransaction, redeemOrReplayUploadLink, 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, boundToPeerDeclarations, 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, VERTICAL_SCOPE_STORES_DDL, forgetVerticalScopeStores, indexVersionScopeStores, driverDeclarationsOf, verticalScopeStoresOf, STORE_DRIVER_RELEASES_DDL, legacyStoreDriverConflict, legacyStoreDriverRenames, beginStoreDriverRolloutIn, finishStoreDriverRolloutIn, holdStoreDriverIn, advanceStoreDriverRebuildIn, claimStoreDriverRebuildIn, storeDriverRebuildIn, recordStoreDriverReleaseIn, releaseStoreDriverHoldIn, storeDriverReleaseIn, storeDriverStateIn, storeDriverRolloutPendingIn, storeDriverRolloutWalkedIn, splitManifestMigrations, splitVersionMigrationsBatch, versionMigrationsOf, deriveVersionMigrationDigest, publishedMigrationDigest, migrationChangeRefusal, 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, migrationSetDivergence, migrationSteps, planMigrations, isSearchIndexTable, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, ATTACHMENT_CONDEMNED_DDL, ATTACHMENT_SWEEP_LICENCE_DDL, ATTACHMENT_SWEEP_LICENCE_TABLE, clearOrphanSweepLicence, condemnLicensedOrphanAttachments, consumeOrphanSweepLicence, licensedOrphanIds, recordOrphanSweepLicence, refuseUnlicensedOrphanSweep, fenceAttachmentSweep, refuseCondemnedAttachment, refuseCondemnedInDump, rowlessAttachmentIds, sweepOrphanAttachments, missingAttachmentStore, SETTLED_COPY_STATES, unsettledCopyRefusal, 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, assertPromoteReviewCurrent, promoteRaceRefusal, rebindRaceRefusal, SERVES_NOW_CONDITION, SERVES_NOW_PREDICATE, servesNowParams, 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
- import { INERT_SCOPE_REASON, isPrimaryScopeRow, UPLOAD_LINK_WRONG_TARGET } from '@substrat-run/kernel';
8
+ import { INERT_SCOPE_REASON, isPrimaryScopeRow, normalizeUploadMediaType } from '@substrat-run/kernel';
9
9
  import { COPY_BACKFILL_SCOPE_SQL, COPY_BACKFILL_SQL, COPY_BACKFILL_SUPERSEDE_SQL, COPY_CLAIM_SQL, COPY_EXPIRED_SQL, copyBackfillParams, copyBackfillRefusal, SUBJECT_ERASURE_CLAIM_SQL, subjectErasureClaimParams, COPY_MOVE_CONFIRM_SQL, COPY_MOVE_LIVE_PREDICATE, SCOPE_COPY_LEASE_MS, SCOPE_SCRIPT_COPY_COLUMNS, copyMoveConfirmParams, copyMoveLiveParams, scopeScriptCopyOf, } from '@substrat-run/kernel';
10
10
  import { GRANT_CHILDREN_INDEX_DDL, SHAPE_MARKER_INDEX_DDL, grantedEntitiesForContext } from '@substrat-run/kernel';
11
11
  import { LEGACY_SCOPE_ROWS_BACKFILL, assertSpineTablesBuilt, capabilitiesForLoad, dumpRowsInsert, isSpineTable, loadDirectoryDump, repointScopeGrants, settleCopiedWork, emittedHere, spineColumnAdditions } from '@substrat-run/kernel';
12
12
  import { ScopeActor } from './actor.js';
13
13
  import { createTupleChecker, directoryTenantReader } from './checker.js';
14
+ import { decidedByOf, entityString, forgetInvocationTrace, invocationTrace, tracedSql } from './trace.js';
14
15
  export { scopeStoreRunnersFor } from './scope-store-runners.js';
15
16
  /**
16
17
  * A fault in the database itself (full, corrupt, I/O), as opposed to one statement failing.
@@ -322,11 +323,22 @@ const KERNEL_DDL = `
322
323
  -- #1575: attachment text and its FTS5 index, shared from @substrat-run/kernel. After
323
324
  -- _substrat_attachments, whose delete trigger it adds.
324
325
  ${ATTACHMENT_TEXT_DDL}
326
+ -- #2314: the orphan sweep's tombstones, shared from @substrat-run/kernel. An id here
327
+ -- never takes an attachment row again: the insert path refuses it.
328
+ ${ATTACHMENT_CONDEMNED_DDL}
329
+ -- #2350: the orphan sweep's dry-run licence, shared from @substrat-run/kernel. Store-local:
330
+ -- never dumped, and every load drops it.
331
+ ${ATTACHMENT_SWEEP_LICENCE_DDL}
325
332
  -- #901: an entity's version is the ULID of the last event about it (a state move
326
333
  -- excepted, #2191), so the newest id per (entity_type, entity_id) is the read. The id
327
334
  -- column sits last so SQLite walks the matched range backwards instead of aggregating.
328
335
  ${OUTBOX_ENTITY_INDEX}
329
336
  `;
337
+ /**
338
+ * The scope spine a fresh scope gets, for a tool that replays a vertical's migrations outside a
339
+ * host — `pnpm lint:migration-fence` (#115), whose reviewed spine migrations read and write it.
340
+ */
341
+ export const SCOPE_SPINE_DDL = KERNEL_DDL;
330
342
  /** Row → contract shape. Never reads the secrets table — that is the point of the split. */
331
343
  const toConnection = (r) => connection.parse({
332
344
  id: r.id,
@@ -698,11 +710,13 @@ export class SqliteScopeHost {
698
710
  secretBox;
699
711
  /** The parsers attachment text is extracted with (K-43); the host's own, never imported here. */
700
712
  attachmentExtractors;
713
+ wrapBlobStore;
701
714
  attachmentTextBounds;
702
715
  fetchImpl;
703
716
  connectorCalls;
704
717
  clock;
705
718
  versionId;
719
+ migrationsDigest;
706
720
  /** #1746: where `ctx.log` lines go — the console unless a caller (a test) says otherwise. */
707
721
  logSink;
708
722
  /** #1901: where async work's invocation lines go. */
@@ -716,11 +730,13 @@ export class SqliteScopeHost {
716
730
  this.secretBox = options.secretBox ?? unconfiguredSecretBox;
717
731
  assertAttachmentExtractors(options.attachmentExtractors ?? []);
718
732
  this.attachmentExtractors = options.attachmentExtractors ?? [];
733
+ this.wrapBlobStore = options.wrapBlobStore ?? ((store) => store);
719
734
  this.attachmentTextBounds = resolveAttachmentTextBounds(options.attachmentTextBounds);
720
735
  this.fetchImpl = options.fetch ?? globalFetch;
721
736
  this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
722
737
  this.clock = options.clock ?? (() => instant.parse(new Date().toISOString()));
723
738
  this.versionId = options.versionId ?? null;
739
+ this.migrationsDigest = options.migrationsDigest;
724
740
  this.scopeStoreVertical = options.scopeStores?.vertical;
725
741
  this.scopeStoreRunners = scopeStoreRunnersOf(options.scopeStores);
726
742
  this.scopeStoreLifecycle = scopeStoreLifecycleOf(this.scopeStoreRunners);
@@ -743,17 +759,17 @@ export class SqliteScopeHost {
743
759
  this.directory.pragma('journal_mode = WAL');
744
760
  this.ensureDirectorySchema();
745
761
  this.loadRoles();
746
- this.checker =
747
- customChecker ??
748
- createTupleChecker({
749
- directory: this.directory,
750
- scopeDb: (scopeId) => this.scopesById.get(scopeId)?.db,
751
- getRole: (tenantId, key) => this.roles.get(`${tenantId}/${key}`),
752
- // #956: the evaluator judges `expires_at` against the host's clock, so a
753
- // scripted one can actually expire a grant. A caller-supplied `checker`
754
- // keeps its own time source — this only binds the built-in one.
755
- clock: () => this.clock(),
756
- });
762
+ // #2263: whichever checker evaluates, a peer holds only what the running declaration names.
763
+ this.checker = boundToPeerDeclarations(customChecker ??
764
+ createTupleChecker({
765
+ directory: this.directory,
766
+ scopeDb: (scopeId) => this.scopesById.get(scopeId)?.db,
767
+ getRole: (tenantId, key) => this.roles.get(`${tenantId}/${key}`),
768
+ // #956: the evaluator judges `expires_at` against the host's clock, so a
769
+ // scripted one can actually expire a grant. A caller-supplied `checker`
770
+ // keeps its own time source — this only binds the built-in one.
771
+ clock: () => this.clock(),
772
+ }), () => this.peerDeclarations());
757
773
  this.admin = this.buildAdmin();
758
774
  this.owner = owner;
759
775
  }
@@ -942,8 +958,8 @@ export class SqliteScopeHost {
942
958
  created_at TEXT NOT NULL,
943
959
  PRIMARY KEY (tenant_id, vertical, binding)
944
960
  );
945
- -- A store kind's stored releases, rollouts and holds (D-63, #2300).
946
- ${STORE_KIND_RELEASES_DDL}
961
+ -- A store driver's stored releases, rollouts and holds (D-63, #2300).
962
+ ${STORE_DRIVER_RELEASES_DDL}
947
963
  -- The vertical + version registry (#31). A scope binds to a VERSION, so
948
964
  -- dev/staging/prod are the same vertical pinned differently, and a preview
949
965
  -- deployment is a version nothing has been promoted to yet.
@@ -1010,6 +1026,9 @@ export class SqliteScopeHost {
1010
1026
  migration_count INTEGER,
1011
1027
  -- 1 once the SQL is out of manifest_json. NULL = stored before #1764, not yet backfilled.
1012
1028
  migrations_split INTEGER,
1029
+ -- 1 once migration_digest is the one derived from the stored migrations (#2339).
1030
+ -- NULL = stored before, still the pushed digest; derived on its next read.
1031
+ migration_digest_derived INTEGER,
1013
1032
  created_at TEXT NOT NULL,
1014
1033
  UNIQUE (vertical_slug, version)
1015
1034
  );
@@ -1414,13 +1433,27 @@ export class SqliteScopeHost {
1414
1433
  * it inside its transaction onto an emptied directory, before loading any row.
1415
1434
  */
1416
1435
  buildDirectorySchema() {
1436
+ // A directory built before the store driver rename keeps its rows under the old names (and one
1437
+ // that ran pre-rename code again, a rollback, holds both names), and
1438
+ // `applyDirectorySchema` would only create empty tables beside them.
1439
+ const tables = this.directory.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((t) => t.name);
1440
+ const hasRows = (table) => this.directory.prepare(`SELECT 1 FROM "${table}" LIMIT 1`).get() !== undefined;
1441
+ // Asked before the moves below, which drop a name with no rows.
1442
+ const bothNames = legacyStoreDriverConflict(tables, hasRows);
1443
+ if (bothNames)
1444
+ console.error(bothNames);
1445
+ for (const stmt of legacyStoreDriverRenames(tables, (table) => builtColumnsOf(this.directory, table), hasRows))
1446
+ this.directory.exec(stmt);
1417
1447
  // #1764: `VERSION_MIGRATIONS_DDL` indexes these two columns, so a directory created
1418
1448
  // before them needs them before `applyDirectorySchema` reaches that statement.
1419
1449
  if (this.directory.prepare('PRAGMA table_info(vertical_versions)').all().length > 0) {
1420
1450
  this.ensureColumn(this.directory, 'vertical_versions', 'migration_count', 'migration_count INTEGER');
1421
1451
  this.ensureColumn(this.directory, 'vertical_versions', 'migrations_split', 'migrations_split INTEGER');
1452
+ this.ensureColumn(this.directory, 'vertical_versions', 'migration_digest_derived', 'migration_digest_derived INTEGER');
1422
1453
  }
1423
1454
  this.applyDirectorySchema();
1455
+ // #2322: a migration's declared moves and discards, on a table created before they were carried.
1456
+ this.ensureColumn(this.directory, 'vertical_version_migrations', 'declarations', 'declarations TEXT');
1424
1457
  }
1425
1458
  /**
1426
1459
  * `applyDirectorySchema`, plus the backfill of the schedule switch's record (#1674) from
@@ -1682,6 +1715,7 @@ export class SqliteScopeHost {
1682
1715
  }
1683
1716
  // #1705: validated whole and kept only if it passes, so a refused module leaves nothing.
1684
1717
  this.crossVertical.register(manifest, registration.imports);
1718
+ this.peerUnion = undefined; // #2263: the next check reads the new module set's declarations
1685
1719
  this.modules.set(manifest.id, {
1686
1720
  id: manifest.id,
1687
1721
  // The order the kernel writes once (#1677): authored, then search, then list indexes.
@@ -2073,7 +2107,7 @@ export class SqliteScopeHost {
2073
2107
  out.push(r);
2074
2108
  }
2075
2109
  };
2076
- return {
2110
+ return this.wrapBlobStore({
2077
2111
  put: async (key, body, opts) => {
2078
2112
  const p = resolveKey(key);
2079
2113
  mkdirSync(dirname(p), { recursive: true });
@@ -2105,7 +2139,7 @@ export class SqliteScopeHost {
2105
2139
  walk(root, '', out);
2106
2140
  return out.filter((k) => k.startsWith(prefix)).sort();
2107
2141
  },
2108
- };
2142
+ }, ref);
2109
2143
  }
2110
2144
  /** Resolve the blob store that carries a scope's attachments (#473): the platform-minted
2111
2145
  * store for (tenant, the scope's vertical). Fails loudly when none was provisioned —
@@ -2148,30 +2182,100 @@ export class SqliteScopeHost {
2148
2182
  const uploadLinks = this.uploadLinkPolicies.get(entityType);
2149
2183
  return uploadLinks ? { write: gate.write, uploadLinks } : { write: gate.write };
2150
2184
  }
2151
- async inspectUploadLink(tenantId, scopeId, secret) {
2185
+ async inspectUploadLink(tenantId, scopeId, secret, target, idempotencyKey) {
2186
+ const rt = await this.openActiveScope(tenantId, scopeId);
2187
+ return rt.actor.enqueue(async () => (await inspectUploadLink({ sql: spineSql(rt.db), now: this.clock() }, secret, target, idempotencyKey)).view);
2188
+ }
2189
+ /**
2190
+ * #2314: one orphan-sweep pass over a scope's attachment bytes (`sweepOrphanAttachments` in the
2191
+ * kernel has the guards). Only an active PRIMARY scope of `tenantId` is judged: a fork or a
2192
+ * preview holds another scope's rows, never this one's.
2193
+ */
2194
+ async sweepOrphanAttachments(tenantId, scopeId, request = {}) {
2195
+ const row = this.directory
2196
+ .prepare('SELECT vertical, kind, forked_from FROM scopes WHERE scope_id = ? AND tenant_id = ?')
2197
+ .get(scopeId, tenantId);
2198
+ if (row && !isPrimaryScopeRow(row)) {
2199
+ throw substratError('conflict', `refused: scope ${scopeId} is a copy, and a copy may not judge orphan attachments`);
2200
+ }
2201
+ const copies = this.directory
2202
+ .prepare(`SELECT script_ref AS scriptRef, state FROM scope_script_copies
2203
+ WHERE tenant_id = ? AND scope_id = ? AND state NOT IN (${SETTLED_COPY_STATES.map(() => '?').join(', ')})`)
2204
+ .all(tenantId, scopeId, ...SETTLED_COPY_STATES);
2205
+ const unsettled = unsettledCopyRefusal(scopeId, copies);
2206
+ if (unsettled)
2207
+ throw unsettled;
2208
+ // Only an active scope: an archived or reaped one is not this pass's to judge.
2152
2209
  const rt = await this.openActiveScope(tenantId, scopeId);
2153
- return rt.actor.enqueue(async () => (await inspectUploadLink({ sql: spineSql(rt.db), now: this.clock() }, secret)).view);
2210
+ const now = this.clock();
2211
+ // #2350: a destructive pass needs the scope's own dry-run licence, asked before anything is
2212
+ // listed, so a pass with nothing to condemn is refused all the same.
2213
+ if (request.dryRun !== true)
2214
+ await rt.actor.enqueue(() => refuseUnlicensedOrphanSweep(spineSql(rt.db), now));
2215
+ // A scope with no blob store holds no bytes: answered as that, marked, after every guard above,
2216
+ // so the platform's pass tells it from a failure and from a store that held nothing.
2217
+ const stores = row?.vertical
2218
+ ? this.directory.prepare('SELECT 1 FROM blob_stores WHERE tenant_id = ? AND vertical = ? LIMIT 1').get(tenantId, row.vertical)
2219
+ : undefined;
2220
+ if (!stores) {
2221
+ // A scope that holds an attachment row does keep files: its missing store is a misconfiguration.
2222
+ const holds = await rt.actor.enqueue(() => {
2223
+ const table = rt.db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_substrat_attachments'`).get();
2224
+ return table !== undefined && rt.db.prepare('SELECT 1 FROM _substrat_attachments LIMIT 1').get() !== undefined;
2225
+ });
2226
+ if (holds)
2227
+ throw missingAttachmentStore(tenantId, scopeId);
2228
+ return { listed: 0, candidates: 0, orphans: 0, deleted: 0, keys: [], dryRun: request.dryRun === true, bucket: 'none' };
2229
+ }
2230
+ const store = this.attachmentStore(tenantId, row?.vertical ?? null);
2231
+ return sweepOrphanAttachments(store, scopeId, {
2232
+ rowless: async (ids) => rt.actor.enqueue(() => rowlessAttachmentIds(spineSql(rt.db), ids)),
2233
+ condemn: async (ids, at) => rt.actor.enqueue(() => rt.db.transaction(() => condemnLicensedOrphanAttachments(spineSql(rt.db), ids, at))()),
2234
+ license: async (ids, at) => {
2235
+ await rt.actor.enqueue(() => rt.db.transaction(() => recordOrphanSweepLicence(spineSql(rt.db), ids, at))());
2236
+ },
2237
+ licensed: async () => rt.actor.enqueue(() => licensedOrphanIds(spineSql(rt.db))),
2238
+ consume: async (ids) => rt.actor.enqueue(() => rt.db.transaction(() => consumeOrphanSweepLicence(spineSql(rt.db), ids))()),
2239
+ }, {
2240
+ // The host's own clock, never a caller's: no request can step past the floor or the fence.
2241
+ now,
2242
+ ...(request.dryRun ? { dryRun: true } : {}),
2243
+ ...(request.limit !== undefined ? { limit: request.limit } : {}),
2244
+ });
2245
+ }
2246
+ /**
2247
+ * #2314: the orphan sweep's restore fence for a scope whose rows are about to be replaced. A
2248
+ * scope whose vertical has no blob store has no bytes, so nothing is fenced; a fence that cannot
2249
+ * be written throws, and the caller refuses the replacement.
2250
+ */
2251
+ fenceOrphanSweep(tenantId, scopeId, vertical) {
2252
+ const held = vertical
2253
+ ? this.directory.prepare('SELECT 1 FROM blob_stores WHERE tenant_id = ? AND vertical = ? LIMIT 1').get(tenantId, vertical)
2254
+ : undefined;
2255
+ if (!held)
2256
+ return Promise.resolve();
2257
+ return fenceAttachmentSweep(this.attachmentStore(tenantId, vertical), scopeId, this.clock());
2154
2258
  }
2155
2259
  async redeemUploadLink(tenantId, scopeId, secret, input) {
2156
2260
  const rt = await this.openActiveScope(tenantId, scopeId);
2157
- const { view, mintedBy } = await rt.actor.enqueue(() => inspectUploadLink({ sql: spineSql(rt.db), now: this.clock() }, secret));
2158
- if (view.entity.entityType !== input.entity.entityType || view.entity.entityId !== input.entity.entityId) {
2159
- throw substratError('not_found', 'This upload link is not valid for that target.', {
2160
- reason: UPLOAD_LINK_WRONG_TARGET,
2261
+ // The target first (the kernel judges it before the state), so a wrong path never reads as
2262
+ // gone; a replay (#2315) is answered from the inspection and never reaches the write.
2263
+ const inspect = () => rt.actor.enqueue(() => inspectUploadLink({ sql: spineSql(rt.db), now: this.clock() }, secret, input.entity, input.idempotencyKey));
2264
+ return redeemOrReplayUploadLink(inspect, input, async ({ view, mintedBy }) => {
2265
+ // The write is made AS the minter: the same gates `attachments()` puts in front of a person.
2266
+ await this.getScope(mintedBy, tenantId, scopeId);
2267
+ const scopeRow = this.directory
2268
+ .prepare('SELECT vertical FROM scopes WHERE scope_id = ?')
2269
+ .get(scopeId);
2270
+ const files = this.buildAttachments(rt, asPrincipal(mintedBy), this.attachmentStore(tenantId, scopeRow?.vertical ?? null), { uploadLink: { secret, idempotencyKey: input.idempotencyKey } });
2271
+ return files.upload({
2272
+ entity: view.entity,
2273
+ filename: view.filename ?? input.filename,
2274
+ // Recorded as it is judged: the essence, never a parameter or a stray space.
2275
+ contentType: normalizeUploadMediaType(input.contentType),
2276
+ visibility: view.visibility,
2277
+ body: input.body,
2161
2278
  });
2162
- }
2163
- // The write is made AS the minter: the same gates `attachments()` puts in front of a person.
2164
- await this.getScope(mintedBy, tenantId, scopeId);
2165
- const scopeRow = this.directory
2166
- .prepare('SELECT vertical FROM scopes WHERE scope_id = ?')
2167
- .get(scopeId);
2168
- const files = this.buildAttachments(rt, asPrincipal(mintedBy), this.attachmentStore(tenantId, scopeRow?.vertical ?? null), { uploadLinkSecret: secret });
2169
- return files.upload({
2170
- entity: view.entity,
2171
- filename: view.filename ?? input.filename,
2172
- contentType: input.contentType,
2173
- visibility: view.visibility,
2174
- body: input.body,
2175
2279
  });
2176
2280
  }
2177
2281
  async getSystemAttachments(moduleId, tenantId, scopeId) {
@@ -2314,6 +2418,8 @@ export class SqliteScopeHost {
2314
2418
  try {
2315
2419
  return await guarded('attachments.upload', async (ctx) => {
2316
2420
  assertAllowed(await ctx.check(gate.write, input.entity));
2421
+ // #2314: an id the orphan sweep condemned takes no row, in this same transaction.
2422
+ refuseCondemnedAttachment(spineSql(rt.db), record.id);
2317
2423
  rt.db
2318
2424
  .prepare(`INSERT INTO _substrat_attachments
2319
2425
  (id, entity_type, entity_id, filename, content_type, size, sha256,
@@ -2332,8 +2438,8 @@ export class SqliteScopeHost {
2332
2438
  enqueueAttachmentText(spineSql(rt.db), record.id, ulid(), this.clock());
2333
2439
  // An upload-link redemption spends its link HERE, in the same transaction as the
2334
2440
  // row it produced: a refusal anywhere above or below rolls the spend back.
2335
- if (opts.uploadLinkSecret !== undefined) {
2336
- await redeemUploadLinkInTransaction({ sql: spineSql(rt.db), now: this.clock(), emit: (event) => kernelEmit(ctx, event) }, opts.uploadLinkSecret, record);
2441
+ if (opts.uploadLink !== undefined) {
2442
+ await redeemUploadLinkInTransaction({ sql: spineSql(rt.db), now: this.clock(), emit: (event) => kernelEmit(ctx, event) }, opts.uploadLink.secret, record, opts.uploadLink.idempotencyKey);
2337
2443
  }
2338
2444
  return record;
2339
2445
  });
@@ -2473,6 +2579,16 @@ export class SqliteScopeHost {
2473
2579
  // rows are gone and the new ones are in. It also covers what a topological sort
2474
2580
  // cannot express: FK cycles, and self-referencing rows within one table.
2475
2581
  db.pragma('defer_foreign_keys = ON');
2582
+ // A dump written before indexes were excluded may still carry them; skip those
2583
+ // too rather than failing a restore over data that is about to be recomputed.
2584
+ // Matched without case, as SQLite resolves a table name (#1883 review).
2585
+ const replayable = dumped.filter((t) => !isSearchIndexTable(t.name.toLowerCase()));
2586
+ // A Durable Object's dump carries spine tables this adapter keeps in its directory, or does
2587
+ // not keep at all; they are skipped by name, and any other unknown spine table is refused.
2588
+ const loadable = replayable.filter((t) => !DO_SCOPE_ONLY_SPINE_TABLES.has(t.name.toLowerCase()));
2589
+ // #2314: a row for an id the orphan sweep condemned here is refused, as the insert refuses it.
2590
+ // Judged on `loadable`, the very tables whose rows the replay below inserts, before any drop.
2591
+ refuseCondemnedInDump(spineSql(db), loadable);
2476
2592
  // Drop the current schema: a vertical's tables are rebuilt from the dump's DDL, the
2477
2593
  // spine from KERNEL_DDL (below). Only real tables (never `sqlite_*` internals, which
2478
2594
  // are auto-managed and un-droppable).
@@ -2487,10 +2603,6 @@ export class SqliteScopeHost {
2487
2603
  continue;
2488
2604
  db.exec(`DROP TABLE IF EXISTS "${name}"`);
2489
2605
  }
2490
- // A dump written before indexes were excluded may still carry them; skip those
2491
- // too rather than failing a restore over data that is about to be recomputed.
2492
- // Matched without case, as SQLite resolves a table name (#1883 review).
2493
- const replayable = dumped.filter((t) => !isSearchIndexTable(t.name.toLowerCase()));
2494
2606
  // The dump is untrusted input (#1143): its names reach SQL as identifiers, and
2495
2607
  // `db.exec` would run every statement its `ddl` contains, not just the CREATE
2496
2608
  // TABLE. Judged as a whole before any of it executes — a check interleaved with
@@ -2502,9 +2614,6 @@ export class SqliteScopeHost {
2502
2614
  // contributes only rows, by column name (`dumpRowsInsert`). Otherwise a dump could
2503
2615
  // declare a column the checker compares on as COLLATE NOCASE. The column pass follows,
2504
2616
  // for the one outbox index KERNEL_DDL leaves to it.
2505
- // A Durable Object's dump carries spine tables this adapter keeps in its directory, or does
2506
- // not keep at all; they are skipped by name, and any other unknown spine table is refused.
2507
- const loadable = replayable.filter((t) => !DO_SCOPE_ONLY_SPINE_TABLES.has(t.name.toLowerCase()));
2508
2617
  // Comment-blanked (#2068), as the Durable Object replays it: the dump's DDL is
2509
2618
  // `sqlite_master.sql` verbatim, and `prepare` still compiles exactly one statement.
2510
2619
  for (const t of loadable)
@@ -2530,6 +2639,10 @@ export class SqliteScopeHost {
2530
2639
  for (const row of t.rows)
2531
2640
  stmt.run(...row);
2532
2641
  }
2642
+ // #2350: the orphan sweep's licence describes this store's last dry run, never the dump's.
2643
+ // A dump does not carry it; a hand-made one that does is dropped here all the same, so a
2644
+ // loaded store's next pass is a dry run.
2645
+ clearOrphanSweepLicence(spineSql(db));
2533
2646
  // #827 / #119 / #811: the search triggers, the archive/trash guard triggers, the purge
2534
2647
  // sweep's index and the derived list indexes went with the dropped tables, and the search index points at rows that are
2535
2648
  // gone. Put back, and the search index rebuilt, AFTER the rows — which may legitimately
@@ -2595,6 +2708,8 @@ export class SqliteScopeHost {
2595
2708
  if (!existing)
2596
2709
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2597
2710
  this.refuseUnusableScopeStores(dump.stores, existing.vertical);
2711
+ // #2314: the load replaces the scope's rows, so the orphan sweep is fenced first.
2712
+ await this.fenceOrphanSweep(tenantId, scopeId, existing.vertical ?? null);
2598
2713
  // #1742: a dump from before a switch was pulled brings the module's grants back live, so
2599
2714
  // the directory's recorded-off modules go back off in the load's own turn, as a
2600
2715
  // provision's seat does — no schedule pass can run in between.
@@ -2828,7 +2943,7 @@ export class SqliteScopeHost {
2828
2943
  }
2829
2944
  /**
2830
2945
  * A dump's stores this host cannot load are refused before any write (#2211, L8): a
2831
- * binding that is not bound, a store with no kind or a kind other than the bound runner's,
2946
+ * binding that is not bound, a store with no driver or a driver other than the bound runner's,
2832
2947
  * any store at all for a destination with no vertical (no store object can be named for
2833
2948
  * it, so loading would drop them), or a destination on a vertical other than the one this
2834
2949
  * host is pinned to.
@@ -2848,11 +2963,11 @@ export class SqliteScopeHost {
2848
2963
  if (!runner) {
2849
2964
  throw substratError('not_found', `dump names scope store '${store.binding}', which is not bound`);
2850
2965
  }
2851
- if (!store.kind) {
2852
- throw substratError('validation_failed', `dump's scope store '${store.binding}' names no kind`);
2966
+ if (!store.driver) {
2967
+ throw substratError('validation_failed', `dump's scope store '${store.binding}' names no driver`);
2853
2968
  }
2854
- if (store.kind !== runner.kind) {
2855
- throw substratError('conflict', `dump's scope store '${store.binding}' is kind '${store.kind}', and this host binds kind '${runner.kind}'`);
2969
+ if (store.driver !== runner.driver) {
2970
+ throw substratError('conflict', `dump's scope store '${store.binding}' is driver '${store.driver}', and this host binds driver '${runner.driver}'`);
2856
2971
  }
2857
2972
  }
2858
2973
  }
@@ -2869,7 +2984,7 @@ export class SqliteScopeHost {
2869
2984
  if (!runner)
2870
2985
  throw substratError('not_found', `no scope store is bound for '${binding}'`);
2871
2986
  const exported = await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.export');
2872
- stores.push({ binding, kind: runner.kind, tables: exported.tables });
2987
+ stores.push({ binding, driver: runner.driver, tables: exported.tables });
2873
2988
  }
2874
2989
  return stores;
2875
2990
  }
@@ -3456,10 +3571,15 @@ export class SqliteScopeHost {
3456
3571
  const store = this.attachmentStore(tenantId, scopeRow?.vertical ?? null);
3457
3572
  return this.buildAttachments(rt, { kind: 'capability-session', hash }, store);
3458
3573
  }
3459
- /** Every registered module's `peers`, as one declaration per peer vertical (#1706). */
3574
+ /**
3575
+ * Every registered module's `peers`, as one declaration per peer vertical (#1706). Built once
3576
+ * per registered module set and dropped by `registerModule` (#2263): the checker reads it on
3577
+ * every check, so it must be a lookup, never a rebuild.
3578
+ */
3460
3579
  peerDeclarations() {
3461
- return collectPeers(this.modules.values());
3580
+ return (this.peerUnion ??= collectPeers(this.modules.values()));
3462
3581
  }
3582
+ peerUnion;
3463
3583
  /**
3464
3584
  * The peer door (#1706) — mirror of the connection, system and capability doors. The caller is
3465
3585
  * the local broker's word (the platform's, on the pure host); the pair and lifecycle gate run
@@ -3833,7 +3953,8 @@ export class SqliteScopeHost {
3833
3953
  try {
3834
3954
  if (schedule.purge) {
3835
3955
  // #119: a purge horizon's schedule runs its operation once per due entity, never once.
3836
- const pass = await this.runPurgeSweep(moduleId, tenantId, scopeId, schedule.operation);
3956
+ // #2192: under the run's own id, so the purges and their deliveries join this run's line.
3957
+ const pass = await this.runPurgeSweep(moduleId, tenantId, scopeId, schedule.operation, invocationId);
3837
3958
  stillDue = purgeStillDue(pass);
3838
3959
  const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
3839
3960
  report.errors.push(...outcome.errors);
@@ -3904,7 +4025,9 @@ export class SqliteScopeHost {
3904
4025
  * through a purge stub (`openSystemScope`) that never leaves this method, each in its own
3905
4026
  * transaction and under the cutoff the host re-checks inside it (`refuseTrashedTarget`).
3906
4027
  */
3907
- async runPurgeSweep(moduleId, tenantId, scopeId, operation) {
4028
+ async runPurgeSweep(moduleId, tenantId, scopeId, operation,
4029
+ /** #2192: the schedule run this pass belongs to; every purge in it runs under that id. */
4030
+ runInvocationId) {
3908
4031
  const entityType = this.modules
3909
4032
  .get(moduleId)
3910
4033
  ?.schedules.find((s) => s.operation === operation && s.purge)?.purge?.entityType;
@@ -3931,7 +4054,7 @@ export class SqliteScopeHost {
3931
4054
  return heldPurgePass(due.held);
3932
4055
  const stub = await this.openSystemScope(moduleId, tenantId, scopeId, true);
3933
4056
  return runPurgePass(operation, due, async (entityId) => {
3934
- await stub.invoke(operation, { [due.idFrom]: entityId }, { invocationId: ulid() });
4057
+ await stub.invoke(operation, { [due.idFrom]: entityId }, { invocationId: runInvocationId });
3935
4058
  }, (write) => rt.actor.turn(() => write(spineSql(rt.db))));
3936
4059
  }
3937
4060
  /**
@@ -4073,6 +4196,15 @@ export class SqliteScopeHost {
4073
4196
  // `finally` so a later consumer carries none rather than the last caller's,
4074
4197
  // which is the leak `causedBy` had to be moved off the host to avoid.
4075
4198
  rt.invocationId = invokeOptions?.invocationId ?? null;
4199
+ // #2358: this call's trace, when `substrat dev` has installed a sink; undefined otherwise.
4200
+ const trace = invocationTrace(rt.invocationId);
4201
+ // #1672: the secrets this invocation mints — withheld from its idempotency recording,
4202
+ // and what the tripwire on its writes looks for. Declared here, outside the `try`, so the
4203
+ // trace's error step can withhold them from a message too.
4204
+ const minted = [];
4205
+ // #2358: the input error already recorded as a `validation` step, so the `error` step
4206
+ // below does not repeat its message.
4207
+ let refusedInput;
4076
4208
  // The outbox's insertion mark, inside the actor task so nothing else writes between it
4077
4209
  // and this invoke (`OUTBOX_MARK_SQL` says why rowid and not the id).
4078
4210
  const exportMark = exportTypes.length > 0 ? Number(rt.db.prepare(OUTBOX_MARK_SQL).get().mark) : null;
@@ -4097,9 +4229,6 @@ export class SqliteScopeHost {
4097
4229
  : authority.kind === 'vertical-peer'
4098
4230
  ? admitPeer(switchSqlOf(rt.db), this.peerDeclarations(), authority.caller, operation)
4099
4231
  : authority;
4100
- // #1672: the secrets this invocation mints — withheld from its idempotency recording,
4101
- // and what the tripwire on its writes looks for.
4102
- const minted = [];
4103
4232
  // Fresh per operation: the context carries the K-34 authorization accumulator,
4104
4233
  // which must not leak across operations (invokes are serialized per scope).
4105
4234
  // #119: the keys the module's system principal holds only for its purge schedules are
@@ -4110,6 +4239,7 @@ export class SqliteScopeHost {
4110
4239
  const withheld = subject.kind === 'system'
4111
4240
  ? withheldKeysFor(this.modules.get(subject.id)?.purgeOnlyKeys, target, purging)
4112
4241
  : undefined;
4242
+ trace?.head({ operation, tenantId, scopeId, principalKind: subject.kind, principal: actorOf(subject) });
4113
4243
  const ctx = this.operationContext(rt, subject, undefined, signals, session, operation, minted, undefined, undefined, withheld);
4114
4244
  const clonedInput = structuredClone(input);
4115
4245
  // #893: parse, don't trust — at the scope door, from the operation's own
@@ -4121,9 +4251,21 @@ export class SqliteScopeHost {
4121
4251
  // schedule. That is the point — parsing at the mount alone would leave
4122
4252
  // the demos' own suites exercising an unparsed path, and the wire is
4123
4253
  // where the untrusted input actually arrives.
4124
- const parsed = this.operationInput.has(operation)
4125
- ? this.operationInput.get(operation).parse(clonedInput)
4126
- : clonedInput;
4254
+ let parsed;
4255
+ try {
4256
+ parsed = this.operationInput.has(operation)
4257
+ ? this.operationInput.get(operation).parse(clonedInput)
4258
+ : clonedInput;
4259
+ }
4260
+ catch (err) {
4261
+ // #2358: the issues a refused input carries, with the dotted path of each.
4262
+ const issues = err?.issues;
4263
+ if (trace && Array.isArray(issues)) {
4264
+ trace.step({ type: 'validation', issues: issues.map((i) => ({ path: i.path.map(String).join('.'), message: i.message })) });
4265
+ refusedInput = err;
4266
+ }
4267
+ throw err;
4268
+ }
4127
4269
  // #116: fingerprinted from the PARSED input, so a retry that omits an
4128
4270
  // optional field the original sent at its default value is still the
4129
4271
  // same request. Computed before `BEGIN` — it is a pure hash of what the
@@ -4271,7 +4413,22 @@ export class SqliteScopeHost {
4271
4413
  }
4272
4414
  return structuredClone(result);
4273
4415
  }
4416
+ catch (err) {
4417
+ // #2358: the error that ended the call, with the secrets this call minted withheld.
4418
+ // A refused input was recorded above, issue by issue. Its error's message is those
4419
+ // issues as JSON, which can carry the values that failed, so it is not repeated here.
4420
+ trace?.step({
4421
+ type: 'error',
4422
+ code: errorCodeOf(err) ?? null,
4423
+ message: err === refusedInput
4424
+ ? 'input failed validation'
4425
+ : redactSecretText(err instanceof Error ? err.message : String(err), minted),
4426
+ ...(err !== refusedInput && err instanceof Error && err.stack ? { stack: redactSecretText(err.stack, minted) } : {}),
4427
+ });
4428
+ throw err;
4429
+ }
4274
4430
  finally {
4431
+ forgetInvocationTrace(rt.invocationId);
4275
4432
  // Cleared on BOTH paths, inside the task. The actor runs one task at a
4276
4433
  // time, so a value left set here would be read by whichever task ran next
4277
4434
  // and did not set its own — a connector drain, say — stamping its events
@@ -5531,10 +5688,15 @@ export class SqliteScopeHost {
5531
5688
  calls: callsOfManifestJson(r.manifest_json),
5532
5689
  createdAt: r.created_at,
5533
5690
  });
5534
- const readVersion = (id) => {
5535
- const r = this.directory
5536
- .prepare('SELECT * FROM vertical_versions WHERE id = ?')
5537
- .get(id);
5691
+ // #2339: a version stored before the server derived migration digests is derived on its
5692
+ // first read, so no gate here ever compares the digest its push sent.
5693
+ const readVersion = async (id) => {
5694
+ const read = () => this.directory.prepare('SELECT * FROM vertical_versions WHERE id = ?').get(id);
5695
+ let r = read();
5696
+ if (r && r.migration_digest_derived === null) {
5697
+ await deriveVersionMigrationDigest(switchSqlOf(this.directory), id);
5698
+ r = read();
5699
+ }
5538
5700
  return r ? mapVersion(r) : undefined;
5539
5701
  };
5540
5702
  const mapOrg = (r) => orgSchema.parse({
@@ -5600,8 +5762,8 @@ export class SqliteScopeHost {
5600
5762
  });
5601
5763
  // The (version, scope) pair a bind and its impact read both start from, and the refusals
5602
5764
  // that come before any export-break question, in the order the bind makes them (#1756).
5603
- const bindTarget = (tenantId, scopeId, versionId) => {
5604
- const v = readVersion(versionId);
5765
+ const bindTarget = async (tenantId, scopeId, versionId) => {
5766
+ const v = await readVersion(versionId);
5605
5767
  if (!v)
5606
5768
  throw substratError('not_found', `unknown version ${versionId}`);
5607
5769
  const scope = this.directory.prepare('SELECT * FROM scopes WHERE scope_id = ?').get(scopeId);
@@ -5982,7 +6144,7 @@ export class SqliteScopeHost {
5982
6144
  throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5983
6145
  }
5984
6146
  const rt = this.runtime(tenantId, scopeId);
5985
- const states = await rt.actor.turn(() => peerGrantsStatus(switchSqlOf(rt.db), new Date().toISOString()));
6147
+ const states = await rt.actor.turn(() => peerGrantsStatus(switchSqlOf(rt.db), new Date().toISOString(), this.peerDeclarations()));
5986
6148
  const offPeers = new Set(states.filter((p) => p.calls === 'off').map((p) => p.vertical));
5987
6149
  const explanations = offPeers.size > 0 ? lastSwitchedOffBy(tenantId, scopeId, 'revokeFromPeer', 'vertical', offPeers) : new Map();
5988
6150
  const result = states.map((p) => ({
@@ -6638,20 +6800,26 @@ export class SqliteScopeHost {
6638
6800
  // The SQL migrations are stored apart from the manifest (#1764), so no read of the
6639
6801
  // version but the promote review's carries them.
6640
6802
  const split = splitManifestMigrations(manifestJson ?? null, 'push');
6803
+ // #2339: the digest a promote compares is the one derived from what is stored, never the pushed one.
6804
+ const migrationDigest = await publishedMigrationDigest({
6805
+ id: audited.id, migrationDigest: audited.migrationDigest, manifestJson: manifestJson ?? null, migrations: split.migrations,
6806
+ });
6641
6807
  this.directory.transaction(() => {
6642
6808
  this.directory
6643
6809
  .prepare(`INSERT INTO vertical_versions
6644
6810
  (id, vertical_slug, version, manifest_digest, permission_digest,
6645
6811
  migration_digest, deployment_ref, admission, admission_note, manifest_json,
6646
- origin_json, migration_count, migrations_split, created_at)
6647
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`)
6648
- .run(audited.id, audited.verticalSlug, audited.version, audited.manifestDigest, audited.permissionDigest, audited.migrationDigest, audited.deploymentRef, selfAdmits ? 'admitted' : 'pending', selfAdmits ? AUTO_ADMISSION_NOTE : null, split.manifestJson, origin ? JSON.stringify(origin) : null, split.migrations?.length ?? null, new Date().toISOString());
6812
+ origin_json, migration_count, migrations_split, migration_digest_derived, created_at)
6813
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?)`)
6814
+ .run(audited.id, audited.verticalSlug, audited.version, audited.manifestDigest, audited.permissionDigest, migrationDigest, audited.deploymentRef, selfAdmits ? 'admitted' : 'pending', selfAdmits ? AUTO_ADMISSION_NOTE : null, split.manifestJson, origin ? JSON.stringify(origin) : null, split.migrations?.length ?? null, new Date().toISOString());
6649
6815
  writeVersionMigrations(switchSqlOf(this.directory), audited.id, split.migrations);
6650
- // #2286: the version's scope-store bindings, refusing one re-kinded, in the insert's transaction.
6816
+ // #2286: the version's scope-store bindings, refusing one moved to another driver, in the insert's transaction.
6651
6817
  indexVersionScopeStores(switchSqlOf(this.directory), audited.verticalSlug, audited.id, split.manifestJson);
6652
6818
  })();
6653
6819
  this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, {
6654
6820
  ...audited,
6821
+ // The digest the registry holds, which differs from the pushed one only for an unheld version.
6822
+ migrationDigest,
6655
6823
  ...(origin ? { origin } : {}),
6656
6824
  admission: selfAdmits ? 'admitted' : 'pending',
6657
6825
  });
@@ -6666,14 +6834,20 @@ export class SqliteScopeHost {
6666
6834
  const where = ['vertical_slug = ?'];
6667
6835
  const params = [verticalSlug];
6668
6836
  const tail = keysetTail(where, params, 'id', page);
6669
- const rows = this.directory
6670
- .prepare(`SELECT * FROM vertical_versions WHERE ${where.join(' AND ')}${tail}`)
6671
- .all(...params);
6837
+ const sql = `SELECT * FROM vertical_versions WHERE ${where.join(' AND ')}${tail}`;
6838
+ let rows = this.directory.prepare(sql).all(...params);
6839
+ // #2339: a page holding versions whose digest is still the pushed one derives them first.
6840
+ const underived = rows.filter((r) => r.migration_digest_derived === null);
6841
+ if (underived.length > 0) {
6842
+ for (const r of underived)
6843
+ await deriveVersionMigrationDigest(switchSqlOf(this.directory), r.id);
6844
+ rows = this.directory.prepare(sql).all(...params);
6845
+ }
6672
6846
  this.recordAccess(actor, 'listVersions', {}, { verticalSlug }, rows.length);
6673
6847
  return rows.map(mapVersion);
6674
6848
  },
6675
6849
  getVersion: async (actor, versionId, verticalSlug) => {
6676
- const v = readVersion(versionId);
6850
+ const v = await readVersion(versionId);
6677
6851
  // A version of another vertical reads as absent when the caller named one.
6678
6852
  const hit = v && (verticalSlug === undefined || v.verticalSlug === verticalSlug) ? v : undefined;
6679
6853
  this.recordAccess(actor, 'getVersion', {}, { versionId, verticalSlug }, hit ? 1 : 0);
@@ -6690,7 +6864,7 @@ export class SqliteScopeHost {
6690
6864
  const prod = this.directory
6691
6865
  .prepare("SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = 'prod'")
6692
6866
  .get(slug);
6693
- const prodVersion = prod ? readVersion(prod.version_id) : undefined;
6867
+ const prodVersion = prod ? await readVersion(prod.version_id) : undefined;
6694
6868
  if (prodVersion?.admissionNote === AUTO_ADMISSION_NOTE) {
6695
6869
  throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
6696
6870
  `a staff admit must vouch for it before listing`);
@@ -6771,7 +6945,7 @@ export class SqliteScopeHost {
6771
6945
  this.recordAdmin(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.ownerTenant }, null);
6772
6946
  },
6773
6947
  admitVersion: async (actor, versionId) => {
6774
- const v = readVersion(versionId);
6948
+ const v = await readVersion(versionId);
6775
6949
  if (!v)
6776
6950
  throw substratError('not_found', `unknown version ${versionId}`);
6777
6951
  if (v.admission === 'admitted') {
@@ -6799,7 +6973,7 @@ export class SqliteScopeHost {
6799
6973
  });
6800
6974
  },
6801
6975
  rejectVersion: async (actor, versionId, note) => {
6802
- const v = readVersion(versionId);
6976
+ const v = await readVersion(versionId);
6803
6977
  if (!v)
6804
6978
  throw substratError('not_found', `unknown version ${versionId}`);
6805
6979
  if (v.admission === 'admitted') {
@@ -6816,7 +6990,7 @@ export class SqliteScopeHost {
6816
6990
  });
6817
6991
  },
6818
6992
  promotionImpact: async (actor, verticalSlug, channel, versionId) => {
6819
- const incoming = readVersion(versionId);
6993
+ const incoming = await readVersion(versionId);
6820
6994
  if (!incoming || incoming.verticalSlug !== verticalSlug) {
6821
6995
  throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
6822
6996
  }
@@ -6828,7 +7002,7 @@ export class SqliteScopeHost {
6828
7002
  return breaks;
6829
7003
  },
6830
7004
  promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
6831
- const incoming = readVersion(versionId);
7005
+ const incoming = await readVersion(versionId);
6832
7006
  if (!incoming)
6833
7007
  throw substratError('not_found', `unknown version ${versionId}`);
6834
7008
  if (incoming.verticalSlug !== verticalSlug) {
@@ -6840,7 +7014,7 @@ export class SqliteScopeHost {
6840
7014
  const current = this.directory
6841
7015
  .prepare('SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = ?')
6842
7016
  .get(verticalSlug, channel);
6843
- const outgoing = current ? readVersion(current.version_id) : undefined;
7017
+ const outgoing = current ? await readVersion(current.version_id) : undefined;
6844
7018
  const ack = promotionAcknowledgement.parse(acknowledge ?? {});
6845
7019
  // The owning tenant's live scopes a PRIVATE prod promote re-points — below for an embedded
6846
7020
  // vertical, after the in-place serve for a dispatch-backed one (#321) — read before
@@ -6863,6 +7037,18 @@ export class SqliteScopeHost {
6863
7037
  served?.versionId,
6864
7038
  ...owned.map((s) => runningVersionOf(mapScope(s), served)),
6865
7039
  ], versionId, served || owned.length > 0 ? this.provisionedVersionsOf(verticalSlug, served, owned.length > 0 ? owning.ownerTenant : null) : []);
7040
+ // #2333: the acknowledgements answer the review they were given against, or none at all.
7041
+ const reviewed = (v) => ({
7042
+ ...v,
7043
+ migrations: versionMigrationsOf(switchSqlOf(this.directory), v.id)?.migrations ?? null,
7044
+ });
7045
+ await assertPromoteReviewCurrent({
7046
+ ack,
7047
+ channel,
7048
+ baseId: outgoing?.id ?? null,
7049
+ migrationsChange: outgoing !== undefined && outgoing.migrationDigest !== incoming.migrationDigest,
7050
+ read: async () => ({ base: outgoing ? reviewed(outgoing) : null, incoming: reviewed(incoming) }),
7051
+ });
6866
7052
  // §4's two checkpoints, fired where the blast radius is. A FIRST promotion
6867
7053
  // has nothing to diff against, so there is nothing to acknowledge — the
6868
7054
  // gate is about change, not about existence.
@@ -6872,8 +7058,7 @@ export class SqliteScopeHost {
6872
7058
  `${incoming.permissionDigest}) — acknowledge it explicitly to promote`);
6873
7059
  }
6874
7060
  if (outgoing.migrationDigest !== incoming.migrationDigest && !ack.migrationChange) {
6875
- throw substratError('conflict', `promotion changes migrations (${outgoing.migrationDigest} → ` +
6876
- `${incoming.migrationDigest}) — acknowledge it explicitly to promote`);
7061
+ throw substratError('conflict', migrationChangeRefusal(outgoing.migrationDigest, incoming.migrationDigest));
6877
7062
  }
6878
7063
  // #1705 PR 3: an export an installed consumer imports, dropped or re-versioned.
6879
7064
  if (!ack.exportBreak) {
@@ -6883,18 +7068,32 @@ export class SqliteScopeHost {
6883
7068
  }
6884
7069
  }
6885
7070
  const promotedAt = new Date().toISOString();
6886
- this.directory
6887
- .prepare(`INSERT INTO vertical_channels (vertical_slug, channel, version_id, updated_at)
6888
- VALUES (?, ?, ?, ?)
6889
- ON CONFLICT (vertical_slug, channel) DO UPDATE SET version_id = ?, updated_at = ?`)
6890
- .run(verticalSlug, channel, versionId, promotedAt, versionId, promotedAt);
6891
- // The timeline row: what makes rollback a choice among recorded moments, and
6892
- // `at` the PITR anchor a data rollback would rewind to.
6893
- this.directory
6894
- .prepare(`INSERT INTO vertical_channel_history
6895
- (id, vertical_slug, channel, version_id, from_version_id, actor, at)
6896
- VALUES (?, ?, ?, ?, ?, ?, ?)`)
6897
- .run(ulid(), verticalSlug, channel, versionId, outgoing?.id ?? null, actor, promotedAt);
7071
+ // #2333: a compare-and-set. Every gate above — the review binding, the digest checkpoints,
7072
+ // the export breaks — was judged against `outgoing`, across awaits another promote can run
7073
+ // in. So the pointer moves only if it still names `outgoing`, read and written in one
7074
+ // transaction; the promote that lost the race is refused as stale and writes nothing.
7075
+ const moved = this.directory.transaction(() => {
7076
+ const now = this.directory
7077
+ .prepare('SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = ?')
7078
+ .get(verticalSlug, channel);
7079
+ if ((now?.version_id ?? null) !== (outgoing?.id ?? null))
7080
+ return false;
7081
+ this.directory
7082
+ .prepare(`INSERT INTO vertical_channels (vertical_slug, channel, version_id, updated_at)
7083
+ VALUES (?, ?, ?, ?)
7084
+ ON CONFLICT (vertical_slug, channel) DO UPDATE SET version_id = ?, updated_at = ?`)
7085
+ .run(verticalSlug, channel, versionId, promotedAt, versionId, promotedAt);
7086
+ // The timeline row: what makes rollback a choice among recorded moments, and
7087
+ // `at` the PITR anchor a data rollback would rewind to.
7088
+ this.directory
7089
+ .prepare(`INSERT INTO vertical_channel_history
7090
+ (id, vertical_slug, channel, version_id, from_version_id, actor, at)
7091
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
7092
+ .run(ulid(), verticalSlug, channel, versionId, outgoing?.id ?? null, actor, promotedAt);
7093
+ return true;
7094
+ })();
7095
+ if (!moved)
7096
+ throw promoteRaceRefusal(channel);
6898
7097
  // For a PRIVATE vertical, prod IS what the owner's apps run: re-point the
6899
7098
  // owning tenant's live scopes in the same act, so merge-to-main (push +
6900
7099
  // promote) is a complete deploy and a rollback promote reaches the running
@@ -6914,7 +7113,7 @@ export class SqliteScopeHost {
6914
7113
  for (const s of incoming.deploymentRef ? [] : owned) {
6915
7114
  if (s.vertical_version_id === versionId)
6916
7115
  continue;
6917
- const prev = s.vertical_version_id ? readVersion(s.vertical_version_id) : undefined;
7116
+ const prev = s.vertical_version_id ? await readVersion(s.vertical_version_id) : undefined;
6918
7117
  if (prev && prev.migrationDigest !== incoming.migrationDigest) {
6919
7118
  await this.snapshotScope(actor, s.tenant_id, s.scope_id);
6920
7119
  }
@@ -6976,7 +7175,7 @@ export class SqliteScopeHost {
6976
7175
  }));
6977
7176
  },
6978
7177
  bindingImpact: async (actor, tenantId, scopeId, versionId, opts) => {
6979
- const { v, scope } = bindTarget(tenantId, scopeId, versionId);
7178
+ const { v, scope } = await bindTarget(tenantId, scopeId, versionId);
6980
7179
  // #2178: a move no acknowledgement passes is refused here too, so a caller asking first
6981
7180
  // (an adopt, an Update) stops before anything is copied.
6982
7181
  this.assertBindingShapesKept(mapScope(scope), versionId, opts?.servingRef);
@@ -6985,22 +7184,30 @@ export class SqliteScopeHost {
6985
7184
  return breaks;
6986
7185
  },
6987
7186
  bindScopeVersion: async (actor, tenantId, scopeId, versionId, opts) => {
6988
- const { v, scope } = bindTarget(tenantId, scopeId, versionId);
6989
- if (opts?.expectedVersionId !== undefined && scope.vertical_version_id !== opts.expectedVersionId) {
6990
- throw substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
6991
- }
7187
+ const { v, scope } = await bindTarget(tenantId, scopeId, versionId);
7188
+ // #2347: a rebind judged against both lineages loses its race as stale, so its client reads
7189
+ // the review again; any other conditional bind keeps the refusal it always had.
7190
+ const lost = () => opts?.expectedServing
7191
+ ? rebindRaceRefusal(opts.expectedServing.vertical)
7192
+ : substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
7193
+ if (opts?.expectedVersionId !== undefined && scope.vertical_version_id !== opts.expectedVersionId)
7194
+ throw lost();
7195
+ if (opts?.expectedServing && !this.servesNow(opts.expectedServing))
7196
+ throw lost();
6992
7197
  const epoch = this.directory.prepare('SELECT COALESCE(erasure_epoch, 0) AS erasure_epoch FROM scopes WHERE tenant_id = ? AND scope_id = ?')
6993
7198
  .get(tenantId, scopeId);
6994
7199
  if (opts?.expectedErasureEpoch !== undefined && epoch?.erasure_epoch !== opts.expectedErasureEpoch) {
6995
7200
  throw substratError('precondition_failed', 'scope erasure changed; reload the scope and retry');
6996
7201
  }
6997
7202
  const ack = bindAcknowledgement.parse(opts?.acknowledge ?? {});
7203
+ // #2347: a route moving in the same write is judged with it, as `bindingImpact` is.
7204
+ const servingRef = opts?.servingRef;
6998
7205
  // #2178 first: no acknowledgement passes it, so none should be asked for ahead of it.
6999
- this.assertBindingShapesKept(mapScope(scope), versionId);
7206
+ this.assertBindingShapesKept(mapScope(scope), versionId, servingRef);
7000
7207
  // #1756: an export an app in this tenant imports, dropped or re-versioned by what this
7001
7208
  // scope would run. Before the snapshot, so a refused bind leaves nothing behind.
7002
7209
  if (!ack.exportBreak) {
7003
- const breaks = await this.bindBreaks(actor, mapScope(scope), v);
7210
+ const breaks = await this.bindBreaks(actor, mapScope(scope), v, servingRef);
7004
7211
  if (breaks.length > 0)
7005
7212
  throw substratError('precondition_failed', bindExportBreakRefusal(breaks));
7006
7213
  }
@@ -7008,19 +7215,29 @@ export class SqliteScopeHost {
7008
7215
  // crosses a migration boundary. Gated on a real digest change — a code-only
7009
7216
  // rebind snapshots nothing — and on the caller opting in (until GC ships).
7010
7217
  if (opts?.snapshot && scope.vertical_version_id) {
7011
- const outgoing = readVersion(scope.vertical_version_id);
7218
+ const outgoing = await readVersion(scope.vertical_version_id);
7012
7219
  if (outgoing && outgoing.migrationDigest !== v.migrationDigest) {
7013
7220
  await this.snapshotScope(actor, tenantId, scopeId);
7014
7221
  }
7015
7222
  }
7016
- if (!this.moveScopeRow('vertical_version_id = ?, vertical = ?', [versionId, v.verticalSlug], tenantId, scopeId, opts)) {
7017
- throw substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
7018
- }
7019
- this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, null, {
7223
+ const moved = servingRef === undefined
7224
+ ? this.moveScopeRow('vertical_version_id = ?, vertical = ?', [versionId, v.verticalSlug], tenantId, scopeId, opts)
7225
+ : this.moveScopeRow('vertical_version_id = ?, vertical = ?, serving_ref = ?', [versionId, v.verticalSlug, servingRef], tenantId, scopeId, opts);
7226
+ if (!moved) {
7227
+ // The erasure and copy-move conditions keep their own refusal: only a moved binding or a
7228
+ // moved serving version is the race a rebind's review answers.
7229
+ const now = this.directory.prepare('SELECT vertical_version_id FROM scopes WHERE scope_id = ?').get(scopeId);
7230
+ const raced = (opts?.expectedVersionId !== undefined && now?.vertical_version_id !== opts.expectedVersionId)
7231
+ || (opts?.expectedServing !== undefined && !this.servesNow(opts.expectedServing));
7232
+ throw raced ? lost() : substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
7233
+ }
7234
+ this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, servingRef === undefined ? null : { servingRef: scope.serving_ref }, {
7020
7235
  versionId,
7021
7236
  vertical: v.verticalSlug,
7022
7237
  version: v.version,
7238
+ ...(servingRef !== undefined ? { servingRef } : {}),
7023
7239
  ...(opts?.expectedVersionId !== undefined ? { expectedVersionId: opts.expectedVersionId } : {}),
7240
+ ...(opts?.expectedServing ? { expectedServing: opts.expectedServing } : {}),
7024
7241
  ...(ack.exportBreak ? { acknowledged: ack } : {}),
7025
7242
  });
7026
7243
  },
@@ -7205,7 +7422,7 @@ export class SqliteScopeHost {
7205
7422
  const ack = bindAcknowledgement.parse(opts?.acknowledge ?? {});
7206
7423
  // #1756: onto (or off) a serving script, the scope runs other code before its pointer
7207
7424
  // moves. Judged here, so no caller that routes first and binds second passes unasked.
7208
- const bound = scope.vertical_version_id ? readVersion(scope.vertical_version_id) : undefined;
7425
+ const bound = scope.vertical_version_id ? await readVersion(scope.vertical_version_id) : undefined;
7209
7426
  if (bound)
7210
7427
  this.assertBindingShapesKept(mapScope(scope), bound.id, servingRef);
7211
7428
  if (!ack.exportBreak && bound) {
@@ -7782,48 +7999,74 @@ export class SqliteScopeHost {
7782
7999
  createdAt: r.created_at,
7783
8000
  }));
7784
8001
  },
7785
- // -- store-kind releases, rollouts and holds (D-63, #2300) -------------------
7786
- // The state is kernel code over the directory (store-kind-releases.ts), shared with the
8002
+ // -- store-driver releases, rollouts and holds (D-63, #2300) -------------------
8003
+ // The state is kernel code over the directory (store-driver-releases.ts), shared with the
7787
8004
  // Cloudflare control plane; each call is one transaction. A hold's expiry is judged against
7788
8005
  // this host's clock, so a test can let a lease lapse without waiting.
7789
- recordStoreKindRelease: async (actor, input) => {
8006
+ recordStoreDriverRelease: async (actor, input) => {
7790
8007
  const at = this.clock();
7791
- const { record, created } = this.directory.transaction(() => recordStoreKindReleaseIn(switchSqlOf(this.directory), { ...input, actor, at }))();
8008
+ const { record, created } = this.directory.transaction(() => recordStoreDriverReleaseIn(switchSqlOf(this.directory), { ...input, actor, at }))();
7792
8009
  if (created)
7793
- this.recordAdmin(actor, 'uploadStoreKindRelease', { tenantId: null }, null, record, at);
8010
+ this.recordAdmin(actor, 'uploadStoreDriverRelease', { tenantId: null }, null, record, at);
7794
8011
  return record;
7795
8012
  },
7796
- getStoreKindRelease: async (actor, kind, sha256) => {
7797
- const record = storeKindReleaseIn(switchSqlOf(this.directory), kind, sha256);
7798
- this.recordAccess(actor, 'getStoreKindRelease', { tenantId: null }, { kind, sha256 }, record ? 1 : 0);
8013
+ getStoreDriverRelease: async (actor, driver, sha256) => {
8014
+ const record = storeDriverReleaseIn(switchSqlOf(this.directory), driver, sha256);
8015
+ this.recordAccess(actor, 'getStoreDriverRelease', { tenantId: null }, { driver, sha256 }, record ? 1 : 0);
7799
8016
  return record;
7800
8017
  },
7801
- storeKindState: async (actor, kind) => {
7802
- const state = storeKindStateIn(switchSqlOf(this.directory), kind, Date.parse(this.clock()));
7803
- this.recordAccess(actor, 'storeKindState', { tenantId: null }, { kind }, state.rollouts.length);
8018
+ storeDriverState: async (actor, driver) => {
8019
+ const state = storeDriverStateIn(switchSqlOf(this.directory), driver, Date.parse(this.clock()));
8020
+ this.recordAccess(actor, 'storeDriverState', { tenantId: null }, { driver }, state.rollouts.length);
7804
8021
  return state;
7805
8022
  },
7806
- storeKindDeclarations: async (actor, kind) => {
7807
- const declared = this.directory.transaction(() => kindDeclarationsOf(switchSqlOf(this.directory), kind))();
7808
- this.recordAccess(actor, 'storeKindDeclarations', { tenantId: null }, { kind }, declared.verticals.length);
8023
+ storeDriverDeclarations: async (actor, driver) => {
8024
+ const declared = this.directory.transaction(() => driverDeclarationsOf(switchSqlOf(this.directory), driver))();
8025
+ this.recordAccess(actor, 'storeDriverDeclarations', { tenantId: null }, { driver }, declared.verticals.length);
7809
8026
  return declared;
7810
8027
  },
7811
- holdStoreKind: async (_actor, input) => this.directory.transaction(() => holdStoreKindIn(switchSqlOf(this.directory), { ...input, nowMs: Date.parse(this.clock()) }))(),
7812
- releaseStoreKindHold: async (_actor, input) => {
7813
- releaseStoreKindHoldIn(switchSqlOf(this.directory), input);
8028
+ storeDriverRolloutPending: async (actor, input) => {
8029
+ const page = this.directory.transaction(() => storeDriverRolloutPendingIn(switchSqlOf(this.directory), input))();
8030
+ this.recordAccess(actor, 'storeDriverRolloutPending', { tenantId: null }, { driver: input.driver, sha256: input.sha256, action: input.action }, page.verticals.length);
8031
+ return page;
8032
+ },
8033
+ storeDriverRolloutWalked: async (actor, input) => {
8034
+ const walked = this.directory.transaction(() => storeDriverRolloutWalkedIn(switchSqlOf(this.directory), input))();
8035
+ this.recordAccess(actor, 'storeDriverRolloutWalked', { tenantId: null }, { driver: input.driver, seq: input.seq }, walked.verticals.length);
8036
+ return walked;
8037
+ },
8038
+ holdStoreDriver: async (_actor, input) => this.directory.transaction(() => holdStoreDriverIn(switchSqlOf(this.directory), { ...input, nowMs: Date.parse(this.clock()) }))(),
8039
+ releaseStoreDriverHold: async (_actor, input) => {
8040
+ releaseStoreDriverHoldIn(switchSqlOf(this.directory), input);
7814
8041
  },
7815
- beginStoreKindRollout: async (actor, input) => {
8042
+ beginStoreDriverRollout: async (actor, input) => {
7816
8043
  const at = this.clock();
7817
- const row = this.directory.transaction(() => beginStoreKindRolloutIn(switchSqlOf(this.directory), { ...input, actor, at, nowMs: Date.parse(at) }))();
7818
- this.recordAdmin(actor, input.action === 'rollback' ? 'rollBackStoreKind' : 'rollOutStoreKind', { tenantId: null }, null, row, at);
8044
+ const row = this.directory.transaction(() => beginStoreDriverRolloutIn(switchSqlOf(this.directory), { ...input, actor, at, nowMs: Date.parse(at) }))();
8045
+ this.recordAdmin(actor, input.action === 'rollback' ? 'rollBackStoreDriver' : 'rollOutStoreDriver', { tenantId: null }, null, row, at);
7819
8046
  return row;
7820
8047
  },
7821
- finishStoreKindRollout: async (actor, input) => {
8048
+ finishStoreDriverRollout: async (actor, input) => {
7822
8049
  const at = this.clock();
7823
- const row = this.directory.transaction(() => finishStoreKindRolloutIn(switchSqlOf(this.directory), { ...input, at, nowMs: Date.parse(at) }))();
7824
- this.recordAdmin(actor, row.action === 'rollback' ? 'rollBackStoreKind' : 'rollOutStoreKind', { tenantId: null }, null, row, at);
8050
+ const row = this.directory.transaction(() => finishStoreDriverRolloutIn(switchSqlOf(this.directory), { ...input, at, nowMs: Date.parse(at) }))();
8051
+ this.recordAdmin(actor, row.action === 'rollback' ? 'rollBackStoreDriver' : 'rollOutStoreDriver', { tenantId: null }, null, row, at);
7825
8052
  return row;
7826
8053
  },
8054
+ storeDriverRebuild: async (actor, driver) => {
8055
+ const rebuild = storeDriverRebuildIn(switchSqlOf(this.directory), driver);
8056
+ this.recordAccess(actor, 'storeDriverRebuild', { tenantId: null }, { driver }, rebuild ? 1 : 0);
8057
+ return rebuild;
8058
+ },
8059
+ claimStoreDriverRebuild: async (_actor, input) => {
8060
+ const at = this.clock();
8061
+ return this.directory.transaction(() => claimStoreDriverRebuildIn(switchSqlOf(this.directory), { ...input, at, nowMs: Date.parse(at) }))();
8062
+ },
8063
+ advanceStoreDriverRebuild: async (_actor, input) => {
8064
+ const at = this.clock();
8065
+ return this.directory.transaction(() => advanceStoreDriverRebuildIn(switchSqlOf(this.directory), { ...input, at, nowMs: Date.parse(at) }))();
8066
+ },
8067
+ recordStoreDriverRebuildRun: async (actor, entry) => {
8068
+ this.recordAdmin(actor, 'rebuildStoreDriver', { tenantId: null }, null, entry, this.clock());
8069
+ },
7827
8070
  getScopeRecord: async (actor, tenantId, scopeId) => {
7828
8071
  const r = this.directory.prepare('SELECT * FROM scopes WHERE scope_id = ?').get(scopeId);
7829
8072
  // Cross-check the pair (K-3): a scope that exists under a DIFFERENT tenant
@@ -7958,7 +8201,10 @@ export class SqliteScopeHost {
7958
8201
  // `.raw(true)` gives positional rows; cells stay as-is (blobs are Buffers, not
7959
8202
  // nulled as in a UI read) so the dump is byte-faithful. The name is from the live
7960
8203
  // schema, never user input, so the quoted identifier is safe.
7961
- const stmt = db.prepare(`SELECT * FROM "${name}"`).raw(true);
8204
+ // #2350: the orphan sweep's licence is this store's own, so its rows never leave in a dump.
8205
+ const stmt = db
8206
+ .prepare(name === ATTACHMENT_SWEEP_LICENCE_TABLE ? `SELECT * FROM "${name}" WHERE 0` : `SELECT * FROM "${name}"`)
8207
+ .raw(true);
7962
8208
  const rows = stmt.all();
7963
8209
  const columns = stmt.columns().map((c) => c.name);
7964
8210
  return { name, ddl: sql, columns, rows };
@@ -8915,6 +9161,11 @@ export class SqliteScopeHost {
8915
9161
  const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
8916
9162
  this.recordAdmin(actor, action === 'mark' ? 'markScopeCopy' : 'clearScopeCopyMark', { tenantId, scopeId }, null, after);
8917
9163
  },
9164
+ /** #2314: one orphan-attachment pass over a scope, written after the vertical's own pass. */
9165
+ recordOrphanSweep: async (actor, entry) => {
9166
+ const { tenantId, scopeId, ...after } = orphanSweepAudit.parse(entry);
9167
+ this.recordAdmin(actor, 'sweepOrphanAttachments', { tenantId, scopeId }, null, after);
9168
+ },
8918
9169
  accessLog: async (actor, filter) => {
8919
9170
  const where = [];
8920
9171
  const params = [];
@@ -9373,7 +9624,8 @@ export class SqliteScopeHost {
9373
9624
  const moved = this.directory.prepare(`UPDATE scopes SET ${set} WHERE scope_id = ?
9374
9625
  ${cond?.expectedVersionId === undefined ? '' : 'AND vertical_version_id IS ?'}
9375
9626
  ${cond?.expectedErasureEpoch === undefined ? '' : 'AND COALESCE(erasure_epoch, 0) = ? AND reap_claimed_at IS NULL'}
9376
- ${cond?.confirmMove === undefined ? '' : COPY_MOVE_LIVE_PREDICATE}`).run(...setArgs, scopeId, ...(cond?.expectedVersionId === undefined ? [] : [cond.expectedVersionId]), ...(cond?.expectedErasureEpoch === undefined ? [] : [cond.expectedErasureEpoch]), ...(cond?.confirmMove === undefined ? [] : copyMoveLiveParams(cond.confirmMove, now)));
9627
+ ${cond?.confirmMove === undefined ? '' : COPY_MOVE_LIVE_PREDICATE}
9628
+ ${cond?.expectedServing === undefined ? '' : SERVES_NOW_PREDICATE}`).run(...setArgs, scopeId, ...(cond?.expectedVersionId === undefined ? [] : [cond.expectedVersionId]), ...(cond?.expectedErasureEpoch === undefined ? [] : [cond.expectedErasureEpoch]), ...(cond?.confirmMove === undefined ? [] : copyMoveLiveParams(cond.confirmMove, now)), ...(cond?.expectedServing === undefined ? [] : servesNowParams(cond.expectedServing)));
9377
9629
  if (moved.changes === 0)
9378
9630
  return false;
9379
9631
  if (cond?.confirmMove) {
@@ -9383,6 +9635,10 @@ export class SqliteScopeHost {
9383
9635
  return true;
9384
9636
  })();
9385
9637
  }
9638
+ /** #2347: whether a lineage's serving script still runs the version a rebind was judged against. */
9639
+ servesNow(expected) {
9640
+ return this.directory.prepare(`SELECT 1 FROM verticals WHERE ${SERVES_NOW_CONDITION}`).get(...servesNowParams(expected)) !== undefined;
9641
+ }
9386
9642
  ensureColumn(db, table, column, ddl) {
9387
9643
  // Without case, as SQLite resolves a column name: a restore may have added this column
9388
9644
  // already, untyped and spelled as a newer kernel's dump spelled it (#1883).
@@ -9805,7 +10061,7 @@ export class SqliteScopeHost {
9805
10061
  // Lifted out of the object literal so `grant` can reuse it: a delegation
9806
10062
  // check has to be the SAME check the operation itself passes, or the two
9807
10063
  // could disagree about what the caller holds.
9808
- const runCheck = async (unparsed, entity) => {
10064
+ const decideCheck = async (unparsed, entity) => {
9809
10065
  // #1642: parsed before the override actor's early return, which never reaches
9810
10066
  // the checker — a cast key would otherwise become that path's proof relation.
9811
10067
  const permission = assertPermissionKey(unparsed);
@@ -9834,6 +10090,59 @@ export class SqliteScopeHost {
9834
10090
  }
9835
10091
  return decision;
9836
10092
  };
10093
+ // #2358: the same check, recorded. It reads the decision `decideCheck` already made and
10094
+ // changes nothing about it; the explanation of a refusal (which roles would have allowed it,
10095
+ // which the caller holds) is gathered only when a sink is installed.
10096
+ // Only inside a call that has an id on the runtime: a background delivery's fresh id would
10097
+ // open a trace nothing ever ends.
10098
+ const trace = invocationTrace(rt.invocationId);
10099
+ const runCheck = trace
10100
+ ? async (unparsed, entity) => {
10101
+ const started = trace.now();
10102
+ const decision = await decideCheck(unparsed, entity);
10103
+ // Taken before the explanation below, which reads the role table and the checker's
10104
+ // holdings: the step times the check, not the work of describing a refusal.
10105
+ const durationMs = Math.max(0, Math.round(trace.now() - started));
10106
+ let requires = [];
10107
+ let callerHas = [];
10108
+ if (!decision.allowed) {
10109
+ try {
10110
+ const prefix = `${rt.tenantId}/`;
10111
+ requires = [...this.roles.entries()]
10112
+ .filter(([key, role]) => key.startsWith(prefix) && role.permissions.includes(decision.checked))
10113
+ .map(([, role]) => role.key);
10114
+ // On one entity, a grant on that entity would also have allowed it: say so, so the
10115
+ // trace does not point at assigning a role when the missing piece is a grant.
10116
+ if (entity)
10117
+ requires.push(`grant:${decision.checked}@${entityString(entity)}`);
10118
+ if (subject.kind === 'principal' && this.checker.holdings) {
10119
+ const held = await this.checker.holdings(asPrincipal(subject.id), { tenantId: rt.tenantId, scopeId: rt.scopeId });
10120
+ callerHas = [
10121
+ ...held.roles,
10122
+ ...held.granted.filter((p) => p === decision.checked).map((p) => `grant:${p}`),
10123
+ ...held.narrowed
10124
+ .filter((n) => n.permission === decision.checked)
10125
+ .map((n) => `grant:${n.permission}@${entityString(n.entity)}`),
10126
+ ];
10127
+ }
10128
+ }
10129
+ catch {
10130
+ /* an explanation that cannot be gathered is left empty, never an error */
10131
+ }
10132
+ }
10133
+ trace.step({
10134
+ type: 'check',
10135
+ permission: decision.allowed ? String(unparsed) : decision.checked,
10136
+ entity: entityString(entity),
10137
+ allowed: decision.allowed,
10138
+ decidedBy: decision.allowed ? decidedByOf(decision.proof) : null,
10139
+ requires,
10140
+ callerHas,
10141
+ durationMs,
10142
+ });
10143
+ return decision;
10144
+ }
10145
+ : decideCheck;
9837
10146
  const runCanAssign = (roleKey) => this.assignmentBound(subject, rt.tenantId, rt.scopeId, roleKey);
9838
10147
  // ctx.emit's writer, and the kernel's own events' (#1864): one path to the outbox, with the
9839
10148
  // reserved-type refusal applied to module code only. Never handed to module code as-is.
@@ -9882,6 +10191,8 @@ export class SqliteScopeHost {
9882
10191
  // #1237: a fact about the surrounding CALL, like the version above — never
9883
10192
  // envelope data module code could set or branch on.
9884
10193
  rt.invocationId, full.payload === undefined ? null : JSON.stringify(full.payload));
10194
+ // #2358: type and entity only; the payload stays out of the trace.
10195
+ trace?.step({ type: 'emit', eventType: full.type, entity: `${full.entity.entityType}:${full.entity.entityId}` });
9885
10196
  };
9886
10197
  // #2192: the call's invocation id, one reader for `ctx.log` and `ctx.requestId`.
9887
10198
  const requestIdNow = () => rt.invocationId ?? delivery?.invocationId ?? null;
@@ -9890,12 +10201,13 @@ export class SqliteScopeHost {
9890
10201
  scopeId: rt.scopeId,
9891
10202
  principal,
9892
10203
  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)),
9893
- sql: guardSecrets(guardSqlLimits(scopedSql(rt.db, true, statefulTablesOf(statePlans),
10204
+ // #2358: the outermost layer, so a statement refused by a guard below is recorded too.
10205
+ sql: tracedSql(guardSecrets(guardSqlLimits(scopedSql(rt.db, true, statefulTablesOf(statePlans),
9894
10206
  // #119 / #2090: after runtime DDL, inside this operation's transaction, what the kernel
9895
10207
  // derived onto any table is repaired — and a lost state column fails the operation.
9896
10208
  derivesAnything(this.derivedPlans())
9897
10209
  ? () => afterRuntimeDdl(spineSql(rt.db), (ddl) => execSqlStatements(rt.db, ddl), this.derivedPlans())
9898
- : undefined)), minted),
10210
+ : undefined)), minted), trace, (text) => redactSecretText(text, minted), (value) => redactSecrets(value, minted)),
9899
10211
  now: () => at,
9900
10212
  // #1746/#1747: the host stamps who and where; module code supplies only the template
9901
10213
  // and its fields. The invocation id is read per call — it is set for the duration of
@@ -9910,7 +10222,12 @@ export class SqliteScopeHost {
9910
10222
  // The string-safe redaction: `redactSecrets` parses its serialization back, and a
9911
10223
  // log's text is not JSON.
9912
10224
  redact: (text) => redactSecretText(text, minted),
9913
- }, this.logSink),
10225
+ }, trace
10226
+ ? (line) => {
10227
+ trace.step({ type: 'log', level: line.level, message: line.message });
10228
+ this.logSink(line);
10229
+ }
10230
+ : this.logSink),
9914
10231
  // #2192: the same id the log lines carry.
9915
10232
  get requestId() {
9916
10233
  return requestIdNow();
@@ -10210,6 +10527,14 @@ export class SqliteScopeHost {
10210
10527
  await this.repairDerived(rt);
10211
10528
  return;
10212
10529
  }
10530
+ // #2349: before any SQL, the registered set is held to the one stored for this version.
10531
+ // Only here, so a scope with nothing pending never computes it.
10532
+ const unstored = await migrationSetDivergence(this.modules.values(), this.migrationsDigest);
10533
+ if (unstored) {
10534
+ const refused = { version: `${pending[0].moduleId}@${pending[0].migration.version}`, error: unstored };
10535
+ this.recordMigrationState(rt, refused);
10536
+ throw migrationFailedError(refused.version, refused.error);
10537
+ }
10213
10538
  // The failing `module@version` and its cause, captured structurally rather than
10214
10539
  // re-parsed out of the thrown message — the directory record has to name both.
10215
10540
  let failure;
@@ -10243,7 +10568,8 @@ export class SqliteScopeHost {
10243
10568
  assertMigrationLeavesLedgerAlone(migration.sql, `migration ${key}`);
10244
10569
  // #2068: one statement at a time, keeping the table set either side of each — the
10245
10570
  // same splitter the Durable Object runs, so both hosts execute identical statements.
10246
- const steps = runMigrationStatements(spineSql(rt.db), migration.sql, (stmt) => rt.db.exec(stmt));
10571
+ // #115: an authored one is also held to the data-loss fence, inside this transaction.
10572
+ const steps = runMigrationStatements(spineSql(rt.db), migration, (stmt) => rt.db.exec(stmt), { key, authored });
10247
10573
  assertTablesWithinColumnLimit(rt.db);
10248
10574
  // #2090: the state columns this migration must have left, and after the last of the
10249
10575
  // pass, the triggers and indexes a create-copy-rename rebuild dropped.
@@ -10523,6 +10849,9 @@ export class SqliteScopeHost {
10523
10849
  // #2009: the copy classification on a scope DB built before it (NULL reads as a copy; see
10524
10850
  // `COPY_ORIGIN_DDL`).
10525
10851
  this.ensureColumn(db, '_substrat_copy_origin', 'is_copy', 'is_copy INTEGER');
10852
+ // #2315: the hash of the key a redemption carried, on a scope DB built before it. NULL = the
10853
+ // spending request carried none, the honest reading of every link already spent.
10854
+ this.ensureColumn(db, '_substrat_upload_links', 'redeemed_key_hash', 'redeemed_key_hash TEXT');
10526
10855
  // #1237: `readInvocation`'s lookup — WHERE invocation_id = ? ORDER BY id — over an outbox
10527
10856
  // that is never pruned. No index leads with invocation_id, so without this one SQLite
10528
10857
  // walks the PRIMARY KEY from the oldest event until it reaches the call, and reading a