@substrat-run/adapter-sqlite 0.160.0 → 0.162.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,9 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync, writeSync } from 'node:fs';
2
+ import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, writeFileSync, writeSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import Database from 'better-sqlite3';
5
5
  import { accessLogEntry, adminLogEntry, delegatedReadParams, operationSeriesCount, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, createTenantInput, domainEvent, domainEventInput, exportReadInput, importBatch, importCursorMove, exportsOfManifestJson, assertNoBindingShapeBreak, importsOfManifestJson, connectionLandingsOfManifestJson, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, 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, deploymentWireAuth, 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';
6
- 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, assertPlatformRequestRoom, isUnreachableParent, heldPurgePass, purgeDueOf, purgeHeldBy, lifecycleRefusal, scopeNotHeldRefusal, purgeOnlyKeysOf, purgeReportOf, purgeStillDue, refuseTrashedTarget, assertBoundOperations, checkHeldOperationsOf, countChecks, noteUncheckedCompletion, 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, isSizedByteStream, knownLengthStream, putAttachmentBytes, entitlementDenial, requiredEntitlementFor, foldMeterReading, guardSpine, guardSqlLimits, DO_SQL_LIMITS, tooManyResultColumns, tooManyTableColumns, parseValidationRecords, readableScopes, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, ATTACHMENT_BYTES_SQL, SCOPE_STORAGE_DDL, forgetScopeStorage, listScopeAttachmentStorageRows, retireScopeStorage, listScopeStorageAttemptRows, listScopeStorageRows, listScopeStoreStorageRows, pruneScopeStorageRows, recordScopeStorageRows, REQUEST_ROLLUP_DDL, listRequestDayRows, listRequestRollupDayRows, pruneRequestDayRows, recordRequestDayRows, METER_DAYS_DDL, VERTICAL_OWNERS_BACKFILL, VERTICAL_OWNERS_DDL, closeVerticalOwnerRow, listVerticalOwnerRows, openVerticalOwnerRow, verticalOwnerNow, listMeterDayRows, listMeterDayTakeRows, pruneMeterDayRows, recordMeterDayRows, 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, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, reservedGrantRefusal, reservedRoleDefinitionRefusal, reservedRoleRefusal, reservedShapeRefusal, SCOPE_SEATED_ROLES_SQL, SEAT_HOLDING_SCOPE_STATUSES, TENANT_SEATED_ROLES_SQL, 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, DEPLOYMENT_WIRE_AUTH_DDL, assertWireAuthFilter, deploymentWireAuthOf, forgetVerticalWireAuth, wireAuthAccessTarget, writeDeploymentWireAuth, 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, fetchLikeOf, deadlineFetch, 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
+ 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, assertPlatformRequestRoom, isUnreachableParent, heldPurgePass, purgeDueOf, purgeHeldBy, lifecycleRefusal, scopeNotHeldRefusal, purgeOnlyKeysOf, purgeReportOf, purgeStillDue, refuseTrashedTarget, assertBoundOperations, checkHeldOperationsOf, countChecks, noteUncheckedCompletion, 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, isSizedByteStream, knownLengthStream, openAttachmentByteStream, putAttachmentBytes, entitlementDenial, requiredEntitlementFor, foldMeterReading, guardSpine, guardSqlLimits, DO_SQL_LIMITS, tooManyResultColumns, tooManyTableColumns, parseValidationRecords, readableScopes, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, ATTACHMENT_BYTES_SQL, SCOPE_STORAGE_DDL, forgetScopeStorage, listScopeAttachmentStorageRows, retireScopeStorage, listScopeStorageAttemptRows, listScopeStorageRows, listScopeStoreStorageRows, pruneScopeStorageRows, recordScopeStorageRows, REQUEST_ROLLUP_DDL, listRequestDayRows, listRequestRollupDayRows, pruneRequestDayRows, recordRequestDayRows, METER_DAYS_DDL, VERTICAL_OWNERS_BACKFILL, VERTICAL_OWNERS_DDL, closeVerticalOwnerRow, listVerticalOwnerRows, openVerticalOwnerRow, verticalOwnerNow, listMeterDayRows, listMeterDayTakeRows, pruneMeterDayRows, recordMeterDayRows, 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, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, dumpRoleRefusal, reservedGrantRefusal, reservedRoleDefinitionRefusal, reservedRoleRefusal, reservedShapeRefusal, SCOPE_SEATED_ROLES_SQL, SEAT_HOLDING_SCOPE_STATUSES, TENANT_SEATED_ROLES_SQL, 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, DEPLOYMENT_WIRE_AUTH_DDL, assertWireAuthFilter, deploymentWireAuthOf, forgetVerticalWireAuth, wireAuthAccessTarget, writeDeploymentWireAuth, 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, servingRaceRefusal, 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, fetchLikeOf, deadlineFetch, 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';
7
7
  import { attributedView, delegatedRevokeSql, writeExplicitTupleIn, grantEntityShapeIn, shapeTopUpBatch, topUpEntityGrantShapes, settlePlatformRequestIn, } from '@substrat-run/kernel';
8
8
  import { ADMIN_LOG_INDEXES_SQL, SETTLE_INTENT_SQL, SETTLE_OUTCOME_SQL, settleOutcomeParamsOf, readAuditedOperations, unknownOutcomeOf, } from '@substrat-run/kernel';
9
9
  import { INERT_SCOPE_REASON, isPrimaryScopeRow, normalizeUploadMediaType } from '@substrat-run/kernel';
@@ -69,6 +69,62 @@ async function writeStreamToFile(path, body) {
69
69
  closeSync(fd);
70
70
  }
71
71
  }
