@substrat-run/adapter-sqlite 0.147.0 → 0.149.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, copyMarkAudit, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
5
- import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, COPY_ORIGIN_DDL, ENTITY_STATE_MOVES_DDL, CAPABILITY_EXCHANGE_OPERATION, CAPABILITY_BECOME_MINT_OPERATION, becomeMintCheck, mintBecomeCapabilityAsPrincipal, revokeBecomeCapabilityAsPrincipal, readBecomeLinkStates, ENTITY_UNBIND_OPERATION, allowedFor, unbindEntityHolderBounded, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, CONTINUATION_POSITION_CAP, visibleContinuation, searchStateWhere, uncheckedView, addStatePlans, addUpdatedStampPlans, watchUpdatedStamps, entityStateMigrations, assertNoCallerPurge, isUnreachableParent, heldPurgePass, purgeDueOf, purgeHeldBy, lifecycleRefusal, purgeOnlyKeysOf, purgeReportOf, purgeStillDue, refuseTrashedTarget, assertBoundOperations, registerTrashTargets, runPurgePass, withheldKeysFor, statefulTablesOf, afterMigration, afterRuntimeDdl, derivesAnything, repairDerivedObjects, StateColumnLost, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, readCapabilityPage, redactSecrets, redactSecretText, moduleLog, consoleLogSink, asyncInvocationId, asyncLinePass, consoleInvocationLineSink, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, guardSpine, guardSqlLimits, DO_SQL_LIMITS, tooManyResultColumns, tooManyTableColumns, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, SCOPE_STORAGE_DDL, forgetScopeStorage, listScopeStorageAttemptRows, listScopeStorageRows, listScopeStoreStorageRows, pruneScopeStorageRows, recordScopeStorageRows, CONNECT_LINKS_DDL, consumeConnectLinkRow, insertConnectLink, listConnectLinks as listConnectLinkRows, readConnectLink, restoreConnectLinkRow, revokeConnectLinkRow, createFindingRule, findingOfOpsFailure, findingOfSweepRun, pruneFindings, listFindingRules, listFindings, observeFinding, revokeFindingRule, setFindingStatus, assertRowLimit, assertRowOffset, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, eraseSubjectFromModules, moduleRowsErased, moduleErasurePlan, redactSubjectScopeText, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_STEP_RECORD_SQL, JOB_LEASE_EXPIRED_NOTE, assertLeaseMs, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, applyScopeRoleChange, changeScopeRole, revokeScopeRoles, scopeRoleHolders, admitPeer, peerCallerAt, collectPeers, peerSeats, resolveVerticalInstanceFrom, resolvePeerInstanceFrom, peerVerticalSpellings, samePeerVertical, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, PEER_SWITCHES_DDL, PEER_BINDINGS_DDL, SWITCH_OWED_DDL, SWITCH_FENCES_DDL, TABLE_OWNERS_DDL, assertMigrationLeavesLedgerAlone, recordOwnershipSteps, runMigrationStatements, executableSqlStatements, blankSqlComments, recordWriteSuperseded, switchFencesOf, switchSupersededMessage, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, moveSwitch, reassertActionOf, reassertEntry, reassertOffRow, recordSwitchedOff, recordSwitchedOn, restoreSwitchRecord, scopesSwitchedOffFor, switchActionOf, switchAuditSubject, recordAuditOutcome, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, switchRecordsOf, switchedOffOf, switchesBackfillSqlOf, switchesTableExists, tenantHeldOf, tenantHoldsGrant, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, VERSION_MIGRATIONS_DDL, splitManifestMigrations, splitVersionMigrationsBatch, versionMigrationsOf, writeVersionMigrations, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, assertSessionUsable, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, impersonationStampOf, mapImpersonationRow, newImpersonationSession, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, memberAddedAudit, orgChangeBound, tenantCoverage, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, moduleMigrations, MIGRATION_DIGEST_FENCE_DDL, MIGRATION_DIGEST_MARK_LEGACY, assertJournalDumpCoherent, assertMigrationSql, migrationDivergence, migrationFailedError, migrationSteps, planMigrations, isSearchIndexTable, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, attachmentRecordOfRow, attachmentTextJob, enqueueAttachmentText, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, queueAttachmentTextBackfill, startAttachmentTextBackfill, attachmentTextBackfillJob, reconcileAttachmentText, recordAttachmentText, searchAttachments, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, emptyImportResult, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, exportedSinceQuery, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, isPrimaryScope, PROVISIONED_VERSIONS_SQL, installsReconciledOf, strandJudgmentsOf, provisionedVersionsParams, boundShapesColumn, boundShapesOfColumn, installReconciled, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, readLifecycleFlow, readOperationSeries, domainEventOf, readUndrainedOutbox, undrainedEventsOf, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, unknownRoleError, callReservedStoreOperation, callScopeStore, observeStoreCall, replayStoreErasures, scopeStoreLifecycleOf, scopeStoreRunnersOf, } from '@substrat-run/kernel';
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, } 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, condemnOrphanAttachments, 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 } from '@substrat-run/kernel';
9
- import { COPY_BACKFILL_SCOPE_SQL, COPY_BACKFILL_SQL, COPY_BACKFILL_SUPERSEDE_SQL, COPY_CLAIM_SQL, COPY_EXPIRED_SQL, copyBackfillParams, copyBackfillRefusal, COPY_MOVE_CONFIRM_SQL, COPY_MOVE_LIVE_PREDICATE, SCOPE_COPY_LEASE_MS, SCOPE_SCRIPT_COPY_COLUMNS, copyMoveConfirmParams, copyMoveLiveParams, scopeScriptCopyOf, } from '@substrat-run/kernel';
8
+ import { INERT_SCOPE_REASON, isPrimaryScopeRow, normalizeUploadMediaType } from '@substrat-run/kernel';
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
+ export { scopeStoreRunnersFor } from './scope-store-runners.js';
14
15
  /**
15
16
  * A fault in the database itself (full, corrupt, I/O), as opposed to one statement failing.
16
17
  * The #1764 backfill logs and opens past a failed batch, but never past one of these.
@@ -213,6 +214,9 @@ const KERNEL_DDL = `
213
214
  -- an exchange trades that secret for. Spine (kernel-written), shared with the DO adapter
214
215
  -- from @substrat-run/kernel so the two cannot part company; the column comments are there.
215
216
  ${CAPABILITY_DDL}
217
+ -- Upload links: a short-lived, single-use URL that lets a caller with no session upload ONE
218
+ -- file to ONE entity. Spine (kernel-written), shared with the DO adapter from the kernel.
219
+ ${UPLOAD_LINK_DDL}
216
220
  -- #1686: where a copied scope's data came from, and the event id its own events start above.
217
221
  -- Shared with the other adapter from @substrat-run/kernel; the column comments are there.
218
222
  ${COPY_ORIGIN_DDL}
@@ -318,11 +322,19 @@ const KERNEL_DDL = `
318
322
  -- #1575: attachment text and its FTS5 index, shared from @substrat-run/kernel. After
319
323
  -- _substrat_attachments, whose delete trigger it adds.
320
324
  ${ATTACHMENT_TEXT_DDL}
325
+ -- #2314: the orphan sweep's tombstones, shared from @substrat-run/kernel. An id here
326
+ -- never takes an attachment row again: the insert path refuses it.
327
+ ${ATTACHMENT_CONDEMNED_DDL}
321
328
  -- #901: an entity's version is the ULID of the last event about it (a state move
322
329
  -- excepted, #2191), so the newest id per (entity_type, entity_id) is the read. The id
323
330
  -- column sits last so SQLite walks the matched range backwards instead of aggregating.
324
331
  ${OUTBOX_ENTITY_INDEX}
325
332
  `;
333
+ /**
334
+ * The scope spine a fresh scope gets, for a tool that replays a vertical's migrations outside a
335
+ * host — `pnpm lint:migration-fence` (#115), whose reviewed spine migrations read and write it.
336
+ */
337
+ export const SCOPE_SPINE_DDL = KERNEL_DDL;
326
338
  /** Row → contract shape. Never reads the secrets table — that is the point of the split. */
