@substrat-run/adapter-sqlite 0.137.0 → 0.139.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/checker.d.ts +7 -1
- package/dist/checker.d.ts.map +1 -1
- package/dist/checker.js +15 -7
- package/dist/checker.js.map +1 -1
- package/dist/index.d.ts +48 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +654 -367
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import Database from 'better-sqlite3';
|
|
4
|
-
import { accessLogEntry, adminLogEntry, delegatedReadParams, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, exportReadInput, importBatch, importCursorMove, exportsOfManifestJson, importsOfManifestJson, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchRecord, peerSwitch, verticalCaller, verticalSlug as verticalSlugOf, subjectRef, requestFingerprint, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, ROUTE_SCOPE_HOSTNAMES_MAX, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, callsOfManifestJson, outboundOfManifestJson, listLimitOf, substratError, assertReplayableDump, delegatedReadRecord, ownerTransferAudit, copyMarkAudit, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
-
import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, COPY_ORIGIN_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, readCapabilityPage, redactSecrets, redactSecretText, moduleLog, consoleLogSink, asyncInvocationId, asyncLinePass, consoleInvocationLineSink, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, guardSpine, guardSqlLimits, DO_SQL_LIMITS, tooManyResultColumns, tooManyTableColumns, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, assertRowLimit, assertRowOffset, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, redactSubjectScopeText, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple,
|
|
6
|
-
import {
|
|
4
|
+
import { accessLogEntry, adminLogEntry, delegatedReadParams, operationSeriesCount, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, exportReadInput, importBatch, importCursorMove, exportsOfManifestJson, importsOfManifestJson, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchRecord, peerSwitch, verticalCaller, verticalSlug as verticalSlugOf, subjectRef, requestFingerprint, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, ROUTE_SCOPE_HOSTNAMES_MAX, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, callsOfManifestJson, outboundOfManifestJson, listLimitOf, SCOPE_GATE_REASONS, substratError, assertReplayableDump, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, copyMarkAudit, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
+
import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, COPY_ORIGIN_DDL, ENTITY_STATE_MOVES_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, capabilityTokenHash, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, searchStateWhere, uncheckedView, addStatePlans, entityStateMigrations, entityStateTriggerDdl, statefulTablesOf, assertEntityStateIntact, stateListIndexNames, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, readCapabilityPage, redactSecrets, redactSecretText, moduleLog, consoleLogSink, asyncInvocationId, asyncLinePass, consoleInvocationLineSink, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, guardSpine, guardSqlLimits, DO_SQL_LIMITS, tooManyResultColumns, tooManyTableColumns, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, createFindingRule, findingOfOpsFailure, findingOfSweepRun, pruneFindings, listFindingRules, listFindings, observeFinding, revokeFindingRule, setFindingStatus, assertRowLimit, assertRowOffset, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, redactSubjectScopeText, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_STEP_RECORD_SQL, JOB_LEASE_EXPIRED_NOTE, assertLeaseMs, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, applyScopeRoleChange, changeScopeRole, revokeScopeRoles, scopeRoleHolders, admitPeer, collectPeers, peerSeats, resolveVerticalInstanceFrom, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, PEER_SWITCHES_DDL, SWITCH_OWED_DDL, SWITCH_FENCES_DDL, recordWriteSuperseded, switchFencesOf, switchSupersededMessage, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, moveSwitch, reassertActionOf, reassertEntry, reassertOffRow, recordSwitchedOff, recordSwitchedOn, restoreSwitchRecord, scopesSwitchedOffFor, switchActionOf, switchAuditSubject, switchNotFoundMessage, switchRecordsOf, switchedOffOf, switchesBackfillSqlOf, switchesTableExists, tenantHeldOf, tenantHoldsGrant, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, VERSION_MIGRATIONS_DDL, splitManifestMigrations, splitVersionMigrationsBatch, versionMigrationsOf, writeVersionMigrations, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, assertSessionUsable, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, impersonationStampOf, mapImpersonationRow, newImpersonationSession, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, memberAddedAudit, orgChangeBound, tenantCoverage, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexDdl, listIndexMigrations, listIndexPlans, listQuery, cursorOf, moduleMigrations, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, attachmentRecordOfRow, attachmentTextJob, enqueueAttachmentText, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, queueAttachmentTextBackfill, startAttachmentTextBackfill, attachmentTextBackfillJob, reconcileAttachmentText, recordAttachmentText, searchAttachments, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, emptyImportResult, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, exportedSinceQuery, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, readLifecycleFlow, readOperationSeries, domainEventOf, readUndrainedOutbox, undrainedEventsOf, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, unknownRoleError, } from '@substrat-run/kernel';
|
|
6
|
+
import { attributedView } from '@substrat-run/kernel';
|
|
7
7
|
import { INERT_SCOPE_REASON, isPrimaryScopeRow } from '@substrat-run/kernel';
|
|
8
8
|
import { LEGACY_SCOPE_ROWS_BACKFILL, assertNoSpineReference, assertSpineTablesBuilt, capabilitiesForLoad, dumpRowsInsert, isSpineTable, loadDirectoryDump, repointScopeGrants, settleCopiedWork, emittedHere, spineColumnAdditions } from '@substrat-run/kernel';
|
|
9
9
|
import { ScopeActor } from './actor.js';
|
|
10
|
-
import { createTupleChecker } from './checker.js';
|
|
10
|
+
import { createTupleChecker, directoryTenantReader } from './checker.js';
|
|
11
11
|
/**
|
|
12
12
|
* A fault in the database itself (full, corrupt, I/O), as opposed to one statement failing.
|
|
13
13
|
* The #1764 backfill logs and opens past a failed batch, but never past one of these.
|
|
@@ -213,6 +213,9 @@ const KERNEL_DDL = `
|
|
|
213
213
|
-- #1686: where a copied scope's data came from, and the event id its own events start above.
|
|
214
214
|
-- Shared with the other adapter from @substrat-run/kernel; the column comments are there.
|
|
215
215
|
${COPY_ORIGIN_DDL}
|
|
216
|
+
-- #119: the kernel's authorization for one archive/trash move, read by the derived update
|
|
217
|
+
-- trigger. Shared with the other adapter from @substrat-run/kernel; the comments are there.
|
|
218
|
+
${ENTITY_STATE_MOVES_DDL}
|
|
216
219
|
-- #383 / #1232 / #1288: the platform sweep's per-scope gating state, holding two
|
|
217
220
|
-- families of row that the kind COLUMN — not the spelling of a key — tells apart.
|
|
218
221
|
-- Spine (kernel-written), never a module migration. Shared with the DO adapter from
|
|
@@ -243,6 +246,7 @@ const KERNEL_DDL = `
|
|
|
243
246
|
rows_changed INTEGER,
|
|
244
247
|
PRIMARY KEY (module_id, version)
|
|
245
248
|
);
|
|
249
|
+
${SWITCH_FENCES_DDL}
|
|
246
250
|
CREATE TABLE IF NOT EXISTS _substrat_tuples (
|
|
247
251
|
subject TEXT NOT NULL,
|
|
248
252
|
relation TEXT NOT NULL,
|
|
@@ -520,6 +524,8 @@ export class SqliteScopeHost {
|
|
|
520
524
|
searchPlans = new Map();
|
|
521
525
|
/** #811: the paged lists modules declare, by entity type. Same one-owner rule as search. */
|
|
522
526
|
listPlans = new Map();
|
|
527
|
+
/** #119: entity type → its archive/trash plan, from every registered module. */
|
|
528
|
+
statePlans = new Map();
|
|
523
529
|
/** entityType → the declared attachment gate (#473): read key + write key (default: read). */
|
|
524
530
|
attachmentTargets = new Map();
|
|
525
531
|
/** operation name → who binds it: the owning module, its entitlementKey and its declared
|
|
@@ -556,10 +562,10 @@ export class SqliteScopeHost {
|
|
|
556
562
|
*/
|
|
557
563
|
jobs = new Map();
|
|
558
564
|
/**
|
|
559
|
-
* The event
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
565
|
+
* The event an executor is effecting, stamped onto the admin rows it writes (K-22). Like
|
|
566
|
+
* `onBehalfOf`, only ever set on a VIEW (`causedByView`, or `attributed` with a
|
|
567
|
+
* `causedBy`), never on the host: a field set around the handler's `await` stamped every
|
|
568
|
+
* admin call the host served meanwhile — a staff call included — with that event (#2055).
|
|
563
569
|
*/
|
|
564
570
|
causedBy = null;
|
|
565
571
|
/**
|
|
@@ -572,6 +578,7 @@ export class SqliteScopeHost {
|
|
|
572
578
|
secretBox;
|
|
573
579
|
/** The parsers attachment text is extracted with (K-43); the host's own, never imported here. */
|
|
574
580
|
attachmentExtractors;
|
|
581
|
+
attachmentTextBounds;
|
|
575
582
|
fetchImpl;
|
|
576
583
|
connectorCalls;
|
|
577
584
|
clock;
|
|
@@ -589,6 +596,7 @@ export class SqliteScopeHost {
|
|
|
589
596
|
this.secretBox = options.secretBox ?? unconfiguredSecretBox;
|
|
590
597
|
assertAttachmentExtractors(options.attachmentExtractors ?? []);
|
|
591
598
|
this.attachmentExtractors = options.attachmentExtractors ?? [];
|
|
599
|
+
this.attachmentTextBounds = resolveAttachmentTextBounds(options.attachmentTextBounds);
|
|
592
600
|
this.fetchImpl = options.fetch ?? globalFetch;
|
|
593
601
|
this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
|
|
594
602
|
this.clock = options.clock ?? (() => instant.parse(new Date().toISOString()));
|
|
@@ -1063,6 +1071,9 @@ export class SqliteScopeHost {
|
|
|
1063
1071
|
);
|
|
1064
1072
|
${IMPERSONATION_DDL}
|
|
1065
1073
|
${SYSTEM_SWITCHES_DDL}
|
|
1074
|
+
${MEMBERSHIP_FENCES_DDL}
|
|
1075
|
+
${PEER_SWITCHES_DDL}
|
|
1076
|
+
${SWITCH_OWED_DDL}
|
|
1066
1077
|
CREATE TABLE IF NOT EXISTS _substrat_admin_log (
|
|
1067
1078
|
id TEXT PRIMARY KEY,
|
|
1068
1079
|
actor TEXT NOT NULL,
|
|
@@ -1142,6 +1153,8 @@ export class SqliteScopeHost {
|
|
|
1142
1153
|
resolved_at TEXT
|
|
1143
1154
|
);
|
|
1144
1155
|
CREATE INDEX IF NOT EXISTS _substrat_issues_seen ON _substrat_issues (last_seen);
|
|
1156
|
+
-- #1748: tenant findings and their suppress rules — kernel-owned DDL (findings.ts).
|
|
1157
|
+
${FINDINGS_DDL}
|
|
1145
1158
|
-- The durable sweep record (#1232): one row per unit outcome per pass —
|
|
1146
1159
|
-- a connection swept/skipped/failed, a schedule fired/skipped/failed. What
|
|
1147
1160
|
-- makes "when was this last swept" answerable after the log line rolls off.
|
|
@@ -1237,10 +1250,16 @@ export class SqliteScopeHost {
|
|
|
1237
1250
|
*/
|
|
1238
1251
|
ensureDirectorySchema() {
|
|
1239
1252
|
this.directory.transaction(() => {
|
|
1240
|
-
|
|
1253
|
+
// Each kill switch's record (#1674, #2029) is backfilled on the pass that creates its table.
|
|
1254
|
+
const newRecords = SWITCH_KINDS.filter((kind) => !switchesTableExists(switchSqlOf(this.directory), kind));
|
|
1255
|
+
const fencesAreNew = !membershipFencesTableExists(switchSqlOf(this.directory));
|
|
1241
1256
|
this.buildDirectorySchema();
|
|
1242
|
-
|
|
1243
|
-
this.directory.exec(
|
|
1257
|
+
for (const kind of newRecords)
|
|
1258
|
+
this.directory.exec(switchesBackfillSqlOf(kind));
|
|
1259
|
+
// #1184: the removal fence, for everyone removed before it existed — the same once-only
|
|
1260
|
+
// gate — stamped now, by the host clock (`membershipFencesBackfillSql` says why).
|
|
1261
|
+
if (fencesAreNew)
|
|
1262
|
+
this.directory.exec(membershipFencesBackfillSql(this.clock()));
|
|
1244
1263
|
})();
|
|
1245
1264
|
this.splitVersionMigrations();
|
|
1246
1265
|
}
|
|
@@ -1448,14 +1467,17 @@ export class SqliteScopeHost {
|
|
|
1448
1467
|
// #811: the list indexes this module's `lists` declare — same placement and
|
|
1449
1468
|
// the same reason as the search indexes above: appended AFTER the module's
|
|
1450
1469
|
// own migrations, so `CREATE INDEX` names a table that exists.
|
|
1451
|
-
|
|
1452
|
-
|
|
1470
|
+
// #119: the archive/trash columns, and the plans the verbs and the reads consult.
|
|
1471
|
+
const stateMigrations = entityStateMigrations(manifest.id, manifest.entityStates);
|
|
1472
|
+
const listMigrations = listIndexMigrations(manifest.id, manifest.lists, manifest.entityStates);
|
|
1473
|
+
for (const m of [...stateMigrations, ...listMigrations]) {
|
|
1453
1474
|
if (seen.has(m.version)) {
|
|
1454
1475
|
throw new Error(`duplicate migration version in ${manifest.id}: ${m.version}`);
|
|
1455
1476
|
}
|
|
1456
1477
|
seen.add(m.version);
|
|
1457
1478
|
}
|
|
1458
|
-
|
|
1479
|
+
addStatePlans(this.statePlans, manifest.id, manifest.entityStates, manifest.permissions);
|
|
1480
|
+
for (const plan of listIndexPlans(manifest.id, manifest.lists, manifest.entityStates)) {
|
|
1459
1481
|
const existing = this.listPlans.get(plan.entityType);
|
|
1460
1482
|
if (existing) {
|
|
1461
1483
|
throw new Error(`list: '${plan.entityType}' declares a paged list in both '${existing.moduleId}' and ` +
|
|
@@ -1578,10 +1600,10 @@ export class SqliteScopeHost {
|
|
|
1578
1600
|
.prepare('SELECT status FROM tenants WHERE tenant_id = ?')
|
|
1579
1601
|
.get(input.tenantId);
|
|
1580
1602
|
if (!tenantRow) {
|
|
1581
|
-
throw
|
|
1603
|
+
throw substratError('conflict', `cannot provision scope under unknown tenant: ${input.tenantId}`);
|
|
1582
1604
|
}
|
|
1583
1605
|
if (tenantRow.status !== 'active') {
|
|
1584
|
-
throw
|
|
1606
|
+
throw substratError('conflict', `cannot provision scope under non-active tenant (status: ${tenantRow.status}): ${input.tenantId}`);
|
|
1585
1607
|
}
|
|
1586
1608
|
const record = resolveScopeRecord(input);
|
|
1587
1609
|
// Idempotency is on the scope_id (§3.3: provisioning is idempotent and
|
|
@@ -1600,7 +1622,7 @@ export class SqliteScopeHost {
|
|
|
1600
1622
|
.prepare("SELECT scope_id FROM scopes WHERE tenant_id = ? AND slug = ? AND status NOT IN ('archived', 'reaped')")
|
|
1601
1623
|
.get(input.tenantId, record.slug);
|
|
1602
1624
|
if (slugOwner) {
|
|
1603
|
-
throw
|
|
1625
|
+
throw substratError('conflict', `scope slug '${record.slug}' already taken under tenant ${input.tenantId} ` +
|
|
1604
1626
|
`by ${slugOwner.scope_id} (slugs are unique within a tenant)`);
|
|
1605
1627
|
}
|
|
1606
1628
|
this.directory
|
|
@@ -1666,51 +1688,52 @@ export class SqliteScopeHost {
|
|
|
1666
1688
|
}
|
|
1667
1689
|
/**
|
|
1668
1690
|
* `HostAdmin.reassertSystemSwitches`' body (#1674), run inside a turn the caller already
|
|
1669
|
-
* holds on the scope's actor: every module the directory records OFF
|
|
1670
|
-
* switched off again. Already off answers `changed: false` and writes nothing,
|
|
1671
|
-
*
|
|
1672
|
-
* alone. Audited only when something moved.
|
|
1691
|
+
* holds on the scope's actor: every module, and (#2029) every peer, the directory records OFF
|
|
1692
|
+
* on the scope is switched off again. Already off answers `changed: false` and writes nothing,
|
|
1693
|
+
* and a subject with no authority reaching the scope (no grant here, no tenant-level grant) is
|
|
1694
|
+
* left alone. Audited only when something moved, under each kind's re-assert action.
|
|
1673
1695
|
*
|
|
1674
1696
|
* `audit` receives each row. By default it writes to the admin log at once. A caller running
|
|
1675
1697
|
* this inside a scope transaction that may still roll back (a restore's replay) collects
|
|
1676
1698
|
* the rows and writes them once the transaction commits. The admin log is another
|
|
1677
1699
|
* database, and a row must not outlive a move that was rolled back.
|
|
1678
1700
|
*/
|
|
1679
|
-
reassertSwitchesInTurn(rt, actor, tenantId, scopeId, opts, audit = (after) => this.recordAdmin(actor,
|
|
1701
|
+
reassertSwitchesInTurn(rt, actor, tenantId, scopeId, opts, audit = (action, after) => this.recordAdmin(actor, action, { tenantId, scopeId }, null, after)) {
|
|
1680
1702
|
const at = new Date().toISOString();
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
const
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
const
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
return recordedOff.map((moduleId) => {
|
|
1699
|
-
// #1823: a module whose only authority here is a tenant-level grant is held too.
|
|
1700
|
-
const tenantHeld = tenantHoldsSystemGrant(switchSqlOf(this.directory), tenantId, moduleId, this.clock());
|
|
1701
|
-
const outcome = rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), { moduleId, scopeId, to: 'off', at, tenantHeld }))();
|
|
1702
|
-
if (outcome.changed) {
|
|
1703
|
-
audit({
|
|
1704
|
-
operationId: ulid(),
|
|
1705
|
-
moduleId,
|
|
1706
|
-
schedules: 'off',
|
|
1707
|
-
phase: 'applied',
|
|
1708
|
-
changed: true,
|
|
1709
|
-
permissions: outcome.permissions,
|
|
1710
|
-
});
|
|
1703
|
+
const directorySql = switchSqlOf(this.directory);
|
|
1704
|
+
const results = [];
|
|
1705
|
+
for (const kind of SWITCH_KINDS) {
|
|
1706
|
+
const action = reassertActionOf(kind);
|
|
1707
|
+
// #2045: every move carries the fence of the call the record holds, so a scope moved since by
|
|
1708
|
+
// a newer call stays as that call put it.
|
|
1709
|
+
const fences = switchFencesOf(directorySql, kind, tenantId, scopeId);
|
|
1710
|
+
const move = (key, to, tenantHeld) => rt.db.transaction(() => moveSwitch(switchSqlOf(rt.db), kind, { key, scopeId, to, at, tenantHeld, fence: fences.get(key) }))();
|
|
1711
|
+
// #1742 review: a move the deployment made from a stale list — the subject was restored
|
|
1712
|
+
// ON after the list was read — is undone first, so the operator's ON stands.
|
|
1713
|
+
const recorded = switchRecordsOf(directorySql, kind, tenantId, scopeId);
|
|
1714
|
+
// One synchronous turn: no switch can land between this read and the moves below.
|
|
1715
|
+
const reverted = new Set(staleCarryReverts(kind, recorded, opts?.appliedInUnit));
|
|
1716
|
+
for (const key of reverted) {
|
|
1717
|
+
const outcome = move(key, 'on');
|
|
1718
|
+
if (outcome.changed)
|
|
1719
|
+
audit(action, { operationId: ulid(), ...staleCarryRevertRow(kind, key, outcome) });
|
|
1711
1720
|
}
|
|
1712
|
-
|
|
1713
|
-
|
|
1721
|
+
const recordedOff = switchedOffOf(directorySql, kind, tenantId, scopeId);
|
|
1722
|
+
// #1742: what a deployment already switched off inside its own unit, audited here — the
|
|
1723
|
+
// switch below answers `changed: false` for it and would write no row. A move the revert
|
|
1724
|
+
// above undid is not credited as an in-unit OFF.
|
|
1725
|
+
for (const row of inUnitMovesToAudit(kind, recordedOff, opts?.appliedInUnit, reverted)) {
|
|
1726
|
+
audit(action, { operationId: ulid(), ...row });
|
|
1727
|
+
}
|
|
1728
|
+
for (const key of recordedOff) {
|
|
1729
|
+
// #1823 (#2030): a subject whose only authority here is a tenant-level grant is held too.
|
|
1730
|
+
const outcome = move(key, 'off', tenantHoldsGrant(directorySql, kind, tenantId, key, this.clock()));
|
|
1731
|
+
if (outcome.changed)
|
|
1732
|
+
audit(action, { operationId: ulid(), ...reassertOffRow(kind, key, outcome.permissions) });
|
|
1733
|
+
results.push(reassertEntry(kind, key, outcome));
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
return results;
|
|
1714
1737
|
}
|
|
1715
1738
|
async provisionTenantStore(actor, input) {
|
|
1716
1739
|
// Fail closed on an unknown/inactive tenant, exactly as provisionScope does: a store
|
|
@@ -1720,10 +1743,10 @@ export class SqliteScopeHost {
|
|
|
1720
1743
|
.prepare('SELECT status FROM tenants WHERE tenant_id = ?')
|
|
1721
1744
|
.get(input.tenantId);
|
|
1722
1745
|
if (!tenantRow) {
|
|
1723
|
-
throw
|
|
1746
|
+
throw substratError('not_found', `cannot provision tenant store under unknown tenant: ${input.tenantId}`);
|
|
1724
1747
|
}
|
|
1725
1748
|
if (tenantRow.status !== 'active') {
|
|
1726
|
-
throw
|
|
1749
|
+
throw substratError('conflict', `cannot provision tenant store under non-active tenant (status: ${tenantRow.status}): ${input.tenantId}`);
|
|
1727
1750
|
}
|
|
1728
1751
|
// Idempotent on (tenant, vertical, binding): a retried provision re-resolves the SAME
|
|
1729
1752
|
// store rather than minting a second database (the K-31 ready-gate retries the whole
|
|
@@ -1791,10 +1814,10 @@ export class SqliteScopeHost {
|
|
|
1791
1814
|
.prepare('SELECT status FROM tenants WHERE tenant_id = ?')
|
|
1792
1815
|
.get(input.tenantId);
|
|
1793
1816
|
if (!tenantRow) {
|
|
1794
|
-
throw
|
|
1817
|
+
throw substratError('not_found', `cannot provision blob store under unknown tenant: ${input.tenantId}`);
|
|
1795
1818
|
}
|
|
1796
1819
|
if (tenantRow.status !== 'active') {
|
|
1797
|
-
throw
|
|
1820
|
+
throw substratError('conflict', `cannot provision blob store under non-active tenant (status: ${tenantRow.status}): ${input.tenantId}`);
|
|
1798
1821
|
}
|
|
1799
1822
|
const existing = this.directory
|
|
1800
1823
|
.prepare('SELECT ref FROM blob_stores WHERE tenant_id = ? AND vertical = ? AND binding = ?')
|
|
@@ -2255,6 +2278,18 @@ export class SqliteScopeHost {
|
|
|
2255
2278
|
if (present.has(plan.table))
|
|
2256
2279
|
db.exec(searchIndexDdl(plan));
|
|
2257
2280
|
}
|
|
2281
|
+
// #119: the guard triggers went with the dropped table. Put back AFTER the rows, which
|
|
2282
|
+
// may legitimately arrive archived or trashed.
|
|
2283
|
+
for (const plan of this.statePlans.values()) {
|
|
2284
|
+
if (present.has(plan.table))
|
|
2285
|
+
db.exec(entityStateTriggerDdl(plan));
|
|
2286
|
+
}
|
|
2287
|
+
// #811 / #119: the derived list indexes went with it too, and a load never put them back —
|
|
2288
|
+
// an archivable entity's partial indexes are part of what the kernel checks after DDL.
|
|
2289
|
+
for (const plan of this.listPlans.values()) {
|
|
2290
|
+
if (present.has(plan.table))
|
|
2291
|
+
db.exec(listIndexDdl(plan));
|
|
2292
|
+
}
|
|
2258
2293
|
// #1575: attachment text is not in a dump, so a load left it as it was. Drop the
|
|
2259
2294
|
// text of attachments the dump did not bring back, and queue extraction for those
|
|
2260
2295
|
// it brought back without text — the bytes decide what that run finds.
|
|
@@ -2316,9 +2351,9 @@ export class SqliteScopeHost {
|
|
|
2316
2351
|
const rows = [];
|
|
2317
2352
|
// #1869: the re-point's source is the separate hint when given; the dump is otherwise as sent.
|
|
2318
2353
|
const source = { tables: dump.tables, scopeId: opts?.sourceScopeId ?? dump.scopeId };
|
|
2319
|
-
await this.loadDump(tenantId, scopeId, source, (rt) => this.reassertSwitchesInTurn(rt, actor, tenantId, scopeId, undefined, (after) => rows.push(after)));
|
|
2320
|
-
for (const after of rows)
|
|
2321
|
-
this.recordAdmin(actor,
|
|
2354
|
+
await this.loadDump(tenantId, scopeId, source, (rt) => this.reassertSwitchesInTurn(rt, actor, tenantId, scopeId, undefined, (action, after) => rows.push([action, after])));
|
|
2355
|
+
for (const [action, after] of rows)
|
|
2356
|
+
this.recordAdmin(actor, action, { tenantId, scopeId }, null, after);
|
|
2322
2357
|
this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
|
|
2323
2358
|
}
|
|
2324
2359
|
async snapshotScope(actor, tenantId, scopeId, opts) {
|
|
@@ -2351,7 +2386,7 @@ export class SqliteScopeHost {
|
|
|
2351
2386
|
if (!rec)
|
|
2352
2387
|
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2353
2388
|
if (!rec.forkedFrom && rec.kind !== 'preview') {
|
|
2354
|
-
throw
|
|
2389
|
+
throw substratError('forbidden', `scope ${scopeId} is not a fork or preview — only previews may be deleted; ` +
|
|
2355
2390
|
`archive a primary scope instead`);
|
|
2356
2391
|
}
|
|
2357
2392
|
// Close and evict the runtime handle before touching the file.
|
|
@@ -2364,7 +2399,7 @@ export class SqliteScopeHost {
|
|
|
2364
2399
|
// orphaned bytes with no record (the §9 hazard).
|
|
2365
2400
|
this.directory.prepare('DELETE FROM hostnames WHERE scope_id = ?').run(scopeId);
|
|
2366
2401
|
rmSync(join(this.dir, `${tenantId}__${scopeId}.sqlite`), { force: true });
|
|
2367
|
-
|
|
2402
|
+
forgetSwitchesOf(switchSqlOf(this.directory), scopeId);
|
|
2368
2403
|
this.directory.prepare('DELETE FROM scopes WHERE scope_id = ?').run(scopeId);
|
|
2369
2404
|
this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
|
|
2370
2405
|
forkedFrom: rec.forkedFrom,
|
|
@@ -2389,10 +2424,12 @@ export class SqliteScopeHost {
|
|
|
2389
2424
|
.prepare('SELECT status FROM tenants WHERE tenant_id = ?')
|
|
2390
2425
|
.get(tenantId);
|
|
2391
2426
|
if (!tenantRow) {
|
|
2392
|
-
throw
|
|
2427
|
+
throw substratError('not_found', `scope has no tenant record: (${tenantId}, ${scopeId})`, {
|
|
2428
|
+
reason: SCOPE_GATE_REASONS.unrecorded,
|
|
2429
|
+
});
|
|
2393
2430
|
}
|
|
2394
2431
|
if (tenantRow.status !== 'active') {
|
|
2395
|
-
throw
|
|
2432
|
+
throw substratError('conflict', `tenant not active (status: ${tenantRow.status}): ${tenantId}`, { reason: SCOPE_GATE_REASONS.notActive });
|
|
2396
2433
|
}
|
|
2397
2434
|
// `provisioning` is handled BELOW rather than here, because a scope that never
|
|
2398
2435
|
// finished setting up should still retry its migrations when touched — that lazy
|
|
@@ -2401,7 +2438,7 @@ export class SqliteScopeHost {
|
|
|
2401
2438
|
// deliberate states, and running migrations for them would be work on behalf of a
|
|
2402
2439
|
// request that is going to be refused anyway.
|
|
2403
2440
|
if (row.status !== 'active' && row.status !== 'provisioning') {
|
|
2404
|
-
throw
|
|
2441
|
+
throw substratError('conflict', `scope not active (status: ${row.status}): ${scopeId}`, { reason: SCOPE_GATE_REASONS.notActive });
|
|
2405
2442
|
}
|
|
2406
2443
|
const rt = this.runtime(tenantId, scopeId);
|
|
2407
2444
|
await this.applyPendingMigrations(rt);
|
|
@@ -2410,7 +2447,7 @@ export class SqliteScopeHost {
|
|
|
2410
2447
|
// the scope exists on the vertical's side (K-31). Refused, but only after the
|
|
2411
2448
|
// retry above has had its chance — and if THAT is what failed, it threw with
|
|
2412
2449
|
// the migration's own message, which is the one an operator needs.
|
|
2413
|
-
throw
|
|
2450
|
+
throw substratError('conflict', `scope not active (status: ${row.status}): ${scopeId}`, { reason: SCOPE_GATE_REASONS.notActive });
|
|
2414
2451
|
}
|
|
2415
2452
|
return this.buildStub(tenantId, scopeId, rt, asPrincipal(principal), options);
|
|
2416
2453
|
}
|
|
@@ -2446,7 +2483,7 @@ export class SqliteScopeHost {
|
|
|
2446
2483
|
`runs '${scope.vertical ?? 'none'}'`);
|
|
2447
2484
|
}
|
|
2448
2485
|
if (scope.status !== 'active') {
|
|
2449
|
-
throw
|
|
2486
|
+
throw substratError('conflict', `scope not active (status: ${scope.status}): ${scopeId}`, { reason: SCOPE_GATE_REASONS.notActive });
|
|
2450
2487
|
}
|
|
2451
2488
|
const rt = this.runtime(conn.tenant_id, scopeId);
|
|
2452
2489
|
await this.applyPendingMigrations(rt);
|
|
@@ -2474,7 +2511,7 @@ export class SqliteScopeHost {
|
|
|
2474
2511
|
`runs '${scope.vertical ?? 'none'}'`);
|
|
2475
2512
|
}
|
|
2476
2513
|
if (scope.status !== 'active') {
|
|
2477
|
-
throw
|
|
2514
|
+
throw substratError('conflict', `scope not active (status: ${scope.status}): ${scopeId}`, { reason: SCOPE_GATE_REASONS.notActive });
|
|
2478
2515
|
}
|
|
2479
2516
|
const rt = this.runtime(conn.tenant_id, scopeId);
|
|
2480
2517
|
await this.applyPendingMigrations(rt);
|
|
@@ -2509,7 +2546,7 @@ export class SqliteScopeHost {
|
|
|
2509
2546
|
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
2510
2547
|
}
|
|
2511
2548
|
if (scope.status !== 'active') {
|
|
2512
|
-
throw
|
|
2549
|
+
throw substratError('conflict', `scope not active (status: ${scope.status}): ${scopeId}`, { reason: SCOPE_GATE_REASONS.notActive });
|
|
2513
2550
|
}
|
|
2514
2551
|
const rt = this.runtime(tenantId, scopeId);
|
|
2515
2552
|
await this.applyPendingMigrations(rt);
|
|
@@ -2521,7 +2558,7 @@ export class SqliteScopeHost {
|
|
|
2521
2558
|
// and the scope must be active. Authority is then an ordinary check against
|
|
2522
2559
|
// `system:<moduleId>` grants inside the stub; nothing here is a person.
|
|
2523
2560
|
if (!this.modules.has(moduleId)) {
|
|
2524
|
-
throw
|
|
2561
|
+
throw substratError('not_found', `module not registered on this host: ${moduleId}`);
|
|
2525
2562
|
}
|
|
2526
2563
|
const scope = this.directory
|
|
2527
2564
|
.prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
|
|
@@ -2530,7 +2567,7 @@ export class SqliteScopeHost {
|
|
|
2530
2567
|
throw substratError('not_found', `unknown scope: ${scopeId}`);
|
|
2531
2568
|
}
|
|
2532
2569
|
if (scope.status !== 'active') {
|
|
2533
|
-
throw
|
|
2570
|
+
throw substratError('conflict', `scope not active (status: ${scope.status}): ${scopeId}`, { reason: SCOPE_GATE_REASONS.notActive });
|
|
2534
2571
|
}
|
|
2535
2572
|
const rt = this.runtime(tenantId, scopeId);
|
|
2536
2573
|
await this.applyPendingMigrations(rt);
|
|
@@ -2600,15 +2637,11 @@ export class SqliteScopeHost {
|
|
|
2600
2637
|
const source = batch.source;
|
|
2601
2638
|
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
2602
2639
|
return rt.actor.enqueue(async () => {
|
|
2603
|
-
const result =
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
cursor: batch.after,
|
|
2609
|
-
stale: false,
|
|
2610
|
-
paused: null,
|
|
2611
|
-
};
|
|
2640
|
+
const result = emptyImportResult(batch);
|
|
2641
|
+
// #2004: a copy consumes no other vertical's events, at the door as in the sweep
|
|
2642
|
+
// (`scope-copy.ts`). A pause: nothing runs and the watermark stays.
|
|
2643
|
+
if (!this.isPrimaryInDirectory(scopeId))
|
|
2644
|
+
return { ...result, paused: { reason: INERT_SCOPE_REASON } };
|
|
2612
2645
|
// #1706's door, for a delivery (`operation: null`): the producer must be a declared peer
|
|
2613
2646
|
// of this vertical with its switch on. Admitted inside the actor task, before anything
|
|
2614
2647
|
// runs, so a switch pulled between two passes refuses the second. A refusal is a PAUSE:
|
|
@@ -2676,16 +2709,14 @@ export class SqliteScopeHost {
|
|
|
2676
2709
|
const { hops: _hops, ...fact } = e;
|
|
2677
2710
|
const event = structuredClone({ ...fact, source });
|
|
2678
2711
|
const unit = unitOf(imp.moduleId, e.id, e.type);
|
|
2679
|
-
// What the handler emits was emitted BECAUSE of the producer's event (#1237). The id
|
|
2680
|
-
// resolves through `_substrat_imports`, which the row above has just written.
|
|
2681
|
-
rt.causedBy = e.id;
|
|
2682
|
-
rt.unitInvocationId = unit.invocationId;
|
|
2683
2712
|
rt.db.exec('BEGIN IMMEDIATE');
|
|
2684
2713
|
try {
|
|
2685
2714
|
// As the producer's principal, admitted above: the handler's checks are real ones,
|
|
2686
2715
|
// against the grants this vertical's `peers` gave it, and its emits carry the actor
|
|
2687
|
-
// `{ vertical, scope }` and the authorization they passed (K-34).
|
|
2688
|
-
|
|
2716
|
+
// `{ vertical, scope }` and the authorization they passed (K-34). What it emits was
|
|
2717
|
+
// emitted BECAUSE of the producer's event (#1237); the id resolves through
|
|
2718
|
+
// `_substrat_imports`, which the row above has just written.
|
|
2719
|
+
await imp.handler(this.deliveryContext(rt, subject, undefined, { causedBy: e.id, invocationId: unit.invocationId }), event);
|
|
2689
2720
|
rt.db
|
|
2690
2721
|
.prepare(`INSERT INTO _substrat_deliveries (event_id, consumer_module, delivered_at, invocation_id)
|
|
2691
2722
|
VALUES (?, ?, ?, NULL)`)
|
|
@@ -2702,10 +2733,6 @@ export class SqliteScopeHost {
|
|
|
2702
2733
|
result.deadLettered += 1;
|
|
2703
2734
|
lines.write({ ...unit, outcome: 'dead-lettered', error: err });
|
|
2704
2735
|
}
|
|
2705
|
-
finally {
|
|
2706
|
-
rt.causedBy = null;
|
|
2707
|
-
rt.unitInvocationId = null;
|
|
2708
|
-
}
|
|
2709
2736
|
}
|
|
2710
2737
|
if (!ran)
|
|
2711
2738
|
result.duplicates += 1;
|
|
@@ -2797,10 +2824,10 @@ export class SqliteScopeHost {
|
|
|
2797
2824
|
.prepare('SELECT status FROM tenants WHERE tenant_id = ?')
|
|
2798
2825
|
.get(tenantId);
|
|
2799
2826
|
if (!tenant || tenant.status !== 'active') {
|
|
2800
|
-
throw
|
|
2827
|
+
throw substratError('conflict', `tenant not active (status: ${tenant?.status ?? 'missing'}): ${tenantId}`, { reason: SCOPE_GATE_REASONS.notActive });
|
|
2801
2828
|
}
|
|
2802
2829
|
if (scope.status !== 'active') {
|
|
2803
|
-
throw
|
|
2830
|
+
throw substratError('conflict', `scope not active (status: ${scope.status}): ${scopeId}`, { reason: SCOPE_GATE_REASONS.notActive });
|
|
2804
2831
|
}
|
|
2805
2832
|
const rt = this.runtime(tenantId, scopeId);
|
|
2806
2833
|
await this.applyPendingMigrations(rt);
|
|
@@ -2916,20 +2943,44 @@ export class SqliteScopeHost {
|
|
|
2916
2943
|
const target = principalId.parse(assignee);
|
|
2917
2944
|
return rt.actor.turn(async () => {
|
|
2918
2945
|
const bound = await this.assignmentBound(subject, tenantId, scopeId, roleKey);
|
|
2919
|
-
if (bound.covered)
|
|
2920
|
-
rt.db
|
|
2921
|
-
VALUES (?, ?, ?, NULL)`).run(`principal:${target}`, `role:${roleKey}`, `scope:${scopeId}`);
|
|
2922
|
-
}
|
|
2946
|
+
if (bound.covered)
|
|
2947
|
+
applyScopeRoleChange(switchSqlOf(rt.db), scopeId, target, { revoke: [], grant: roleKey }, this.clock());
|
|
2923
2948
|
return bound;
|
|
2924
2949
|
});
|
|
2925
2950
|
}
|
|
2951
|
+
async listScopeRoleHolders(tenantId, scopeId, principal) {
|
|
2952
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
2953
|
+
const who = principal === undefined ? undefined : principalId.parse(principal);
|
|
2954
|
+
return rt.actor.enqueue(() => scopeRoleHolders(switchSqlOf(rt.db), scopeId, this.clock(), who));
|
|
2955
|
+
}
|
|
2956
|
+
async changeScopeRoleBounded(tenantId, scopeId, caller, principal, from, to) {
|
|
2957
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
2958
|
+
const target = principalId.parse(principal);
|
|
2959
|
+
const answer = await rt.actor.turn(() => changeScopeRole(switchSqlOf(rt.db), scopeId, target, from, to, this.clock(), this.roleBound(caller, tenantId, scopeId), (run) => rt.db.transaction(run)()));
|
|
2960
|
+
if (answer === 'not-held')
|
|
2961
|
+
throw substratError('conflict', `${target} does not hold '${from}' at this scope`);
|
|
2962
|
+
if (answer === 'unknown-to')
|
|
2963
|
+
throw unknownRoleError(to);
|
|
2964
|
+
return answer;
|
|
2965
|
+
}
|
|
2966
|
+
async revokeScopeRolesBounded(tenantId, scopeId, caller, principal) {
|
|
2967
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
2968
|
+
return rt.actor.turn(() => revokeScopeRoles(switchSqlOf(rt.db), scopeId, principalId.parse(principal), this.clock(), this.roleBound(caller, tenantId, scopeId), (run) => rt.db.transaction(run)()));
|
|
2969
|
+
}
|
|
2970
|
+
/** The caller's bound per role at the scope, `null` for a role this tenant does not define (#1150). */
|
|
2971
|
+
roleBound(caller, tenantId, scopeId) {
|
|
2972
|
+
const subject = asPrincipal(principalId.parse(caller));
|
|
2973
|
+
return async (roleKey) => this.roles.has(`${tenantId}/${roleKey}`) ? this.assignmentBound(subject, tenantId, scopeId, roleKey) : null;
|
|
2974
|
+
}
|
|
2926
2975
|
/**
|
|
2927
2976
|
* §5.1's assignment bound, resolved against the SAME role table the checker expands
|
|
2928
2977
|
* — not a vertical's compile-time `ROLES` array. A tenant's projected role is what
|
|
2929
2978
|
* assignment would actually confer, and that is what has to be bounded. The one
|
|
2930
2979
|
* resolution both `ctx.canAssign` and the host's `canAssign` answer from.
|
|
2931
2980
|
*/
|
|
2932
|
-
async assignmentBound(subject, tenantId,
|
|
2981
|
+
async assignmentBound(subject, tenantId,
|
|
2982
|
+
/** Null for the tenant node — the executor's `level: 'tenant'` (#1184). */
|
|
2983
|
+
scopeId, roleKey) {
|
|
2933
2984
|
const role = this.roles.get(`${tenantId}/${roleKey}`);
|
|
2934
2985
|
if (!role) {
|
|
2935
2986
|
throw unknownRoleError(roleKey);
|
|
@@ -3280,6 +3331,8 @@ export class SqliteScopeHost {
|
|
|
3280
3331
|
let replayed = false;
|
|
3281
3332
|
// #1746: what this call itself emitted, for `onEmitted`. Set only after a commit.
|
|
3282
3333
|
let emittedReport;
|
|
3334
|
+
// #1184: what this call's executors did inline, for `onExecutorOutcomes`.
|
|
3335
|
+
const executorOutcomes = invokeOptions?.onExecutorOutcomes ? [] : undefined;
|
|
3283
3336
|
const invoked = await rt.actor.enqueue(async () => {
|
|
3284
3337
|
// #1237: the invocation this call belongs to, for the duration of it. Set
|
|
3285
3338
|
// INSIDE the actor task — the actor serializes invoke and dispatch alike, so
|
|
@@ -3449,7 +3502,7 @@ export class SqliteScopeHost {
|
|
|
3449
3502
|
// #1525: still inside the actor task that set it, so these deliveries are
|
|
3450
3503
|
// this call's own work — the same tail its consumers' emits are stamped in.
|
|
3451
3504
|
await this.dispatch(rt, rt.invocationId);
|
|
3452
|
-
await this.dispatchExecutors(rt, rt.invocationId);
|
|
3505
|
+
await this.dispatchExecutors(rt, rt.invocationId, executorOutcomes);
|
|
3453
3506
|
}
|
|
3454
3507
|
if (exportMark !== null && !replayed) {
|
|
3455
3508
|
const q = exportedSinceQuery(exportTypes, exportMark);
|
|
@@ -3476,6 +3529,8 @@ export class SqliteScopeHost {
|
|
|
3476
3529
|
invokeOptions?.onIdempotentReplay?.();
|
|
3477
3530
|
if (emittedReport)
|
|
3478
3531
|
invokeOptions?.onEmitted?.(emittedReport);
|
|
3532
|
+
if (executorOutcomes && !replayed && session?.mode !== 'read-only')
|
|
3533
|
+
invokeOptions?.onExecutorOutcomes?.(executorOutcomes);
|
|
3479
3534
|
return invoked;
|
|
3480
3535
|
},
|
|
3481
3536
|
};
|
|
@@ -3564,6 +3619,17 @@ export class SqliteScopeHost {
|
|
|
3564
3619
|
* At-least-once still requires idempotent handlers. Retry is the backstop, not a
|
|
3565
3620
|
* substitute.
|
|
3566
3621
|
*/
|
|
3622
|
+
/**
|
|
3623
|
+
* #1184: the scope reads an executor's handler may make, built over `rt` directly. The
|
|
3624
|
+
* handler runs INSIDE this scope's actor task, so a host read that re-enqueued — the
|
|
3625
|
+
* public `canAssign`, `admin.entityHistory` — would wait on the task holding it.
|
|
3626
|
+
*/
|
|
3627
|
+
executorScope(rt) {
|
|
3628
|
+
return {
|
|
3629
|
+
history: async (entity, page) => readHistory({ sql: scopedSql(rt.db) }, entity, page),
|
|
3630
|
+
covers: (principal, roleKey, level) => this.assignmentBound(asPrincipal(principalId.parse(principal)), rt.tenantId, level === 'scope' ? rt.scopeId : null, roleKey),
|
|
3631
|
+
};
|
|
3632
|
+
}
|
|
3567
3633
|
async dispatchExecutors(rt,
|
|
3568
3634
|
/**
|
|
3569
3635
|
* #1525: the call this drain pass is running in, or null — PASSED, not read off
|
|
@@ -3572,7 +3638,9 @@ export class SqliteScopeHost {
|
|
|
3572
3638
|
* and journal over RPC, so the id has to travel as an argument on that side, and
|
|
3573
3639
|
* a recorded fact whose correctness argument differs per adapter drifts.
|
|
3574
3640
|
*/
|
|
3575
|
-
invocationId
|
|
3641
|
+
invocationId,
|
|
3642
|
+
/** #1184: what each attempt did, for the emitting call's `onExecutorOutcomes`. */
|
|
3643
|
+
outcomes) {
|
|
3576
3644
|
const report = {
|
|
3577
3645
|
attempted: 0,
|
|
3578
3646
|
delivered: 0,
|
|
@@ -3583,15 +3651,16 @@ export class SqliteScopeHost {
|
|
|
3583
3651
|
return report;
|
|
3584
3652
|
const lines = asyncLinePass(this.invocationLineSink);
|
|
3585
3653
|
try {
|
|
3586
|
-
await this.dispatchExecutorsPass(rt, invocationId, report, lines);
|
|
3654
|
+
await this.dispatchExecutorsPass(rt, invocationId, report, lines, outcomes);
|
|
3587
3655
|
}
|
|
3588
3656
|
finally {
|
|
3589
3657
|
lines.end();
|
|
3590
3658
|
}
|
|
3591
3659
|
return report;
|
|
3592
3660
|
}
|
|
3593
|
-
async dispatchExecutorsPass(rt, invocationId, report, lines) {
|
|
3661
|
+
async dispatchExecutorsPass(rt, invocationId, report, lines, outcomes) {
|
|
3594
3662
|
const now = new Date().toISOString();
|
|
3663
|
+
const scope = this.executorScope(rt);
|
|
3595
3664
|
// #2005: a non-primary scope (a fork, a snapshot, a preview of either kind) causes no
|
|
3596
3665
|
// outbound effects. Every executor is host code acting with platform authority — a
|
|
3597
3666
|
// connector with the tenant's credential, a plain executor with `HostAdmin` — so its
|
|
@@ -3643,26 +3712,44 @@ export class SqliteScopeHost {
|
|
|
3643
3712
|
lines.write({ ...unit, attempt: attempts, outcome: 'dead-lettered' });
|
|
3644
3713
|
continue;
|
|
3645
3714
|
}
|
|
3715
|
+
const outcomeOf = (outcome, error) => {
|
|
3716
|
+
outcomes?.push(executorOutcomeOf(id, event, outcome, error));
|
|
3717
|
+
};
|
|
3646
3718
|
if (isInert()) {
|
|
3647
3719
|
const { attempts } = this.recordExecutorDelivery(rt, row.id, deliveryId, INERT_SCOPE_REASON, TERMINAL_RETRY, invocationId);
|
|
3648
3720
|
report.inert = (report.inert ?? 0) + 1;
|
|
3721
|
+
outcomeOf('inert', INERT_SCOPE_REASON);
|
|
3649
3722
|
// Its own outcome, never `delivered`: no handler ran (#2005).
|
|
3650
3723
|
lines.write({ ...unit, attempt: attempts, outcome: 'inert' });
|
|
3651
3724
|
continue;
|
|
3652
3725
|
}
|
|
3653
|
-
|
|
3726
|
+
// #2055: the handler writes through a view bound to its event, never through the host.
|
|
3727
|
+
const caused = this.causedByView(event.id);
|
|
3654
3728
|
try {
|
|
3729
|
+
let result;
|
|
3655
3730
|
if (executor.kind === 'connector') {
|
|
3656
3731
|
// `true`: dispatchExecutors is only ever reached from inside
|
|
3657
3732
|
// `rt.actor.enqueue` (invoke's post-commit tail, or drainDue).
|
|
3658
|
-
await executor.handler(
|
|
3733
|
+
await executor.handler(caused.connectorContext(rt, executor.timeoutMs, true, event.id), event);
|
|
3659
3734
|
}
|
|
3660
3735
|
else {
|
|
3661
|
-
await executor.handler(
|
|
3736
|
+
result = await executor.handler(caused.admin, event, scope);
|
|
3737
|
+
}
|
|
3738
|
+
// #1184: a refusal is the handler's own terminal decision — journaled with its
|
|
3739
|
+
// reason, never retried, and listed by `executorDeadLetters` beside an exhausted one.
|
|
3740
|
+
const refused = isDeliveryRefusal(result) ? result : null;
|
|
3741
|
+
const text = refused && refusalJournalText(refused);
|
|
3742
|
+
const { attempts } = this.recordExecutorDelivery(rt, row.id, deliveryId, text, refused ? TERMINAL_RETRY : executor.retry, invocationId);
|
|
3743
|
+
if (refused) {
|
|
3744
|
+
report.deadLettered += 1;
|
|
3745
|
+
outcomeOf('refused', refused.reason);
|
|
3746
|
+
lines.write({ ...unit, attempt: attempts, outcome: 'dead-lettered', error: text });
|
|
3747
|
+
}
|
|
3748
|
+
else {
|
|
3749
|
+
report.delivered += 1;
|
|
3750
|
+
outcomeOf('delivered');
|
|
3751
|
+
lines.write({ ...unit, attempt: attempts, outcome: 'delivered' });
|
|
3662
3752
|
}
|
|
3663
|
-
const { attempts } = this.recordExecutorDelivery(rt, row.id, deliveryId, null, executor.retry, invocationId);
|
|
3664
|
-
report.delivered += 1;
|
|
3665
|
-
lines.write({ ...unit, attempt: attempts, outcome: 'delivered' });
|
|
3666
3753
|
}
|
|
3667
3754
|
catch (err) {
|
|
3668
3755
|
const { dead, attempts } = this.recordExecutorDelivery(rt, row.id, deliveryId, err instanceof Error ? (err.stack ?? err.message) : String(err), executor.retry, invocationId);
|
|
@@ -3670,11 +3757,9 @@ export class SqliteScopeHost {
|
|
|
3670
3757
|
report.deadLettered += 1;
|
|
3671
3758
|
else
|
|
3672
3759
|
report.retrying += 1;
|
|
3760
|
+
outcomeOf(dead ? 'dead-lettered' : 'retrying', err);
|
|
3673
3761
|
lines.write({ ...unit, attempt: attempts, outcome: dead ? 'dead-lettered' : 'retrying', error: err });
|
|
3674
3762
|
}
|
|
3675
|
-
finally {
|
|
3676
|
-
this.causedBy = null;
|
|
3677
|
-
}
|
|
3678
3763
|
}
|
|
3679
3764
|
}
|
|
3680
3765
|
}
|
|
@@ -3727,13 +3812,14 @@ export class SqliteScopeHost {
|
|
|
3727
3812
|
// emitting operation's own tail made.
|
|
3728
3813
|
return rt.actor.enqueue(() => this.dispatchExecutors(rt, null));
|
|
3729
3814
|
}
|
|
3730
|
-
registerJob(moduleId, name, handler, retry) {
|
|
3815
|
+
registerJob(moduleId, name, handler, retry, options) {
|
|
3731
3816
|
// #1575: the kernel's own jobs are dispatched before this registry is read.
|
|
3732
3817
|
assertJobRegistrable(moduleId, name);
|
|
3818
|
+
assertLeaseMs(options?.leaseMs);
|
|
3733
3819
|
const key = `${moduleId}/${name}`;
|
|
3734
3820
|
if (this.jobs.has(key))
|
|
3735
3821
|
throw new Error(`job '${key}' is already registered`);
|
|
3736
|
-
this.jobs.set(key, { handler, retry });
|
|
3822
|
+
this.jobs.set(key, { handler, retry, leaseMs: options?.leaseMs });
|
|
3737
3823
|
}
|
|
3738
3824
|
/**
|
|
3739
3825
|
* D-14's in-process driver: the run store is direct SQL on the scope db.
|
|
@@ -3766,11 +3852,24 @@ export class SqliteScopeHost {
|
|
|
3766
3852
|
(id, module_id, job, instance, payload, status, cursor, counters, attempts,
|
|
3767
3853
|
last_error, started_at, updated_at, next_attempt_at, ended_at, subject_id)
|
|
3768
3854
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
3769
|
-
// A compare-and-set on `running` (#1632)
|
|
3770
|
-
//
|
|
3771
|
-
// hold the same line.
|
|
3855
|
+
// A compare-and-set on `running` (#1632) and on the pass's lease (#2034): a pass that
|
|
3856
|
+
// outlived an erasure, or lost its run to a takeover, must not write back over it. The
|
|
3857
|
+
// kernel's statement, so both adapters hold the same line.
|
|
3772
3858
|
const patchRun = db.prepare(JOB_RUN_PATCH_SQL);
|
|
3773
|
-
const patchArgs = (id, p) => [p.status, p.cursor, p.counters, p.attempts, p.lastError, p.updatedAt, p.nextAttemptAt, p.endedAt, id];
|
|
3859
|
+
const patchArgs = (id, p, owner) => [p.status, p.cursor, p.counters, p.attempts, p.lastError, p.updatedAt, p.nextAttemptAt, p.endedAt, id, owner];
|
|
3860
|
+
const beganAt = db.prepare('SELECT lease_began_at FROM _substrat_job_runs WHERE id = ?');
|
|
3861
|
+
const beginRun = db.prepare(JOB_RUN_BEGIN_SQL);
|
|
3862
|
+
const missRun = db.prepare(JOB_RUN_MISS_SQL);
|
|
3863
|
+
const missSettle = db.prepare(JOB_RUN_MISS_SETTLE_SQL);
|
|
3864
|
+
// #2042 r4: every lease time is this host's clock as the statement runs, inside its turn —
|
|
3865
|
+
// never a time the drive computed, which a skew or a wait in the queue would make wrong.
|
|
3866
|
+
const now = () => this.clock();
|
|
3867
|
+
const plus = (at, ms) => new Date(Date.parse(at) + ms).toISOString();
|
|
3868
|
+
const claimRun = db.prepare(JOB_RUN_CLAIM_SQL);
|
|
3869
|
+
const renewRun = db.prepare(JOB_RUN_RENEW_SQL);
|
|
3870
|
+
const recordStepRow = db.prepare(JOB_STEP_RECORD_SQL);
|
|
3871
|
+
const dropSteps = db.prepare('DELETE FROM _substrat_job_steps WHERE run_id = ?');
|
|
3872
|
+
const stepRow = db.prepare('SELECT step, result, attempts, last_error FROM _substrat_job_steps WHERE run_id = ? AND step = ?');
|
|
3774
3873
|
// `db.transaction` on better-sqlite3 runs its body SYNCHRONOUSLY inside a real
|
|
3775
3874
|
// SQLite transaction, so nothing — not another turn of the event loop, not
|
|
3776
3875
|
// another caller of this host — can interleave between the read and the write.
|
|
@@ -3782,9 +3881,37 @@ export class SqliteScopeHost {
|
|
|
3782
3881
|
insertRun.run(r.id, r.module_id, r.job, r.instance, r.payload, r.status, r.cursor, r.counters, r.attempts, r.last_error, r.started_at, r.updated_at, r.next_attempt_at, r.ended_at, r.subject_id ?? null);
|
|
3783
3882
|
return r;
|
|
3784
3883
|
});
|
|
3785
|
-
const commitPassTx = db.transaction((id, p) => {
|
|
3786
|
-
|
|
3787
|
-
|
|
3884
|
+
const commitPassTx = db.transaction((id, p, owner) => {
|
|
3885
|
+
// #2034: the ledger goes only with a patch that applied — a stale holder's commit must
|
|
3886
|
+
// not empty the ledger of the pass that took the run over.
|
|
3887
|
+
if (patchRun.run(...patchArgs(id, p, owner)).changes === 0)
|
|
3888
|
+
return false;
|
|
3889
|
+
dropSteps.run(id);
|
|
3890
|
+
return true;
|
|
3891
|
+
});
|
|
3892
|
+
// #2034: the claim, and whether it took over a lease, read in the same transaction.
|
|
3893
|
+
const claimTx = db.transaction((id, owner, leaseMs) => {
|
|
3894
|
+
// #2042 r2, r4: a takeover is charged only when the lease it takes had BEGUN its pass.
|
|
3895
|
+
const before = beganAt.get(id);
|
|
3896
|
+
const at = now();
|
|
3897
|
+
const claimed = row(claimRun.get(owner, plus(at, leaseMs), at, JOB_LEASE_EXPIRED_NOTE, id, at));
|
|
3898
|
+
return claimed && { run: claimed, takeover: (before?.lease_began_at ?? null) !== null };
|
|
3899
|
+
});
|
|
3900
|
+
// #2042 r3, r4: an admission miss — the relative count, then the backoff or failure it calls for.
|
|
3901
|
+
const missTx = db.transaction((id, owner, note) => {
|
|
3902
|
+
const at = now();
|
|
3903
|
+
const counted = missRun.get(at, id, owner);
|
|
3904
|
+
if (!counted)
|
|
3905
|
+
return null;
|
|
3906
|
+
const o = admissionMissOutcome(counted.admission_misses, at, note);
|
|
3907
|
+
missSettle.run(o.status, o.nextAttemptAt, o.endedAt, o.lastError, id, counted.admission_misses);
|
|
3908
|
+
return { misses: counted.admission_misses, failed: o.status === 'failed' };
|
|
3909
|
+
});
|
|
3910
|
+
const recordStepTx = db.transaction((runId, name, result, attempts, lastError, at, owner, leaseMs) => {
|
|
3911
|
+
if (renewRun.run(plus(now(), leaseMs), runId, owner).changes === 0)
|
|
3912
|
+
return false;
|
|
3913
|
+
recordStepRow.run(runId, name, result, attempts, lastError, at, runId, owner);
|
|
3914
|
+
return true;
|
|
3788
3915
|
});
|
|
3789
3916
|
// Every store operation takes its own short turn on the scope actor, so no
|
|
3790
3917
|
// statement here can land inside an `invoke`'s open transaction. `turn` is the
|
|
@@ -3792,7 +3919,13 @@ export class SqliteScopeHost {
|
|
|
3792
3919
|
const turn = (fn) => rt.actor.enqueue(fn);
|
|
3793
3920
|
return {
|
|
3794
3921
|
startOrJoin: (key, r) => turn(() => startOrJoinTx(key, r)),
|
|
3795
|
-
|
|
3922
|
+
claim: (id, owner, leaseMs) => turn(() => claimTx(id, owner, leaseMs)),
|
|
3923
|
+
// #2042 r4: BEGIN, the commitment point, judged by this host's clock as it runs.
|
|
3924
|
+
begin: (id, owner, marginMs) => turn(() => {
|
|
3925
|
+
const at = now();
|
|
3926
|
+
return beginRun.run(at, id, owner, plus(at, marginMs)).changes > 0;
|
|
3927
|
+
}),
|
|
3928
|
+
miss: (id, owner, note) => turn(() => missTx(id, owner, note)),
|
|
3796
3929
|
// #1834: the drive's one snapshot — keys only, in the order runs became due.
|
|
3797
3930
|
dueKeys: (now, max) => turn(() => db
|
|
3798
3931
|
.prepare(`SELECT id, module_id, job FROM _substrat_job_runs
|
|
@@ -3820,17 +3953,18 @@ export class SqliteScopeHost {
|
|
|
3820
3953
|
ORDER BY id DESC LIMIT ?`)
|
|
3821
3954
|
.all(...params);
|
|
3822
3955
|
}),
|
|
3823
|
-
patch: (id, p) => turn(() =>
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
.get(runId, name) ?? null
|
|
3830
|
-
recordStep: (runId, name, result, attempts, lastError, at) => turn(() => {
|
|
3831
|
-
// Only while the run is still `running` (#1632) — see `JOB_STEP_RECORD_SQL`.
|
|
3832
|
-
db.prepare(JOB_STEP_RECORD_SQL).run(runId, name, result, attempts, lastError, at, runId);
|
|
3956
|
+
patch: (id, p, owner) => turn(() => patchRun.run(...patchArgs(id, p, owner)).changes > 0),
|
|
3957
|
+
commitPass: (id, p, owner) => turn(() => commitPassTx(id, p, owner)),
|
|
3958
|
+
// #2034: a step boundary renews the lease; a pass that lost it reads nothing.
|
|
3959
|
+
beginStep: (runId, name, owner, leaseMs) => turn(() => {
|
|
3960
|
+
if (renewRun.run(plus(now(), leaseMs), runId, owner).changes === 0)
|
|
3961
|
+
return { held: false, row: null };
|
|
3962
|
+
return { held: true, row: stepRow.get(runId, name) ?? null };
|
|
3833
3963
|
}),
|
|
3964
|
+
recordStep: (runId, name, result, attempts, lastError, at, owner, leaseMs) =>
|
|
3965
|
+
// Only while the run is still `running` (#1632) and this pass holds it (#2034), renewing
|
|
3966
|
+
// the lease in the same transaction — see `JOB_STEP_RECORD_SQL`.
|
|
3967
|
+
turn(() => recordStepTx(runId, name, result, attempts, lastError, at, owner, leaseMs)),
|
|
3834
3968
|
};
|
|
3835
3969
|
}
|
|
3836
3970
|
/**
|
|
@@ -3852,7 +3986,7 @@ export class SqliteScopeHost {
|
|
|
3852
3986
|
write: (attachmentId, outcome) => rt.actor.enqueue(() => recordAttachmentText(spineSql(rt.db), attachmentId, outcome, this.clock())),
|
|
3853
3987
|
},
|
|
3854
3988
|
// K-43: the host's parsers, handed in — this adapter imports none.
|
|
3855
|
-
this.attachmentExtractors);
|
|
3989
|
+
this.attachmentExtractors, this.attachmentTextBounds);
|
|
3856
3990
|
}
|
|
3857
3991
|
async startJobRun(tenantId, scopeId, input) {
|
|
3858
3992
|
const rt = this.runtime(tenantId, scopeId);
|
|
@@ -3862,12 +3996,19 @@ export class SqliteScopeHost {
|
|
|
3862
3996
|
async runDueJobs(tenantId, scopeId, options) {
|
|
3863
3997
|
const rt = this.runtime(tenantId, scopeId);
|
|
3864
3998
|
await this.applyPendingMigrations(rt);
|
|
3865
|
-
// #1575: the kernel's extraction
|
|
3866
|
-
// deployment registers
|
|
3867
|
-
|
|
3999
|
+
// #1575: the kernel's extraction jobs are this host's own, bound to this scope — no
|
|
4000
|
+
// deployment registers them, and none can shadow them. The backfill starts here, on
|
|
4001
|
+
// the drive, so a scope's first request never pays for attachments that predate it.
|
|
4002
|
+
await rt.actor.enqueue(() => startAttachmentTextBackfill(spineSql(rt.db), ulid(), this.clock()));
|
|
4003
|
+
const kernelJobs = {
|
|
4004
|
+
[ATTACHMENT_TEXT_JOB]: this.attachmentTextHandler(rt),
|
|
4005
|
+
[ATTACHMENT_TEXT_BACKFILL_JOB]: attachmentTextBackfillJob({
|
|
4006
|
+
queueBatch: (after) => rt.actor.enqueue(() => rt.db.transaction(() => queueAttachmentTextBackfill(spineSql(rt.db), after, ulid, this.clock()))()),
|
|
4007
|
+
}),
|
|
4008
|
+
};
|
|
3868
4009
|
return runDueJobRuns({
|
|
3869
4010
|
store: this.jobStore(rt),
|
|
3870
|
-
handlerFor: (run) =>
|
|
4011
|
+
handlerFor: (run) => kernelJobFor(run, kernelJobs) ?? this.jobs.get(`${run.module_id}/${run.job}`),
|
|
3871
4012
|
now: this.clock,
|
|
3872
4013
|
openScope: (run) => this.getSystemScope(run.module_id, tenantId, scopeId),
|
|
3873
4014
|
maxPasses: options?.maxPasses,
|
|
@@ -3887,15 +4028,10 @@ export class SqliteScopeHost {
|
|
|
3887
4028
|
// Cloudflare one does; the context build is the same one `dispatchExecutors` hands
|
|
3888
4029
|
// an in-process connector.
|
|
3889
4030
|
const rt = this.runtime(tenantId, scopeId);
|
|
3890
|
-
this
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
await handler(this.connectorContext(rt, options?.timeoutMs ?? 30_000, false, event.id), event);
|
|
3895
|
-
}
|
|
3896
|
-
finally {
|
|
3897
|
-
this.causedBy = null;
|
|
3898
|
-
}
|
|
4031
|
+
// `false`: this path deliberately does NOT enqueue, so nothing is held and
|
|
4032
|
+
// the connection's reads take an ordinary serialized turn. Built on a view bound to
|
|
4033
|
+
// the event, so its admin rows carry it and nothing else the host serves does (#2055).
|
|
4034
|
+
await handler(this.causedByView(event.id).connectorContext(rt, options?.timeoutMs ?? 30_000, false, event.id), event);
|
|
3899
4035
|
}
|
|
3900
4036
|
/**
|
|
3901
4037
|
* The scope's own answer to "what may this connection do here" (#726 gap 1) — read
|
|
@@ -3959,7 +4095,7 @@ export class SqliteScopeHost {
|
|
|
3959
4095
|
// `provisioning` allowed — a scope stuck there on a failed migration is a
|
|
3960
4096
|
// sweep target. Suspended/archived are deliberate states; not disturbed.
|
|
3961
4097
|
if (row.status !== 'active' && row.status !== 'provisioning') {
|
|
3962
|
-
throw
|
|
4098
|
+
throw substratError('conflict', `scope not migratable (status: ${row.status}): ${scopeId}`);
|
|
3963
4099
|
}
|
|
3964
4100
|
const rt = this.runtime(tenantId, scopeId);
|
|
3965
4101
|
// Nothing pending FOR THIS HOST → noop, and deliberately no state write: a
|
|
@@ -4115,16 +4251,12 @@ export class SqliteScopeHost {
|
|
|
4115
4251
|
lines.write({ ...unit, outcome: 'dead-lettered' });
|
|
4116
4252
|
continue;
|
|
4117
4253
|
}
|
|
4118
|
-
|
|
4119
|
-
|
|
4254
|
+
// #1237: anything this consumer emits was emitted BECAUSE of this event — where a
|
|
4255
|
+
// backwards walk used to stop. #1901: its `ctx.log` lines join this delivery's line.
|
|
4256
|
+
const ctx = this.deliveryContext(rt, asPrincipal(this.systemPrincipal), { system: mod.id }, {
|
|
4257
|
+
causedBy: event.id,
|
|
4258
|
+
invocationId: unit.invocationId,
|
|
4120
4259
|
});
|
|
4121
|
-
// #1237: anything this consumer emits was emitted BECAUSE of this event.
|
|
4122
|
-
// `dispatchExecutors` already did this for the admin log; the module
|
|
4123
|
-
// consumers never did, which is exactly where a backwards walk stopped.
|
|
4124
|
-
// On the runtime rather than the host — see `ScopeRuntime.causedBy`.
|
|
4125
|
-
rt.causedBy = event.id;
|
|
4126
|
-
// #1901: the handler's `ctx.log` lines join this delivery's line.
|
|
4127
|
-
rt.unitInvocationId = unit.invocationId;
|
|
4128
4260
|
rt.db.exec('BEGIN IMMEDIATE');
|
|
4129
4261
|
try {
|
|
4130
4262
|
await consumer.handler(ctx, event);
|
|
@@ -4148,14 +4280,6 @@ export class SqliteScopeHost {
|
|
|
4148
4280
|
.run(event.id, mod.id, new Date().toISOString(), String(err), invocationId);
|
|
4149
4281
|
lines.write({ ...unit, outcome: 'dead-lettered', error: err });
|
|
4150
4282
|
}
|
|
4151
|
-
finally {
|
|
4152
|
-
rt.unitInvocationId = null;
|
|
4153
|
-
// Cleared on BOTH paths. Left set, the id would leak onto every later
|
|
4154
|
-
// emit in this scope — an operation's own event stamped as caused by
|
|
4155
|
-
// whatever happened to be delivered last, which is worse than no cause
|
|
4156
|
-
// at all, because it reads as a recorded fact.
|
|
4157
|
-
rt.causedBy = null;
|
|
4158
|
-
}
|
|
4159
4283
|
}
|
|
4160
4284
|
}
|
|
4161
4285
|
}
|
|
@@ -4436,14 +4560,26 @@ export class SqliteScopeHost {
|
|
|
4436
4560
|
throw new Error(`connection not found: ${id}`);
|
|
4437
4561
|
return row;
|
|
4438
4562
|
}
|
|
4439
|
-
|
|
4563
|
+
/**
|
|
4564
|
+
* A findings mutation (#1748) and the audit rows it hands back, in ONE directory transaction:
|
|
4565
|
+
* an audit write that fails rolls the mutation back, so no verdict, rule or stale resolution
|
|
4566
|
+
* stands without its row.
|
|
4567
|
+
*/
|
|
4568
|
+
auditedFindings(actor, run) {
|
|
4569
|
+
return this.directory.transaction(() => run(redactionSqlOf(this.directory), (a) => this.recordAdmin(actor, a.action, a.target, a.before, a.after)))();
|
|
4570
|
+
}
|
|
4571
|
+
recordAdmin(actor, action, target, before, after,
|
|
4572
|
+
/** When the row says it happened; the wall clock unless the write took its time elsewhere (#1184). */
|
|
4573
|
+
at = new Date().toISOString()) {
|
|
4440
4574
|
this.directory
|
|
4441
4575
|
.prepare(`INSERT INTO _substrat_admin_log
|
|
4442
4576
|
(id, actor, action, tenant_id, scope_id, vertical, before, after, caused_by, on_behalf_of, at)
|
|
4443
4577
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
4444
|
-
.run(ulid(), actor, action, target.tenantId ?? null, target.scopeId ?? null, target.vertical ?? null, before == null ? null : JSON.stringify(before), after == null ? null : JSON.stringify(after),
|
|
4578
|
+
.run(ulid(), actor, action, target.tenantId ?? null, target.scopeId ?? null, target.vertical ?? null, before == null ? null : JSON.stringify(before), after == null ? null : JSON.stringify(after),
|
|
4579
|
+
// #2055: like `onBehalfOf`, set only on a view, never on the host itself.
|
|
4580
|
+
this.causedBy,
|
|
4445
4581
|
// #977: set only on an attributed view (`attributed`), never on the host itself.
|
|
4446
|
-
this.onBehalfOf === null ? null : JSON.stringify(this.onBehalfOf),
|
|
4582
|
+
this.onBehalfOf === null ? null : JSON.stringify(this.onBehalfOf), at);
|
|
4447
4583
|
}
|
|
4448
4584
|
tenantHoldsEntitlement(tenantId, key) {
|
|
4449
4585
|
// An expired grant fails closed, exactly as if revoked (#33) — evaluated
|
|
@@ -4464,8 +4600,12 @@ export class SqliteScopeHost {
|
|
|
4464
4600
|
.all(tenantId).map((r) => ({ key: r.entitlement_key, expired: r.expires_at !== null && r.expires_at <= now }));
|
|
4465
4601
|
}
|
|
4466
4602
|
/** #977: this host, with every admin row it writes naming who the actor acted for. */
|
|
4467
|
-
attributed(onBehalfOf) {
|
|
4468
|
-
return
|
|
4603
|
+
attributed(onBehalfOf, options) {
|
|
4604
|
+
return attributedView(this, { onBehalfOf, causedBy: options?.causedBy }, this.buildAdmin);
|
|
4605
|
+
}
|
|
4606
|
+
/** #2055: this host, with every admin row it writes naming `eventId` as its cause. */
|
|
4607
|
+
causedByView(eventId) {
|
|
4608
|
+
return attributedView(this, { causedBy: eventId }, this.buildAdmin);
|
|
4469
4609
|
}
|
|
4470
4610
|
buildAdmin() {
|
|
4471
4611
|
// #977: an admin whose audit rows say on whose behalf it acts. Built over a VIEW of this
|
|
@@ -4503,12 +4643,12 @@ export class SqliteScopeHost {
|
|
|
4503
4643
|
const requirePoolServes = (provider, tenant) => {
|
|
4504
4644
|
const pool = readPool(provider);
|
|
4505
4645
|
if (!pool) {
|
|
4506
|
-
throw
|
|
4646
|
+
throw substratError('conflict', `identity pool '${provider}' is not registered — a pool must declare its ` +
|
|
4507
4647
|
`topology before it may link (central vs tenant-bound decides whether the ` +
|
|
4508
4648
|
`same externalId in two tenants is one person or two)`);
|
|
4509
4649
|
}
|
|
4510
4650
|
if (pool.topology === 'tenant-bound' && pool.tenantId !== tenant) {
|
|
4511
|
-
throw
|
|
4651
|
+
throw substratError('conflict', `identity pool '${provider}' is bound to tenant ${pool.tenantId} and cannot link into ${tenant}`);
|
|
4512
4652
|
}
|
|
4513
4653
|
};
|
|
4514
4654
|
const mapVertical = (r) => verticalSchema.parse({
|
|
@@ -4578,7 +4718,7 @@ export class SqliteScopeHost {
|
|
|
4578
4718
|
*/
|
|
4579
4719
|
const requireOrg = (tenant, id) => {
|
|
4580
4720
|
if (!readOrg(tenant, id)) {
|
|
4581
|
-
throw
|
|
4721
|
+
throw substratError('not_found', `unknown org ${id} in tenant ${tenant}`);
|
|
4582
4722
|
}
|
|
4583
4723
|
};
|
|
4584
4724
|
// The directory row → the `scope` contract. Parsed, not cast: the columns are
|
|
@@ -4650,7 +4790,7 @@ export class SqliteScopeHost {
|
|
|
4650
4790
|
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4651
4791
|
}
|
|
4652
4792
|
if (!from.includes(row.status)) {
|
|
4653
|
-
throw
|
|
4793
|
+
throw substratError('conflict', `illegal scope transition for ${action}: ${row.status} → ${to} ` +
|
|
4654
4794
|
`(allowed from: ${from.join('|')})`);
|
|
4655
4795
|
}
|
|
4656
4796
|
// One directory transaction: the status, the audit row and — on a reap — the scope's
|
|
@@ -4674,7 +4814,7 @@ export class SqliteScopeHost {
|
|
|
4674
4814
|
this.directory.prepare('UPDATE scopes SET status = ? WHERE scope_id = ?').run(to, scopeId);
|
|
4675
4815
|
}
|
|
4676
4816
|
if (to === 'reaped')
|
|
4677
|
-
|
|
4817
|
+
forgetSwitchesOf(switchSqlOf(this.directory), scopeId);
|
|
4678
4818
|
// The audit target carries the scope's vertical (control-plane.md §4.4:
|
|
4679
4819
|
// "vertical stays null until §4.2 lifecycle actions that name one"). It is
|
|
4680
4820
|
// read from the scope rather than passed in, so the trail cannot disagree
|
|
@@ -4687,6 +4827,42 @@ export class SqliteScopeHost {
|
|
|
4687
4827
|
(tenant_id, subject, relation, object, expires_at)
|
|
4688
4828
|
VALUES (?, ?, ?, ?, ?)`)
|
|
4689
4829
|
.run(tenantId, subject, relation, object, expiresAt ?? null);
|
|
4830
|
+
// #1184: a tenant-level removal, for inside a directory transaction — the tombstone
|
|
4831
|
+
// (K-21), the removal fence, raised even when nothing was held so an add still on its way
|
|
4832
|
+
// cannot land after this, and the audit row (`audit`), which K-21 writes only when
|
|
4833
|
+
// something changed. The shape of the Durable-Object adapter's `revokeAndFence`.
|
|
4834
|
+
//
|
|
4835
|
+
// All three are stamped by the HOST clock, the one that stamps a scope event's `occurredAt`:
|
|
4836
|
+
// the fence is compared with an add's `occurredAt`, and two clocks would let a host clock
|
|
4837
|
+
// running ahead of the wall push a removal under the skew window (Codex round 3).
|
|
4838
|
+
let revokeStmt;
|
|
4839
|
+
let fenceStmt;
|
|
4840
|
+
const revokeAndFence = (tenantId, principal, relation, object, audit) => {
|
|
4841
|
+
const now = this.clock();
|
|
4842
|
+
const changed = (revokeStmt ??= this.directory.prepare(`UPDATE _substrat_tenant_tuples SET revoked_at = ?
|
|
4843
|
+
WHERE tenant_id = ? AND subject = ? AND relation = ? AND object = ? AND revoked_at IS NULL`)).run(now, tenantId, `principal:${principal}`, relation, object).changes > 0;
|
|
4844
|
+
(fenceStmt ??= this.directory.prepare(RAISE_MEMBERSHIP_FENCE_SQL)).run(tenantId, principal, now);
|
|
4845
|
+
if (changed)
|
|
4846
|
+
audit(now);
|
|
4847
|
+
return changed;
|
|
4848
|
+
};
|
|
4849
|
+
const revokeTenantRoleAndFence = (actor, assignment) => {
|
|
4850
|
+
const { tenantId } = assignment.node;
|
|
4851
|
+
return revokeAndFence(tenantId, assignment.principalId, `role:${assignment.roleKey}`, `tenant:${tenantId}`, (at) => this.recordAdmin(actor, 'unassignRole', { tenantId, scopeId: null }, assignment, null, at));
|
|
4852
|
+
};
|
|
4853
|
+
// An org membership written or tombstoned with its audit row — `addMember` and `removeMember`,
|
|
4854
|
+
// and the membership executor's unit (#2047), one spelling each. Leaving raises the fence too.
|
|
4855
|
+
const joinOrg = (actor, tenantId, principal, orgId, expiresAt) => {
|
|
4856
|
+
writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${orgId}`, expiresAt);
|
|
4857
|
+
this.recordAdmin(actor, 'addMember', { tenantId }, null, memberAddedAudit(principal, orgId, expiresAt));
|
|
4858
|
+
};
|
|
4859
|
+
const leaveOrgAndFence = (actor, tenantId, principal, orgId) => revokeAndFence(tenantId, principal, 'member', `org:${orgId}`, (at) => this.recordAdmin(actor, 'removeMember', { tenantId }, { principal, orgId }, null, at));
|
|
4860
|
+
// The tenant-level reads the checker makes, synchronous, for the bound inside a unit.
|
|
4861
|
+
const directoryReader = directoryTenantReader({
|
|
4862
|
+
directory: this.directory,
|
|
4863
|
+
getRole: (tenantId, key) => this.roles.get(`${tenantId}/${key}`),
|
|
4864
|
+
clock: () => this.clock(),
|
|
4865
|
+
});
|
|
4690
4866
|
// The EXPLICIT grant: `INSERT OR REPLACE` clears a tombstone, because a re-grant must
|
|
4691
4867
|
// grant. Provisioning seats with `seatScopeTuple` instead, which never un-revokes
|
|
4692
4868
|
// (#1659) — keep the two apart.
|
|
@@ -4722,10 +4898,14 @@ export class SqliteScopeHost {
|
|
|
4722
4898
|
const onNode = (node, body) => node.scopeId
|
|
4723
4899
|
? this.runtime(node.tenantId, node.scopeId).actor.turn(body)
|
|
4724
4900
|
: Promise.resolve().then(body);
|
|
4725
|
-
/**
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4901
|
+
/**
|
|
4902
|
+
* One kill switch moved on one scope — the ONE body `revokeFromSystem` / `restoreToSystem`
|
|
4903
|
+
* (#1666, a module's `system:` authority) and `revokeFromPeer` / `restoreToPeer` (#1706, a
|
|
4904
|
+
* peer vertical's) share, so the two levers cannot disagree about the record, the audit or
|
|
4905
|
+
* the undo. `kind` says which; `key` is the module id or the peer's slug.
|
|
4906
|
+
*/
|
|
4907
|
+
const switchSubject = async (actor, kind, node, key, reason, to) => {
|
|
4908
|
+
const { tenantId, scopeId } = node;
|
|
4729
4909
|
const scope = this.directory
|
|
4730
4910
|
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
4731
4911
|
.get(scopeId);
|
|
@@ -4737,20 +4917,20 @@ export class SqliteScopeHost {
|
|
|
4737
4917
|
// scope's file are separate databases, so a crash between them leaves an intent with
|
|
4738
4918
|
// no recorded outcome — never a moved switch with no audit row.
|
|
4739
4919
|
const operationId = ulid();
|
|
4740
|
-
const action = to
|
|
4920
|
+
const action = switchActionOf(kind, to);
|
|
4741
4921
|
const target = { tenantId, scopeId };
|
|
4742
|
-
const base = { operationId,
|
|
4743
|
-
this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason
|
|
4744
|
-
// The directory's record (#1674), written BEFORE the scope moves, both
|
|
4745
|
-
// if the move throws or holds nothing — see `
|
|
4746
|
-
// `
|
|
4922
|
+
const base = { operationId, ...switchAuditSubject(kind, key, to) };
|
|
4923
|
+
this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason });
|
|
4924
|
+
// The directory's record (#1674; #2029 for a peer), written BEFORE the scope moves, both
|
|
4925
|
+
// ways, and undone if the move throws or holds nothing — see `recordSwitchedOn` and
|
|
4926
|
+
// (#1823) `recordSwitchedOff` for why that order is the safe one.
|
|
4747
4927
|
const at = new Date().toISOString();
|
|
4748
4928
|
const directorySql = switchSqlOf(this.directory);
|
|
4749
|
-
const record = { tenantId, scopeId,
|
|
4929
|
+
const record = { kind, tenantId, scopeId, key, actor, reason, operationId, at };
|
|
4750
4930
|
const errorOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
4751
4931
|
let prior;
|
|
4752
4932
|
try {
|
|
4753
|
-
prior = to === 'on' ?
|
|
4933
|
+
prior = to === 'on' ? recordSwitchedOn(directorySql, record) : recordSwitchedOff(directorySql, record);
|
|
4754
4934
|
}
|
|
4755
4935
|
catch (err) {
|
|
4756
4936
|
// Nothing has moved: fail the call here, audited — the Cloudflare adapter's posture.
|
|
@@ -4762,9 +4942,15 @@ export class SqliteScopeHost {
|
|
|
4762
4942
|
}
|
|
4763
4943
|
throw err;
|
|
4764
4944
|
}
|
|
4945
|
+
// #2045: a newer call on this subject has recorded its position already: this one writes
|
|
4946
|
+
// nothing, here or in the scope, and says so.
|
|
4947
|
+
if (recordWriteSuperseded(prior, record)) {
|
|
4948
|
+
this.recordAdmin(actor, action, target, null, { ...base, phase: 'refused', superseded: true });
|
|
4949
|
+
throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
|
|
4950
|
+
}
|
|
4765
4951
|
/** The record's undo, after the scope's move threw or held nothing: retried once, never swallowed. */
|
|
4766
4952
|
const undoRecord = () => {
|
|
4767
|
-
const undo = () =>
|
|
4953
|
+
const undo = () => restoreSwitchRecord(directorySql, record, prior);
|
|
4768
4954
|
try {
|
|
4769
4955
|
undo();
|
|
4770
4956
|
return null;
|
|
@@ -4787,12 +4973,13 @@ export class SqliteScopeHost {
|
|
|
4787
4973
|
// with it — after this verb had already audited `applied`. `turn` is re-entrant, so a
|
|
4788
4974
|
// caller already inside one of this scope's tasks joins it rather than deadlocking.
|
|
4789
4975
|
//
|
|
4790
|
-
// #1823: read in the same turn, so no tenant grant moves between the
|
|
4791
|
-
// The directory is this process's own database, so the read is
|
|
4976
|
+
// #1823 (#2030 for a peer): read in the same turn, so no tenant grant moves between the
|
|
4977
|
+
// read and the switch. The directory is this process's own database, so the read is
|
|
4978
|
+
// synchronous.
|
|
4792
4979
|
const rt = this.runtime(tenantId, scopeId);
|
|
4793
4980
|
outcome = await rt.actor.turn(() => {
|
|
4794
|
-
const tenantHeld =
|
|
4795
|
-
return rt.db.transaction(() =>
|
|
4981
|
+
const tenantHeld = tenantHoldsGrant(directorySql, kind, tenantId, key, this.clock());
|
|
4982
|
+
return rt.db.transaction(() => moveSwitch(switchSqlOf(rt.db), kind, { key, scopeId, to, at, tenantHeld, fence: operationId }))();
|
|
4796
4983
|
});
|
|
4797
4984
|
}
|
|
4798
4985
|
catch (err) {
|
|
@@ -4810,10 +4997,16 @@ export class SqliteScopeHost {
|
|
|
4810
4997
|
}
|
|
4811
4998
|
throw err;
|
|
4812
4999
|
}
|
|
4813
|
-
//
|
|
4814
|
-
// its
|
|
4815
|
-
|
|
4816
|
-
|
|
5000
|
+
// #2045: the scope has applied a newer call on this subject, so this move wrote nothing. The
|
|
5001
|
+
// record is that newer call's too (its write overwrote this one's), so nothing is undone.
|
|
5002
|
+
if (outcome.superseded) {
|
|
5003
|
+
this.recordAdmin(actor, action, target, null, { ...base, phase: 'refused', superseded: true });
|
|
5004
|
+
throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
|
|
5005
|
+
}
|
|
5006
|
+
// A call that held nothing moved nothing (the subject has no authority reaching the
|
|
5007
|
+
// scope, its own or the tenant's), so its record write is undone: left `off`, it would
|
|
5008
|
+
// switch the subject off the day it is installed; left `on`, the next reconcile of a
|
|
5009
|
+
// wiped scope would leave it running.
|
|
4817
5010
|
const recordError = outcome.held ? null : undoRecord();
|
|
4818
5011
|
this.recordAdmin(actor, action, target, null, {
|
|
4819
5012
|
...base,
|
|
@@ -4823,10 +5016,15 @@ export class SqliteScopeHost {
|
|
|
4823
5016
|
...(recordError ? { recordError } : {}),
|
|
4824
5017
|
});
|
|
4825
5018
|
if (!outcome.held) {
|
|
4826
|
-
throw substratError('not_found',
|
|
4827
|
-
`(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')` +
|
|
5019
|
+
throw substratError('not_found', switchNotFoundMessage(kind, scopeId, key, to) +
|
|
4828
5020
|
(recordError ? `; and its directory record could not be put back (${recordError})` : ''));
|
|
4829
5021
|
}
|
|
5022
|
+
return { operationId, outcome };
|
|
5023
|
+
};
|
|
5024
|
+
/** #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`. */
|
|
5025
|
+
const switchSystem = async (actor, raw, to) => {
|
|
5026
|
+
const input = systemSwitch.parse(raw);
|
|
5027
|
+
const { operationId, outcome } = await switchSubject(actor, 'system', input.node, input.moduleId, input.reason, to);
|
|
4830
5028
|
return {
|
|
4831
5029
|
operationId,
|
|
4832
5030
|
moduleId: input.moduleId,
|
|
@@ -4835,58 +5033,10 @@ export class SqliteScopeHost {
|
|
|
4835
5033
|
permissions: outcome.permissions,
|
|
4836
5034
|
};
|
|
4837
5035
|
};
|
|
4838
|
-
/**
|
|
4839
|
-
* #1706: move one PEER's kill switch on one scope — see `HostAdmin.revokeFromPeer`. The
|
|
4840
|
-
* schedule switch's body with the subject swapped (`switchPeer` → `switchSubjectGrants`):
|
|
4841
|
-
* audit first, one turn on the scope actor, outcome after, `not_found` when nothing was held.
|
|
4842
|
-
*/
|
|
5036
|
+
/** #1706: move one PEER's kill switch on one scope — see `HostAdmin.revokeFromPeer`. */
|
|
4843
5037
|
const switchPeerAt = async (actor, raw, to) => {
|
|
4844
5038
|
const input = peerSwitch.parse(raw);
|
|
4845
|
-
const {
|
|
4846
|
-
const scope = this.directory
|
|
4847
|
-
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
4848
|
-
.get(scopeId);
|
|
4849
|
-
if (!scope || scope.tenant_id !== tenantId) {
|
|
4850
|
-
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4851
|
-
}
|
|
4852
|
-
const operationId = ulid();
|
|
4853
|
-
const action = to === 'off' ? 'revokeFromPeer' : 'restoreToPeer';
|
|
4854
|
-
const target = { tenantId, scopeId };
|
|
4855
|
-
const base = { operationId, vertical: input.vertical, calls: to };
|
|
4856
|
-
this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
|
|
4857
|
-
let outcome;
|
|
4858
|
-
try {
|
|
4859
|
-
const rt = this.runtime(tenantId, scopeId);
|
|
4860
|
-
outcome = await rt.actor.turn(() => rt.db.transaction(() => switchPeer(switchSqlOf(rt.db), {
|
|
4861
|
-
vertical: input.vertical,
|
|
4862
|
-
scopeId,
|
|
4863
|
-
to,
|
|
4864
|
-
at: new Date().toISOString(),
|
|
4865
|
-
}))());
|
|
4866
|
-
}
|
|
4867
|
-
catch (err) {
|
|
4868
|
-
try {
|
|
4869
|
-
this.recordAdmin(actor, action, target, null, {
|
|
4870
|
-
...base,
|
|
4871
|
-
phase: 'failed',
|
|
4872
|
-
error: err instanceof Error ? err.message : String(err),
|
|
4873
|
-
});
|
|
4874
|
-
}
|
|
4875
|
-
catch {
|
|
4876
|
-
// Best effort: the original error is what the caller must see.
|
|
4877
|
-
}
|
|
4878
|
-
throw err;
|
|
4879
|
-
}
|
|
4880
|
-
this.recordAdmin(actor, action, target, null, {
|
|
4881
|
-
...base,
|
|
4882
|
-
phase: outcome.held ? 'applied' : 'refused',
|
|
4883
|
-
changed: outcome.changed,
|
|
4884
|
-
permissions: outcome.permissions,
|
|
4885
|
-
});
|
|
4886
|
-
if (!outcome.held) {
|
|
4887
|
-
throw substratError('not_found', `scope ${scopeId} holds no grant for peer '${input.vertical}' — nothing to switch ${to} ` +
|
|
4888
|
-
`(check the slug: it is the calling vertical's registry id, as the target's \`peers\` names it)`);
|
|
4889
|
-
}
|
|
5039
|
+
const { operationId, outcome } = await switchSubject(actor, 'peer', input.node, input.vertical, input.reason, to);
|
|
4890
5040
|
return {
|
|
4891
5041
|
operationId,
|
|
4892
5042
|
vertical: input.vertical,
|
|
@@ -4961,7 +5111,7 @@ export class SqliteScopeHost {
|
|
|
4961
5111
|
const states = await rt.actor.turn(() => systemGrantsStatus(switchSqlOf(rt.db), new Date().toISOString()));
|
|
4962
5112
|
const offModules = new Set(states.filter((s) => s.schedules === 'off').map((s) => s.moduleId));
|
|
4963
5113
|
const explanations = offModules.size > 0 ? lastSwitchedOff(tenantId, scopeId, offModules) : new Map();
|
|
4964
|
-
const recorded =
|
|
5114
|
+
const recorded = switchRecordsOf(switchSqlOf(this.directory), 'system', tenantId, scopeId);
|
|
4965
5115
|
const result = withRecorded(states, recorded).map((s) => ({
|
|
4966
5116
|
moduleId: s.moduleId,
|
|
4967
5117
|
schedules: s.schedules,
|
|
@@ -5070,29 +5220,22 @@ export class SqliteScopeHost {
|
|
|
5070
5220
|
// Tombstone (K-21), never DELETE — the checker skips revoked_at rows, so the
|
|
5071
5221
|
// assignment stops resolving while staying visible to audit. Guarded on
|
|
5072
5222
|
// `revoked_at IS NULL` so a repeat unassign is a silent no-op.
|
|
5073
|
-
const
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
// On the scope actor (#1678): a revoke that joined a stranger's transaction and
|
|
5079
|
-
// rolled back with it left the role LIVE after the caller was told it was gone.
|
|
5080
|
-
const scopeNode = assignment.node.scopeId;
|
|
5081
|
-
const rt = this.runtime(assignment.node.tenantId, scopeNode);
|
|
5082
|
-
changes = await rt.actor.turn(() => rt.db
|
|
5083
|
-
.prepare(`UPDATE _substrat_tuples SET revoked_at = ?
|
|
5084
|
-
WHERE subject = ? AND relation = ? AND object = ? AND revoked_at IS NULL`)
|
|
5085
|
-
.run(now, subject, relation, `scope:${scopeNode}`).changes);
|
|
5086
|
-
}
|
|
5087
|
-
else {
|
|
5088
|
-
changes = this.directory
|
|
5089
|
-
.prepare(`UPDATE _substrat_tenant_tuples SET revoked_at = ?
|
|
5090
|
-
WHERE tenant_id = ? AND subject = ? AND relation = ? AND object = ? AND revoked_at IS NULL`)
|
|
5091
|
-
.run(now, assignment.node.tenantId, subject, relation, `tenant:${assignment.node.tenantId}`).changes;
|
|
5223
|
+
const scopeNode = assignment.node.scopeId;
|
|
5224
|
+
if (!scopeNode) {
|
|
5225
|
+
// One directory transaction (#1184): the revoke, the removal fence and the audit row.
|
|
5226
|
+
this.directory.transaction(() => revokeTenantRoleAndFence(actor, assignment))();
|
|
5227
|
+
return;
|
|
5092
5228
|
}
|
|
5229
|
+
// On the scope actor (#1678): a revoke that joined a stranger's transaction and
|
|
5230
|
+
// rolled back with it left the role LIVE after the caller was told it was gone.
|
|
5231
|
+
const rt = this.runtime(assignment.node.tenantId, scopeNode);
|
|
5232
|
+
const changes = await rt.actor.turn(() => rt.db
|
|
5233
|
+
.prepare(`UPDATE _substrat_tuples SET revoked_at = ?
|
|
5234
|
+
WHERE subject = ? AND relation = ? AND object = ? AND revoked_at IS NULL`)
|
|
5235
|
+
.run(new Date().toISOString(), `principal:${assignment.principalId}`, `role:${assignment.roleKey}`, `scope:${scopeNode}`).changes);
|
|
5093
5236
|
if (changes === 0)
|
|
5094
5237
|
return; // never assigned, or already revoked — idempotent, unaudited
|
|
5095
|
-
this.recordAdmin(actor, 'unassignRole', { tenantId: assignment.node.tenantId, scopeId:
|
|
5238
|
+
this.recordAdmin(actor, 'unassignRole', { tenantId: assignment.node.tenantId, scopeId: scopeNode }, assignment, null);
|
|
5096
5239
|
},
|
|
5097
5240
|
grant: async (actor, raw) => {
|
|
5098
5241
|
// Parsed like its `grantToConnection`/`grantToSystem` siblings, not taken on
|
|
@@ -5213,10 +5356,19 @@ export class SqliteScopeHost {
|
|
|
5213
5356
|
},
|
|
5214
5357
|
tenantHeldSystemModules: async (actor, tenantId, moduleIds) => {
|
|
5215
5358
|
const now = this.clock();
|
|
5216
|
-
const held =
|
|
5359
|
+
const held = tenantHeldOf(switchSqlOf(this.directory), 'system', tenantId, moduleIds, now);
|
|
5217
5360
|
this.recordAccess(actor, 'tenantHeldSystemModules', { tenantId }, { moduleIds: [...moduleIds] }, held.length);
|
|
5218
5361
|
return held;
|
|
5219
5362
|
},
|
|
5363
|
+
peerSwitchCarry: async (actor, node) => {
|
|
5364
|
+
const directorySql = switchSqlOf(this.directory);
|
|
5365
|
+
const switchedOffPeers = switchedOffOf(directorySql, 'peer', node.tenantId, node.scopeId);
|
|
5366
|
+
const tenantHeldPeers = tenantHeldOf(directorySql, 'peer', node.tenantId, switchedOffPeers, this.clock());
|
|
5367
|
+
const all = switchFencesOf(directorySql, 'peer', node.tenantId, node.scopeId);
|
|
5368
|
+
const fences = Object.fromEntries(switchedOffPeers.map((v) => [v, all.get(v)]));
|
|
5369
|
+
this.recordAccess(actor, 'peerSwitchCarry', node, null, switchedOffPeers.length);
|
|
5370
|
+
return { switchedOffPeers, tenantHeldPeers, fences };
|
|
5371
|
+
},
|
|
5220
5372
|
reassertSystemSwitches: async (actor, node, opts) => {
|
|
5221
5373
|
this.assertScope(node.tenantId, node.scopeId);
|
|
5222
5374
|
const rt = this.runtime(node.tenantId, node.scopeId);
|
|
@@ -5300,7 +5452,7 @@ export class SqliteScopeHost {
|
|
|
5300
5452
|
// rebinding it would move another tenant's traffic. Exception: the holder is
|
|
5301
5453
|
// ARCHIVED or REAPED (a deleted app, storage since wiped) — it released the
|
|
5302
5454
|
// name, so the rebind reclaims it.
|
|
5303
|
-
throw
|
|
5455
|
+
throw substratError('conflict', `hostname '${parsed.hostname}' is already bound to another scope`);
|
|
5304
5456
|
}
|
|
5305
5457
|
// Exactly one canonical per (scope, surface): "which one do certs and
|
|
5306
5458
|
// redirects use" has to have one answer, so a new canonical demotes the old.
|
|
@@ -5326,7 +5478,7 @@ export class SqliteScopeHost {
|
|
|
5326
5478
|
.prepare('SELECT tenant_id, scope_id, status FROM hostnames WHERE hostname = ?')
|
|
5327
5479
|
.get(hostname);
|
|
5328
5480
|
if (!row)
|
|
5329
|
-
throw
|
|
5481
|
+
throw substratError('not_found', `unknown hostname '${hostname}'`);
|
|
5330
5482
|
if (row.status === status)
|
|
5331
5483
|
return; // idempotent, and a no-op is not audited
|
|
5332
5484
|
this.directory
|
|
@@ -5340,7 +5492,7 @@ export class SqliteScopeHost {
|
|
|
5340
5492
|
.prepare('SELECT tenant_id, scope_id, status, custom_hostname_id, validation_records FROM hostnames WHERE hostname = ?')
|
|
5341
5493
|
.get(hostname);
|
|
5342
5494
|
if (!row)
|
|
5343
|
-
throw
|
|
5495
|
+
throw substratError('not_found', `unknown hostname '${hostname}'`);
|
|
5344
5496
|
const recordsJson = fields.validationRecords.length
|
|
5345
5497
|
? JSON.stringify(fields.validationRecords)
|
|
5346
5498
|
: null;
|
|
@@ -5529,7 +5681,7 @@ export class SqliteScopeHost {
|
|
|
5529
5681
|
if (existing.ownerTenant !== parsed.ownerTenant) {
|
|
5530
5682
|
throw substratError('conflict', `vertical '${parsed.slug}' is owned by ${existing.ownerTenant ?? 'the platform'}, not ${parsed.ownerTenant ?? 'the platform'}`);
|
|
5531
5683
|
}
|
|
5532
|
-
throw
|
|
5684
|
+
throw substratError('conflict', `vertical '${parsed.slug}' is already registered as ${existing.source}`);
|
|
5533
5685
|
}
|
|
5534
5686
|
this.directory
|
|
5535
5687
|
.prepare('INSERT INTO verticals (slug, name, source, owner_tenant, env_spec, install_spec, listed, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
|
@@ -5762,11 +5914,11 @@ export class SqliteScopeHost {
|
|
|
5762
5914
|
// gate is about change, not about existence.
|
|
5763
5915
|
if (outgoing) {
|
|
5764
5916
|
if (outgoing.permissionDigest !== incoming.permissionDigest && !ack.permissionChange) {
|
|
5765
|
-
throw
|
|
5917
|
+
throw substratError('conflict', `promotion changes the permission surface (${outgoing.permissionDigest} → ` +
|
|
5766
5918
|
`${incoming.permissionDigest}) — acknowledge it explicitly to promote`);
|
|
5767
5919
|
}
|
|
5768
5920
|
if (outgoing.migrationDigest !== incoming.migrationDigest && !ack.migrationChange) {
|
|
5769
|
-
throw
|
|
5921
|
+
throw substratError('conflict', `promotion changes migrations (${outgoing.migrationDigest} → ` +
|
|
5770
5922
|
`${incoming.migrationDigest}) — acknowledge it explicitly to promote`);
|
|
5771
5923
|
}
|
|
5772
5924
|
// #1705 PR 3: an export an installed consumer imports, dropped or re-versioned.
|
|
@@ -6264,6 +6416,13 @@ export class SqliteScopeHost {
|
|
|
6264
6416
|
this.recordAccess(actor, 'lifecycleFlow', { tenantId, scopeId }, delegatedReadParams.lifecycleFlow(input), flow.observation.events);
|
|
6265
6417
|
return flow;
|
|
6266
6418
|
},
|
|
6419
|
+
operationSeries: async (actor, tenantId, scopeId, input) => {
|
|
6420
|
+
// #1750: business volumes per bucket, for Pulse.
|
|
6421
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
6422
|
+
const series = readOperationSeries({ sql: scopedSql(db) }, input);
|
|
6423
|
+
this.recordAccess(actor, 'operationSeries', { tenantId, scopeId }, delegatedReadParams.operationSeries(input), operationSeriesCount(series));
|
|
6424
|
+
return series;
|
|
6425
|
+
},
|
|
6267
6426
|
scopeAppliedMigrations: async (actor, tenantId, scopeId) => {
|
|
6268
6427
|
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
6269
6428
|
const rows = db
|
|
@@ -6315,7 +6474,7 @@ export class SqliteScopeHost {
|
|
|
6315
6474
|
.prepare('SELECT org_id FROM orgs WHERE tenant_id = ? AND slug = ?')
|
|
6316
6475
|
.get(parsed.tenantId, parsed.slug);
|
|
6317
6476
|
if (slugOwner) {
|
|
6318
|
-
throw
|
|
6477
|
+
throw substratError('conflict', `org slug '${parsed.slug}' already taken by ${slugOwner.org_id} (slugs are unique per tenant)`);
|
|
6319
6478
|
}
|
|
6320
6479
|
this.directory
|
|
6321
6480
|
.prepare('INSERT INTO orgs (org_id, tenant_id, slug, name, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
@@ -6334,32 +6493,66 @@ export class SqliteScopeHost {
|
|
|
6334
6493
|
this.recordAccess(actor, 'getOrg', { tenantId }, { orgId }, o ? 1 : 0);
|
|
6335
6494
|
return o;
|
|
6336
6495
|
},
|
|
6337
|
-
addMember: async (actor, tenantId, principal, orgId) => {
|
|
6496
|
+
addMember: async (actor, tenantId, principal, orgId, opts) => {
|
|
6338
6497
|
requireOrg(tenantId, orgId);
|
|
6339
6498
|
// INSERT OR REPLACE, so re-adding a revoked member clears the tombstone —
|
|
6340
6499
|
// they are a member again. The add/revoke history is not lost: it lives in
|
|
6341
6500
|
// the append-only admin log, which is where "what happened" belongs. The
|
|
6342
6501
|
// tuple carries "what is true now" plus enough to explain a live proof.
|
|
6343
|
-
|
|
6344
|
-
|
|
6502
|
+
joinOrg(actor, tenantId, principal, orgId, opts?.expiresAt ?? null);
|
|
6503
|
+
},
|
|
6504
|
+
applyMembership: async (actor, change) => {
|
|
6505
|
+
// ONE directory transaction, synchronous throughout (#1184): the fence (an add's), the
|
|
6506
|
+
// bound and the write. Nothing else on this host lands between them, and a failure
|
|
6507
|
+
// leaves none.
|
|
6508
|
+
const { tenantId, principal, roleKey, boundedBy } = change;
|
|
6509
|
+
const assignment = { principalId: principal, roleKey, node: { tenantId, scopeId: null } };
|
|
6510
|
+
return this.directory.transaction(() => {
|
|
6511
|
+
if (change.op === 'add') {
|
|
6512
|
+
const fence = this.directory
|
|
6513
|
+
.prepare(MEMBERSHIP_FENCE_SINCE_SQL)
|
|
6514
|
+
.get(tenantId, principal, change.unlessRemovedSince);
|
|
6515
|
+
if (fence)
|
|
6516
|
+
return { applied: false, removedAt: fence.removed_at };
|
|
6517
|
+
}
|
|
6518
|
+
const role = directoryReader.getRole(tenantId, roleKey);
|
|
6519
|
+
if (!role)
|
|
6520
|
+
return { applied: false, unknownRole: roleKey };
|
|
6521
|
+
const bound = tenantCoverage(directoryReader, tenantId, boundedBy, role.permissions);
|
|
6522
|
+
if (!bound.covered)
|
|
6523
|
+
return { applied: false, missing: bound.missing };
|
|
6524
|
+
// #2047: the org, bounded by `boundedBy`'s own live membership of it.
|
|
6525
|
+
const { orgId } = change;
|
|
6526
|
+
const org = orgId ? { orgId, ...orgChangeBound(directoryReader, tenantId, { ...change, orgId }) } : undefined;
|
|
6527
|
+
if (org) {
|
|
6528
|
+
if (!readOrg(tenantId, org.orgId))
|
|
6529
|
+
return { applied: false, unknownOrg: org.orgId };
|
|
6530
|
+
if (!org.bounded)
|
|
6531
|
+
return { applied: false, notMember: org.orgId };
|
|
6532
|
+
}
|
|
6533
|
+
if (change.op === 'remove') {
|
|
6534
|
+
const roleTaken = revokeTenantRoleAndFence(actor, assignment);
|
|
6535
|
+
const orgLeft = !!org && leaveOrgAndFence(actor, tenantId, principal, org.orgId);
|
|
6536
|
+
return { applied: true, changed: roleTaken || orgLeft };
|
|
6537
|
+
}
|
|
6538
|
+
writeTenantTuple(tenantId, `principal:${principal}`, `role:${roleKey}`, `tenant:${tenantId}`);
|
|
6539
|
+
this.recordAdmin(actor, 'assignRole', { tenantId, scopeId: null }, null, assignment);
|
|
6540
|
+
if (org?.bounded)
|
|
6541
|
+
joinOrg(actor, tenantId, principal, org.orgId, org.expiresAt);
|
|
6542
|
+
return { applied: true };
|
|
6543
|
+
})();
|
|
6345
6544
|
},
|
|
6346
6545
|
removeMember: async (actor, tenantId, principal, orgId) => {
|
|
6347
6546
|
requireOrg(tenantId, orgId);
|
|
6348
|
-
// Tombstone (K-21), never DELETE
|
|
6349
|
-
//
|
|
6350
|
-
|
|
6351
|
-
|
|
6352
|
-
WHERE tenant_id = ? AND subject = ? AND relation = 'member' AND object = ?
|
|
6353
|
-
AND revoked_at IS NULL`)
|
|
6354
|
-
.run(new Date().toISOString(), tenantId, `principal:${principal}`, `org:${orgId}`);
|
|
6355
|
-
if (info.changes === 0)
|
|
6356
|
-
return; // never a member, or already revoked
|
|
6357
|
-
this.recordAdmin(actor, 'removeMember', { tenantId }, { principal, orgId }, null);
|
|
6547
|
+
// Tombstone (K-21), never DELETE: a repeat revoke neither moves the timestamp nor writes
|
|
6548
|
+
// a second audit row. One transaction with the removal fence (#1184), which a no-op
|
|
6549
|
+
// raises too.
|
|
6550
|
+
this.directory.transaction(() => leaveOrgAndFence(actor, tenantId, principal, orgId))();
|
|
6358
6551
|
},
|
|
6359
6552
|
listMembers: async (actor, tenantId, orgId, options) => {
|
|
6360
6553
|
requireOrg(tenantId, orgId);
|
|
6361
6554
|
const rows = this.directory
|
|
6362
|
-
.prepare(`SELECT subject, revoked_at FROM _substrat_tenant_tuples
|
|
6555
|
+
.prepare(`SELECT subject, revoked_at, expires_at FROM _substrat_tenant_tuples
|
|
6363
6556
|
WHERE tenant_id = ? AND relation = 'member' AND object = ?
|
|
6364
6557
|
${options?.includeRevoked ? '' : 'AND revoked_at IS NULL'}
|
|
6365
6558
|
ORDER BY subject`)
|
|
@@ -6369,6 +6562,7 @@ export class SqliteScopeHost {
|
|
|
6369
6562
|
principal: r.subject.slice('principal:'.length),
|
|
6370
6563
|
orgId,
|
|
6371
6564
|
revokedAt: r.revoked_at,
|
|
6565
|
+
expiresAt: r.expires_at,
|
|
6372
6566
|
}));
|
|
6373
6567
|
},
|
|
6374
6568
|
createTenant: async (actor, input) => {
|
|
@@ -6385,7 +6579,7 @@ export class SqliteScopeHost {
|
|
|
6385
6579
|
.prepare('SELECT tenant_id FROM tenants WHERE slug = ?')
|
|
6386
6580
|
.get(parsed.slug);
|
|
6387
6581
|
if (slugOwner) {
|
|
6388
|
-
throw
|
|
6582
|
+
throw substratError('conflict', `tenant slug '${parsed.slug}' already taken by ${slugOwner.tenant_id} (slugs are unique)`);
|
|
6389
6583
|
}
|
|
6390
6584
|
this.directory
|
|
6391
6585
|
.prepare(`INSERT INTO tenants (tenant_id, slug, name, status, created_at, provisioned_by_tenant)
|
|
@@ -6396,12 +6590,12 @@ export class SqliteScopeHost {
|
|
|
6396
6590
|
setTenantStatus: async (actor, tenantId, status) => {
|
|
6397
6591
|
const before = readTenant(tenantId);
|
|
6398
6592
|
if (!before)
|
|
6399
|
-
throw
|
|
6593
|
+
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
6400
6594
|
// `reaped` is terminal and destroys data — it is unreachable here and only
|
|
6401
6595
|
// ever set by reapTenant, so a plain status flip cannot forge a tombstone
|
|
6402
6596
|
// over live data (§4.8, the tenant analogue of reapScope's archived-only gate).
|
|
6403
6597
|
if (status === 'reaped') {
|
|
6404
|
-
throw
|
|
6598
|
+
throw substratError('validation_failed', `tenant ${tenantId} cannot be set to 'reaped' via setTenantStatus — reap goes through reapTenant (control-plane.md §4.8)`);
|
|
6405
6599
|
}
|
|
6406
6600
|
// Stamp/clear deleting_at so the grace-window sweep can age tenants (§4.8):
|
|
6407
6601
|
// entering `deleting` stamps, leaving it (an un-delete) clears — exactly the
|
|
@@ -6421,7 +6615,7 @@ export class SqliteScopeHost {
|
|
|
6421
6615
|
setTenantName: async (actor, tenantId, name) => {
|
|
6422
6616
|
const before = readTenant(tenantId);
|
|
6423
6617
|
if (!before)
|
|
6424
|
-
throw
|
|
6618
|
+
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
6425
6619
|
if (before.name === name)
|
|
6426
6620
|
return; // no-op is not audited — nothing changed
|
|
6427
6621
|
this.directory.prepare('UPDATE tenants SET name = ? WHERE tenant_id = ?').run(name, tenantId);
|
|
@@ -6553,7 +6747,7 @@ export class SqliteScopeHost {
|
|
|
6553
6747
|
// the query, so the quoted identifier below carries no user input.
|
|
6554
6748
|
const known = new Set(db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((r) => r.name));
|
|
6555
6749
|
if (!known.has(input.table))
|
|
6556
|
-
throw
|
|
6750
|
+
throw substratError('not_found', `unknown table '${input.table}'`);
|
|
6557
6751
|
// The ceiling clamps; a bound SQLite would misread (NaN, non-finite, fractional,
|
|
6558
6752
|
// negative) is refused instead of reaching LIMIT / OFFSET (#1632).
|
|
6559
6753
|
const limit = Math.min(assertRowLimit('limit', input.limit ?? SCOPE_TABLE_PAGE_DEFAULT), SCOPE_TABLE_PAGE_MAX);
|
|
@@ -6574,7 +6768,7 @@ export class SqliteScopeHost {
|
|
|
6574
6768
|
// itself rejects multi-statement strings and bad SQL with the driver's message.
|
|
6575
6769
|
const stmt = db.prepare(sql);
|
|
6576
6770
|
if (!stmt.readonly)
|
|
6577
|
-
throw
|
|
6771
|
+
throw substratError('validation_failed', 'read-only console: statement is not read-only');
|
|
6578
6772
|
if (!stmt.reader)
|
|
6579
6773
|
return { columns: [], rows: [], truncated: false };
|
|
6580
6774
|
stmt.raw(true);
|
|
@@ -6720,6 +6914,7 @@ export class SqliteScopeHost {
|
|
|
6720
6914
|
loadDirectoryDump(dump.tables, {
|
|
6721
6915
|
columnsOf: (name) => builtColumnsOf(this.directory, name),
|
|
6722
6916
|
exec: (sql) => this.directory.exec(sql),
|
|
6917
|
+
now: () => this.clock(),
|
|
6723
6918
|
insert: (sql, rows) => {
|
|
6724
6919
|
const insert = this.directory.prepare(sql);
|
|
6725
6920
|
for (const row of rows)
|
|
@@ -6771,7 +6966,7 @@ export class SqliteScopeHost {
|
|
|
6771
6966
|
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
6772
6967
|
}
|
|
6773
6968
|
if (rec.status !== 'archived') {
|
|
6774
|
-
throw
|
|
6969
|
+
throw substratError('conflict', `scope ${scopeId} is ${rec.status}, not archived — only an archived scope may be reaped`);
|
|
6775
6970
|
}
|
|
6776
6971
|
// A serving scope always holds ≥1 bound hostname; a truly-dead one has been
|
|
6777
6972
|
// unbound. The dashboard delete path unbinds AT archive, but a bare console
|
|
@@ -6787,7 +6982,7 @@ export class SqliteScopeHost {
|
|
|
6787
6982
|
.prepare('SELECT hostname FROM hostnames WHERE scope_id = ? LIMIT 1')
|
|
6788
6983
|
.get(scopeId);
|
|
6789
6984
|
if (bound) {
|
|
6790
|
-
throw
|
|
6985
|
+
throw substratError('conflict', `scope ${scopeId} still resolves hostname '${bound.hostname}' — ` +
|
|
6791
6986
|
`unbind it before reaping (reap wipes storage and cannot be undone)`);
|
|
6792
6987
|
}
|
|
6793
6988
|
// The reader goes with the writer (#1624): an open handle on a removed file keeps
|
|
@@ -6938,9 +7133,9 @@ export class SqliteScopeHost {
|
|
|
6938
7133
|
// reaped — an illegal source fails closed, like reapScope's archived-only gate.
|
|
6939
7134
|
const before = readTenant(tenantId);
|
|
6940
7135
|
if (!before)
|
|
6941
|
-
throw
|
|
7136
|
+
throw substratError('not_found', `unknown tenant: ${tenantId}`);
|
|
6942
7137
|
if (before.status !== 'deleting') {
|
|
6943
|
-
throw
|
|
7138
|
+
throw substratError('conflict', `tenant ${tenantId} is ${before.status}, not deleting — only a deleting tenant may be reaped`);
|
|
6944
7139
|
}
|
|
6945
7140
|
// Clear the tenant's directory-side rows. Idempotent (set-to-empty), so a crash
|
|
6946
7141
|
// mid-reap converges on retry. KEPT as the tombstone: the `tenants` row itself
|
|
@@ -6954,6 +7149,11 @@ export class SqliteScopeHost {
|
|
|
6954
7149
|
'_substrat_entitlements', // per-tenant SKU flags
|
|
6955
7150
|
'orgs', // K-22 org records
|
|
6956
7151
|
'_substrat_system_switches', // #1674: the schedule switch's record, per scope
|
|
7152
|
+
'_substrat_membership_fences', // #1184: the latest removal, per principal
|
|
7153
|
+
'_substrat_peer_switches', // #2029: the peer switch's record, per scope
|
|
7154
|
+
'_substrat_switch_owed', // #2045: subjects owed a re-assert, per scope
|
|
7155
|
+
'_substrat_findings', // #1748: the tenant's findings
|
|
7156
|
+
'_substrat_finding_rules', // #1748: the tenant's suppress rules
|
|
6957
7157
|
];
|
|
6958
7158
|
const clear = this.directory.transaction(() => {
|
|
6959
7159
|
for (const table of tables) {
|
|
@@ -7072,7 +7272,7 @@ export class SqliteScopeHost {
|
|
|
7072
7272
|
if (existing.topology === parsed.topology && existing.tenantId === parsed.tenantId) {
|
|
7073
7273
|
return;
|
|
7074
7274
|
}
|
|
7075
|
-
throw
|
|
7275
|
+
throw substratError('conflict', `identity pool '${parsed.provider}' is already registered as ${existing.topology}` +
|
|
7076
7276
|
`${existing.tenantId ? ` for tenant ${existing.tenantId}` : ''}`);
|
|
7077
7277
|
}
|
|
7078
7278
|
this.directory
|
|
@@ -7091,9 +7291,9 @@ export class SqliteScopeHost {
|
|
|
7091
7291
|
listIdentityTenants: async (actor, provider, externalId) => {
|
|
7092
7292
|
const pool = readPool(provider);
|
|
7093
7293
|
if (!pool)
|
|
7094
|
-
throw
|
|
7294
|
+
throw substratError('not_found', `identity pool '${provider}' is not registered`);
|
|
7095
7295
|
if (pool.topology !== 'central') {
|
|
7096
|
-
throw
|
|
7296
|
+
throw substratError('forbidden', `identity pool '${provider}' is tenant-bound — enumerating tenants is only ` +
|
|
7097
7297
|
`meaningful on a central pool, where the same externalId is the same person`);
|
|
7098
7298
|
}
|
|
7099
7299
|
const tenants = this.directory
|
|
@@ -7107,9 +7307,9 @@ export class SqliteScopeHost {
|
|
|
7107
7307
|
listIdentityMemberships: async (actor, provider, externalId) => {
|
|
7108
7308
|
const pool = readPool(provider);
|
|
7109
7309
|
if (!pool)
|
|
7110
|
-
throw
|
|
7310
|
+
throw substratError('not_found', `identity pool '${provider}' is not registered`);
|
|
7111
7311
|
if (pool.topology !== 'central') {
|
|
7112
|
-
throw
|
|
7312
|
+
throw substratError('forbidden', `identity pool '${provider}' is tenant-bound — enumerating tenants is only ` +
|
|
7113
7313
|
`meaningful on a central pool, where the same externalId is the same person`);
|
|
7114
7314
|
}
|
|
7115
7315
|
const rows = this.directory
|
|
@@ -7408,7 +7608,7 @@ export class SqliteScopeHost {
|
|
|
7408
7608
|
if (existing) {
|
|
7409
7609
|
if (existing.principal_id === parsed.principal)
|
|
7410
7610
|
return; // idempotent, unaudited
|
|
7411
|
-
throw
|
|
7611
|
+
throw substratError('conflict', `identity ${parsed.provider}:${parsed.externalId} in tenant ${parsed.tenantId} ` +
|
|
7412
7612
|
`is already bound to ${existing.principal_id}`);
|
|
7413
7613
|
}
|
|
7414
7614
|
this.directory
|
|
@@ -7455,6 +7655,11 @@ export class SqliteScopeHost {
|
|
|
7455
7655
|
const { tenantId, scopeId, ...after } = ownerTransferAudit.parse(entry);
|
|
7456
7656
|
this.recordAdmin(actor, 'transferOwner', { tenantId, scopeId }, null, after);
|
|
7457
7657
|
},
|
|
7658
|
+
/** #1150: one phase of a dashboard member change, written around the vertical's own. */
|
|
7659
|
+
recordMemberChange: async (actor, entry) => {
|
|
7660
|
+
const { tenantId, scopeId, ...after } = memberChangeAudit.parse(entry);
|
|
7661
|
+
this.recordAdmin(actor, 'manageScopeMember', { tenantId, scopeId }, null, after);
|
|
7662
|
+
},
|
|
7458
7663
|
/** #2005: one change to a scope's copy marker, written around the vertical's own change. */
|
|
7459
7664
|
recordCopyMark: async (actor, entry) => {
|
|
7460
7665
|
const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
|
|
@@ -7597,40 +7802,49 @@ export class SqliteScopeHost {
|
|
|
7597
7802
|
recordOpsFailure: async (entry) => {
|
|
7598
7803
|
const at = new Date().toISOString();
|
|
7599
7804
|
const fingerprint = opsFailureFingerprint(entry);
|
|
7600
|
-
|
|
7601
|
-
|
|
7602
|
-
|
|
7603
|
-
|
|
7604
|
-
.
|
|
7605
|
-
|
|
7606
|
-
|
|
7607
|
-
|
|
7608
|
-
|
|
7609
|
-
|
|
7610
|
-
|
|
7611
|
-
|
|
7612
|
-
|
|
7613
|
-
|
|
7614
|
-
|
|
7615
|
-
|
|
7616
|
-
|
|
7617
|
-
|
|
7618
|
-
|
|
7619
|
-
|
|
7620
|
-
|
|
7621
|
-
|
|
7622
|
-
|
|
7623
|
-
|
|
7624
|
-
|
|
7625
|
-
|
|
7626
|
-
|
|
7627
|
-
|
|
7628
|
-
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7805
|
+
// #1748: the evidence, its issue and its finding in ONE transaction — a detector that
|
|
7806
|
+
// throws takes the evidence with it, so the caller's retry observes it rather than finding
|
|
7807
|
+
// the row already there and the observation lost.
|
|
7808
|
+
this.directory.transaction(() => {
|
|
7809
|
+
this.directory
|
|
7810
|
+
.prepare(`INSERT INTO _substrat_ops_failures
|
|
7811
|
+
(id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
|
|
7812
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
7813
|
+
.run(ulid(), entry.actor, entry.operation, entry.stage ?? null, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.status ?? null,
|
|
7814
|
+
// Bounded here, not trusted from the catch site: one runaway upstream
|
|
7815
|
+
// body must not become a runaway directory row (#559).
|
|
7816
|
+
entry.message.slice(0, 2000), entry.reference ?? null, entry.origin ?? null, entry.code ?? null, fingerprint, at);
|
|
7817
|
+
// Prune-on-write (#559): retention lives here, not in a cron — every insert
|
|
7818
|
+
// pays for its own housekeeping, so the table stays bounded even where no
|
|
7819
|
+
// scheduled pass runs (this adapter has none).
|
|
7820
|
+
const horizon = new Date(Date.now() - OPS_FAILURE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
7821
|
+
this.directory.prepare('DELETE FROM _substrat_ops_failures WHERE at < ?').run(horizon);
|
|
7822
|
+
// The issues materialization (#1233): the group's counters live on their
|
|
7823
|
+
// own row, bumped in the same call, because the evidence self-prunes above
|
|
7824
|
+
// and a count must survive its own exemplars. A fresh arrival regresses a
|
|
7825
|
+
// resolved issue; an ignored one stays ignored — that is what ignoring means.
|
|
7826
|
+
this.directory
|
|
7827
|
+
.prepare(`INSERT INTO _substrat_issues
|
|
7828
|
+
(fingerprint, operation, stage, origin, code, status, seen_count, first_seen, last_seen, last_message, last_tenant_id, last_owner_kind, last_vertical, last_version, resolved_version, resolved_at)
|
|
7829
|
+
VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
|
|
7830
|
+
ON CONFLICT (fingerprint) DO UPDATE SET
|
|
7831
|
+
seen_count = seen_count + 1,
|
|
7832
|
+
last_seen = excluded.last_seen,
|
|
7833
|
+
last_message = excluded.last_message,
|
|
7834
|
+
last_tenant_id = excluded.last_tenant_id,
|
|
7835
|
+
last_owner_kind = excluded.last_owner_kind,
|
|
7836
|
+
last_vertical = COALESCE(excluded.last_vertical, last_vertical),
|
|
7837
|
+
last_version = COALESCE(excluded.last_version, last_version),
|
|
7838
|
+
origin = COALESCE(excluded.origin, origin),
|
|
7839
|
+
status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`)
|
|
7840
|
+
.run(fingerprint, entry.operation, entry.stage ?? null, entry.origin ?? null, entry.code ?? null, at, at, entry.message.slice(0, 2000), entry.tenantId ?? null, issueExemplarOwner(entry.tenantId ?? null), entry.vertical ?? null, entry.version ?? null);
|
|
7841
|
+
const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
|
|
7842
|
+
this.directory.prepare('DELETE FROM _substrat_issues WHERE last_seen < ?').run(issueHorizon);
|
|
7843
|
+
// #1748: the tenant's Recurring finding, over the tenant's own occurrences only.
|
|
7844
|
+
const finding = findingOfOpsFailure(entry);
|
|
7845
|
+
if (finding)
|
|
7846
|
+
observeFinding(redactionSqlOf(this.directory), finding, at);
|
|
7847
|
+
})();
|
|
7634
7848
|
},
|
|
7635
7849
|
listOpsFailures: async (actor, filter) => {
|
|
7636
7850
|
const where = [];
|
|
@@ -7712,19 +7926,30 @@ export class SqliteScopeHost {
|
|
|
7712
7926
|
}));
|
|
7713
7927
|
},
|
|
7714
7928
|
recordSweepRun: async (entry) => {
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
|
|
7723
|
-
|
|
7724
|
-
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
|
|
7929
|
+
const at = entry.at ?? new Date().toISOString();
|
|
7930
|
+
// #1748: the row and its finding in ONE transaction. A replayed drain the unique index
|
|
7931
|
+
// ignores observes nothing, which is right only because the first arrival's observation
|
|
7932
|
+
// committed with it, or did not commit at all.
|
|
7933
|
+
this.directory.transaction(() => {
|
|
7934
|
+
const { changes } = this.directory
|
|
7935
|
+
.prepare(`INSERT OR IGNORE INTO _substrat_sweep_runs
|
|
7936
|
+
(id, kind, unit, outcome, tenant_id, scope_id, vertical, version, operation,
|
|
7937
|
+
connection_id, error, elapsed_ms, request_id, event_type, observed_at, platform_requests, at)
|
|
7938
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
7939
|
+
.run(ulid(), entry.kind, entry.unit, entry.outcome, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.operation ?? null, entry.connectionId ?? null,
|
|
7940
|
+
// Bounded here, not trusted from the sweep: one runaway provider body
|
|
7941
|
+
// must not become a runaway directory row (the #559 rule).
|
|
7942
|
+
entry.error == null ? null : entry.error.slice(0, 2000), entry.elapsedMs ?? null, entry.requestId ?? null, entry.eventType ?? null, entry.observedAt ?? null, entry.platformRequests == null ? null : JSON.stringify(entry.platformRequests), at);
|
|
7943
|
+
// Prune-on-write, like ops failures and for the same reason: the table stays
|
|
7944
|
+
// bounded even on a deployment whose scheduled pass is broken.
|
|
7945
|
+
const horizon = new Date(Date.now() - SWEEP_RUN_RETENTION_DAYS * 86_400_000).toISOString();
|
|
7946
|
+
this.directory.prepare('DELETE FROM _substrat_sweep_runs WHERE at < ?').run(horizon);
|
|
7947
|
+
// #1748: a failed schedule (Invariant) or a stale freshness verdict (Drift). Only when the
|
|
7948
|
+
// row was written: a replayed drain the unique index ignored is not a second occurrence.
|
|
7949
|
+
const finding = changes > 0 ? findingOfSweepRun(entry) : null;
|
|
7950
|
+
if (finding)
|
|
7951
|
+
observeFinding(redactionSqlOf(this.directory), finding, at);
|
|
7952
|
+
})();
|
|
7728
7953
|
},
|
|
7729
7954
|
listSweepRuns: async (actor, filter) => {
|
|
7730
7955
|
const where = [];
|
|
@@ -7794,6 +8019,8 @@ export class SqliteScopeHost {
|
|
|
7794
8019
|
}
|
|
7795
8020
|
return pruned;
|
|
7796
8021
|
},
|
|
8022
|
+
// #1748: one transaction, so a stale resolution never lands without its audit row.
|
|
8023
|
+
pruneFindings: async (actor, limit) => this.auditedFindings(actor, (sql, audit) => pruneFindings(sql, Date.now(), limit, audit)),
|
|
7797
8024
|
listIssues: async (actor, filter) => {
|
|
7798
8025
|
const where = [];
|
|
7799
8026
|
const params = [];
|
|
@@ -7838,6 +8065,25 @@ export class SqliteScopeHost {
|
|
|
7838
8065
|
this.recordAdmin(actor, 'setIssueStatus', { tenantId: null, vertical: after.lastVertical }, { fingerprint, status: existing.status }, { fingerprint, status: after.status });
|
|
7839
8066
|
return after;
|
|
7840
8067
|
},
|
|
8068
|
+
listFindings: async (actor, filter) => {
|
|
8069
|
+
const rows = listFindings(redactionSqlOf(this.directory), filter);
|
|
8070
|
+
this.recordAccess(actor, 'listFindings', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
|
|
8071
|
+
return rows;
|
|
8072
|
+
},
|
|
8073
|
+
// #1748: every findings mutation runs in ONE transaction with its audit row (`audited`).
|
|
8074
|
+
setFindingStatus: async (actor, tenantId, id, status) => this.auditedFindings(actor, (sql, audit) => setFindingStatus(sql, tenantId, id, status, new Date().toISOString(), audit))
|
|
8075
|
+
?.after,
|
|
8076
|
+
createFindingRule: async (actor, tenantId, input) => {
|
|
8077
|
+
const { rule, suppressed } = this.auditedFindings(actor, (sql, audit) => createFindingRule(sql, tenantId, input, actor, new Date().toISOString(), audit));
|
|
8078
|
+
return { rule, suppressed: suppressed.length };
|
|
8079
|
+
},
|
|
8080
|
+
revokeFindingRule: async (actor, tenantId, ruleId) => this.auditedFindings(actor, (sql, audit) => revokeFindingRule(sql, tenantId, ruleId, new Date().toISOString(), audit))
|
|
8081
|
+
?.after,
|
|
8082
|
+
listFindingRules: async (actor, tenantId, filter) => {
|
|
8083
|
+
const rows = listFindingRules(redactionSqlOf(this.directory), tenantId, filter?.active ? new Date().toISOString() : undefined, filter?.limit);
|
|
8084
|
+
this.recordAccess(actor, 'listFindingRules', { tenantId }, filter, rows.length);
|
|
8085
|
+
return rows;
|
|
8086
|
+
},
|
|
7841
8087
|
recordModelUsage: async (input) => {
|
|
7842
8088
|
const l = input.line;
|
|
7843
8089
|
// INSERT OR IGNORE on the UNIQUE request id: the drain may replay a settled-then-
|
|
@@ -8179,6 +8425,10 @@ export class SqliteScopeHost {
|
|
|
8179
8425
|
* can never disagree about who acted (#97). `overrideActor` remains for the
|
|
8180
8426
|
* system-actor path, where the acting module is the honest answer.
|
|
8181
8427
|
*/
|
|
8428
|
+
/** The context a consumer or an import runs in: no operation, and the delivery it is for (#2055). */
|
|
8429
|
+
deliveryContext(rt, subject, overrideActor, delivery) {
|
|
8430
|
+
return this.operationContext(rt, subject, overrideActor, undefined, undefined, undefined, [], undefined, delivery);
|
|
8431
|
+
}
|
|
8182
8432
|
operationContext(rt, subject, overrideActor,
|
|
8183
8433
|
/** #458: per-invoke tally of `ctx.requestPlatform` calls; absent for consumer dispatch. */
|
|
8184
8434
|
signals,
|
|
@@ -8208,7 +8458,9 @@ export class SqliteScopeHost {
|
|
|
8208
8458
|
* instant its row write used, so the `capability.exercised` event's `occurredAt` and the
|
|
8209
8459
|
* row's `last_used_at` are the same value — a second clock read could disagree.
|
|
8210
8460
|
*/
|
|
8211
|
-
at = this.clock()
|
|
8461
|
+
at = this.clock(),
|
|
8462
|
+
/** #2055: the delivery a consumer's or an import's context runs for; absent otherwise. */
|
|
8463
|
+
delivery) {
|
|
8212
8464
|
// For a connection, system, capability or peer-vertical subject this carries THAT id so
|
|
8213
8465
|
// the type holds — it is not a person, and the event actor below says what it is instead.
|
|
8214
8466
|
const principal = subject.id;
|
|
@@ -8216,6 +8468,7 @@ export class SqliteScopeHost {
|
|
|
8216
8468
|
const relations = this.relations;
|
|
8217
8469
|
const searchPlans = this.searchPlans;
|
|
8218
8470
|
const listPlans = this.listPlans;
|
|
8471
|
+
const statePlans = this.statePlans;
|
|
8219
8472
|
// K-34: the checks that passed in THIS operation. The context is created per invoke
|
|
8220
8473
|
// (see buildStub), so this accumulates one operation's authorizations; `emit` snapshots
|
|
8221
8474
|
// whatever has passed up to that point. A system/override actor is unconditionally
|
|
@@ -8337,11 +8590,10 @@ export class SqliteScopeHost {
|
|
|
8337
8590
|
// about the process, so it never rides `DomainEvent` for module code to
|
|
8338
8591
|
// branch on; it exists for the observability joins the column serves.
|
|
8339
8592
|
this.versionId,
|
|
8340
|
-
// #1237:
|
|
8341
|
-
// the runtime
|
|
8342
|
-
//
|
|
8343
|
-
|
|
8344
|
-
rt.causedBy,
|
|
8593
|
+
// #1237: the delivery this context was built for, if any — passed in, never read
|
|
8594
|
+
// off the runtime or the host (#2055). A fact about the surrounding dispatch,
|
|
8595
|
+
// never envelope data module code could set or branch on.
|
|
8596
|
+
delivery?.causedBy ?? null,
|
|
8345
8597
|
// #1237: a fact about the surrounding CALL, like the version above — never
|
|
8346
8598
|
// envelope data module code could set or branch on.
|
|
8347
8599
|
rt.invocationId, full.payload === undefined ? null : JSON.stringify(full.payload));
|
|
@@ -8350,7 +8602,11 @@ export class SqliteScopeHost {
|
|
|
8350
8602
|
tenantId: rt.tenantId,
|
|
8351
8603
|
scopeId: rt.scopeId,
|
|
8352
8604
|
principal,
|
|
8353
|
-
sql: guardSecrets(guardSqlLimits(scopedSql(rt.db, true
|
|
8605
|
+
sql: guardSecrets(guardSqlLimits(scopedSql(rt.db, true, statefulTablesOf(statePlans),
|
|
8606
|
+
// #119: after runtime DDL, the stateful tables must still carry what the kernel derived.
|
|
8607
|
+
statePlans.size
|
|
8608
|
+
? () => assertEntityStateIntact(spineSql(rt.db), statePlans, stateListIndexNames(listPlans.values()))
|
|
8609
|
+
: undefined)), minted),
|
|
8354
8610
|
now: () => at,
|
|
8355
8611
|
// #1746/#1747: the host stamps who and where; module code supplies only the template
|
|
8356
8612
|
// and its fields. The invocation id is read per call — it is set for the duration of
|
|
@@ -8359,7 +8615,7 @@ export class SqliteScopeHost {
|
|
|
8359
8615
|
tenantId: rt.tenantId,
|
|
8360
8616
|
scopeId: rt.scopeId,
|
|
8361
8617
|
operation: operation ?? null,
|
|
8362
|
-
invocationId: () => rt.invocationId ??
|
|
8618
|
+
invocationId: () => rt.invocationId ?? delivery?.invocationId ?? null,
|
|
8363
8619
|
// A consumer runs under the system override, so it logs as `system`.
|
|
8364
8620
|
principalKind: overrideActor ? 'system' : subject.kind,
|
|
8365
8621
|
// The string-safe redaction: `redactSecrets` parses its serialization back, and a
|
|
@@ -8420,7 +8676,9 @@ export class SqliteScopeHost {
|
|
|
8420
8676
|
const plan = searchPlans.get(entityType);
|
|
8421
8677
|
if (!plan)
|
|
8422
8678
|
throw new NotSearchable(entityType);
|
|
8423
|
-
const q = searchQuery(plan, searchMatchExpression(term, plan.tokenizer), searchLimit(options?.limit)
|
|
8679
|
+
const q = searchQuery(plan, searchMatchExpression(term, plan.tokenizer), searchLimit(options?.limit),
|
|
8680
|
+
// #119: active rows unless the archive is asked for.
|
|
8681
|
+
searchStateWhere(statePlans, entityType, options?.view));
|
|
8424
8682
|
return rt.db.prepare(q.sql).all(...q.params).map((row) => ({ entityType, id: row.id, rank: row.rank }));
|
|
8425
8683
|
},
|
|
8426
8684
|
/**
|
|
@@ -8441,13 +8699,15 @@ export class SqliteScopeHost {
|
|
|
8441
8699
|
order: params.order,
|
|
8442
8700
|
cursor: params.cursor,
|
|
8443
8701
|
filters: params.filters,
|
|
8702
|
+
// #119: the bin is the checked reader's, never this one's.
|
|
8703
|
+
view: uncheckedView('ctx.page', entityType, params.view),
|
|
8444
8704
|
});
|
|
8445
8705
|
const rows = rt.db.prepare(q.sql).all(...q.params);
|
|
8446
8706
|
// A FULL page may have more; a short one is the end of the walk. Same rule
|
|
8447
8707
|
// as `pageOf`, applied here because the cursor is a COLUMN's value (plus
|
|
8448
8708
|
// the tie-break) rather than a field the caller could name.
|
|
8449
8709
|
const last = rows.length >= limit ? rows[rows.length - 1] : undefined;
|
|
8450
|
-
const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn, q.order);
|
|
8710
|
+
const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn, q.order, q.view);
|
|
8451
8711
|
const page = { entries: rows, nextCursor };
|
|
8452
8712
|
if (!params.total)
|
|
8453
8713
|
return page;
|
|
@@ -8526,6 +8786,24 @@ export class SqliteScopeHost {
|
|
|
8526
8786
|
emit: (event) => writeEvent(event, 'kernel'),
|
|
8527
8787
|
assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
|
|
8528
8788
|
}),
|
|
8789
|
+
// #119: archive and trash, written once in the kernel. The raw seam, because the guarded
|
|
8790
|
+
// `ctx.sql` refuses the very columns these write; the operation's own check, so the
|
|
8791
|
+
// declared key's pass is one of its authorizations.
|
|
8792
|
+
...createEntityStateVerbs({
|
|
8793
|
+
sql: spineSql(rt.db),
|
|
8794
|
+
plans: statePlans,
|
|
8795
|
+
now: at,
|
|
8796
|
+
check: runCheck,
|
|
8797
|
+
emit: (event) => writeEvent(event, 'kernel'),
|
|
8798
|
+
assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
|
|
8799
|
+
}),
|
|
8800
|
+
...createTrashedReads({
|
|
8801
|
+
query: (q, params) => rt.db.prepare(q).all(...params),
|
|
8802
|
+
listPlans,
|
|
8803
|
+
searchPlans,
|
|
8804
|
+
statePlans,
|
|
8805
|
+
check: runCheck,
|
|
8806
|
+
}),
|
|
8529
8807
|
// #304: the request-time entitlement read. The pure adapter is single-process, so the
|
|
8530
8808
|
// directory is local — no projection needed; it reads `_substrat_entitlements` straight,
|
|
8531
8809
|
// with the same expiry-at-read filter as the gate (#33), and the SAME contract the DO
|
|
@@ -8689,7 +8967,7 @@ export class SqliteScopeHost {
|
|
|
8689
8967
|
if (!r || r.tenant_id !== tenantId)
|
|
8690
8968
|
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
8691
8969
|
if (r.status === 'reaped') {
|
|
8692
|
-
throw
|
|
8970
|
+
throw substratError('conflict', `scope ${scopeId} is reaped — its storage is gone and cannot be read`);
|
|
8693
8971
|
}
|
|
8694
8972
|
}
|
|
8695
8973
|
/**
|
|
@@ -8859,6 +9137,13 @@ export class SqliteScopeHost {
|
|
|
8859
9137
|
// executes, the table always already has `kind` in its key, never `invocation_id`.
|
|
8860
9138
|
this.ensureColumn(db, '_substrat_schedule_state', 'invocation_id', 'invocation_id TEXT');
|
|
8861
9139
|
this.ensureColumn(db, '_substrat_job_runs', 'subject_id', 'subject_id TEXT');
|
|
9140
|
+
// #2034: the lease, on a scope DB built before it. NULL = nobody holds the run.
|
|
9141
|
+
this.ensureColumn(db, '_substrat_job_runs', 'lease_owner', 'lease_owner TEXT');
|
|
9142
|
+
// #2042 r2, r4: whether the holder BEGAN its pass. NULL = it has not, which is right for any lease
|
|
9143
|
+
// already there: a takeover of it costs nothing.
|
|
9144
|
+
this.ensureColumn(db, '_substrat_job_runs', 'lease_began_at', 'lease_began_at TEXT');
|
|
9145
|
+
// #2042 r3: consecutive admission misses. NULL = none, right for every run already there.
|
|
9146
|
+
this.ensureColumn(db, '_substrat_job_runs', 'admission_misses', 'admission_misses INTEGER');
|
|
8862
9147
|
// #2009: the copy classification on a scope DB built before it (NULL reads as a copy; see
|
|
8863
9148
|
// `COPY_ORIGIN_DDL`).
|
|
8864
9149
|
this.ensureColumn(db, '_substrat_copy_origin', 'is_copy', 'is_copy INTEGER');
|
|
@@ -8907,9 +9192,7 @@ export class SqliteScopeHost {
|
|
|
8907
9192
|
actor: new ScopeActor(),
|
|
8908
9193
|
appliedMigrations,
|
|
8909
9194
|
mintEventId,
|
|
8910
|
-
causedBy: null,
|
|
8911
9195
|
invocationId: null,
|
|
8912
|
-
unitInvocationId: null,
|
|
8913
9196
|
};
|
|
8914
9197
|
this.scopes.set(key, created);
|
|
8915
9198
|
this.scopesById.set(scopeId, created);
|
|
@@ -8974,7 +9257,11 @@ function assertTablesWithinColumnLimit(db) {
|
|
|
8974
9257
|
if (wide)
|
|
8975
9258
|
throw new Error(tooManyTableColumns(wide.name, wide.width));
|
|
8976
9259
|
}
|
|
8977
|
-
function scopedSql(db, judgeWidth = false
|
|
9260
|
+
function scopedSql(db, judgeWidth = false,
|
|
9261
|
+
/** #119: the module tables carrying archive/trash columns — `guardSpine` refuses positional writes to them. */
|
|
9262
|
+
statefulTables,
|
|
9263
|
+
/** #119: the kernel's integrity check, run after any runtime DDL — see `guardSpine`. */
|
|
9264
|
+
afterDdl) {
|
|
8978
9265
|
// #1811: a Durable Object refuses a result set over its column cap, `exec` of a SELECT included.
|
|
8979
9266
|
// The driver knows the width, so it is read off the prepared statement rather than parsed out
|
|
8980
9267
|
// of the text (a `SELECT *` is as wide as the tables under it). Module SQL only, like the
|
|
@@ -9001,7 +9288,7 @@ function scopedSql(db, judgeWidth = false) {
|
|
|
9001
9288
|
assertTablesWithinColumnLimit(db);
|
|
9002
9289
|
return { changes: info.changes };
|
|
9003
9290
|
},
|
|
9004
|
-
});
|
|
9291
|
+
}, statefulTables, afterDdl);
|
|
9005
9292
|
}
|
|
9006
9293
|
const schemaVersion = (db) => db.pragma('schema_version', { simple: true });
|
|
9007
9294
|
//# sourceMappingURL=index.js.map
|