72
+ /**
73
+ * A dropped stream's file handle, closed when the stream is collected (#2514): a consumer that neither
74
+ * reads a stream to its end nor cancels it would otherwise hold the handle for the life of the process.
75
+ * Best effort, since collection has no deadline; reading or cancelling is what closes it promptly.
76
+ */
77
+ const unreadFiles = new FinalizationRegistry((handle) => handle.close());
78
+ /**
79
+ * The file at `path` as a stream read 64 KiB at a time as it is pulled (#2514), with its size, or
80
+ * `null` when there is no such file. The file is opened here, so a key removed after the open is still
81
+ * read whole from the open handle, and closed once the stream ends, errors or is cancelled, or (best
82
+ * effort) once a stream nobody finished is collected.
83
+ */
84
+ function fileStream(path) {
85
+ const CHUNK = 64 * 1024;
86
+ let fd;
87
+ try {
88
+ fd = openSync(path, 'r');
89
+ }
90
+ catch (err) {
91
+ // Removed between the read gate and here: the same answer as a key that was never there.
92
+ if (err.code === 'ENOENT')
93
+ return null;
94
+ throw err;
95
+ }
96
+ const size = fstatSync(fd).size;
97
+ let closed = false;
98
+ const handle = {
99
+ close: () => {
100
+ if (!closed)
101
+ closeSync(fd);
102
+ closed = true;
103
+ },
104
+ };
105
+ const close = handle.close;
106
+ const stream = new ReadableStream({
107
+ pull(controller) {
108
+ try {
109
+ const chunk = new Uint8Array(CHUNK);
110
+ const read = readSync(fd, chunk, 0, CHUNK, null);
111
+ if (read === 0) {
112
+ close();
113
+ controller.close();
114
+ return;
115
+ }
116
+ controller.enqueue(chunk.subarray(0, read));
117
+ }
118
+ catch (err) {
119
+ close();
120
+ controller.error(err);
121
+ }
122
+ },
123
+ cancel: close,
124
+ }, { highWaterMark: 0 });
125
+ unreadFiles.register(stream, handle);
126
+ return { stream, size };
127
+ }
72
128
  function kernelEmit(ctx, event) {
73
129
  const write = kernelEmitters.get(ctx);
74
130
  if (!write)
@@ -553,6 +609,89 @@ const PROCESS_HOST_DIRS = new Map();
553
609
  const databaseBytesOf = (db) => db.pragma('page_count', { simple: true }) * db.pragma('page_size', { simple: true });
554
610
  /** What a scope database's attachment rows sum to (#2501). */
555
611
  const attachmentBytesOf = (db) => db.prepare(ATTACHMENT_BYTES_SQL).get().bytes;
612
+ /**
613
+ * The introspection reads, over an already-resolved connection: the bodies behind both the
614
+ * directory-checked admin verbs and the `*Local` verbs a vertical's `/internal/*` routes call by
615
+ * scope id alone (#2544). How the connection was reached, and what is recorded, stay with the caller.
616
+ */
617
+ function scopeTablesOf(db) {
618
+ const names = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`).all().map((r) => r.name);
619
+ const count = (sql) => db.prepare(sql).get().c;
620
+ const tombstones = (name) => db.prepare(`SELECT name FROM pragma_table_info(?)`).all(name).some((c) => c.name === 'revoked_at');
621
+ return names.map((name) => ({
622
+ name,
623
+ rowCount: count(`SELECT COUNT(*) AS c FROM "${name}"`),
624
+ system: isSystemTable(name),
625
+ // #2531: rows whose `revoked_at` is null, for any table with that column (`ScopeTable.liveRowCount`; the Cloudflare adapter's twin).
626
+ ...(tombstones(name) ? { liveRowCount: count(`SELECT COUNT(*) AS c FROM "${name}" WHERE revoked_at IS NULL`) } : {}),
627
+ }));
628
+ }
629
+ function scopeTablePageOf(db, input) {
630
+ // Validate the table against the LIVE schema — an unknown name throws, it is
631
+ // never interpolated blind. That validated name is the only thing that reaches
632
+ // the query, so the quoted identifier below carries no user input.
633
+ const known = new Set(db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((r) => r.name));
634
+ if (!known.has(input.table))
635
+ throw substratError('not_found', `unknown table '${input.table}'`);
636
+ // The ceiling clamps; a bound SQLite would misread (NaN, non-finite, fractional,
637
+ // negative) is refused instead of reaching LIMIT / OFFSET (#1632).
638
+ const limit = Math.min(assertRowLimit('limit', input.limit ?? SCOPE_TABLE_PAGE_DEFAULT), SCOPE_TABLE_PAGE_MAX);
639
+ const offset = assertRowOffset('offset', input.offset ?? 0);
640
+ const stmt = db.prepare(`SELECT * FROM "${input.table}" LIMIT ? OFFSET ?`).raw(true);
641
+ const rows = stmt.all(limit, offset).map((row) => row.map(cellToJson));
642
+ const columns = stmt.columns().map((c) => c.name);
643
+ const rowCount = db.prepare(`SELECT COUNT(*) AS c FROM "${input.table}"`).get().c;
644
+ return { table: input.table, columns, rows, rowCount, limit, offset };
645
+ }
646
+ function scopeQueryOf(db, input) {
647
+ // Layer 1, shared: the kernel's textual gate (single statement, read verbs
648
+ // only) — the checked statement is the one that runs.
649
+ const sql = assertReadOnlyQuery(input.sql);
650
+ // Layer 2, authoritative: sqlite3_stmt_readonly via better-sqlite3. prepare()
651
+ // itself rejects multi-statement strings and bad SQL with the driver's message.
652
+ const stmt = db.prepare(sql);
653
+ if (!stmt.readonly)
654
+ throw substratError('validation_failed', 'read-only console: statement is not read-only');
655
+ if (!stmt.reader)
656
+ return { sql, reader: false, result: { columns: [], rows: [], truncated: false } };
657
+ stmt.raw(true);
658
+ const rows = [];
659
+ let truncated = false;
660
+ // Iterate rather than .all(): the row cap bounds what leaves the DB, so an
661
+ // over-broad SELECT costs a screenful, not the table.
662
+ for (const row of stmt.iterate()) {
663
+ if (rows.length >= SCOPE_QUERY_ROW_MAX) {
664
+ truncated = true;
665
+ break;
666
+ }
667
+ rows.push(row.map(cellToJson));
668
+ }
669
+ return { sql, reader: true, result: { columns: stmt.columns().map((c) => c.name), rows, truncated } };
670
+ }
671
+ function appliedMigrationsOf(db) {
672
+ const rows = db
673
+ .prepare(`SELECT module_id, version, applied_at, duration_ms, rows_changed FROM _substrat_migrations
674
+ ORDER BY applied_at DESC, rowid DESC`)
675
+ .all();
676
+ return rows.map((r) => ({
677
+ moduleId: r.module_id,
678
+ version: r.version,
679
+ appliedAt: r.applied_at ?? null,
680
+ durationMs: r.duration_ms,
681
+ rowsChanged: r.rows_changed,
682
+ }));
683
+ }
684
+ function undrainedEventsFrom(db, limit) {
685
+ const page = db.prepare(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL AND ${emittedHere()} ORDER BY id LIMIT ? OFFSET ?`);
686
+ // The kernel's read, shared with the DO: the envelope and the columns lifted beside
687
+ // it (#1242, #1237) are decoded once, and a row that will not decode is stepped over
688
+ // rather than stalling the scope on it (#1636).
689
+ return undrainedEventsOf(readUndrainedOutbox((offset, count) => page.all(count, offset), Math.min(assertRowLimit('limit', limit), 1000)));
690
+ }
691
+ const redrainCountOf = (db, drainedBefore) => db
692
+ .prepare(`SELECT COUNT(*) AS c FROM _substrat_outbox
693
+ WHERE drained_at IS NOT NULL AND drained_at < ?`)
694
+ .get(drainedBefore).c;
556
695
  /**
557
696
  * #119: claim a host directory for this process, for one live `SqliteScopeHost`. The first host in
558
697
  * the process takes an exclusive lock on `_host.lock` (a SQLite file: `locking_mode = EXCLUSIVE`
@@ -1633,6 +1772,12 @@ export class SqliteScopeHost {
1633
1772
  if (!open) {
1634
1773
  throw new Error(`no live '${provider}' connection for tenant ${rt.tenantId} / vertical '${vertical}'`);
1635
1774
  }
1775
+ const readSurface = () => this.buildAttachments(rt, { kind: 'connection', id: open.id }, this.attachmentStore(rt.tenantId, vertical), {
1776
+ reentrant: holdsActor,
1777
+ // #726 remedy (B): inside a dispatch the authority to read is the DELIVERY,
1778
+ // not a standing grant. See `admitByEvent` on `buildAttachments`.
1779
+ admitByEvent: eventId,
1780
+ });
1636
1781
  return {
1637
1782
  ...open,
1638
1783
  // The outbound read (#711), on THIS connection — so it is authorized as
@@ -1652,15 +1797,9 @@ export class SqliteScopeHost {
1652
1797
  grants: async () => (await this.connectionGrantsInScope(rt.tenantId, rt.scopeId))
1653
1798
  .filter((g) => g.connectionId === open.id)
1654
1799
  .map((g) => g.permission),
1655
- openAttachment: async (attachmentId) => {
1656
- const store = this.attachmentStore(rt.tenantId, vertical);
1657
- return this.buildAttachments(rt, { kind: 'connection', id: open.id }, store, {
1658
- reentrant: holdsActor,
1659
- // #726 remedy (B): inside a dispatch the authority to read is the DELIVERY,
1660
- // not a standing grant. See `admitByEvent` on `buildAttachments`.
1661
- admitByEvent: eventId,
1662
- }).open(attachmentId);
1663
- },
1800
+ openAttachment: async (attachmentId) => readSurface().open(attachmentId),
1801
+ // #2514: the same read, its bytes a stream.
1802
+ openAttachmentStream: async (attachmentId) => readSurface().openStream(attachmentId),
1664
1803
  // #687: open a cell the scope sealed TO this connection. The keyId-indexed
1665
1804
  // map goes in, never out — a connector is handed the plaintext of one cell,
1666
1805
  // not a private key it could mislay. Retired keys are in the map too, so a
@@ -2184,6 +2323,16 @@ export class SqliteScopeHost {
2184
2323
  out.push(r);
2185
2324
  }
2186
2325
  };
2326
+ const contentTypeOf = (p) => {
2327
+ try {
2328
+ const meta = JSON.parse(readFileSync(`${p}.meta`, 'utf8'));
2329
+ return meta.contentType != null ? { contentType: meta.contentType } : {};
2330
+ }
2331
+ catch {
2332
+ // No sidecar (or unreadable): the caller falls back to the metadata row.
2333
+ return {};
2334
+ }
2335
+ };
2187
2336
  return this.wrapBlobStore({
2188
2337
  put: async (key, body, opts) => {
2189
2338
  const p = resolveKey(key);
@@ -2196,18 +2345,23 @@ export class SqliteScopeHost {
2196
2345
  },
2197
2346
  get: async (key) => {
2198
2347
  const p = resolveKey(key);
2199
- if (!existsSync(p))
2200
- return null;
2201
- const body = new Uint8Array(readFileSync(p));
2202
- let contentType;
2348
+ let body;
2203
2349
  try {
2204
- const meta = JSON.parse(readFileSync(`${p}.meta`, 'utf8'));
2205
- contentType = meta.contentType ?? undefined;
2350
+ body = new Uint8Array(readFileSync(p));
2206
2351
  }
2207
- catch {
2208
- // No sidecar (or unreadable): the caller falls back to the metadata row.
2352
+ catch (err) {
2353
+ // Absent, or removed after the read gate: one answer either way, naming no host path (#2514).
2354
+ if (err.code === 'ENOENT')
2355
+ return null;
2356
+ throw err;
2209
2357
  }
2210
- return contentType !== undefined ? { body, contentType } : { body };
2358
+ return { body, ...contentTypeOf(p) };
2359
+ },
2360
+ // #2514: the file read as the stream is read, never whole.
2361
+ getStream: async (key) => {
2362
+ const p = resolveKey(key);
2363
+ const file = fileStream(p);
2364
+ return file && { ...file, ...contentTypeOf(p) };
2211
2365
  },
2212
2366
  delete: async (key) => {
2213
2367
  const p = resolveKey(key);
@@ -2464,6 +2618,21 @@ export class SqliteScopeHost {
2464
2618
  }
2465
2619
  };
2466
2620
  const guarded = opts.reentrant ? reentrant : serialized;
2621
+ // The read gate, decided before the blob store is touched: a refused read never reaches it.
2622
+ const authorizeRead = (attachmentId) => guarded('attachments.open', async (ctx) => {
2623
+ const row = rt.db
2624
+ .prepare('SELECT * FROM _substrat_attachments WHERE id = ?')
2625
+ .get(attachmentId);
2626
+ if (!row)
2627
+ return null;
2628
+ if (opts.admitByEvent !== undefined) {
2629
+ admitByDelivery(rt, opts.admitByEvent, row);
2630
+ return attachmentRecordOfRow(row);
2631
+ }
2632
+ const gate = targetGate(row.entity_type);
2633
+ assertAllowed(await ctx.check(gate.read, { entityType: row.entity_type, entityId: row.entity_id }));
2634
+ return attachmentRecordOfRow(row);
2635
+ });
2467
2636
  return {
2468
2637
  upload: async (input) => {
2469
2638
  if (capabilitySession !== undefined) {
@@ -2551,20 +2720,7 @@ export class SqliteScopeHost {
2551
2720
  return rows.map(attachmentRecordOfRow);
2552
2721
  }),
2553
2722
  open: async (attachmentId) => {
2554
- const record = await guarded('attachments.open', async (ctx) => {
2555
- const row = rt.db
2556
- .prepare('SELECT * FROM _substrat_attachments WHERE id = ?')
2557
- .get(attachmentId);
2558
- if (!row)
2559
- return null;
2560
- if (opts.admitByEvent !== undefined) {
2561
- admitByDelivery(rt, opts.admitByEvent, row);
2562
- return attachmentRecordOfRow(row);
2563
- }
2564
- const gate = targetGate(row.entity_type);
2565
- assertAllowed(await ctx.check(gate.read, { entityType: row.entity_type, entityId: row.entity_id }));
2566
- return attachmentRecordOfRow(row);
2567
- });
2723
+ const record = await authorizeRead(attachmentId);
2568
2724
  if (!record)
2569
2725
  return null;
2570
2726
  const obj = await store.get(attachmentBlobKey(rt.scopeId, record.id));
@@ -2577,6 +2733,14 @@ export class SqliteScopeHost {
2577
2733
  }
2578
2734
  return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
2579
2735
  },
2736
+ // #2514: the same gate, then the file as it is read, checked against the record as it passes.
2737
+ // The bytes are read after the gate's turn has ended, so a long read holds no actor turn.
2738
+ openStream: async (attachmentId) => {
2739
+ const record = await authorizeRead(attachmentId);
2740
+ if (!record)
2741
+ return null;
2742
+ return openAttachmentByteStream(store, attachmentBlobKey(rt.scopeId, record.id), record, nodeSha256);
2743
+ },
2580
2744
  remove: async (attachmentId) => {
2581
2745
  const removed = await guarded('attachments.remove', async (ctx, subject) => {
2582
2746
  const row = rt.db
@@ -2623,7 +2787,31 @@ export class SqliteScopeHost {
2623
2787
  };
2624
2788
  }
2625
2789
  async importScope(actor, input, dump) {
2790
+ await this.importScopeFrom(actor, input, dump, true);
2791
+ }
2792
+ /**
2793
+ * `importScope`, with its role judgement (#2555) when `judge`: once before the scope is created,
2794
+ * so a refused import leaves nothing behind, and again inside the load's transaction, so a role
2795
+ * defined since that read is judged too. That second refusal comes after the scope was created, so
2796
+ * the scope goes with it. A snapshot passes false: it is the platform's copy of a live scope of this
2797
+ * host, made at once, so its seats are the ones that scope holds now (K-55).
2798
+ */
2799
+ async importScopeFrom(actor, input, dump, judge) {
2626
2800
  this.refuseUnusableScopeStores(dump.stores, input.vertical ?? null);
2801
+ if (judge)
2802
+ this.refuseDumpRoles(input.tenantId, dump);
2803
+ let refusedInLoad = false;
2804
+ const recheck = judge
2805
+ ? () => {
2806
+ try {
2807
+ this.refuseDumpRoles(input.tenantId, dump);
2808
+ }
2809
+ catch (e) {
2810
+ refusedInLoad = true;
2811
+ throw e;
2812
+ }
2813
+ }
2814
+ : undefined;
2627
2815
  // Create the destination scope (directory row + storage). Its migration step
2628
2816
  // creates some tables; the dump then replaces them wholesale, so the end state
2629
2817
  // is the dump, not whatever the local module set would have built. Provenance is
@@ -2635,7 +2823,33 @@ export class SqliteScopeHost {
2635
2823
  forkedAt: input.forkedAt ?? dump.capturedAt,
2636
2824
  });