327
339
  const toConnection = (r) => connection.parse({
328
340
  id: r.id,
@@ -637,6 +649,8 @@ export class SqliteScopeHost {
637
649
  erasurePlans = [];
638
650
  /** entityType → the declared attachment gate (#473): read key + write key (default: read). */
639
651
  attachmentTargets = new Map();
652
+ /** entityType → the `uploadLinks` policy its `attachmentTargets` entry declares; absent = no links. */
653
+ uploadLinkPolicies = new Map();
640
654
  /** operation name → who binds it: the owning module, its entitlementKey and its declared
641
655
  * schedules — the §4.3 gate's input, resolved per invoke by `requiredEntitlementFor` (#1654). */
642
656
  operationEntitlement = new Map();
@@ -692,11 +706,13 @@ export class SqliteScopeHost {
692
706
  secretBox;
693
707
  /** The parsers attachment text is extracted with (K-43); the host's own, never imported here. */
694
708
  attachmentExtractors;
709
+ wrapBlobStore;
695
710
  attachmentTextBounds;
696
711
  fetchImpl;
697
712
  connectorCalls;
698
713
  clock;
699
714
  versionId;
715
+ migrationsDigest;
700
716
  /** #1746: where `ctx.log` lines go — the console unless a caller (a test) says otherwise. */
701
717
  logSink;
702
718
  /** #1901: where async work's invocation lines go. */
@@ -710,11 +726,13 @@ export class SqliteScopeHost {
710
726
  this.secretBox = options.secretBox ?? unconfiguredSecretBox;
711
727
  assertAttachmentExtractors(options.attachmentExtractors ?? []);
712
728
  this.attachmentExtractors = options.attachmentExtractors ?? [];
729
+ this.wrapBlobStore = options.wrapBlobStore ?? ((store) => store);
713
730
  this.attachmentTextBounds = resolveAttachmentTextBounds(options.attachmentTextBounds);
714
731
  this.fetchImpl = options.fetch ?? globalFetch;
715
732
  this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
716
733
  this.clock = options.clock ?? (() => instant.parse(new Date().toISOString()));
717
734
  this.versionId = options.versionId ?? null;
735
+ this.migrationsDigest = options.migrationsDigest;
718
736
  this.scopeStoreVertical = options.scopeStores?.vertical;
719
737
  this.scopeStoreRunners = scopeStoreRunnersOf(options.scopeStores);
720
738
  this.scopeStoreLifecycle = scopeStoreLifecycleOf(this.scopeStoreRunners);
@@ -737,17 +755,17 @@ export class SqliteScopeHost {
737
755
  this.directory.pragma('journal_mode = WAL');
738
756
  this.ensureDirectorySchema();
739
757
  this.loadRoles();
740
- this.checker =
741
- customChecker ??
742
- createTupleChecker({
743
- directory: this.directory,
744
- scopeDb: (scopeId) => this.scopesById.get(scopeId)?.db,
745
- getRole: (tenantId, key) => this.roles.get(`${tenantId}/${key}`),
746
- // #956: the evaluator judges `expires_at` against the host's clock, so a
747
- // scripted one can actually expire a grant. A caller-supplied `checker`
748
- // keeps its own time source — this only binds the built-in one.
749
- clock: () => this.clock(),
750
- });
758
+ // #2263: whichever checker evaluates, a peer holds only what the running declaration names.
759
+ this.checker = boundToPeerDeclarations(customChecker ??
760
+ createTupleChecker({
761
+ directory: this.directory,
762
+ scopeDb: (scopeId) => this.scopesById.get(scopeId)?.db,
763
+ getRole: (tenantId, key) => this.roles.get(`${tenantId}/${key}`),
764
+ // #956: the evaluator judges `expires_at` against the host's clock, so a
765
+ // scripted one can actually expire a grant. A caller-supplied `checker`
766
+ // keeps its own time source — this only binds the built-in one.
767
+ clock: () => this.clock(),
768
+ }), () => this.peerDeclarations());
751
769
  this.admin = this.buildAdmin();
752
770
  this.owner = owner;
753
771
  }
@@ -936,6 +954,8 @@ export class SqliteScopeHost {
936
954
  created_at TEXT NOT NULL,
937
955
  PRIMARY KEY (tenant_id, vertical, binding)
938
956
  );
957
+ -- A store driver's stored releases, rollouts and holds (D-63, #2300).
958
+ ${STORE_DRIVER_RELEASES_DDL}
939
959
  -- The vertical + version registry (#31). A scope binds to a VERSION, so
940
960
  -- dev/staging/prod are the same vertical pinned differently, and a preview
941
961
  -- deployment is a version nothing has been promoted to yet.
@@ -1002,10 +1022,14 @@ export class SqliteScopeHost {
1002
1022
  migration_count INTEGER,
1003
1023
  -- 1 once the SQL is out of manifest_json. NULL = stored before #1764, not yet backfilled.
1004
1024
  migrations_split INTEGER,
1025
+ -- 1 once migration_digest is the one derived from the stored migrations (#2339).
1026
+ -- NULL = stored before, still the pushed digest; derived on its next read.
1027
+ migration_digest_derived INTEGER,
1005
1028
  created_at TEXT NOT NULL,
1006
1029
  UNIQUE (vertical_slug, version)
1007
1030
  );
1008
1031
  ${VERSION_MIGRATIONS_DDL}
1032
+ ${VERTICAL_SCOPE_STORES_DDL}
1009
1033
  -- Channels (#31 step 2): a named pointer per vertical. Promotion moves it,
1010
1034
  -- and promotion is where the migration and permission diffs fire — the
1011
1035
  -- moment a change reaches anyone, rather than the moment it was typed.
@@ -1405,13 +1429,27 @@ export class SqliteScopeHost {
1405
1429
  * it inside its transaction onto an emptied directory, before loading any row.
1406
1430
  */
1407
1431
  buildDirectorySchema() {
1432
+ // A directory built before the store driver rename keeps its rows under the old names (and one
1433
+ // that ran pre-rename code again, a rollback, holds both names), and
1434
+ // `applyDirectorySchema` would only create empty tables beside them.
1435
+ const tables = this.directory.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((t) => t.name);
1436
+ const hasRows = (table) => this.directory.prepare(`SELECT 1 FROM "${table}" LIMIT 1`).get() !== undefined;
1437
+ // Asked before the moves below, which drop a name with no rows.
1438
+ const bothNames = legacyStoreDriverConflict(tables, hasRows);
1439
+ if (bothNames)
1440
+ console.error(bothNames);
1441
+ for (const stmt of legacyStoreDriverRenames(tables, (table) => builtColumnsOf(this.directory, table), hasRows))
1442
+ this.directory.exec(stmt);
1408
1443
  // #1764: `VERSION_MIGRATIONS_DDL` indexes these two columns, so a directory created
1409
1444
  // before them needs them before `applyDirectorySchema` reaches that statement.
1410
1445
  if (this.directory.prepare('PRAGMA table_info(vertical_versions)').all().length > 0) {
1411
1446
  this.ensureColumn(this.directory, 'vertical_versions', 'migration_count', 'migration_count INTEGER');
1412
1447
  this.ensureColumn(this.directory, 'vertical_versions', 'migrations_split', 'migrations_split INTEGER');
1448
+ this.ensureColumn(this.directory, 'vertical_versions', 'migration_digest_derived', 'migration_digest_derived INTEGER');
1413
1449
  }
1414
1450
  this.applyDirectorySchema();
1451
+ // #2322: a migration's declared moves and discards, on a table created before they were carried.
1452
+ this.ensureColumn(this.directory, 'vertical_version_migrations', 'declarations', 'declarations TEXT');
1415
1453
  }
1416
1454
  /**
1417
1455
  * `applyDirectorySchema`, plus the backfill of the schedule switch's record (#1674) from
@@ -1673,6 +1711,7 @@ export class SqliteScopeHost {
1673
1711
  }
1674
1712
  // #1705: validated whole and kept only if it passes, so a refused module leaves nothing.
1675
1713
  this.crossVertical.register(manifest, registration.imports);
1714
+ this.peerUnion = undefined; // #2263: the next check reads the new module set's declarations
1676
1715
  this.modules.set(manifest.id, {
1677
1716
  id: manifest.id,
1678
1717
  // The order the kernel writes once (#1677): authored, then search, then list indexes.
@@ -1707,6 +1746,13 @@ export class SqliteScopeHost {
1707
1746
  `(${existing.read}/${existing.write}) vs (${gate.read}/${gate.write})`);
1708
1747
  }
1709
1748
  this.attachmentTargets.set(target.entityType, gate);
1749
+ if (target.uploadLinks) {
1750
+ const known = this.uploadLinkPolicies.get(target.entityType);
1751
+ if (known && JSON.stringify(known) !== JSON.stringify(target.uploadLinks)) {
1752
+ throw new Error(`conflicting uploadLinks policies for attachment target '${target.entityType}'`);
1753
+ }
1754
+ this.uploadLinkPolicies.set(target.entityType, target.uploadLinks);
1755
+ }
1710
1756
  }
1711
1757
  // WITHDRAWAL (K-17): suppress another module's default binding. Order
1712
1758
  // independent — a manifest may withdraw an operation whose module has not
@@ -2057,7 +2103,7 @@ export class SqliteScopeHost {
2057
2103
  out.push(r);
2058
2104
  }
2059
2105
  };
2060
- return {
2106
+ return this.wrapBlobStore({
2061
2107
  put: async (key, body, opts) => {
2062
2108
  const p = resolveKey(key);
2063
2109
  mkdirSync(dirname(p), { recursive: true });
@@ -2089,7 +2135,7 @@ export class SqliteScopeHost {
2089
2135
  walk(root, '', out);
2090
2136
  return out.filter((k) => k.startsWith(prefix)).sort();
2091
2137
  },
2092
- };
2138
+ }, ref);
2093
2139
  }
2094
2140
  /** Resolve the blob store that carries a scope's attachments (#473): the platform-minted
2095
2141
  * store for (tenant, the scope's vertical). Fails loudly when none was provisioned —
@@ -2124,6 +2170,100 @@ export class SqliteScopeHost {
2124
2170
  const store = this.attachmentStore(tenantId, scopeRow?.vertical ?? null);
2125
2171
  return this.buildAttachments(rt, asPrincipal(principal), store);
2126
2172
  }
2173
+ /** The declared write key and link policy of an attachment target — what `ctx.attachments` mints against. */
2174
+ uploadTargetOf(entityType) {
2175
+ const gate = this.attachmentTargets.get(entityType);
2176
+ if (!gate)
2177
+ return undefined;
2178
+ const uploadLinks = this.uploadLinkPolicies.get(entityType);
2179
+ return uploadLinks ? { write: gate.write, uploadLinks } : { write: gate.write };
2180
+ }
2181
+ async inspectUploadLink(tenantId, scopeId, secret, target, idempotencyKey) {
2182
+ const rt = await this.openActiveScope(tenantId, scopeId);
2183
+ return rt.actor.enqueue(async () => (await inspectUploadLink({ sql: spineSql(rt.db), now: this.clock() }, secret, target, idempotencyKey)).view);
2184
+ }
2185
+ /**
2186
+ * #2314: one orphan-sweep pass over a scope's attachment bytes (`sweepOrphanAttachments` in the
2187
+ * kernel has the guards). Only an active PRIMARY scope of `tenantId` is judged: a fork or a
2188
+ * preview holds another scope's rows, never this one's.
2189
+ */
2190
+ async sweepOrphanAttachments(tenantId, scopeId, request = {}) {
2191
+ const row = this.directory
2192
+ .prepare('SELECT vertical, kind, forked_from FROM scopes WHERE scope_id = ? AND tenant_id = ?')
2193
+ .get(scopeId, tenantId);
2194
+ if (row && !isPrimaryScopeRow(row)) {
2195
+ throw substratError('conflict', `refused: scope ${scopeId} is a copy, and a copy may not judge orphan attachments`);
2196
+ }
2197
+ const copies = this.directory
2198
+ .prepare(`SELECT script_ref AS scriptRef, state FROM scope_script_copies
2199
+ WHERE tenant_id = ? AND scope_id = ? AND state NOT IN (${SETTLED_COPY_STATES.map(() => '?').join(', ')})`)
2200
+ .all(tenantId, scopeId, ...SETTLED_COPY_STATES);
2201
+ const unsettled = unsettledCopyRefusal(scopeId, copies);
2202
+ if (unsettled)
2203
+ throw unsettled;
2204
+ // Only an active scope: an archived or reaped one is not this pass's to judge.
2205
+ const rt = await this.openActiveScope(tenantId, scopeId);
2206
+ // A scope with no blob store holds no bytes: answered as that, marked, after every guard above,
2207
+ // so the platform's pass tells it from a failure and from a store that held nothing.
2208
+ const stores = row?.vertical
2209
+ ? this.directory.prepare('SELECT 1 FROM blob_stores WHERE tenant_id = ? AND vertical = ? LIMIT 1').get(tenantId, row.vertical)
2210
+ : undefined;
2211
+ if (!stores) {
2212
+ // A scope that holds an attachment row does keep files: its missing store is a misconfiguration.
2213
+ const holds = await rt.actor.enqueue(() => {
2214
+ const table = rt.db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_substrat_attachments'`).get();
2215
+ return table !== undefined && rt.db.prepare('SELECT 1 FROM _substrat_attachments LIMIT 1').get() !== undefined;
2216
+ });
2217
+ if (holds)
2218
+ throw missingAttachmentStore(tenantId, scopeId);
2219
+ return { listed: 0, candidates: 0, orphans: 0, deleted: 0, keys: [], dryRun: request.dryRun === true, bucket: 'none' };
2220
+ }
2221
+ const store = this.attachmentStore(tenantId, row?.vertical ?? null);
2222
+ return sweepOrphanAttachments(store, scopeId, {
2223
+ rowless: async (ids) => rt.actor.enqueue(() => rowlessAttachmentIds(spineSql(rt.db), ids)),
2224
+ condemn: async (ids, now) => rt.actor.enqueue(() => rt.db.transaction(() => condemnOrphanAttachments(spineSql(rt.db), ids, now))()),
2225
+ }, {
2226
+ // The host's own clock, never a caller's: no request can step past the floor or the fence.
2227
+ now: this.clock(),
2228
+ ...(request.dryRun ? { dryRun: true } : {}),
2229
+ ...(request.limit !== undefined ? { limit: request.limit } : {}),
2230
+ });
2231
+ }
2232
+ /**
2233
+ * #2314: the orphan sweep's restore fence for a scope whose rows are about to be replaced. A
2234
+ * scope whose vertical has no blob store has no bytes, so nothing is fenced; a fence that cannot
2235
+ * be written throws, and the caller refuses the replacement.
2236
+ */
2237
+ fenceOrphanSweep(tenantId, scopeId, vertical) {
2238
+ const held = vertical
2239
+ ? this.directory.prepare('SELECT 1 FROM blob_stores WHERE tenant_id = ? AND vertical = ? LIMIT 1').get(tenantId, vertical)
2240
+ : undefined;
2241
+ if (!held)
2242
+ return Promise.resolve();
2243
+ return fenceAttachmentSweep(this.attachmentStore(tenantId, vertical), scopeId, this.clock());
2244
+ }
2245
+ async redeemUploadLink(tenantId, scopeId, secret, input) {
2246
+ const rt = await this.openActiveScope(tenantId, scopeId);
2247
+ // The target first (the kernel judges it before the state), so a wrong path never reads as
2248
+ // gone; a replay (#2315) is answered from the inspection and never reaches the write.
2249
+ const inspect = () => rt.actor.enqueue(() => inspectUploadLink({ sql: spineSql(rt.db), now: this.clock() }, secret, input.entity, input.idempotencyKey));
2250
+ return redeemOrReplayUploadLink(inspect, input, async ({ view, mintedBy }) => {
2251
+ // The write is made AS the minter: the same gates `attachments()` puts in front of a person.
2252
+ await this.getScope(mintedBy, tenantId, scopeId);
2253
+ const scopeRow = this.directory
2254
+ .prepare('SELECT vertical FROM scopes WHERE scope_id = ?')
2255
+ .get(scopeId);
2256
+ const files = this.buildAttachments(rt, asPrincipal(mintedBy), this.attachmentStore(tenantId, scopeRow?.vertical ?? null), { uploadLink: { secret, idempotencyKey: input.idempotencyKey } });
2257
+ return files.upload({
2258
+ entity: view.entity,
2259
+ filename: view.filename ?? input.filename,
2260
+ // Recorded as it is judged: the essence, never a parameter or a stray space.
2261
+ contentType: normalizeUploadMediaType(input.contentType),
2262
+ visibility: view.visibility,
2263
+ body: input.body,
2264
+ });
2265
+ });
2266
+ }
2127
2267
  async getSystemAttachments(moduleId, tenantId, scopeId) {
2128
2268
  // The same registered-module and lifecycle gate as a system invoke. The
2129
2269
  // attachment surface checks the target's read key as system:<moduleId>.
@@ -2188,8 +2328,17 @@ export class SqliteScopeHost {
2188
2328
  }
2189
2329
  // #1525: null, for the reason the denial two lines up records null — this task
2190
2330
  // is the attachment RPC's own and no invoke set an id on the runtime.
2191
- await this.dispatch(rt, null);
2192
- await this.dispatchExecutors(rt, null);
2331
+ // The write has COMMITTED, so a failed drain must not reach the caller as a refusal:
2332
+ // `upload` deletes the bytes on a refusal, which would leave this row without them
2333
+ // (and a redeemed link spent) (#2311). An undelivered event has no delivery row, so
2334
+ // the next drain in this scope delivers it.
2335
+ try {
2336
+ await this.dispatch(rt, null);
2337
+ await this.dispatchExecutors(rt, null);
2338
+ }
2339
+ catch (err) {
2340
+ console.error('substrat: consumer drain failed after an attachment committed', err);
2341
+ }
2193
2342
  return result;
2194
2343
  });
2195
2344
  /**
@@ -2248,13 +2397,15 @@ export class SqliteScopeHost {
2248
2397
  createdBy: creator.id,
2249
2398
  createdAt: new Date().toISOString(),
2250
2399
  });
2251
- // Bytes first, row second: a crash between the two leaves an orphaned object
2252
- // (harmless, GC-able via list), never a row without bytes. A refused check
2253
- // compensates the object away below.
2400
+ // Bytes first, row second: a crash between the two leaves an orphaned object that no
2401
+ // row points at (#2314). A refused check compensates the object away below; a drain
2402
+ // fault after the commit is not a refusal (`serialized` keeps it from becoming one).
2254
2403
  await store.put(key, input.body, { contentType: input.contentType });
2255
2404
  try {
2256
2405
  return await guarded('attachments.upload', async (ctx) => {
2257
2406
  assertAllowed(await ctx.check(gate.write, input.entity));
2407
+ // #2314: an id the orphan sweep condemned takes no row, in this same transaction.
2408
+ refuseCondemnedAttachment(spineSql(rt.db), record.id);
2258
2409
  rt.db
2259
2410
  .prepare(`INSERT INTO _substrat_attachments
2260
2411
  (id, entity_type, entity_id, filename, content_type, size, sha256,
@@ -2271,6 +2422,11 @@ export class SqliteScopeHost {
2271
2422
  // #1575: queue its text extraction in the same transaction — a `pending` row
2272
2423
  // and a job run. Nothing is extracted here, so nothing here can fail the upload.
2273
2424
  enqueueAttachmentText(spineSql(rt.db), record.id, ulid(), this.clock());
2425
+ // An upload-link redemption spends its link HERE, in the same transaction as the
2426
+ // row it produced: a refusal anywhere above or below rolls the spend back.
2427
+ if (opts.uploadLink !== undefined) {
2428
+ await redeemUploadLinkInTransaction({ sql: spineSql(rt.db), now: this.clock(), emit: (event) => kernelEmit(ctx, event) }, opts.uploadLink.secret, record, opts.uploadLink.idempotencyKey);
2429
+ }
2274
2430
  return record;
2275
2431
  });
2276
2432
  }
@@ -2409,6 +2565,16 @@ export class SqliteScopeHost {
2409
2565
  // rows are gone and the new ones are in. It also covers what a topological sort
2410
2566
  // cannot express: FK cycles, and self-referencing rows within one table.
2411
2567
  db.pragma('defer_foreign_keys = ON');
2568
+ // A dump written before indexes were excluded may still carry them; skip those
2569
+ // too rather than failing a restore over data that is about to be recomputed.
2570
+ // Matched without case, as SQLite resolves a table name (#1883 review).
2571
+ const replayable = dumped.filter((t) => !isSearchIndexTable(t.name.toLowerCase()));
2572
+ // A Durable Object's dump carries spine tables this adapter keeps in its directory, or does
2573
+ // not keep at all; they are skipped by name, and any other unknown spine table is refused.
2574
+ const loadable = replayable.filter((t) => !DO_SCOPE_ONLY_SPINE_TABLES.has(t.name.toLowerCase()));
2575
+ // #2314: a row for an id the orphan sweep condemned here is refused, as the insert refuses it.
2576
+ // Judged on `loadable`, the very tables whose rows the replay below inserts, before any drop.
2577
+ refuseCondemnedInDump(spineSql(db), loadable);
2412
2578
  // Drop the current schema: a vertical's tables are rebuilt from the dump's DDL, the
2413
2579
  // spine from KERNEL_DDL (below). Only real tables (never `sqlite_*` internals, which
2414
2580
  // are auto-managed and un-droppable).
@@ -2423,10 +2589,6 @@ export class SqliteScopeHost {
2423
2589
  continue;
2424
2590
  db.exec(`DROP TABLE IF EXISTS "${name}"`);
2425
2591
  }
2426
- // A dump written before indexes were excluded may still carry them; skip those
2427
- // too rather than failing a restore over data that is about to be recomputed.
2428
- // Matched without case, as SQLite resolves a table name (#1883 review).
2429
- const replayable = dumped.filter((t) => !isSearchIndexTable(t.name.toLowerCase()));
2430
2592
  // The dump is untrusted input (#1143): its names reach SQL as identifiers, and
2431
2593
  // `db.exec` would run every statement its `ddl` contains, not just the CREATE
2432
2594
  // TABLE. Judged as a whole before any of it executes — a check interleaved with
@@ -2438,9 +2600,6 @@ export class SqliteScopeHost {
2438
2600
  // contributes only rows, by column name (`dumpRowsInsert`). Otherwise a dump could
2439
2601
  // declare a column the checker compares on as COLLATE NOCASE. The column pass follows,
2440
2602
  // for the one outbox index KERNEL_DDL leaves to it.
2441
- // A Durable Object's dump carries spine tables this adapter keeps in its directory, or does
2442
- // not keep at all; they are skipped by name, and any other unknown spine table is refused.
2443
- const loadable = replayable.filter((t) => !DO_SCOPE_ONLY_SPINE_TABLES.has(t.name.toLowerCase()));
2444
2603
  // Comment-blanked (#2068), as the Durable Object replays it: the dump's DDL is
2445
2604
  // `sqlite_master.sql` verbatim, and `prepare` still compiles exactly one statement.
2446
2605
  for (const t of loadable)
@@ -2531,6 +2690,8 @@ export class SqliteScopeHost {
2531
2690
  if (!existing)
2532
2691
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2533
2692
  this.refuseUnusableScopeStores(dump.stores, existing.vertical);
2693
+ // #2314: the load replaces the scope's rows, so the orphan sweep is fenced first.
2694
+ await this.fenceOrphanSweep(tenantId, scopeId, existing.vertical ?? null);
2534
2695
  // #1742: a dump from before a switch was pulled brings the module's grants back live, so
2535
2696
  // the directory's recorded-off modules go back off in the load's own turn, as a
2536
2697
  // provision's seat does — no schedule pass can run in between.
@@ -2733,6 +2894,13 @@ export class SqliteScopeHost {
2733
2894
  return undefined;
2734
2895
  return vertical;
2735
2896
  }
2897
+ /** The erasure claim (`SUBJECT_ERASURE_CLAIM_SQL`); a scope that moved refuses with the key kept. */
2898
+ claimSubjectErasure(tenantId, scopeId, fence) {
2899
+ const claimed = this.directory.prepare(SUBJECT_ERASURE_CLAIM_SQL).run(...subjectErasureClaimParams(tenantId, scopeId, fence));
2900
+ if (claimed.changes === 0) {
2901
+ throw substratError('precondition_failed', 'scope route or copy inventory changed during subject erasure; retry after it settles');
2902
+ }
2903
+ }
2736
2904
  /**
2737
2905
  * `substrat.erase` on each declared binding. A store error throws, so the caller
2738
2906
  * can leave the subject key untouched. Fail closed (#2242): an empty answer means
@@ -2757,7 +2925,7 @@ export class SqliteScopeHost {
2757
2925
  }
2758
2926
  /**
2759
2927
  * A dump's stores this host cannot load are refused before any write (#2211, L8): a
2760
- * binding that is not bound, a store with no kind or a kind other than the bound runner's,
2928
+ * binding that is not bound, a store with no driver or a driver other than the bound runner's,
2761
2929
  * any store at all for a destination with no vertical (no store object can be named for
2762
2930
  * it, so loading would drop them), or a destination on a vertical other than the one this
2763
2931
  * host is pinned to.
@@ -2777,11 +2945,11 @@ export class SqliteScopeHost {
2777
2945
  if (!runner) {
2778
2946
  throw substratError('not_found', `dump names scope store '${store.binding}', which is not bound`);
2779
2947
  }
2780
- if (!store.kind) {
2781
- throw substratError('validation_failed', `dump's scope store '${store.binding}' names no kind`);
2948
+ if (!store.driver) {
2949
+ throw substratError('validation_failed', `dump's scope store '${store.binding}' names no driver`);
2782
2950
  }
2783
- if (store.kind !== runner.kind) {
2784
- throw substratError('conflict', `dump's scope store '${store.binding}' is kind '${store.kind}', and this host binds kind '${runner.kind}'`);
2951
+ if (store.driver !== runner.driver) {
2952
+ throw substratError('conflict', `dump's scope store '${store.binding}' is driver '${store.driver}', and this host binds driver '${runner.driver}'`);
2785
2953
  }
2786
2954
  }
2787
2955
  }
@@ -2798,7 +2966,7 @@ export class SqliteScopeHost {
2798
2966
  if (!runner)
2799
2967
  throw substratError('not_found', `no scope store is bound for '${binding}'`);
2800
2968
  const exported = await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.export');
2801
- stores.push({ binding, kind: runner.kind, tables: exported.tables });
2969
+ stores.push({ binding, driver: runner.driver, tables: exported.tables });
2802
2970
  }
2803
2971
  return stores;
2804
2972
  }
@@ -3385,10 +3553,15 @@ export class SqliteScopeHost {
3385
3553
  const store = this.attachmentStore(tenantId, scopeRow?.vertical ?? null);
3386
3554
  return this.buildAttachments(rt, { kind: 'capability-session', hash }, store);
3387
3555
  }
3388
- /** Every registered module's `peers`, as one declaration per peer vertical (#1706). */
3556
+ /**
3557
+ * Every registered module's `peers`, as one declaration per peer vertical (#1706). Built once
3558
+ * per registered module set and dropped by `registerModule` (#2263): the checker reads it on
3559
+ * every check, so it must be a lookup, never a rebuild.
3560
+ */
3389
3561
  peerDeclarations() {
3390
- return collectPeers(this.modules.values());
3562
+ return (this.peerUnion ??= collectPeers(this.modules.values()));
3391
3563
  }
3564
+ peerUnion;
3392
3565
  /**
3393
3566
  * The peer door (#1706) — mirror of the connection, system and capability doors. The caller is
3394
3567
  * the local broker's word (the platform's, on the pure host); the pair and lifecycle gate run
@@ -3762,7 +3935,8 @@ export class SqliteScopeHost {
3762
3935
  try {
3763
3936
  if (schedule.purge) {
3764
3937
  // #119: a purge horizon's schedule runs its operation once per due entity, never once.
3765
- const pass = await this.runPurgeSweep(moduleId, tenantId, scopeId, schedule.operation);
3938
+ // #2192: under the run's own id, so the purges and their deliveries join this run's line.
3939
+ const pass = await this.runPurgeSweep(moduleId, tenantId, scopeId, schedule.operation, invocationId);
3766
3940
  stillDue = purgeStillDue(pass);
3767
3941
  const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
3768
3942
  report.errors.push(...outcome.errors);
@@ -3833,7 +4007,9 @@ export class SqliteScopeHost {
3833
4007
  * through a purge stub (`openSystemScope`) that never leaves this method, each in its own
3834
4008
  * transaction and under the cutoff the host re-checks inside it (`refuseTrashedTarget`).
3835
4009
  */
3836
- async runPurgeSweep(moduleId, tenantId, scopeId, operation) {
4010
+ async runPurgeSweep(moduleId, tenantId, scopeId, operation,
4011
+ /** #2192: the schedule run this pass belongs to; every purge in it runs under that id. */
4012
+ runInvocationId) {
3837
4013
  const entityType = this.modules
3838
4014
  .get(moduleId)
3839
4015
  ?.schedules.find((s) => s.operation === operation && s.purge)?.purge?.entityType;
@@ -3860,7 +4036,7 @@ export class SqliteScopeHost {
3860
4036
  return heldPurgePass(due.held);
3861
4037
  const stub = await this.openSystemScope(moduleId, tenantId, scopeId, true);
3862
4038
  return runPurgePass(operation, due, async (entityId) => {
3863
- await stub.invoke(operation, { [due.idFrom]: entityId }, { invocationId: ulid() });
4039
+ await stub.invoke(operation, { [due.idFrom]: entityId }, { invocationId: runInvocationId });
3864
4040
  }, (write) => rt.actor.turn(() => write(spineSql(rt.db))));
3865
4041
  }
3866
4042
  /**
@@ -5460,10 +5636,15 @@ export class SqliteScopeHost {
5460
5636
  calls: callsOfManifestJson(r.manifest_json),
5461
5637
  createdAt: r.created_at,
5462
5638
  });
5463
- const readVersion = (id) => {
5464
- const r = this.directory
5465
- .prepare('SELECT * FROM vertical_versions WHERE id = ?')
5466
- .get(id);
5639
+ // #2339: a version stored before the server derived migration digests is derived on its
5640
+ // first read, so no gate here ever compares the digest its push sent.
5641
+ const readVersion = async (id) => {
5642
+ const read = () => this.directory.prepare('SELECT * FROM vertical_versions WHERE id = ?').get(id);
5643
+ let r = read();
5644
+ if (r && r.migration_digest_derived === null) {
5645
+ await deriveVersionMigrationDigest(switchSqlOf(this.directory), id);
5646
+ r = read();
5647
+ }
5467
5648
  return r ? mapVersion(r) : undefined;
5468
5649
  };
5469
5650
  const mapOrg = (r) => orgSchema.parse({
@@ -5529,8 +5710,8 @@ export class SqliteScopeHost {
5529
5710
  });
5530
5711
  // The (version, scope) pair a bind and its impact read both start from, and the refusals
5531
5712
  // that come before any export-break question, in the order the bind makes them (#1756).
5532
- const bindTarget = (tenantId, scopeId, versionId) => {
5533
- const v = readVersion(versionId);
5713
+ const bindTarget = async (tenantId, scopeId, versionId) => {
5714
+ const v = await readVersion(versionId);
5534
5715
  if (!v)
5535
5716
  throw substratError('not_found', `unknown version ${versionId}`);
5536
5717
  const scope = this.directory.prepare('SELECT * FROM scopes WHERE scope_id = ?').get(scopeId);
@@ -5911,7 +6092,7 @@ export class SqliteScopeHost {
5911
6092
  throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5912
6093
  }
5913
6094
  const rt = this.runtime(tenantId, scopeId);
5914
- const states = await rt.actor.turn(() => peerGrantsStatus(switchSqlOf(rt.db), new Date().toISOString()));
6095
+ const states = await rt.actor.turn(() => peerGrantsStatus(switchSqlOf(rt.db), new Date().toISOString(), this.peerDeclarations()));
5915
6096
  const offPeers = new Set(states.filter((p) => p.calls === 'off').map((p) => p.vertical));
5916
6097
  const explanations = offPeers.size > 0 ? lastSwitchedOffBy(tenantId, scopeId, 'revokeFromPeer', 'vertical', offPeers) : new Map();
5917
6098
  const result = states.map((p) => ({
@@ -6331,14 +6512,16 @@ export class SqliteScopeHost {
6331
6512
  }
6332
6513
  this.recordAdmin(actor, 'setHostnameIssuance', { tenantId: row.tenant_id, scopeId: row.scope_id }, { status: row.status }, { status: fields.status, note: fields.note ?? null });
6333
6514
  },
6334
- unbindHostname: async (actor, raw) => {
6515
+ unbindHostname: async (actor, raw, owner) => {
6335
6516
  const hostname = raw.toLowerCase(); // DNS is case-insensitive; the map is normalized
6517
+ // One statement: the row it removes is the row it audits, and an owner is judged by the
6518
+ // delete itself, so a name rebound to another scope in between is left alone (#2285).
6336
6519
  const row = this.directory
6337
- .prepare('SELECT tenant_id, scope_id, status FROM hostnames WHERE hostname = ?')
6338
- .get(hostname);
6520
+ .prepare(`DELETE FROM hostnames WHERE hostname = ?${owner ? ' AND tenant_id = ? AND scope_id = ?' : ''}
6521
+ RETURNING tenant_id, scope_id, status`)
6522
+ .get(hostname, ...(owner ? [owner.tenantId, owner.scopeId] : []));
6339
6523
  if (!row)
6340
6524
  return; // idempotent, and a no-op is not audited
6341
- this.directory.prepare('DELETE FROM hostnames WHERE hostname = ?').run(hostname);
6342
6525
  this.recordAdmin(actor, 'unbindHostname', { tenantId: row.tenant_id, scopeId: row.scope_id }, { hostname, status: row.status }, null);
6343
6526
  },
6344
6527
  listHostnames: async (actor, filter) => {
@@ -6565,34 +6748,54 @@ export class SqliteScopeHost {
6565
6748
  // The SQL migrations are stored apart from the manifest (#1764), so no read of the
6566
6749
  // version but the promote review's carries them.
6567
6750
  const split = splitManifestMigrations(manifestJson ?? null, 'push');
6751
+ // #2339: the digest a promote compares is the one derived from what is stored, never the pushed one.
6752
+ const migrationDigest = await publishedMigrationDigest({
6753
+ id: audited.id, migrationDigest: audited.migrationDigest, manifestJson: manifestJson ?? null, migrations: split.migrations,
6754
+ });
6568
6755
  this.directory.transaction(() => {
6569
6756
  this.directory
6570
6757
  .prepare(`INSERT INTO vertical_versions
6571
6758
  (id, vertical_slug, version, manifest_digest, permission_digest,
6572
6759
  migration_digest, deployment_ref, admission, admission_note, manifest_json,
6573
- origin_json, migration_count, migrations_split, created_at)
6574
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`)
6575
- .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());
6760
+ origin_json, migration_count, migrations_split, migration_digest_derived, created_at)
6761
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?)`)
6762
+ .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());
6576
6763
  writeVersionMigrations(switchSqlOf(this.directory), audited.id, split.migrations);
6764
+ // #2286: the version's scope-store bindings, refusing one moved to another driver, in the insert's transaction.
6765
+ indexVersionScopeStores(switchSqlOf(this.directory), audited.verticalSlug, audited.id, split.manifestJson);
6577
6766
  })();
6578
6767
  this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, {
6579
6768
  ...audited,
6769
+ // The digest the registry holds, which differs from the pushed one only for an unheld version.
6770
+ migrationDigest,
6580
6771
  ...(origin ? { origin } : {}),
6581
6772
  admission: selfAdmits ? 'admitted' : 'pending',
6582
6773
  });
6583
6774
  },
6775
+ verticalScopeStores: async (actor, verticalSlug) => {
6776
+ // One transaction: versions not indexed yet are indexed by the read (#2286).
6777
+ const stores = this.directory.transaction(() => verticalScopeStoresOf(switchSqlOf(this.directory), verticalSlug))();
6778
+ this.recordAccess(actor, 'verticalScopeStores', {}, { verticalSlug }, stores.length);
6779
+ return stores;
6780
+ },
6584
6781
  listVersions: async (actor, verticalSlug, page) => {
6585
6782
  const where = ['vertical_slug = ?'];
6586
6783
  const params = [verticalSlug];
6587
6784
  const tail = keysetTail(where, params, 'id', page);
6588
- const rows = this.directory
6589
- .prepare(`SELECT * FROM vertical_versions WHERE ${where.join(' AND ')}${tail}`)
6590
- .all(...params);
6785
+ const sql = `SELECT * FROM vertical_versions WHERE ${where.join(' AND ')}${tail}`;
6786
+ let rows = this.directory.prepare(sql).all(...params);
6787
+ // #2339: a page holding versions whose digest is still the pushed one derives them first.
6788
+ const underived = rows.filter((r) => r.migration_digest_derived === null);
6789
+ if (underived.length > 0) {
6790
+ for (const r of underived)
6791
+ await deriveVersionMigrationDigest(switchSqlOf(this.directory), r.id);
6792
+ rows = this.directory.prepare(sql).all(...params);
6793
+ }
6591
6794
  this.recordAccess(actor, 'listVersions', {}, { verticalSlug }, rows.length);
6592
6795
  return rows.map(mapVersion);
6593
6796
  },
6594
6797
  getVersion: async (actor, versionId, verticalSlug) => {
6595
- const v = readVersion(versionId);
6798
+ const v = await readVersion(versionId);
6596
6799
  // A version of another vertical reads as absent when the caller named one.
6597
6800
  const hit = v && (verticalSlug === undefined || v.verticalSlug === verticalSlug) ? v : undefined;
6598
6801
  this.recordAccess(actor, 'getVersion', {}, { versionId, verticalSlug }, hit ? 1 : 0);
@@ -6609,7 +6812,7 @@ export class SqliteScopeHost {
6609
6812
  const prod = this.directory
6610
6813
  .prepare("SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = 'prod'")
6611
6814
  .get(slug);
6612
- const prodVersion = prod ? readVersion(prod.version_id) : undefined;
6815
+ const prodVersion = prod ? await readVersion(prod.version_id) : undefined;
6613
6816
  if (prodVersion?.admissionNote === AUTO_ADMISSION_NOTE) {
6614
6817
  throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
6615
6818
  `a staff admit must vouch for it before listing`);
@@ -6684,12 +6887,13 @@ export class SqliteScopeHost {
6684
6887
  .prepare(`DELETE FROM vertical_version_migrations
6685
6888
  WHERE version_id IN (SELECT id FROM vertical_versions WHERE vertical_slug = ?)`)
6686
6889
  .run(slug);
6890
+ forgetVerticalScopeStores(switchSqlOf(this.directory), slug);
6687
6891
  this.directory.prepare('DELETE FROM vertical_versions WHERE vertical_slug = ?').run(slug);
6688
6892
  this.directory.prepare('DELETE FROM verticals WHERE slug = ?').run(slug);
6689
6893
  this.recordAdmin(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.ownerTenant }, null);
6690
6894
  },
6691
6895
  admitVersion: async (actor, versionId) => {
6692
- const v = readVersion(versionId);
6896
+ const v = await readVersion(versionId);
6693
6897
  if (!v)
6694
6898
  throw substratError('not_found', `unknown version ${versionId}`);
6695
6899
  if (v.admission === 'admitted') {
@@ -6717,7 +6921,7 @@ export class SqliteScopeHost {
6717
6921
  });
6718
6922
  },
6719
6923
  rejectVersion: async (actor, versionId, note) => {
6720
- const v = readVersion(versionId);
6924
+ const v = await readVersion(versionId);
6721
6925
  if (!v)
6722
6926
  throw substratError('not_found', `unknown version ${versionId}`);
6723
6927
  if (v.admission === 'admitted') {
@@ -6734,7 +6938,7 @@ export class SqliteScopeHost {
6734
6938
  });
6735
6939
  },
6736
6940
  promotionImpact: async (actor, verticalSlug, channel, versionId) => {
6737
- const incoming = readVersion(versionId);
6941
+ const incoming = await readVersion(versionId);
6738
6942
  if (!incoming || incoming.verticalSlug !== verticalSlug) {
6739
6943
  throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
6740
6944
  }
@@ -6746,7 +6950,7 @@ export class SqliteScopeHost {
6746
6950
  return breaks;
6747
6951
  },
6748
6952
  promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
6749
- const incoming = readVersion(versionId);
6953
+ const incoming = await readVersion(versionId);
6750
6954
  if (!incoming)
6751
6955
  throw substratError('not_found', `unknown version ${versionId}`);
6752
6956
  if (incoming.verticalSlug !== verticalSlug) {
@@ -6758,7 +6962,7 @@ export class SqliteScopeHost {
6758
6962
  const current = this.directory
6759
6963
  .prepare('SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = ?')
6760
6964
  .get(verticalSlug, channel);
6761
- const outgoing = current ? readVersion(current.version_id) : undefined;
6965
+ const outgoing = current ? await readVersion(current.version_id) : undefined;
6762
6966
  const ack = promotionAcknowledgement.parse(acknowledge ?? {});
6763
6967
  // The owning tenant's live scopes a PRIVATE prod promote re-points — below for an embedded
6764
6968
  // vertical, after the in-place serve for a dispatch-backed one (#321) — read before
@@ -6781,6 +6985,18 @@ export class SqliteScopeHost {
6781
6985
  served?.versionId,
6782
6986
  ...owned.map((s) => runningVersionOf(mapScope(s), served)),
6783
6987
  ], versionId, served || owned.length > 0 ? this.provisionedVersionsOf(verticalSlug, served, owned.length > 0 ? owning.ownerTenant : null) : []);
6988
+ // #2333: the acknowledgements answer the review they were given against, or none at all.
6989
+ const reviewed = (v) => ({
6990
+ ...v,
6991
+ migrations: versionMigrationsOf(switchSqlOf(this.directory), v.id)?.migrations ?? null,
6992
+ });
6993
+ await assertPromoteReviewCurrent({
6994
+ ack,
6995
+ channel,
6996
+ baseId: outgoing?.id ?? null,
6997
+ migrationsChange: outgoing !== undefined && outgoing.migrationDigest !== incoming.migrationDigest,
6998
+ read: async () => ({ base: outgoing ? reviewed(outgoing) : null, incoming: reviewed(incoming) }),
6999
+ });
6784
7000
  // §4's two checkpoints, fired where the blast radius is. A FIRST promotion
6785
7001
  // has nothing to diff against, so there is nothing to acknowledge — the
6786
7002
  // gate is about change, not about existence.
@@ -6790,8 +7006,7 @@ export class SqliteScopeHost {
6790
7006
  `${incoming.permissionDigest}) — acknowledge it explicitly to promote`);
6791
7007
  }
6792
7008
  if (outgoing.migrationDigest !== incoming.migrationDigest && !ack.migrationChange) {
6793
- throw substratError('conflict', `promotion changes migrations (${outgoing.migrationDigest} → ` +
6794
- `${incoming.migrationDigest}) — acknowledge it explicitly to promote`);
7009
+ throw substratError('conflict', migrationChangeRefusal(outgoing.migrationDigest, incoming.migrationDigest));
6795
7010
  }
6796
7011
  // #1705 PR 3: an export an installed consumer imports, dropped or re-versioned.
6797
7012
  if (!ack.exportBreak) {
@@ -6801,18 +7016,32 @@ export class SqliteScopeHost {
6801
7016
  }
6802
7017
  }
6803
7018
  const promotedAt = new Date().toISOString();
6804
- this.directory
6805
- .prepare(`INSERT INTO vertical_channels (vertical_slug, channel, version_id, updated_at)
6806
- VALUES (?, ?, ?, ?)
6807
- ON CONFLICT (vertical_slug, channel) DO UPDATE SET version_id = ?, updated_at = ?`)
6808
- .run(verticalSlug, channel, versionId, promotedAt, versionId, promotedAt);
6809
- // The timeline row: what makes rollback a choice among recorded moments, and
6810
- // `at` the PITR anchor a data rollback would rewind to.
6811
- this.directory
6812
- .prepare(`INSERT INTO vertical_channel_history
6813
- (id, vertical_slug, channel, version_id, from_version_id, actor, at)
6814
- VALUES (?, ?, ?, ?, ?, ?, ?)`)
6815
- .run(ulid(), verticalSlug, channel, versionId, outgoing?.id ?? null, actor, promotedAt);
7019
+ // #2333: a compare-and-set. Every gate above — the review binding, the digest checkpoints,
7020
+ // the export breaks — was judged against `outgoing`, across awaits another promote can run
7021
+ // in. So the pointer moves only if it still names `outgoing`, read and written in one
7022
+ // transaction; the promote that lost the race is refused as stale and writes nothing.
7023
+ const moved = this.directory.transaction(() => {
7024
+ const now = this.directory
7025
+ .prepare('SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = ?')
7026
+ .get(verticalSlug, channel);
7027
+ if ((now?.version_id ?? null) !== (outgoing?.id ?? null))
7028
+ return false;
7029
+ this.directory
7030
+ .prepare(`INSERT INTO vertical_channels (vertical_slug, channel, version_id, updated_at)
7031
+ VALUES (?, ?, ?, ?)
7032
+ ON CONFLICT (vertical_slug, channel) DO UPDATE SET version_id = ?, updated_at = ?`)
7033
+ .run(verticalSlug, channel, versionId, promotedAt, versionId, promotedAt);
7034
+ // The timeline row: what makes rollback a choice among recorded moments, and
7035
+ // `at` the PITR anchor a data rollback would rewind to.
7036
+ this.directory
7037
+ .prepare(`INSERT INTO vertical_channel_history
7038
+ (id, vertical_slug, channel, version_id, from_version_id, actor, at)
7039
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
7040
+ .run(ulid(), verticalSlug, channel, versionId, outgoing?.id ?? null, actor, promotedAt);
7041
+ return true;
7042
+ })();
7043
+ if (!moved)
7044
+ throw promoteRaceRefusal(channel);
6816
7045
  // For a PRIVATE vertical, prod IS what the owner's apps run: re-point the
6817
7046
  // owning tenant's live scopes in the same act, so merge-to-main (push +
6818
7047
  // promote) is a complete deploy and a rollback promote reaches the running
@@ -6832,7 +7061,7 @@ export class SqliteScopeHost {
6832
7061
  for (const s of incoming.deploymentRef ? [] : owned) {
6833
7062
  if (s.vertical_version_id === versionId)
6834
7063
  continue;
6835
- const prev = s.vertical_version_id ? readVersion(s.vertical_version_id) : undefined;
7064
+ const prev = s.vertical_version_id ? await readVersion(s.vertical_version_id) : undefined;
6836
7065
  if (prev && prev.migrationDigest !== incoming.migrationDigest) {
6837
7066
  await this.snapshotScope(actor, s.tenant_id, s.scope_id);
6838
7067
  }
@@ -6894,7 +7123,7 @@ export class SqliteScopeHost {
6894
7123
  }));
6895
7124
  },
6896
7125
  bindingImpact: async (actor, tenantId, scopeId, versionId, opts) => {
6897
- const { v, scope } = bindTarget(tenantId, scopeId, versionId);
7126
+ const { v, scope } = await bindTarget(tenantId, scopeId, versionId);
6898
7127
  // #2178: a move no acknowledgement passes is refused here too, so a caller asking first
6899
7128
  // (an adopt, an Update) stops before anything is copied.
6900
7129
  this.assertBindingShapesKept(mapScope(scope), versionId, opts?.servingRef);
@@ -6903,22 +7132,30 @@ export class SqliteScopeHost {
6903
7132
  return breaks;
6904
7133
  },
6905
7134
  bindScopeVersion: async (actor, tenantId, scopeId, versionId, opts) => {
6906
- const { v, scope } = bindTarget(tenantId, scopeId, versionId);
6907
- if (opts?.expectedVersionId !== undefined && scope.vertical_version_id !== opts.expectedVersionId) {
6908
- throw substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
6909
- }
7135
+ const { v, scope } = await bindTarget(tenantId, scopeId, versionId);
7136
+ // #2347: a rebind judged against both lineages loses its race as stale, so its client reads
7137
+ // the review again; any other conditional bind keeps the refusal it always had.
7138
+ const lost = () => opts?.expectedServing
7139
+ ? rebindRaceRefusal(opts.expectedServing.vertical)
7140
+ : substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
7141
+ if (opts?.expectedVersionId !== undefined && scope.vertical_version_id !== opts.expectedVersionId)
7142
+ throw lost();
7143
+ if (opts?.expectedServing && !this.servesNow(opts.expectedServing))
7144
+ throw lost();
6910
7145
  const epoch = this.directory.prepare('SELECT COALESCE(erasure_epoch, 0) AS erasure_epoch FROM scopes WHERE tenant_id = ? AND scope_id = ?')
6911
7146
  .get(tenantId, scopeId);
6912
7147
  if (opts?.expectedErasureEpoch !== undefined && epoch?.erasure_epoch !== opts.expectedErasureEpoch) {
6913
7148
  throw substratError('precondition_failed', 'scope erasure changed; reload the scope and retry');
6914
7149
  }
6915
7150
  const ack = bindAcknowledgement.parse(opts?.acknowledge ?? {});
7151
+ // #2347: a route moving in the same write is judged with it, as `bindingImpact` is.
7152
+ const servingRef = opts?.servingRef;
6916
7153
  // #2178 first: no acknowledgement passes it, so none should be asked for ahead of it.
6917
- this.assertBindingShapesKept(mapScope(scope), versionId);
7154
+ this.assertBindingShapesKept(mapScope(scope), versionId, servingRef);
6918
7155
  // #1756: an export an app in this tenant imports, dropped or re-versioned by what this
6919
7156
  // scope would run. Before the snapshot, so a refused bind leaves nothing behind.
6920
7157
  if (!ack.exportBreak) {
6921
- const breaks = await this.bindBreaks(actor, mapScope(scope), v);
7158
+ const breaks = await this.bindBreaks(actor, mapScope(scope), v, servingRef);
6922
7159
  if (breaks.length > 0)
6923
7160
  throw substratError('precondition_failed', bindExportBreakRefusal(breaks));
6924
7161
  }
@@ -6926,19 +7163,29 @@ export class SqliteScopeHost {
6926
7163
  // crosses a migration boundary. Gated on a real digest change — a code-only
6927
7164
  // rebind snapshots nothing — and on the caller opting in (until GC ships).
6928
7165
  if (opts?.snapshot && scope.vertical_version_id) {
6929
- const outgoing = readVersion(scope.vertical_version_id);
7166
+ const outgoing = await readVersion(scope.vertical_version_id);
6930
7167
  if (outgoing && outgoing.migrationDigest !== v.migrationDigest) {
6931
7168
  await this.snapshotScope(actor, tenantId, scopeId);
6932
7169
  }
6933
7170
  }
6934
- if (!this.moveScopeRow('vertical_version_id = ?, vertical = ?', [versionId, v.verticalSlug], tenantId, scopeId, opts)) {
6935
- throw substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
6936
- }
6937
- this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, null, {
7171
+ const moved = servingRef === undefined
7172
+ ? this.moveScopeRow('vertical_version_id = ?, vertical = ?', [versionId, v.verticalSlug], tenantId, scopeId, opts)
7173
+ : this.moveScopeRow('vertical_version_id = ?, vertical = ?, serving_ref = ?', [versionId, v.verticalSlug, servingRef], tenantId, scopeId, opts);
7174
+ if (!moved) {
7175
+ // The erasure and copy-move conditions keep their own refusal: only a moved binding or a
7176
+ // moved serving version is the race a rebind's review answers.
7177
+ const now = this.directory.prepare('SELECT vertical_version_id FROM scopes WHERE scope_id = ?').get(scopeId);
7178
+ const raced = (opts?.expectedVersionId !== undefined && now?.vertical_version_id !== opts.expectedVersionId)
7179
+ || (opts?.expectedServing !== undefined && !this.servesNow(opts.expectedServing));
7180
+ throw raced ? lost() : substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
7181
+ }
7182
+ this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, servingRef === undefined ? null : { servingRef: scope.serving_ref }, {
6938
7183
  versionId,
6939
7184
  vertical: v.verticalSlug,
6940
7185
  version: v.version,
7186
+ ...(servingRef !== undefined ? { servingRef } : {}),
6941
7187
  ...(opts?.expectedVersionId !== undefined ? { expectedVersionId: opts.expectedVersionId } : {}),
7188
+ ...(opts?.expectedServing ? { expectedServing: opts.expectedServing } : {}),
6942
7189
  ...(ack.exportBreak ? { acknowledged: ack } : {}),
6943
7190
  });
6944
7191
  },
@@ -7123,7 +7370,7 @@ export class SqliteScopeHost {
7123
7370
  const ack = bindAcknowledgement.parse(opts?.acknowledge ?? {});
7124
7371
  // #1756: onto (or off) a serving script, the scope runs other code before its pointer
7125
7372
  // moves. Judged here, so no caller that routes first and binds second passes unasked.
7126
- const bound = scope.vertical_version_id ? readVersion(scope.vertical_version_id) : undefined;
7373
+ const bound = scope.vertical_version_id ? await readVersion(scope.vertical_version_id) : undefined;
7127
7374
  if (bound)
7128
7375
  this.assertBindingShapesKept(mapScope(scope), bound.id, servingRef);
7129
7376
  if (!ack.exportBreak && bound) {
@@ -7700,6 +7947,74 @@ export class SqliteScopeHost {
7700
7947
  createdAt: r.created_at,
7701
7948
  }));
7702
7949
  },
7950
+ // -- store-driver releases, rollouts and holds (D-63, #2300) -------------------
7951
+ // The state is kernel code over the directory (store-driver-releases.ts), shared with the
7952
+ // Cloudflare control plane; each call is one transaction. A hold's expiry is judged against
7953
+ // this host's clock, so a test can let a lease lapse without waiting.
7954
+ recordStoreDriverRelease: async (actor, input) => {
7955
+ const at = this.clock();
7956
+ const { record, created } = this.directory.transaction(() => recordStoreDriverReleaseIn(switchSqlOf(this.directory), { ...input, actor, at }))();
7957
+ if (created)
7958
+ this.recordAdmin(actor, 'uploadStoreDriverRelease', { tenantId: null }, null, record, at);
7959
+ return record;
7960
+ },
7961
+ getStoreDriverRelease: async (actor, driver, sha256) => {
7962
+ const record = storeDriverReleaseIn(switchSqlOf(this.directory), driver, sha256);
7963
+ this.recordAccess(actor, 'getStoreDriverRelease', { tenantId: null }, { driver, sha256 }, record ? 1 : 0);
7964
+ return record;
7965
+ },
7966
+ storeDriverState: async (actor, driver) => {
7967
+ const state = storeDriverStateIn(switchSqlOf(this.directory), driver, Date.parse(this.clock()));
7968
+ this.recordAccess(actor, 'storeDriverState', { tenantId: null }, { driver }, state.rollouts.length);
7969
+ return state;
7970
+ },
7971
+ storeDriverDeclarations: async (actor, driver) => {
7972
+ const declared = this.directory.transaction(() => driverDeclarationsOf(switchSqlOf(this.directory), driver))();
7973
+ this.recordAccess(actor, 'storeDriverDeclarations', { tenantId: null }, { driver }, declared.verticals.length);
7974
+ return declared;
7975
+ },
7976
+ storeDriverRolloutPending: async (actor, input) => {
7977
+ const page = this.directory.transaction(() => storeDriverRolloutPendingIn(switchSqlOf(this.directory), input))();
7978
+ this.recordAccess(actor, 'storeDriverRolloutPending', { tenantId: null }, { driver: input.driver, sha256: input.sha256, action: input.action }, page.verticals.length);
7979
+ return page;
7980
+ },
7981
+ storeDriverRolloutWalked: async (actor, input) => {
7982
+ const walked = this.directory.transaction(() => storeDriverRolloutWalkedIn(switchSqlOf(this.directory), input))();
7983
+ this.recordAccess(actor, 'storeDriverRolloutWalked', { tenantId: null }, { driver: input.driver, seq: input.seq }, walked.verticals.length);
7984
+ return walked;
7985
+ },
7986
+ holdStoreDriver: async (_actor, input) => this.directory.transaction(() => holdStoreDriverIn(switchSqlOf(this.directory), { ...input, nowMs: Date.parse(this.clock()) }))(),
7987
+ releaseStoreDriverHold: async (_actor, input) => {
7988
+ releaseStoreDriverHoldIn(switchSqlOf(this.directory), input);
7989
+ },
7990
+ beginStoreDriverRollout: async (actor, input) => {
7991
+ const at = this.clock();
7992
+ const row = this.directory.transaction(() => beginStoreDriverRolloutIn(switchSqlOf(this.directory), { ...input, actor, at, nowMs: Date.parse(at) }))();
7993
+ this.recordAdmin(actor, input.action === 'rollback' ? 'rollBackStoreDriver' : 'rollOutStoreDriver', { tenantId: null }, null, row, at);
7994
+ return row;
7995
+ },
7996
+ finishStoreDriverRollout: async (actor, input) => {
7997
+ const at = this.clock();
7998
+ const row = this.directory.transaction(() => finishStoreDriverRolloutIn(switchSqlOf(this.directory), { ...input, at, nowMs: Date.parse(at) }))();
7999
+ this.recordAdmin(actor, row.action === 'rollback' ? 'rollBackStoreDriver' : 'rollOutStoreDriver', { tenantId: null }, null, row, at);
8000
+ return row;
8001
+ },
8002
+ storeDriverRebuild: async (actor, driver) => {
8003
+ const rebuild = storeDriverRebuildIn(switchSqlOf(this.directory), driver);
8004
+ this.recordAccess(actor, 'storeDriverRebuild', { tenantId: null }, { driver }, rebuild ? 1 : 0);
8005
+ return rebuild;
8006
+ },
8007
+ claimStoreDriverRebuild: async (_actor, input) => {
8008
+ const at = this.clock();
8009
+ return this.directory.transaction(() => claimStoreDriverRebuildIn(switchSqlOf(this.directory), { ...input, at, nowMs: Date.parse(at) }))();
8010
+ },
8011
+ advanceStoreDriverRebuild: async (_actor, input) => {
8012
+ const at = this.clock();
8013
+ return this.directory.transaction(() => advanceStoreDriverRebuildIn(switchSqlOf(this.directory), { ...input, at, nowMs: Date.parse(at) }))();
8014
+ },
8015
+ recordStoreDriverRebuildRun: async (actor, entry) => {
8016
+ this.recordAdmin(actor, 'rebuildStoreDriver', { tenantId: null }, null, entry, this.clock());
8017
+ },
7703
8018
  getScopeRecord: async (actor, tenantId, scopeId) => {
7704
8019
  const r = this.directory.prepare('SELECT * FROM scopes WHERE scope_id = ?').get(scopeId);
7705
8020
  // Cross-check the pair (K-3): a scope that exists under a DIFFERENT tenant
@@ -8021,13 +8336,17 @@ export class SqliteScopeHost {
8021
8336
  this.recordAccess(actor, 'listShreddedSubjects', { tenantId, scopeId }, null, subjects.length);
8022
8337
  return { subjects, nextCursor };
8023
8338
  },
8024
- shredSubject: async (actor, tenantId, scopeId, subjectId, stamp, storeErasures) => {
8339
+ shredSubject: async (actor, tenantId, scopeId, subjectId, fence, stamp, storeErasures) => {
8025
8340
  this.assertScope(tenantId, scopeId);
8026
8341
  const before = stamp ?? {
8027
8342
  erasureEpoch: this.directory.prepare(COPY_BACKFILL_SCOPE_SQL).get(tenantId, scopeId)
8028
8343
  ?.erasure_epoch ?? 0,
8029
8344
  path: 'direct',
8030
8345
  };
8346
+ // #2285: a direct shred with a fence claims what it read, as the finalize does; the epoch
8347
+ // it records is the one its claim compared.
8348
+ if (fence)
8349
+ before.erasureEpoch = fence.epoch;
8031
8350
  // Redact the live spine FIRST. Both halves are idempotent and a crash between them
8032
8351
  // converges on retry, so the order is decided by which half-done state harms the
8033
8352
  // person: dying after this leaves ciphertext in a backup that no key opens; dying
@@ -8101,6 +8420,8 @@ export class SqliteScopeHost {
8101
8420
  // An orchestrated caller passes the serving script's result and this host does
8102
8421
  // not erase again.
8103
8422
  const erasures = storeErasures ?? (await this.eraseScopeStores(tenantId, scopeId, subjectId));
8423
+ if (fence)
8424
+ this.claimSubjectErasure(tenantId, scopeId, fence);
8104
8425
  const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
8105
8426
  const receipt = subjectShredReceipt.parse({
8106
8427
  subjectId,
@@ -8132,20 +8453,11 @@ export class SqliteScopeHost {
8132
8453
  finalizeSubjectShred: async (actor, tenantId, scopeId, subjectId, redactions, expected, storeErasures) => {
8133
8454
  if (redactions.length === 0)
8134
8455
  throw substratError('conflict', 'subject erasure has no confirmed scope redaction');
8135
- const claimed = this.directory.prepare(`UPDATE scopes SET erasure_epoch = COALESCE(erasure_epoch, 0) + 1
8136
- WHERE tenant_id = ? AND scope_id = ? AND vertical_version_id IS ?
8137
- AND serving_ref IS ? AND COALESCE(erasure_epoch, 0) = ?
8138
- AND NOT EXISTS (SELECT 1 FROM scope_script_copies AS copy
8139
- WHERE copy.tenant_id = scopes.tenant_id AND copy.scope_id = scopes.scope_id
8140
- AND copy.state = 'pending')
8141
- AND (SELECT COUNT(*) FROM scope_script_copies AS copy
8142
- WHERE copy.tenant_id = scopes.tenant_id AND copy.scope_id = scopes.scope_id) = ?`).run(tenantId, scopeId, expected.versionId, expected.servingRef, expected.epoch, expected.copyCount);
8143
- if (claimed.changes === 0)
8144
- throw substratError('precondition_failed', 'scope route or copy inventory changed during subject erasure; retry after it settles');
8456
+ this.claimSubjectErasure(tenantId, scopeId, expected);
8145
8457
  // The pure adapter has one co-located scope store. Its existing atomic shred is
8146
8458
  // the final local redaction and key destruction, after remote copies confirmed.
8147
8459
  const shred = this.admin.shredSubject;
8148
- return shred(actor, tenantId, scopeId, subjectId, { erasureEpoch: expected.epoch, path: 'orchestrated' }, storeErasures);
8460
+ return shred(actor, tenantId, scopeId, subjectId, undefined, { erasureEpoch: expected.epoch, path: 'orchestrated' }, storeErasures);
8149
8461
  },
8150
8462
  // -- impersonation (K-42, #868) ----------------------------------------
8151
8463
  beginImpersonation: async (actor, input) => {
@@ -8781,6 +9093,10 @@ export class SqliteScopeHost {
8781
9093
  const { tenantId, scopeId, ...after } = memberChangeAudit.parse(entry);
8782
9094
  this.recordAdmin(actor, 'manageScopeMember', { tenantId, scopeId }, null, after);
8783
9095
  },
9096
+ recordConnectionBindingChange: async (actor, entry) => {
9097
+ const { tenantId, scopeId, ...after } = connectionBindingAudit.parse(entry);
9098
+ this.recordAdmin(actor, 'bindConnectionScope', { tenantId, scopeId }, null, after);
9099
+ },
8784
9100
  recordPeerBindingChange: async (actor, entry) => {
8785
9101
  const { tenantId, scopeId, ...after } = peerBindingAudit.parse(entry);
8786
9102
  this.recordAdmin(actor, 'setPeerBinding', { tenantId, scopeId }, null, after);
@@ -8790,6 +9106,11 @@ export class SqliteScopeHost {
8790
9106
  const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
8791
9107
  this.recordAdmin(actor, action === 'mark' ? 'markScopeCopy' : 'clearScopeCopyMark', { tenantId, scopeId }, null, after);
8792
9108
  },
9109
+ /** #2314: one orphan-attachment pass over a scope, written after the vertical's own pass. */
9110
+ recordOrphanSweep: async (actor, entry) => {
9111
+ const { tenantId, scopeId, ...after } = orphanSweepAudit.parse(entry);
9112
+ this.recordAdmin(actor, 'sweepOrphanAttachments', { tenantId, scopeId }, null, after);
9113
+ },
8793
9114
  accessLog: async (actor, filter) => {
8794
9115
  const where = [];
8795
9116
  const params = [];
@@ -9248,7 +9569,8 @@ export class SqliteScopeHost {
9248
9569
  const moved = this.directory.prepare(`UPDATE scopes SET ${set} WHERE scope_id = ?
9249
9570
  ${cond?.expectedVersionId === undefined ? '' : 'AND vertical_version_id IS ?'}
9250
9571
  ${cond?.expectedErasureEpoch === undefined ? '' : 'AND COALESCE(erasure_epoch, 0) = ? AND reap_claimed_at IS NULL'}
9251
- ${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)));
9572
+ ${cond?.confirmMove === undefined ? '' : COPY_MOVE_LIVE_PREDICATE}
9573
+ ${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)));
9252
9574
  if (moved.changes === 0)
9253
9575
  return false;
9254
9576
  if (cond?.confirmMove) {
@@ -9258,6 +9580,10 @@ export class SqliteScopeHost {
9258
9580
  return true;
9259
9581
  })();
9260
9582
  }
9583
+ /** #2347: whether a lineage's serving script still runs the version a rebind was judged against. */
9584
+ servesNow(expected) {
9585
+ return this.directory.prepare(`SELECT 1 FROM verticals WHERE ${SERVES_NOW_CONDITION}`).get(...servesNowParams(expected)) !== undefined;
9586
+ }
9261
9587
  ensureColumn(db, table, column, ddl) {
9262
9588
  // Without case, as SQLite resolves a column name: a restore may have added this column
9263
9589
  // already, untyped and spelled as a newer kernel's dump spelled it (#1883).
@@ -9947,6 +10273,18 @@ export class SqliteScopeHost {
9947
10273
  assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
9948
10274
  minted,
9949
10275
  }),
10276
+ // Upload links: mint / revoke / list, written once in the kernel — the same four things
10277
+ // `capabilities` is handed, and the same consumer-is-system-actor rule.
10278
+ attachments: createAttachmentVerbs({
10279
+ sql: spineSql(rt.db),
10280
+ subject: overrideActor ? { kind: 'system', id: overrideActor.system } : subject,
10281
+ now: at,
10282
+ check: runCheck,
10283
+ emit: (event) => writeEvent(event, 'kernel'),
10284
+ assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
10285
+ minted,
10286
+ targetOf: (entityType) => this.uploadTargetOf(entityType),
10287
+ }),
9950
10288
  // K-16 / #1864: link and relink, written once in the kernel over the raw spine seam.
9951
10289
  ...createEntityEdgeVerbs({
9952
10290
  sql: spineSql(rt.db),
@@ -10073,6 +10411,14 @@ export class SqliteScopeHost {
10073
10411
  await this.repairDerived(rt);
10074
10412
  return;
10075
10413
  }
10414
+ // #2349: before any SQL, the registered set is held to the one stored for this version.
10415
+ // Only here, so a scope with nothing pending never computes it.
10416
+ const unstored = await migrationSetDivergence(this.modules.values(), this.migrationsDigest);
10417
+ if (unstored) {
10418
+ const refused = { version: `${pending[0].moduleId}@${pending[0].migration.version}`, error: unstored };
10419
+ this.recordMigrationState(rt, refused);
10420
+ throw migrationFailedError(refused.version, refused.error);
10421
+ }
10076
10422
  // The failing `module@version` and its cause, captured structurally rather than
10077
10423
  // re-parsed out of the thrown message — the directory record has to name both.
10078
10424
  let failure;
@@ -10106,7 +10452,8 @@ export class SqliteScopeHost {
10106
10452
  assertMigrationLeavesLedgerAlone(migration.sql, `migration ${key}`);
10107
10453
  // #2068: one statement at a time, keeping the table set either side of each — the
10108
10454
  // same splitter the Durable Object runs, so both hosts execute identical statements.
10109
- const steps = runMigrationStatements(spineSql(rt.db), migration.sql, (stmt) => rt.db.exec(stmt));
10455
+ // #115: an authored one is also held to the data-loss fence, inside this transaction.
10456
+ const steps = runMigrationStatements(spineSql(rt.db), migration, (stmt) => rt.db.exec(stmt), { key, authored });
10110
10457
  assertTablesWithinColumnLimit(rt.db);
10111
10458
  // #2090: the state columns this migration must have left, and after the last of the
10112
10459
  // pass, the triggers and indexes a create-copy-rename rebuild dropped.
@@ -10386,6 +10733,9 @@ export class SqliteScopeHost {
10386
10733
  // #2009: the copy classification on a scope DB built before it (NULL reads as a copy; see
10387
10734
  // `COPY_ORIGIN_DDL`).
10388
10735
  this.ensureColumn(db, '_substrat_copy_origin', 'is_copy', 'is_copy INTEGER');
10736
+ // #2315: the hash of the key a redemption carried, on a scope DB built before it. NULL = the
10737
+ // spending request carried none, the honest reading of every link already spent.
10738
+ this.ensureColumn(db, '_substrat_upload_links', 'redeemed_key_hash', 'redeemed_key_hash TEXT');
10389
10739
  // #1237: `readInvocation`'s lookup — WHERE invocation_id = ? ORDER BY id — over an outbox
10390
10740
  // that is never pruned. No index leads with invocation_id, so without this one SQLite
10391
10741
  // walks the PRIMARY KEY from the oldest event until it reaches the call, and reading a