2637
2825
  // A fork: its callers (snapshotScope) hand it a dump the platform exported.
2638
- await this.loadDump(input.tenantId, input.scopeId, { ...dump, exact: true });
2826
+ try {
2827
+ await this.loadDump(input.tenantId, input.scopeId, { ...dump, exact: true }, undefined, recheck);
2828
+ }
2829
+ catch (e) {
2830
+ // The scope was created for this import alone and holds nothing loaded, so it goes with the
2831
+ // refusal, and a retry meets no scope. Best effort: the refusal is the answer whatever the
2832
+ // teardown does, and a teardown that fails is a staff-only ops row (no tenant on it), with the
2833
+ // scope left in `provisioning` for staff to reap. No admin row is written for the teardown:
2834
+ // the import never happened, and its `provisionScope` row stays as the record of the attempt.
2835
+ if (refusedInLoad) {
2836
+ await this.dropScopeOutright(input.tenantId, input.scopeId).catch((teardown) => {
2837
+ try {
2838
+ this.writeOpsFailure({
2839
+ actor,
2840
+ operation: 'importScope',
2841
+ stage: 'refused-import-teardown',
2842
+ scopeId: input.scopeId,
2843
+ message: `a refused import's scope could not be taken down: ${teardown instanceof Error ? teardown.message : String(teardown)}`,
2844
+ });
2845
+ }
2846
+ catch {
2847
+ // Nothing more to do here: the refusal below is still the answer.
2848
+ }
2849
+ });
2850
+ }
2851
+ throw e;
2852
+ }
2639
2853
  await this.importScopeStores(input.tenantId, input.scopeId, dump.stores);
2640
2854
  await this.admin.activateScope(actor, input.tenantId, input.scopeId);
2641
2855
  this.recordAdmin(actor, 'importScope', { tenantId: input.tenantId, scopeId: input.scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
@@ -2647,10 +2861,14 @@ export class SqliteScopeHost {
2647
2861
  dump,
2648
2862
  /** Run inside the load's own transaction, after the replay (#1742) — the restore's re-assert
2649
2863
  * of the switch. It throws to roll the load back. */
2650
- afterLoad) {
2864
+ afterLoad,
2865
+ /** Run first inside the load's own transaction, before any drop (#2555): the role judgement,
2866
+ * against the roles stored at the moment of the load. It throws to refuse with nothing moved. */
2867
+ beforeLoad) {
2651
2868
  const rt = this.runtime(tenantId, scopeId);
2652
2869
  const db = rt.db;
2653
2870
  const load = db.transaction((dumped) => {
2871
+ beforeLoad?.();
2654
2872
  // Deferred foreign keys cover the WHOLE drop-then-replay, drops included.
2655
2873
  //
2656
2874
  // Two distinct FK hazards, and the second is why this cannot wrap the inserts alone:
@@ -2797,6 +3015,8 @@ export class SqliteScopeHost {
2797
3015
  if (!existing)
2798
3016
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2799
3017
  this.refuseUnusableScopeStores(dump.stores, existing.vertical);
3018
+ // #2555: judged before the fence, so a refused restore moves nothing; again inside the load.
3019
+ this.refuseDumpRoles(tenantId, dump);
2800
3020
  // #2314: the load replaces the scope's rows, so the orphan sweep is fenced first.
2801
3021
  await this.fenceOrphanSweep(tenantId, scopeId, existing.vertical ?? null);
2802
3022
  // #1742: a dump from before a switch was pulled brings the module's grants back live, so
@@ -2806,7 +3026,7 @@ export class SqliteScopeHost {
2806
3026
  const rows = [];
2807
3027
  // #1869: the re-point's source is the separate hint when given; the dump is otherwise as sent.
2808
3028
  const source = { tables: dump.tables, scopeId: opts?.sourceScopeId ?? dump.scopeId };
2809
- await this.loadDump(tenantId, scopeId, source, (rt) => this.reassertSwitchesInTurn(rt, actor, tenantId, scopeId, undefined, (action, after) => rows.push([action, after])));
3029
+ await this.loadDump(tenantId, scopeId, source, (rt) => this.reassertSwitchesInTurn(rt, actor, tenantId, scopeId, undefined, (action, after) => rows.push([action, after])), () => this.refuseDumpRoles(tenantId, dump));
2810
3030
  await this.importScopeStores(tenantId, scopeId, dump.stores);
2811
3031
  const replay = await this.replayShreddedStoreErasures(actor, tenantId, scopeId, (opts?.sourceScopeId ?? dump.scopeId));
2812
3032
  for (const [action, after] of rows)
@@ -2825,7 +3045,7 @@ export class SqliteScopeHost {
2825
3045
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2826
3046
  const dump = await this.admin.exportScope(actor, tenantId, scopeId);
2827
3047
  const snapshotId = ulid();
2828
- await this.importScope(actor, {
3048
+ await this.importScopeFrom(actor, {
2829
3049
  tenantId,
2830
3050
  scopeId: snapshotId,
2831
3051
  kind: opts?.kind ?? 'archive',
@@ -2834,7 +3054,7 @@ export class SqliteScopeHost {
2834
3054
  jurisdiction: source.jurisdiction,
2835
3055
  expiresAt: opts?.expiresAt,
2836
3056
  // forkedFrom/forkedAt are stamped from the dump by importScope.
2837
- }, dump);
3057
+ }, dump, false);
2838
3058
  // Bind the snapshot to the source's current version so it is a runnable copy at the
2839
3059
  // same frontier (a fresh bind, so it never re-triggers the snapshot path).
2840
3060
  if (source.verticalVersionId) {
@@ -2854,6 +3074,16 @@ export class SqliteScopeHost {
2854
3074
  throw substratError('forbidden', `scope ${scopeId} is not a fork or preview — only previews may be deleted; ` +
2855
3075
  `archive a primary scope instead`);
2856
3076
  }
3077
+ await this.dropScopeOutright(tenantId, scopeId);
3078
+ this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
3079
+ forkedFrom: rec.forkedFrom,
3080
+ forkedAt: rec.forkedAt,
3081
+ expiresAt: rec.expiresAt,
3082
+ kind: rec.kind,
3083
+ });
3084
+ }
3085
+ /** `deleteSnapshot`'s teardown, without its refusal or its audit row: the scope's stores, file and directory rows. */
3086
+ async dropScopeOutright(tenantId, scopeId) {
2857
3087
  await this.wipeScopeStores(tenantId, scopeId);
2858
3088
  // Close and evict the runtime handle before touching the file.
2859
3089
  const rt = this.scopes.get(`${tenantId}/${scopeId}`);
@@ -2873,12 +3103,6 @@ export class SqliteScopeHost {
2873
3103
  forgetScopeStorage(redactionSqlOf(this.directory), scopeId);
2874
3104
  this.directory.prepare('DELETE FROM scopes WHERE scope_id = ?').run(scopeId);
2875
3105
  })();
2876
- this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
2877
- forkedFrom: rec.forkedFrom,
2878
- forkedAt: rec.forkedAt,
2879
- expiresAt: rec.expiresAt,
2880
- kind: rec.kind,
2881
- });
2882
3106
  }
2883
3107
  async getScope(principal, tenantId, scopeId, options) {
2884
3108
  const row = this.directory
@@ -3871,6 +4095,19 @@ export class SqliteScopeHost {
3871
4095
  if (refusal)
3872
4096
  throw substratError('forbidden', refusal);
3873
4097
  }
4098
+ /**
4099
+ * #2555: refuse loading `dump` into a scope of `tenantId` (`dumpRoleRefusal`): the tenant's roles
4100
+ * as this host stores them now, against the dump's role rows and seats, with the keys the modules
4101
+ * registered or listed here reserve and declare. Synchronous, so a caller inside the load's
4102
+ * transaction judges the roles stored at that moment.
4103
+ */
4104
+ refuseDumpRoles(tenantId, dump) {
4105
+ const prefix = `${tenantId}/`;
4106
+ const roles = [...this.roles].filter(([k]) => k.startsWith(prefix)).map(([, r]) => r);
4107
+ const refusal = dumpRoleRefusal(roles, { reserved: this.reservedKeys, declared: this.declaredKeys }, dump, this.clock());
4108
+ if (refusal)
4109
+ throw substratError('forbidden', refusal);
4110
+ }
3874
4111
  /**
3875
4112
  * #2447: does anyone but a system principal hold a live seat in `roleKey` in this tenant — a
3876
4113
  * tenant-level assignment, or a scope-level one in any of its scopes not reaped
@@ -3928,6 +4165,66 @@ export class SqliteScopeHost {
3928
4165
  async attachmentSizeLocal(scopeId) {
3929
4166
  return attachmentBytesOf(this.heldReadDb(scopeId));
3930
4167
  }
4168
+ // -- the introspection reads a vertical's `/internal/*` routes make by scope id alone (#2544) --------
4169
+ // The Cloudflare host's `*Local` verbs, refused from the directory before any store is opened
4170
+ // (`heldReadDb`), so a scope this host does not hold is `conflict`, never an empty answer and never
4171
+ // a store file created for it. No audit row, as there: the control plane that called is the auditor.
4172
+ async facetEventsLocal(scopeId, input) {
4173
+ return facetEvents({ sql: scopedSql(this.heldReadDb(scopeId)) }, input);
4174
+ }
4175
+ async eventEffectsLocal(scopeId, input) {
4176
+ return walkEventEffects({ sql: scopedSql(this.heldReadDb(scopeId)) }, input.eventId, input.maxNodes);
4177
+ }
4178
+ async invocationEventsLocal(scopeId, input) {
4179
+ return readInvocation({ sql: scopedSql(this.heldReadDb(scopeId)) }, input.invocationId, input.limit);
4180
+ }
4181
+ async deadLettersLocal(scopeId, input) {
4182
+ return readDeadLetters({ sql: scopedSql(this.heldReadDb(scopeId)) }, { limit: input.limit, cursor: input.cursor });
4183
+ }
4184
+ async lifecycleFlowLocal(scopeId, input) {
4185
+ return readLifecycleFlow({ sql: scopedSql(this.heldReadDb(scopeId)) }, input);
4186
+ }
4187
+ async operationSeriesLocal(scopeId, input) {
4188
+ return readOperationSeries({ sql: scopedSql(this.heldReadDb(scopeId)) }, input);
4189
+ }
4190
+ async eventCauseLocal(scopeId, input) {
4191
+ return walkEventCause({ sql: scopedSql(this.heldReadDb(scopeId)) }, input.eventId, input.maxDepth);
4192
+ }
4193
+ async entityHistoryLocal(scopeId, input) {
4194
+ return readHistory({ sql: scopedSql(this.heldReadDb(scopeId)) }, { entityType: input.entityType, entityId: input.entityId }, { limit: input.limit, cursor: input.cursor });
4195
+ }
4196
+ async appliedMigrationsLocal(scopeId) {
4197
+ return appliedMigrationsOf(this.heldReadDb(scopeId));
4198
+ }
4199
+ /** No PITR on plain SQLite: an empty list for a held scope, as `scopeMigrationBookmarks` answers. */
4200
+ async migrationBookmarksLocal(scopeId) {
4201
+ this.heldReadDb(scopeId);
4202
+ return [];
4203
+ }
4204
+ async introspectScopeTables(scopeId) {
4205
+ return scopeTablesOf(this.heldReadDb(scopeId));
4206
+ }
4207
+ /** The Data view's table page (#2563): refused `conflict` with the shared sentence for a scope this host does not hold. */
4208
+ async introspectScopeTable(scopeId, input) {
4209
+ return scopeTablePageOf(this.heldReadDb(scopeId), input);
4210
+ }
4211
+ /**
4212
+ * The carry's read of a wiped copy's tombstone (#2563). Plain SQLite has no carry and no wipe, so
4213
+ * a store it does not hold is one it does not have: this reads what `introspectScopeTable` reads.
4214
+ */
4215
+ async readTombstoneTable(scopeId, input) {
4216
+ return scopeTablePageOf(this.heldReadDb(scopeId), input);
4217
+ }
4218
+ /** The SQL console's CP-less path (#219), guarded as the table page is (#2563). */
4219
+ async introspectScopeQuery(scopeId, input) {
4220
+ return scopeQueryOf(this.heldReadDb(scopeId), input).result;
4221
+ }
4222
+ async undrainedEventsLocal(scopeId, limit) {
4223
+ return undrainedEventsFrom(this.heldReadDb(scopeId), Math.max(limit, 1));
4224
+ }
4225
+ async redrainCountLocal(scopeId, drainedBefore) {
4226
+ return redrainCountOf(this.heldReadDb(scopeId), drainedBefore);
4227
+ }
3931
4228
  /**
3932
4229
  * The read connection of a scope this host holds, for a read that names only the scope id (#2541),
3933
4230
  * with the directory read ONCE. Refused `conflict` before anything is opened: no row, a reaped one
@@ -5937,6 +6234,20 @@ export class SqliteScopeHost {
5937
6234
  .get(slugValue);
5938
6235
  return r ? mapVertical(r) : undefined;
5939
6236
  };
6237
+ /**
6238
+ * #2528: one vertical row update whose admin row takes its `before` (and whose guard runs, by
6239
+ * throwing) from the vertical as the write's own transaction reads it.
6240
+ */
6241
+ const updateVertical = (actor, action, slugValue, set, args, judge, after) => {
6242
+ let before = null;
6243
+ this.directoryWriteAndAudit(() => {
6244
+ const existing = readVertical(slugValue);
6245
+ if (!existing)
6246
+ throw substratError('not_found', `unknown vertical '${slugValue}'`);
6247
+ before = judge(existing);
6248
+ return this.directory.prepare(`UPDATE verticals SET ${set} WHERE slug = ?`).run(...args, slugValue).changes > 0;
6249
+ }, () => this.recordAdmin(actor, action, { tenantId: null }, before, after));
6250
+ };
5940
6251
  const mapVersion = (r) => verticalVersion.parse({
5941
6252
  id: r.id,
5942
6253
  verticalSlug: r.vertical_slug,
@@ -5963,6 +6274,14 @@ export class SqliteScopeHost {
5963
6274
  }
5964
6275
  return r ? mapVersion(r) : undefined;
5965
6276
  };
6277
+ /** A version's admission, read synchronously so a transaction can judge it (#2528). */
6278
+ const versionAdmission = (id) => {
6279
+ const v = this.directory.prepare('SELECT admission, admission_note FROM vertical_versions WHERE id = ?')
6280
+ .get(id);
6281
+ if (!v)
6282
+ throw substratError('not_found', `unknown version ${id}`);
6283
+ return v;
6284
+ };
5966
6285
  const mapOrg = (r) => orgSchema.parse({
5967
6286
  id: r.org_id,
5968
6287
  tenantId: r.tenant_id,
@@ -6200,6 +6519,19 @@ export class SqliteScopeHost {
6200
6519
  const onNode = (node, body) => node.scopeId
6201
6520
  ? this.runtime(node.tenantId, node.scopeId).actor.turn(body)
6202
6521
  : Promise.resolve().then(body);
6522
+ /**
6523
+ * `onNode`, with the write's admin row (#2526). At the tenant node the write is a directory
6524
+ * row, so it and its row are one directory transaction. At a scope node it is in the scope's
6525
+ * own store, which no directory transaction reaches, so the row follows it (#1292's window).
6526
+ */
6527
+ const auditedOnNode = async (node, write, audit) => {
6528
+ if (!node.scopeId) {
6529
+ this.directoryWriteAndAudit(() => { write(); return true; }, audit);
6530
+ return;
6531
+ }
6532
+ await onNode(node, write);
6533
+ audit();
6534
+ };
6203
6535
  /**
6204
6536
  * One kill switch moved on one scope — the ONE body `revokeFromSystem` / `restoreToSystem`
6205
6537
  * (#1666, a module's `system:` authority) and `revokeFromPeer` / `restoreToPeer` (#1706, a
@@ -6456,14 +6788,25 @@ export class SqliteScopeHost {
6456
6788
  // #2447: judged before the write, against this host's keys and the tenant's other roles, so a
6457
6789
  // role assigned through a door cannot be widened afterwards. A refusal writes nothing, so a
6458
6790
  // seat of the role as it was stored keeps working.
6459
- this.refuseReservedDefinition(tenantId, parsed);
6460
- const before = this.roles.get(`${tenantId}/${parsed.key}`) ?? null;
6461
- this.directory
6462
- .prepare(`INSERT OR REPLACE INTO _substrat_roles (tenant_id, role_key, permissions, source, holder)
6463
- VALUES (?, ?, ?, ?, ?)`)
6464
- .run(tenantId, parsed.key, JSON.stringify(parsed.permissions), String(parsed.source), parsed.holder ?? 'person');
6791
+ // #2526: the judgement, the row's `before`, the write and the row are one directory unit.
6792
+ let before = null;
6793
+ this.directoryWriteAndAudit(() => {
6794
+ this.refuseReservedDefinition(tenantId, parsed);
6795
+ // The row as stored, read as `loadRoles` reads one.
6796
+ const stored = this.directory
6797
+ .prepare('SELECT permissions, source, holder FROM _substrat_roles WHERE tenant_id = ? AND role_key = ?')
6798
+ .get(tenantId, parsed.key);
6799
+ before = stored
6800
+ ? { key: parsed.key, permissions: JSON.parse(stored.permissions), source: stored.source, holder: stored.holder ?? undefined }
6801
+ : null;
6802
+ this.directory
6803
+ .prepare(`INSERT OR REPLACE INTO _substrat_roles (tenant_id, role_key, permissions, source, holder)
6804
+ VALUES (?, ?, ?, ?, ?)`)
6805
+ .run(tenantId, parsed.key, JSON.stringify(parsed.permissions), String(parsed.source), parsed.holder ?? 'person');
6806
+ return true;
6807
+ }, () => this.recordAdmin(actor, 'defineRole', { tenantId }, before, parsed));
6808
+ // The cache follows the commit, so a rolled-back definition is never served.
6465
6809
  this.roles.set(`${tenantId}/${parsed.key}`, { ...parsed, holder: parsed.holder ?? 'person' });
6466
- this.recordAdmin(actor, 'defineRole', { tenantId }, before, parsed);
6467
6810
  },
6468
6811
  listRoles: async (actor, filter) => {
6469
6812
  const where = [];
@@ -6508,17 +6851,18 @@ export class SqliteScopeHost {
6508
6851
  }));
6509
6852
  },
6510
6853
  assignRole: async (actor, assignment) => {
6511
- // #2430: the platform's door — a role mixing reserved and other keys is refused (K-55).
6512
- this.refuseReservedRole(assignment.node.tenantId, assignment.roleKey, 'platform');
6513
6854
  const subject = `principal:${assignment.principalId}`;
6514
- if (assignment.node.scopeId) {
6515
- const scopeNode = assignment.node.scopeId;
6516
- await onNode(assignment.node, () => writeScopeTuple(assignment.node, subject, `role:${assignment.roleKey}`, `scope:${scopeNode}`));
6517
- }
6518
- else {
6519
- writeTenantTuple(assignment.node.tenantId, subject, `role:${assignment.roleKey}`, `tenant:${assignment.node.tenantId}`);
6520
- }
6521
- this.recordAdmin(actor, 'assignRole', { tenantId: assignment.node.tenantId, scopeId: assignment.node.scopeId }, null, assignment);
6855
+ const { tenantId, scopeId } = assignment.node;
6856
+ // #2430: the platform's door — a role mixing reserved and other keys is refused (K-55).
6857
+ // Before the write opens a scope's file; synchronous with a tenant node's unit, so no
6858
+ // definition lands between the two.
6859
+ this.refuseReservedRole(tenantId, assignment.roleKey, 'platform');
6860
+ await auditedOnNode(assignment.node, () => {
6861
+ if (scopeId)
6862
+ writeScopeTuple(assignment.node, subject, `role:${assignment.roleKey}`, `scope:${scopeId}`);
6863
+ else
6864
+ writeTenantTuple(tenantId, subject, `role:${assignment.roleKey}`, `tenant:${tenantId}`);
6865
+ }, () => this.recordAdmin(actor, 'assignRole', { tenantId, scopeId }, null, assignment));
6522
6866
  },
6523
6867
  unassignRole: async (actor, assignment) => {
6524
6868
  // Tombstone (K-21), never DELETE — the checker skips revoked_at rows, so the
@@ -6547,8 +6891,7 @@ export class SqliteScopeHost {
6547
6891
  // to Z text (#963), and liveness here is a lexicographic `expires_at > ?`.
6548
6892
  const grant = capabilityGrant.parse(raw);
6549
6893
  this.refuseReservedGrant([grant.permission]); // #2430
6550
- await onNode(grant.node, () => writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt));
6551
- this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
6894
+ await auditedOnNode(grant.node, () => writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt), () => this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant));
6552
6895
  },
6553
6896
  grantEntityShape: async (actor, grant) => {
6554
6897
  const { tenantId, scopeId } = grant.node;
@@ -6629,29 +6972,35 @@ export class SqliteScopeHost {
6629
6972
  // row, so a refused grant is gathered by no later provision. A grant recorded before stays.
6630
6973
  this.refuseReservedGrant([grant.permission]);
6631
6974
  // Once BEFORE the turn, so a refused grant never opens (and so creates) the scope's
6632
- // file; then again inside it, with the tuple and the directory row, in ONE body on
6633
- // the scope actor (#1678) — so the tuple cannot roll back with a stranger's
6634
- // transaction and leave the directory row (#592) claiming a grant the scope lacks.
6975
+ // file; then again inside it, in ONE body on the scope actor (#1678), so the tuple
6976
+ // cannot roll back with a stranger's transaction.
6635
6977
  check();
6636
- const conn = await onNode(grant.node, () => {
6637
- const row = check();
6638
- writeGrant(subjectRef({ kind: 'connection', id: grant.connectionId }), grant.permission, grant.node, undefined, grant.expiresAt);
6639
- // #592: the directory-side record, alongside the tuple. The tuple is checked
6640
- // where it lives; this row is what provision/reconcile gather FROM, so the
6641
- // grant reaches scopes provisioned after it without a human replaying it.
6642
- this.directory
6643
- .prepare(`INSERT OR REPLACE INTO _substrat_connection_grants
6644
- (connection_id, tenant_id, vertical, permission, scope_id, expires_at,
6645
- granted_by, granted_at, revoked_at)
6646
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`)
6647
- .run(grant.connectionId, grant.node.tenantId, row.vertical, grant.permission, grant.node.scopeId ?? null, grant.expiresAt ?? null, grant.grantedBy, new Date().toISOString());
6648
- return row;
6649
- });
6650
- this.recordAdmin(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
6651
- connectionId: grant.connectionId,
6652
- provider: conn.provider,
6653
- permission: grant.permission,
6654
- node: grant.node,
6978
+ const tuple = () => writeGrant(subjectRef({ kind: 'connection', id: grant.connectionId }), grant.permission, grant.node, undefined, grant.expiresAt);
6979
+ await onNode(grant.node, () => {
6980
+ // #2526: the check, the directory record (#592) and the admin row are one directory unit,
6981
+ // with the tuple too at the tenant node, where it is a directory row. A scope tuple lives
6982
+ // in the scope's own store and is written after the unit: if it fails, the call throws and
6983
+ // the record and its row stand without it. The Cloudflare host's reconcile re-seats a
6984
+ // tuple from the record; this host does not (#2586), so a re-grant is what delivers it.
6985
+ this.directory.transaction(() => {
6986
+ const conn = check();
6987
+ if (!grant.node.scopeId)
6988
+ tuple();
6989
+ this.directory
6990
+ .prepare(`INSERT OR REPLACE INTO _substrat_connection_grants
6991
+ (connection_id, tenant_id, vertical, permission, scope_id, expires_at,
6992
+ granted_by, granted_at, revoked_at)
6993
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`)
6994
+ .run(grant.connectionId, grant.node.tenantId, conn.vertical, grant.permission, grant.node.scopeId ?? null, grant.expiresAt ?? null, grant.grantedBy, new Date().toISOString());
6995
+ this.recordAdmin(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
6996
+ connectionId: grant.connectionId,
6997
+ provider: conn.provider,
6998
+ permission: grant.permission,
6999
+ node: grant.node,
7000
+ });
7001
+ })();
7002
+ if (grant.node.scopeId)
7003
+ tuple();
6655
7004
  });
6656
7005
  },
6657
7006
  grantToSystem: async (actor, raw) => {
@@ -6664,6 +7013,7 @@ export class SqliteScopeHost {
6664
7013
  const grant = systemGrant.parse(raw);
6665
7014
  const write = () => writeGrant(subjectRef({ kind: 'system', id: grant.moduleId }), grant.permission, grant.node, undefined, grant.expiresAt);
6666
7015
  const scope = grant.node.scopeId;
7016
+ const audit = () => this.recordAdmin(actor, 'grantToSystem', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, { moduleId: grant.moduleId, permission: grant.permission, node: grant.node });
6667
7017
  if (scope) {
6668
7018
  // #1666: refused while the module is switched off on this scope. The check and the
6669
7019
  // write share one synchronous body inside one turn on the scope actor, so no switch
@@ -6678,19 +7028,21 @@ export class SqliteScopeHost {
6678
7028
  }
6679
7029
  write();
6680
7030
  });
7031
+ audit();
6681
7032
  }
6682
7033
  else {
6683
7034
  // Tenant-level: a directory write, which no scope's transaction can hold. #1743:
6684
7035
  // refused while the record holds the module off on any scope (`scopesSwitchedOffFor`);
6685
- // the record and the tenant tuple share the directory, so this is one transaction.
6686
- this.directory.transaction(() => {
7036
+ // the record, the tenant tuple and (#2526) the row share the directory, so this is one
7037
+ // transaction.
7038
+ this.directoryWriteAndAudit(() => {
6687
7039
  const off = scopesSwitchedOffFor(switchSqlOf(this.directory), grant.node.tenantId, grant.moduleId);
6688
7040
  if (off.length > 0)
6689
7041
  throw substratError('conflict', tenantSystemSwitchedOffMessage(grant.moduleId, off));
6690
7042
  write();
6691
- })();
7043
+ return true;
7044
+ }, audit);
6692
7045
  }
6693
- this.recordAdmin(actor, 'grantToSystem', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, { moduleId: grant.moduleId, permission: grant.permission, node: grant.node });
6694
7046
  },
6695
7047
  // #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
6696
7048
  // whole rule, shared with the Cloudflare adapter; this is the directory check, the
@@ -6781,11 +7133,10 @@ export class SqliteScopeHost {
6781
7133
  // the write in one body, on the scope actor when the node is a scope (#1678).
6782
7134
  requireOrg(node.tenantId, orgId);
6783
7135
  this.refuseReservedGrant([permission]); // #2430: an org's members are people
6784
- await onNode(node, () => {
7136
+ await auditedOnNode(node, () => {
6785
7137
  requireOrg(node.tenantId, orgId);
6786
7138
  writeGrant(`org:${orgId}`, permission, node, entity);
6787
- });
6788
- this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity });
7139
+ }, () => this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity }));
6789
7140
  },
6790
7141
  // -- vertical + version registry (#31) ---------------------------------
6791
7142
  // -- the hostname map (K-26) -------------------------------------------
@@ -7100,15 +7451,15 @@ export class SqliteScopeHost {
7100
7451
  },
7101
7452
  publishVersion: async (actor, input) => {
7102
7453
  const parsed = publishVersionInput.parse(input);
7103
- const owning = readVertical(parsed.verticalSlug);
7104
- if (!owning) {
7105
- throw substratError('not_found', `unknown vertical '${parsed.verticalSlug}'`);
7106
- }
7454
+ const unknownVertical = () => substratError('not_found', `unknown vertical '${parsed.verticalSlug}'`);
7455
+ if (!readVertical(parsed.verticalSlug))
7456
+ throw unknownVertical();
7107
7457
  // Lands PENDING — a push is not a deploy — except for a PRIVATE vertical
7108
7458
  // (tenant-owned, not listed), whose blast radius is its own tenant: there the
7109
7459
  // sandbox contract is the gate and the version self-admits, noted so the
7110
- // publish seam can tell a staff vouch from this shortcut.
7111
- const selfAdmits = owning.ownerTenant !== null && !owning.listed;
7460
+ // publish seam can tell a staff vouch from this shortcut. Judged in the insert's
7461
+ // transaction (#2528), on the vertical as it reads it there.
7462
+ let selfAdmits = false;
7112
7463
  // The manifest is retained for the serving upload (#286), not audited — a whole
7113
7464
  // manifest per publish would drown the admin log in bundle metadata.
7114
7465
  const { manifestJson, origin, ...audited } = parsed;
@@ -7120,6 +7471,10 @@ export class SqliteScopeHost {
7120
7471
  id: audited.id, migrationDigest: audited.migrationDigest, manifestJson: manifestJson ?? null, migrations: split.migrations,
7121
7472
  });
7122
7473
  this.directoryWriteAndAudit(() => {
7474
+ const owning = readVertical(parsed.verticalSlug);
7475
+ if (!owning)
7476
+ throw unknownVertical();
7477
+ selfAdmits = owning.ownerTenant !== null && !owning.listed;
7123
7478
  this.directory
7124
7479
  .prepare(`INSERT INTO vertical_versions
7125
7480
  (id, vertical_slug, version, manifest_digest, permission_digest,
@@ -7168,85 +7523,66 @@ export class SqliteScopeHost {
7168
7523
  this.recordAccess(actor, 'getVersion', {}, { versionId, verticalSlug }, hit ? 1 : 0);
7169
7524
  return hit;
7170
7525
  },
7526
+ // #2528: each vertical write below reads its row's `before`, and runs its guard, inside the
7527
+ // transaction that writes (`updateVertical`).
7171
7528
  setVerticalListed: async (actor, slug, listed) => {
7172
- const existing = readVertical(slug);
7173
- if (!existing)
7174
- throw substratError('not_found', `unknown vertical '${slug}'`);
7175
- // Listing is the moment other tenants start trusting this code, so the
7176
- // version they would install must carry a real staff vouch — an auto-admitted
7177
- // prod version has never been read by anyone but its author.
7178
- if (listed) {
7179
- const prod = this.directory
7180
- .prepare("SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = 'prod'")
7181
- .get(slug);
7182
- const prodVersion = prod ? await readVersion(prod.version_id) : undefined;
7183
- if (prodVersion?.admissionNote === AUTO_ADMISSION_NOTE) {
7184
- throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
7185
- `a staff admit must vouch for it before listing`);
7186
- }
7187
- }
7188
7529
  // Resolve any pending publish request either way, and set the flag.
7189
- this.directoryWriteAndAudit(() => this.directory
7190
- .prepare('UPDATE verticals SET listed = ?, publish_requested_at = NULL WHERE slug = ?')
7191
- .run(listed ? 1 : 0, slug).changes > 0, () => this.recordAdmin(actor, 'setVerticalListed', { tenantId: null }, { listed: existing.listed }, { listed }));
7530
+ updateVertical(actor, 'setVerticalListed', slug, 'listed = ?, publish_requested_at = NULL', [listed ? 1 : 0], (existing) => {
7531
+ // Listing is the moment other tenants start trusting this code, so the
7532
+ // version they would install must carry a real staff vouch — an auto-admitted
7533
+ // prod version has never been read by anyone but its author.
7534
+ if (listed) {
7535
+ const prod = this.directory
7536
+ .prepare(`SELECT v.id, v.admission_note FROM vertical_channels c JOIN vertical_versions v ON v.id = c.version_id
7537
+ WHERE c.vertical_slug = ? AND c.channel = 'prod'`)
7538
+ .get(slug);
7539
+ if (prod?.admission_note === AUTO_ADMISSION_NOTE) {
7540
+ throw substratError('conflict', `vertical '${slug}' prod version ${prod.id} is auto-admitted (private self-serve) — ` +
7541
+ `a staff admit must vouch for it before listing`);
7542
+ }
7543
+ }
7544
+ return { listed: existing.listed };
7545
+ }, { listed });
7192
7546
  },
7193
7547
  requestPublish: async (actor, slug) => {
7194
- const existing = readVertical(slug);
7195
- if (!existing)
7196
- throw substratError('not_found', `unknown vertical '${slug}'`);
7197
- this.directoryWriteAndAudit(() => this.directory
7198
- .prepare('UPDATE verticals SET publish_requested_at = ? WHERE slug = ?')
7199
- .run(new Date().toISOString(), slug).changes > 0, () => this.recordAdmin(actor, 'requestPublish', { tenantId: null }, null, { slug }));
7548
+ updateVertical(actor, 'requestPublish', slug, 'publish_requested_at = ?', [new Date().toISOString()], () => null, { slug });
7200
7549
  },
7201
7550
  setVerticalInstallsBlocked: async (actor, slug, blocked) => {
7202
- const existing = readVertical(slug);
7203
- if (!existing)
7204
- throw substratError('not_found', `unknown vertical '${slug}'`);
7205
- this.directoryWriteAndAudit(() => this.directory
7206
- .prepare('UPDATE verticals SET installs_blocked = ? WHERE slug = ?')
7207
- .run(blocked ? 1 : 0, slug).changes > 0, () => this.recordAdmin(actor, 'setVerticalInstallsBlocked', { tenantId: null }, { installsBlocked: existing.installsBlocked }, { installsBlocked: blocked }));
7551
+ updateVertical(actor, 'setVerticalInstallsBlocked', slug, 'installs_blocked = ?', [blocked ? 1 : 0], (existing) => ({ installsBlocked: existing.installsBlocked }), { installsBlocked: blocked });
7208
7552
  },
7209
7553
  setVerticalTenantProvisioner: async (actor, slug, granted) => {
7210
- const existing = readVertical(slug);
7211
- if (!existing)
7212
- throw substratError('not_found', `unknown vertical '${slug}'`);
7213
- this.directoryWriteAndAudit(() => this.directory
7214
- .prepare('UPDATE verticals SET tenant_provisioner = ? WHERE slug = ?')
7215
- .run(granted ? 1 : 0, slug).changes > 0, () => this.recordAdmin(actor, 'setVerticalTenantProvisioner', { tenantId: null }, { tenantProvisioner: existing.tenantProvisioner }, { tenantProvisioner: granted }));
7554
+ updateVertical(actor, 'setVerticalTenantProvisioner', slug, 'tenant_provisioner = ?', [granted ? 1 : 0], (existing) => ({ tenantProvisioner: existing.tenantProvisioner }), { tenantProvisioner: granted });
7216
7555
  },
7217
7556
  setVerticalEmailSender: async (actor, slug, granted) => {
7218
- const existing = readVertical(slug);
7219
- if (!existing)
7220
- throw substratError('not_found', `unknown vertical '${slug}'`);
7221
- this.directoryWriteAndAudit(() => this.directory
7222
- .prepare('UPDATE verticals SET email_sender = ? WHERE slug = ?')
7223
- .run(granted ? 1 : 0, slug).changes > 0, () => this.recordAdmin(actor, 'setVerticalEmailSender', { tenantId: null }, { emailSender: existing.emailSender }, { emailSender: granted }));
7557
+ updateVertical(actor, 'setVerticalEmailSender', slug, 'email_sender = ?', [granted ? 1 : 0], (existing) => ({ emailSender: existing.emailSender }), { emailSender: granted });
7224
7558
  },
7225
7559
  deleteVertical: async (actor, slug) => {
7226
- const existing = readVertical(slug);
7227
- if (!existing)
7228
- throw substratError('not_found', `unknown vertical '${slug}'`);
7229
- // Refuse while any restorable scope is bound: a deleted registry row would strand
7230
- // those scopes' version pins and routing. An `archived` scope (a deleted app) still
7231
- // blocks — unarchive can bring it back — but the refusal names reap/restore, not
7232
- // "delete", because the app itself is already gone. `reaped` is terminal history
7233
- // and never blocks. The count names the blast radius.
7234
- const bound = this.directory
7235
- .prepare('SELECT ' +
7236
- "COUNT(*) FILTER (WHERE status NOT IN ('archived', 'reaped')) AS live, " +
7237
- "COUNT(*) FILTER (WHERE status = 'archived') AS archived " +
7238
- 'FROM scopes WHERE vertical = ?')
7239
- .get(slug);
7240
- if (bound.live > 0) {
7241
- throw substratError('conflict', `vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
7242
- }
7243
- if (bound.archived > 0) {
7244
- throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
7245
- }
7246
7560
  // Deployed dispatch scripts are NOT reaped here — they become orphans for the
7247
7561
  // cleanup script (#248), never destroyed alongside a registry row.
7248
- // #2501: one transaction, so the row and its ownership period close together.
7562
+ // #2501: one transaction, so the row and its ownership period close together; #2528: its
7563
+ // `before` and the bound-scope refusal are read in it too.
7564
+ let existing;
7249
7565
  this.directoryWriteAndAudit(() => {
7566
+ existing = readVertical(slug);
7567
+ if (!existing)
7568
+ throw substratError('not_found', `unknown vertical '${slug}'`);
7569
+ // Refuse while any restorable scope is bound: a deleted registry row would strand
7570
+ // those scopes' version pins and routing. An `archived` scope (a deleted app) still
7571
+ // blocks — unarchive can bring it back — but the refusal names reap/restore, not
7572
+ // "delete", because the app itself is already gone. `reaped` is terminal history
7573
+ // and never blocks. The count names the blast radius.
7574
+ const bound = this.directory
7575
+ .prepare('SELECT ' +
7576
+ "COUNT(*) FILTER (WHERE status NOT IN ('archived', 'reaped')) AS live, " +
7577
+ "COUNT(*) FILTER (WHERE status = 'archived') AS archived " +
7578
+ 'FROM scopes WHERE vertical = ?')
7579
+ .get(slug);
7580
+ if (bound.live > 0) {
7581
+ throw substratError('conflict', `vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
7582
+ }
7583
+ if (bound.archived > 0) {
7584
+ throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
7585
+ }
7250
7586
  this.directory.prepare('DELETE FROM vertical_channels WHERE vertical_slug = ?').run(slug);
7251
7587
  this.directory.prepare('DELETE FROM vertical_channel_history WHERE vertical_slug = ?').run(slug);
7252
7588
  this.directory
@@ -7267,47 +7603,40 @@ export class SqliteScopeHost {
7267
7603
  this.recordAccess(actor, 'listVerticalOwners', { tenantId: null }, { slug }, periods.length);
7268
7604
  return periods;
7269
7605
  },
7606
+ // #2528: the admission is judged, and the row's `before` read, in the transaction that writes.
7270
7607
  admitVersion: async (actor, versionId) => {
7271
- const v = await readVersion(versionId);
7272
- if (!v)
7273
- throw substratError('not_found', `unknown version ${versionId}`);
7274
- if (v.admission === 'admitted') {
7275
- // Idempotent — except an AUTO-admitted version, which this upgrades to a
7276
- // manual vouch by clearing the note (what the publish seam requires).
7277
- if (v.admissionNote !== AUTO_ADMISSION_NOTE)
7278
- return;
7279
- this.directoryWriteAndAudit(() => this.directory
7280
- .prepare('UPDATE vertical_versions SET admission_note = NULL WHERE id = ?')
7281
- .run(versionId).changes > 0, () => this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission, note: v.admissionNote }, {
7282
- admission: 'admitted',
7283
- note: null,
7284
- }));
7285
- return;
7286
- }
7287
- if (v.admission === 'rejected') {
7288
- throw substratError('conflict', `version ${versionId} was rejected — publish a new one`);
7289
- }
7290
- this.directoryWriteAndAudit(() => this.directory
7291
- .prepare("UPDATE vertical_versions SET admission = 'admitted' WHERE id = ?")
7292
- .run(versionId).changes > 0, () => this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, {
7293
- admission: 'admitted',
7294
- }));
7608
+ let row = { before: null, after: null };
7609
+ this.directoryWriteAndAudit(() => {
7610
+ const v = versionAdmission(versionId);
7611
+ if (v.admission === 'admitted') {
7612
+ // Idempotent — except an AUTO-admitted version, which this upgrades to a
7613
+ // manual vouch by clearing the note (what the publish seam requires).
7614
+ if (v.admission_note !== AUTO_ADMISSION_NOTE)
7615
+ return false;
7616
+ row = { before: { admission: v.admission, note: v.admission_note }, after: { admission: 'admitted', note: null } };
7617
+ return this.directory.prepare('UPDATE vertical_versions SET admission_note = NULL WHERE id = ?').run(versionId).changes > 0;
7618
+ }
7619
+ if (v.admission === 'rejected') {
7620
+ throw substratError('conflict', `version ${versionId} was rejected — publish a new one`);
7621
+ }
7622
+ row = { before: { admission: v.admission }, after: { admission: 'admitted' } };
7623
+ return this.directory.prepare("UPDATE vertical_versions SET admission = 'admitted' WHERE id = ?").run(versionId).changes > 0;
7624
+ }, () => this.recordAdmin(actor, 'admitVersion', { tenantId: null }, row.before, row.after));
7295
7625
  },
7296
7626
  rejectVersion: async (actor, versionId, note) => {
7297
- const v = await readVersion(versionId);
7298
- if (!v)
7299
- throw substratError('not_found', `unknown version ${versionId}`);
7300
- if (v.admission === 'admitted') {
7301
- throw substratError('conflict', `version ${versionId} is already admitted — it may be bound`);
7302
- }
7303
- if (v.admission === 'rejected')
7304
- return; // idempotent
7305
- this.directoryWriteAndAudit(() => this.directory
7306
- .prepare("UPDATE vertical_versions SET admission = 'rejected', admission_note = ? WHERE id = ?")
7307
- .run(note, versionId).changes > 0, () => this.recordAdmin(actor, 'rejectVersion', { tenantId: null }, { admission: v.admission }, {
7308
- admission: 'rejected',
7309
- note,
7310
- }));
7627
+ let before = null;
7628
+ this.directoryWriteAndAudit(() => {
7629
+ const v = versionAdmission(versionId);
7630
+ if (v.admission === 'admitted') {
7631
+ throw substratError('conflict', `version ${versionId} is already admitted — it may be bound`);
7632
+ }
7633
+ if (v.admission === 'rejected')
7634
+ return false; // idempotent
7635
+ before = { admission: v.admission };
7636
+ return this.directory
7637
+ .prepare("UPDATE vertical_versions SET admission = 'rejected', admission_note = ? WHERE id = ?")
7638
+ .run(note, versionId).changes > 0;
7639
+ }, () => this.recordAdmin(actor, 'rejectVersion', { tenantId: null }, before, { admission: 'rejected', note }));
7311
7640
  },
7312
7641
  promotionImpact: async (actor, verticalSlug, channel, versionId) => {
7313
7642
  const incoming = await readVersion(versionId);
@@ -7716,10 +8045,27 @@ export class SqliteScopeHost {
7716
8045
  throw substratError('not_found', `unknown vertical '${verticalSlug}'`);
7717
8046
  // #2178: every install on the serving script runs the new version from this moment.
7718
8047
  this.assertNoStrandFrom([r.serving_version_id], parsed.versionId, r.serving_ref ? this.provisionedVersionsOf(verticalSlug, r.serving_version_id ? { ref: r.serving_ref, versionId: r.serving_version_id } : null, null) : []);
7719
- this.directoryWriteAndAudit(() => this.directory
7720
- .prepare(`UPDATE verticals SET serving_ref = ?, serving_version_id = ?,
7721
- serving_do_classes = ?, serving_migration_tag = ? WHERE slug = ?`)
7722
- .run(parsed.ref, parsed.versionId, JSON.stringify(parsed.doClasses), parsed.migrationTag, verticalSlug).changes > 0, () => this.recordAdmin(actor, 'setVerticalServing', { tenantId: null }, r.serving_ref ? { ref: r.serving_ref, versionId: r.serving_version_id } : null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId }));
8048
+ // #2528: a compare-and-set on the serving ref and version the gate judged
8049
+ // (`SERVES_NOW_CONDITION`), and the row's `before` read in the transaction that moves it,
8050
+ // as the Cloudflare host does.
8051
+ let before = null;
8052
+ let raced = false;
8053
+ this.directoryWriteAndAudit(() => {
8054
+ const now = this.directory
8055
+ .prepare('SELECT serving_ref, serving_version_id FROM verticals WHERE slug = ?')
8056
+ .get(verticalSlug);
8057
+ if (!now)
8058
+ throw substratError('not_found', `unknown vertical '${verticalSlug}'`);
8059
+ before = now.serving_ref ? { ref: now.serving_ref, versionId: now.serving_version_id } : null;
8060
+ const moved = this.directory
8061
+ .prepare(`UPDATE verticals SET serving_ref = ?, serving_version_id = ?,
8062
+ serving_do_classes = ?, serving_migration_tag = ? WHERE ${SERVES_NOW_CONDITION}`)
8063
+ .run(parsed.ref, parsed.versionId, JSON.stringify(parsed.doClasses), parsed.migrationTag, verticalSlug, r.serving_ref ?? null, r.serving_version_id ?? null).changes > 0;
8064
+ raced = !moved;
8065
+ return moved;
8066
+ }, () => this.recordAdmin(actor, 'setVerticalServing', { tenantId: null }, before, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId }));
8067
+ if (raced)
8068
+ throw servingRaceRefusal(verticalSlug);
7723
8069
  },
7724
8070
  versionManifest: async (actor, verticalSlug, versionId) => {
7725
8071
  const v = this.directory
@@ -7741,10 +8087,9 @@ export class SqliteScopeHost {
7741
8087
  },
7742
8088
  recordWireAuth: async (actor, input) => {
7743
8089
  const row = deploymentWireAuth.parse(input);
7744
- this.directoryWriteAndAudit(() => {
7745
- writeDeploymentWireAuth(switchSqlOf(this.directory), row);
7746
- return true;
7747
- }, () => this.recordAdmin(actor, 'recordWireAuth', { tenantId: null, vertical: row.verticalSlug }, null, { vertical: row.verticalSlug, deploymentRef: row.deploymentRef, versionId: row.versionId, record: row.record }));
8090
+ const written = this.directoryWriteAndAudit(() => writeDeploymentWireAuth(switchSqlOf(this.directory), row), () => this.recordAdmin(actor, 'recordWireAuth', { tenantId: null, vertical: row.verticalSlug }, null, { vertical: row.verticalSlug, deploymentRef: row.deploymentRef, versionId: row.versionId, record: row.record }));
8091
+ if (!written)
8092
+ throw substratError('not_found', `no capability record for '${row.deploymentRef}': vertical '${row.verticalSlug}' is not registered`);
7748
8093
  },
7749
8094
  listWireAuth: async (actor, input) => {
7750
8095
  // The judged copy, not the caller's object: only its own fields, the reach first, reach the log.
@@ -7802,14 +8147,9 @@ export class SqliteScopeHost {
7802
8147
  // operation's uncommitted event, the drain shipped it, and the rollback left the lake
7803
8148
  // — which is append-only — holding an event the scope never had. The mark that
7804
8149
  // follows needs no such care: it stamps only rows that exist and are undrained.
7805
- const db = this.scopeReadDbFor(tenantId, scopeId);
7806
- const page = db.prepare(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL AND ${emittedHere()} ORDER BY id LIMIT ? OFFSET ?`);
7807
- // The kernel's read, shared with the DO: the envelope and the columns lifted beside
7808
- // it (#1242, #1237) are decoded once, and a row that will not decode is stepped over
7809
- // rather than stalling the scope on it (#1636).
7810
- const read = readUndrainedOutbox((offset, count) => page.all(count, offset), Math.min(assertRowLimit('limit', limit ?? 200), 1000));
7811
- this.recordAccess(actor, 'readUndrainedEvents', { tenantId, scopeId }, { limit }, read.events.length);
7812
- return undrainedEventsOf(read);
8150
+ const events = undrainedEventsFrom(this.scopeReadDbFor(tenantId, scopeId), limit ?? 200);
8151
+ this.recordAccess(actor, 'readUndrainedEvents', { tenantId, scopeId }, { limit }, events.length);
8152
+ return events;
7813
8153
  },
7814
8154
  markEventsDrained: async (actor, tenantId, scopeId, eventIds) => {
7815
8155
  // BEFORE the empty-batch shortcut, not after. "Nothing to mark" and "you may
@@ -7938,10 +8278,7 @@ export class SqliteScopeHost {
7938
8278
  // auditor asking "who counted this tenant's outbox, and how much was there" wants.
7939
8279
  if (countOnly) {
7940
8280
  // The committed count (#1624): a stranger's uncommitted drain mark is not a fact yet.
7941
- const redrainable = this.scopeReadDbFor(tenantId, scopeId)
7942
- .prepare(`SELECT COUNT(*) AS c FROM _substrat_outbox
7943
- WHERE drained_at IS NOT NULL AND drained_at < ?`)
7944
- .get(drainedBefore).c;
8281
+ const redrainable = redrainCountOf(this.scopeReadDbFor(tenantId, scopeId), drainedBefore);
7945
8282
  this.recordAccess(actor, 'redrainEvents', { tenantId, scopeId }, { drainedBefore, countOnly: true }, redrainable);
7946
8283
  return redrainable;
7947
8284
  }
@@ -8046,18 +8383,9 @@ export class SqliteScopeHost {
8046
8383
  // Every row, newest first (#2553); `rowid DESC` orders migrations stamped in one millisecond.
8047
8384
  scopeAppliedMigrations: async (actor, tenantId, scopeId) => {
8048
8385
  const db = this.scopeReadDbFor(tenantId, scopeId);
8049
- const rows = db
8050
- .prepare(`SELECT module_id, version, applied_at, duration_ms, rows_changed FROM _substrat_migrations
8051
- ORDER BY applied_at DESC, rowid DESC`)
8052
- .all();
8053
- this.recordAccess(actor, 'scopeAppliedMigrations', { tenantId, scopeId }, null, rows.length);
8054
- return rows.map((r) => ({
8055
- moduleId: r.module_id,
8056
- version: r.version,
8057
- appliedAt: r.applied_at ?? null,
8058
- durationMs: r.duration_ms,
8059
- rowsChanged: r.rows_changed,
8060
- }));
8386
+ const migrations = appliedMigrationsOf(db);
8387
+ this.recordAccess(actor, 'scopeAppliedMigrations', { tenantId, scopeId }, null, migrations.length);
8388
+ return migrations;
8061
8389
  },
8062
8390
  scopeMigrationBookmarks: async (actor, tenantId, scopeId) => {
8063
8391
  const scope = this.directory
@@ -8093,21 +8421,24 @@ export class SqliteScopeHost {
8093
8421
  },
8094
8422
  createOrg: async (actor, input) => {
8095
8423
  const parsed = createOrgInput.parse(input);
8096
- if (readOrg(parsed.tenantId, parsed.id))
8097
- return; // idempotent, unaudited
8098
- // Checked explicitly rather than left to the UNIQUE index: OR IGNORE would
8099
- // swallow a collision from a DIFFERENT id and report success, silently not
8100
- // creating the org the caller asked for. Fail closed instead (as createTenant).
8101
- const slugOwner = this.directory
8102
- .prepare('SELECT org_id FROM orgs WHERE tenant_id = ? AND slug = ?')
8103
- .get(parsed.tenantId, parsed.slug);
8104
- if (slugOwner) {
8105
- throw substratError('conflict', `org slug '${parsed.slug}' already taken by ${slugOwner.org_id} (slugs are unique per tenant)`);
8106
- }
8107
- this.directory
8108
- .prepare('INSERT INTO orgs (org_id, tenant_id, slug, name, created_at) VALUES (?, ?, ?, ?, ?)')
8109
- .run(parsed.id, parsed.tenantId, parsed.slug, parsed.name, new Date().toISOString());
8110
- this.recordAdmin(actor, 'createOrg', { tenantId: parsed.tenantId }, null, parsed);
8424
+ // #2526: the checks, the insert and the row are one directory unit.
8425
+ this.directoryWriteAndAudit(() => {
8426
+ if (readOrg(parsed.tenantId, parsed.id))
8427
+ return false; // idempotent, unaudited
8428
+ // Checked explicitly rather than left to the UNIQUE index: OR IGNORE would
8429
+ // swallow a collision from a DIFFERENT id and report success, silently not
8430
+ // creating the org the caller asked for. Fail closed instead (as createTenant).
8431
+ const slugOwner = this.directory
8432
+ .prepare('SELECT org_id FROM orgs WHERE tenant_id = ? AND slug = ?')
8433
+ .get(parsed.tenantId, parsed.slug);
8434
+ if (slugOwner) {
8435
+ throw substratError('conflict', `org slug '${parsed.slug}' already taken by ${slugOwner.org_id} (slugs are unique per tenant)`);
8436
+ }
8437
+ this.directory
8438
+ .prepare('INSERT INTO orgs (org_id, tenant_id, slug, name, created_at) VALUES (?, ?, ?, ?, ?)')
8439
+ .run(parsed.id, parsed.tenantId, parsed.slug, parsed.name, new Date().toISOString());
8440
+ return true;
8441
+ }, () => this.recordAdmin(actor, 'createOrg', { tenantId: parsed.tenantId }, null, parsed));
8111
8442
  },
8112
8443
  listOrgs: async (actor, tenantId) => {
8113
8444
  const rows = this.directory
@@ -8122,12 +8453,15 @@ export class SqliteScopeHost {
8122
8453
  return o;
8123
8454
  },
8124
8455
  addMember: async (actor, tenantId, principal, orgId, opts) => {
8125
- requireOrg(tenantId, orgId);
8126
8456
  // INSERT OR REPLACE, so re-adding a revoked member clears the tombstone —
8127
8457
  // they are a member again. The add/revoke history is not lost: it lives in
8128
8458
  // the append-only admin log, which is where "what happened" belongs. The
8129
8459
  // tuple carries "what is true now" plus enough to explain a live proof.
8130
- joinOrg(actor, tenantId, principal, orgId, opts?.expiresAt ?? null);
8460
+ // #2526: the org's check, the tuple and the row are one directory unit.
8461
+ this.directory.transaction(() => {
8462
+ requireOrg(tenantId, orgId);
8463
+ joinOrg(actor, tenantId, principal, orgId, opts?.expiresAt ?? null);
8464
+ })();
8131
8465
  },
8132
8466
  applyMembership: async (actor, change) => {
8133
8467
  // ONE directory transaction, synchronous throughout (#1184): the fence (an add's), the
@@ -8210,8 +8544,10 @@ export class SqliteScopeHost {
8210
8544
  const slugOwner = this.directory
8211
8545
  .prepare('SELECT tenant_id FROM tenants WHERE slug = ?')
8212
8546
  .get(parsed.slug);
8547
+ // #2556: the refusal never names the owner. Another tenant holds it, and the text
8548
+ // reaches whoever asked (a manager vertical's intent journal among them).
8213
8549
  if (slugOwner) {
8214
- throw substratError('conflict', `tenant slug '${parsed.slug}' already taken by ${slugOwner.tenant_id} (slugs are unique)`);
8550
+ throw substratError('conflict', `tenant slug '${parsed.slug}' is already taken (slugs are unique)`);
8215
8551
  }
8216
8552
  this.directoryWriteAndAudit(() => this.directory
8217
8553
  .prepare(`INSERT INTO tenants (tenant_id, slug, name, status, created_at, provisioned_by_tenant)
@@ -8219,8 +8555,7 @@ export class SqliteScopeHost {
8219
8555
  .run(parsed.id, parsed.slug, parsed.name, new Date().toISOString(), parsed.provisionedByTenant ?? null).changes > 0, () => this.recordAdmin(actor, 'createTenant', { tenantId: parsed.id }, null, readTenant(parsed.id)));
8220
8556
  },
8221
8557
  setTenantStatus: async (actor, tenantId, status) => {
8222
- const before = readTenant(tenantId);
8223
- if (!before)
8558
+ if (!readTenant(tenantId))
8224
8559
  throw substratError('not_found', `unknown tenant: ${tenantId}`);
8225
8560
  // `reaped` is terminal and destroys data — it is unreachable here and only
8226
8561
  // ever set by reapTenant, so a plain status flip cannot forge a tombstone
@@ -8230,22 +8565,33 @@ export class SqliteScopeHost {
8230
8565
  }
8231
8566
  // Stamp/clear deleting_at so the grace-window sweep can age tenants (§4.8):
8232
8567
  // entering `deleting` stamps, leaving it (an un-delete) clears — exactly the
8233
- // shape suspendScope/archiveScope use for archived_at.
8234
- this.directoryWriteAndAudit(() => (status === 'deleting'
8235
- ? this.directory
8236
- .prepare('UPDATE tenants SET status = ?, deleting_at = ? WHERE tenant_id = ?')
8237
- .run(status, new Date().toISOString(), tenantId)
8238
- : this.directory
8239
- .prepare('UPDATE tenants SET status = ?, deleting_at = NULL WHERE tenant_id = ?')
8240
- .run(status, tenantId)).changes > 0, () => this.recordAdmin(actor, 'setTenantStatus', { tenantId }, { status: before.status }, { status }));
8568
+ // shape suspendScope/archiveScope use for archived_at. #2528: the row's `before` is the
8569
+ // status the write's own transaction reads.
8570
+ let before;
8571
+ this.directoryWriteAndAudit(() => {
8572
+ before = readTenant(tenantId);
8573
+ if (!before)
8574
+ throw substratError('not_found', `unknown tenant: ${tenantId}`);
8575
+ return (status === 'deleting'
8576
+ ? this.directory
8577
+ .prepare('UPDATE tenants SET status = ?, deleting_at = ? WHERE tenant_id = ?')
8578
+ .run(status, new Date().toISOString(), tenantId)
8579
+ : this.directory
8580
+ .prepare('UPDATE tenants SET status = ?, deleting_at = NULL WHERE tenant_id = ?')
8581
+ .run(status, tenantId)).changes > 0;
8582
+ }, () => this.recordAdmin(actor, 'setTenantStatus', { tenantId }, { status: before.status }, { status }));
8241
8583
  },
8242
8584
  setTenantName: async (actor, tenantId, name) => {
8243
- const before = readTenant(tenantId);
8244
- if (!before)
8245
- throw substratError('not_found', `unknown tenant: ${tenantId}`);
8246
- if (before.name === name)
8247
- return; // no-op is not audited — nothing changed
8248
- this.directoryWriteAndAudit(() => this.directory.prepare('UPDATE tenants SET name = ? WHERE tenant_id = ?').run(name, tenantId).changes > 0, () => this.recordAdmin(actor, 'setTenantName', { tenantId }, { name: before.name }, { name }));
8585
+ // A no-op rename is not audited — nothing changed. #2528: judged, and `before` read, in the
8586
+ // write's own transaction.
8587
+ let before;
8588
+ this.directoryWriteAndAudit(() => {
8589
+ before = readTenant(tenantId);
8590
+ if (!before)
8591
+ throw substratError('not_found', `unknown tenant: ${tenantId}`);
8592
+ return before.name !== name
8593
+ && this.directory.prepare('UPDATE tenants SET name = ? WHERE tenant_id = ?').run(name, tenantId).changes > 0;
8594
+ }, () => this.recordAdmin(actor, 'setTenantName', { tenantId }, { name: before.name }, { name }));
8249
8595
  },
8250
8596
  listTenants: async (actor, page) => {
8251
8597
  const where = [];
@@ -8392,6 +8738,9 @@ export class SqliteScopeHost {
8392
8738
  recordStoreDriverRebuildRun: async (actor, entry) => {
8393
8739
  this.recordAdmin(actor, 'rebuildStoreDriver', { tenantId: null }, null, entry, this.clock());
8394
8740
  },
8741
+ recordWireWalk: async (actor, entry) => {
8742
+ this.recordAdmin(actor, 'walkWireAuth', { tenantId: null }, null, entry, this.clock());
8743
+ },
8395
8744
  getScopeRecord: async (actor, tenantId, scopeId) => {
8396
8745
  const r = this.directory.prepare('SELECT * FROM scopes WHERE scope_id = ?').get(scopeId);
8397
8746
  // Cross-check the pair (K-3): a scope that exists under a DIFFERENT tenant
@@ -8406,65 +8755,26 @@ export class SqliteScopeHost {
8406
8755
  // K-3: the (tenantId, scopeId) pair is cross-checked before we open anything;
8407
8756
  // a scope under a different tenant is unreachable, never another tenant's DB.
8408
8757
  const db = this.scopeReadDbFor(tenantId, scopeId);
8409
- const names = db
8410
- .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
8411
- .all().map((r) => r.name);
8412
- const tables = names.map((name) => ({
8413
- name,
8414
- rowCount: db.prepare(`SELECT COUNT(*) AS c FROM "${name}"`).get().c,
8415
- system: isSystemTable(name),
8416
- }));
8758
+ const tables = scopeTablesOf(db);
8417
8759
  this.recordAccess(actor, 'listScopeTables', { tenantId, scopeId }, null, tables.length);
8418
8760
  return tables;
8419
8761
  },
8420
8762
  readScopeTable: async (actor, tenantId, scopeId, input) => {
8421
8763
  const db = this.scopeReadDbFor(tenantId, scopeId);
8422
- // Validate the table against the LIVE schema — an unknown name throws, it is
8423
- // never interpolated blind. That validated name is the only thing that reaches
8424
- // the query, so the quoted identifier below carries no user input.
8425
- const known = new Set(db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((r) => r.name));
8426
- if (!known.has(input.table))
8427
- throw substratError('not_found', `unknown table '${input.table}'`);
8428
- // The ceiling clamps; a bound SQLite would misread (NaN, non-finite, fractional,
8429
- // negative) is refused instead of reaching LIMIT / OFFSET (#1632).
8430
- const limit = Math.min(assertRowLimit('limit', input.limit ?? SCOPE_TABLE_PAGE_DEFAULT), SCOPE_TABLE_PAGE_MAX);
8431
- const offset = assertRowOffset('offset', input.offset ?? 0);
8432
- const stmt = db.prepare(`SELECT * FROM "${input.table}" LIMIT ? OFFSET ?`).raw(true);
8433
- const rows = stmt.all(limit, offset).map((row) => row.map(cellToJson));
8434
- const columns = stmt.columns().map((c) => c.name);
8435
- const rowCount = db.prepare(`SELECT COUNT(*) AS c FROM "${input.table}"`).get().c;
8436
- this.recordAccess(actor, 'readScopeTable', { tenantId, scopeId }, { table: input.table, limit, offset }, rows.length);
8437
- return { table: input.table, columns, rows, rowCount, limit, offset };
8764
+ const page = scopeTablePageOf(db, input);
8765
+ this.recordAccess(actor, 'readScopeTable', { tenantId, scopeId }, { table: input.table, limit: page.limit, offset: page.offset }, page.rows.length);
8766
+ return page;
8438
8767
  },
8439
8768
  queryScope: async (actor, tenantId, scopeId, input) => {
8440
8769
  const db = this.scopeReadDbFor(tenantId, scopeId);
8441
- // Layer 1, shared: the kernel's textual gate (single statement, read verbs
8442
- // only) — the checked statement is the one that runs.
8443
- const sql = assertReadOnlyQuery(input.sql);
8444
- // Layer 2, authoritative: sqlite3_stmt_readonly via better-sqlite3. prepare()
8445
- // itself rejects multi-statement strings and bad SQL with the driver's message.
8446
- const stmt = db.prepare(sql);
8447
- if (!stmt.readonly)
8448
- throw substratError('validation_failed', 'read-only console: statement is not read-only');
8449
- if (!stmt.reader)
8450
- return { columns: [], rows: [], truncated: false };
8451
- stmt.raw(true);
8452
- const rows = [];
8453
- let truncated = false;
8454
- // Iterate rather than .all(): the row cap bounds what leaves the DB, so an
8455
- // over-broad SELECT costs a screenful, not the table.
8456
- for (const row of stmt.iterate()) {
8457
- if (rows.length >= SCOPE_QUERY_ROW_MAX) {
8458
- truncated = true;
8459
- break;
8460
- }
8461
- rows.push(row.map(cellToJson));
8462
- }
8463
- const columns = stmt.columns().map((c) => c.name);
8770
+ const { sql, reader, result } = scopeQueryOf(db, input);
8771
+ // A statement that reads nothing returns before any row is recorded, as it always has.
8772
+ if (!reader)
8773
+ return result;
8464
8774
  // The statement itself is the logged argument — the K-24 access log is the
8465
8775
  // evidence trail for what staff read, and here the read IS the SQL.
8466
- this.recordAccess(actor, 'queryScope', { tenantId, scopeId }, { sql }, rows.length);
8467
- return { columns, rows, truncated };
8776
+ this.recordAccess(actor, 'queryScope', { tenantId, scopeId }, { sql }, result.rows.length);
8777
+ return result;
8468
8778
  },
8469
8779
  // -- the denial log (K-35, #867) -------------------------------------
8470
8780
  // Same shape as the introspection reads above: `scopeDbFor` is the K-3
@@ -8886,13 +9196,9 @@ export class SqliteScopeHost {
8886
9196
  // caller (the reap route / grace-window sweep) has already reaped every scope's
8887
9197
  // storage above the kernel; this clears the tenant's directory-side PII/config
8888
9198
  // and flips the row to a `reaped` tombstone. Only a `deleting` tenant may be
8889
- // reaped — an illegal source fails closed, like reapScope's archived-only gate.
8890
- const before = readTenant(tenantId);
8891
- if (!before)
8892
- throw substratError('not_found', `unknown tenant: ${tenantId}`);
8893
- if (before.status !== 'deleting') {
8894
- throw substratError('conflict', `tenant ${tenantId} is ${before.status}, not deleting — only a deleting tenant may be reaped`);
8895
- }
9199
+ // reaped — an illegal source fails closed, like reapScope's archived-only gate. #2528: that
9200
+ // gate and the row's `before` are read in the transaction that reaps.
9201
+ let before;
8896
9202
  // Clear the tenant's directory-side rows. Idempotent (set-to-empty), so a crash
8897
9203
  // mid-reap converges on retry. KEPT as the tombstone: the `tenants` row itself
8898
9204
  // and `_substrat_admin_log` (the compliance witness — never swept). Scope rows
@@ -8917,6 +9223,12 @@ export class SqliteScopeHost {
8917
9223
  '_substrat_scope_attachment_storage', // #2501: attachment samples, gone with the tenant
8918
9224
  ];
8919
9225
  this.directoryWriteAndAudit(() => {
9226
+ before = readTenant(tenantId);
9227
+ if (!before)
9228
+ throw substratError('not_found', `unknown tenant: ${tenantId}`);
9229
+ if (before.status !== 'deleting') {
9230
+ throw substratError('conflict', `tenant ${tenantId} is ${before.status}, not deleting — only a deleting tenant may be reaped`);
9231
+ }
8920
9232
  for (const table of tables) {
8921
9233
  this.directory.prepare(`DELETE FROM ${table} WHERE tenant_id = ?`).run(tenantId);
8922
9234
  }