@substrat-run/adapter-sqlite 0.138.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/index.js CHANGED
@@ -1,9 +1,9 @@
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, 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, 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_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, 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, tenantCoverage, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, moduleMigrations, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, assertAttachmentExtractors, assertJobRegistrable, attachmentRecordOfRow, attachmentTextJob, enqueueAttachmentText, isAttachmentTextRun, 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 { attributedHost } from '@substrat-run/kernel';
4
+ import { accessLogEntry, adminLogEntry, delegatedReadParams, operationSeriesCount, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, exportReadInput, importBatch, importCursorMove, exportsOfManifestJson, 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';
@@ -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
@@ -521,6 +524,8 @@ export class SqliteScopeHost {
521
524
  searchPlans = new Map();
522
525
  /** #811: the paged lists modules declare, by entity type. Same one-owner rule as search. */
523
526
  listPlans = new Map();
527
+ /** #119: entity type → its archive/trash plan, from every registered module. */
528
+ statePlans = new Map();
524
529
  /** entityType → the declared attachment gate (#473): read key + write key (default: read). */
525
530
  attachmentTargets = new Map();
526
531
  /** operation name → who binds it: the owning module, its entitlementKey and its declared
@@ -557,10 +562,10 @@ export class SqliteScopeHost {
557
562
  */
558
563
  jobs = new Map();
559
564
  /**
560
- * The event currently being effected by an executor, stamped onto any admin rows
561
- * it writes. Ambient rather than threaded through every HostAdmin signature: it is
562
- * set and cleared immediately around one `await`, and executors run sequentially,
563
- * so there is no window where it belongs to a different event.
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).
564
569
  */
565
570
  causedBy = null;
566
571
  /**
@@ -573,6 +578,7 @@ export class SqliteScopeHost {
573
578
  secretBox;
574
579
  /** The parsers attachment text is extracted with (K-43); the host's own, never imported here. */
575
580
  attachmentExtractors;
581
+ attachmentTextBounds;
576
582
  fetchImpl;
577
583
  connectorCalls;
578
584
  clock;
@@ -590,6 +596,7 @@ export class SqliteScopeHost {
590
596
  this.secretBox = options.secretBox ?? unconfiguredSecretBox;
591
597
  assertAttachmentExtractors(options.attachmentExtractors ?? []);
592
598
  this.attachmentExtractors = options.attachmentExtractors ?? [];
599
+ this.attachmentTextBounds = resolveAttachmentTextBounds(options.attachmentTextBounds);
593
600
  this.fetchImpl = options.fetch ?? globalFetch;
594
601
  this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
595
602
  this.clock = options.clock ?? (() => instant.parse(new Date().toISOString()));
@@ -1146,6 +1153,8 @@ export class SqliteScopeHost {
1146
1153
  resolved_at TEXT
1147
1154
  );
1148
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}
1149
1158
  -- The durable sweep record (#1232): one row per unit outcome per pass —
1150
1159
  -- a connection swept/skipped/failed, a schedule fired/skipped/failed. What
1151
1160
  -- makes "when was this last swept" answerable after the log line rolls off.
@@ -1458,14 +1467,17 @@ export class SqliteScopeHost {
1458
1467
  // #811: the list indexes this module's `lists` declare — same placement and
1459
1468
  // the same reason as the search indexes above: appended AFTER the module's
1460
1469
  // own migrations, so `CREATE INDEX` names a table that exists.
1461
- const listMigrations = listIndexMigrations(manifest.id, manifest.lists);
1462
- for (const m of listMigrations) {
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]) {
1463
1474
  if (seen.has(m.version)) {
1464
1475
  throw new Error(`duplicate migration version in ${manifest.id}: ${m.version}`);
1465
1476
  }
1466
1477
  seen.add(m.version);
1467
1478
  }
1468
- for (const plan of listIndexPlans(manifest.id, manifest.lists)) {
1479
+ addStatePlans(this.statePlans, manifest.id, manifest.entityStates, manifest.permissions);
1480
+ for (const plan of listIndexPlans(manifest.id, manifest.lists, manifest.entityStates)) {
1469
1481
  const existing = this.listPlans.get(plan.entityType);
1470
1482
  if (existing) {
1471
1483
  throw new Error(`list: '${plan.entityType}' declares a paged list in both '${existing.moduleId}' and ` +
@@ -2266,6 +2278,18 @@ export class SqliteScopeHost {
2266
2278
  if (present.has(plan.table))
2267
2279
  db.exec(searchIndexDdl(plan));
2268
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
+ }
2269
2293
  // #1575: attachment text is not in a dump, so a load left it as it was. Drop the
2270
2294
  // text of attachments the dump did not bring back, and queue extraction for those
2271
2295
  // it brought back without text — the bytes decide what that run finds.
@@ -2685,16 +2709,14 @@ export class SqliteScopeHost {
2685
2709
  const { hops: _hops, ...fact } = e;
2686
2710
  const event = structuredClone({ ...fact, source });
2687
2711
  const unit = unitOf(imp.moduleId, e.id, e.type);
2688
- // What the handler emits was emitted BECAUSE of the producer's event (#1237). The id
2689
- // resolves through `_substrat_imports`, which the row above has just written.
2690
- rt.causedBy = e.id;
2691
- rt.unitInvocationId = unit.invocationId;
2692
2712
  rt.db.exec('BEGIN IMMEDIATE');
2693
2713
  try {
2694
2714
  // As the producer's principal, admitted above: the handler's checks are real ones,
2695
2715
  // against the grants this vertical's `peers` gave it, and its emits carry the actor
2696
- // `{ vertical, scope }` and the authorization they passed (K-34).
2697
- await imp.handler(this.operationContext(rt, subject), event);
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);
2698
2720
  rt.db
2699
2721
  .prepare(`INSERT INTO _substrat_deliveries (event_id, consumer_module, delivered_at, invocation_id)
2700
2722
  VALUES (?, ?, ?, NULL)`)
@@ -2711,10 +2733,6 @@ export class SqliteScopeHost {
2711
2733
  result.deadLettered += 1;
2712
2734
  lines.write({ ...unit, outcome: 'dead-lettered', error: err });
2713
2735
  }
2714
- finally {
2715
- rt.causedBy = null;
2716
- rt.unitInvocationId = null;
2717
- }
2718
2736
  }
2719
2737
  if (!ran)
2720
2738
  result.duplicates += 1;
@@ -2925,13 +2943,35 @@ export class SqliteScopeHost {
2925
2943
  const target = principalId.parse(assignee);
2926
2944
  return rt.actor.turn(async () => {
2927
2945
  const bound = await this.assignmentBound(subject, tenantId, scopeId, roleKey);
2928
- if (bound.covered) {
2929
- rt.db.prepare(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
2930
- VALUES (?, ?, ?, NULL)`).run(`principal:${target}`, `role:${roleKey}`, `scope:${scopeId}`);
2931
- }
2946
+ if (bound.covered)
2947
+ applyScopeRoleChange(switchSqlOf(rt.db), scopeId, target, { revoke: [], grant: roleKey }, this.clock());
2932
2948
  return bound;
2933
2949
  });
2934
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
+ }
2935
2975
  /**
2936
2976
  * §5.1's assignment bound, resolved against the SAME role table the checker expands
2937
2977
  * — not a vertical's compile-time `ROLES` array. A tenant's projected role is what
@@ -3683,16 +3723,17 @@ export class SqliteScopeHost {
3683
3723
  lines.write({ ...unit, attempt: attempts, outcome: 'inert' });
3684
3724
  continue;
3685
3725
  }
3686
- this.causedBy = event.id;
3726
+ // #2055: the handler writes through a view bound to its event, never through the host.
3727
+ const caused = this.causedByView(event.id);
3687
3728
  try {
3688
3729
  let result;
3689
3730
  if (executor.kind === 'connector') {
3690
3731
  // `true`: dispatchExecutors is only ever reached from inside
3691
3732
  // `rt.actor.enqueue` (invoke's post-commit tail, or drainDue).
3692
- await executor.handler(this.connectorContext(rt, executor.timeoutMs, true, event.id), event);
3733
+ await executor.handler(caused.connectorContext(rt, executor.timeoutMs, true, event.id), event);
3693
3734
  }
3694
3735
  else {
3695
- result = await executor.handler(this.admin, event, scope);
3736
+ result = await executor.handler(caused.admin, event, scope);
3696
3737
  }
3697
3738
  // #1184: a refusal is the handler's own terminal decision — journaled with its
3698
3739
  // reason, never retried, and listed by `executorDeadLetters` beside an exhausted one.
@@ -3719,9 +3760,6 @@ export class SqliteScopeHost {
3719
3760
  outcomeOf(dead ? 'dead-lettered' : 'retrying', err);
3720
3761
  lines.write({ ...unit, attempt: attempts, outcome: dead ? 'dead-lettered' : 'retrying', error: err });
3721
3762
  }
3722
- finally {
3723
- this.causedBy = null;
3724
- }
3725
3763
  }
3726
3764
  }
3727
3765
  }
@@ -3948,7 +3986,7 @@ export class SqliteScopeHost {
3948
3986
  write: (attachmentId, outcome) => rt.actor.enqueue(() => recordAttachmentText(spineSql(rt.db), attachmentId, outcome, this.clock())),
3949
3987
  },
3950
3988
  // K-43: the host's parsers, handed in — this adapter imports none.
3951
- this.attachmentExtractors);
3989
+ this.attachmentExtractors, this.attachmentTextBounds);
3952
3990
  }
3953
3991
  async startJobRun(tenantId, scopeId, input) {
3954
3992
  const rt = this.runtime(tenantId, scopeId);
@@ -3958,12 +3996,19 @@ export class SqliteScopeHost {
3958
3996
  async runDueJobs(tenantId, scopeId, options) {
3959
3997
  const rt = this.runtime(tenantId, scopeId);
3960
3998
  await this.applyPendingMigrations(rt);
3961
- // #1575: the kernel's extraction job is this host's own, bound to this scope — no
3962
- // deployment registers it, and none can shadow it.
3963
- const attachmentText = { handler: this.attachmentTextHandler(rt) };
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
+ };
3964
4009
  return runDueJobRuns({
3965
4010
  store: this.jobStore(rt),
3966
- handlerFor: (run) => isAttachmentTextRun(run) ? attachmentText : this.jobs.get(`${run.module_id}/${run.job}`),
4011
+ handlerFor: (run) => kernelJobFor(run, kernelJobs) ?? this.jobs.get(`${run.module_id}/${run.job}`),
3967
4012
  now: this.clock,
3968
4013
  openScope: (run) => this.getSystemScope(run.module_id, tenantId, scopeId),
3969
4014
  maxPasses: options?.maxPasses,
@@ -3983,15 +4028,10 @@ export class SqliteScopeHost {
3983
4028
  // Cloudflare one does; the context build is the same one `dispatchExecutors` hands
3984
4029
  // an in-process connector.
3985
4030
  const rt = this.runtime(tenantId, scopeId);
3986
- this.causedBy = event.id;
3987
- try {
3988
- // `false`: this path deliberately does NOT enqueue, so nothing is held and
3989
- // the connection's reads take an ordinary serialized turn.
3990
- await handler(this.connectorContext(rt, options?.timeoutMs ?? 30_000, false, event.id), event);
3991
- }
3992
- finally {
3993
- this.causedBy = null;
3994
- }
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);
3995
4035
  }
3996
4036
  /**
3997
4037
  * The scope's own answer to "what may this connection do here" (#726 gap 1) — read
@@ -4211,16 +4251,12 @@ export class SqliteScopeHost {
4211
4251
  lines.write({ ...unit, outcome: 'dead-lettered' });
4212
4252
  continue;
4213
4253
  }
4214
- const ctx = this.operationContext(rt, asPrincipal(this.systemPrincipal), {
4215
- system: mod.id,
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,
4216
4259
  });
4217
- // #1237: anything this consumer emits was emitted BECAUSE of this event.
4218
- // `dispatchExecutors` already did this for the admin log; the module
4219
- // consumers never did, which is exactly where a backwards walk stopped.
4220
- // On the runtime rather than the host — see `ScopeRuntime.causedBy`.
4221
- rt.causedBy = event.id;
4222
- // #1901: the handler's `ctx.log` lines join this delivery's line.
4223
- rt.unitInvocationId = unit.invocationId;
4224
4260
  rt.db.exec('BEGIN IMMEDIATE');
4225
4261
  try {
4226
4262
  await consumer.handler(ctx, event);
@@ -4244,14 +4280,6 @@ export class SqliteScopeHost {
4244
4280
  .run(event.id, mod.id, new Date().toISOString(), String(err), invocationId);
4245
4281
  lines.write({ ...unit, outcome: 'dead-lettered', error: err });
4246
4282
  }
4247
- finally {
4248
- rt.unitInvocationId = null;
4249
- // Cleared on BOTH paths. Left set, the id would leak onto every later
4250
- // emit in this scope — an operation's own event stamped as caused by
4251
- // whatever happened to be delivered last, which is worse than no cause
4252
- // at all, because it reads as a recorded fact.
4253
- rt.causedBy = null;
4254
- }
4255
4283
  }
4256
4284
  }
4257
4285
  }
@@ -4532,6 +4560,14 @@ export class SqliteScopeHost {
4532
4560
  throw new Error(`connection not found: ${id}`);
4533
4561
  return row;
4534
4562
  }
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
+ }
4535
4571
  recordAdmin(actor, action, target, before, after,
4536
4572
  /** When the row says it happened; the wall clock unless the write took its time elsewhere (#1184). */
4537
4573
  at = new Date().toISOString()) {
@@ -4539,7 +4575,9 @@ export class SqliteScopeHost {
4539
4575
  .prepare(`INSERT INTO _substrat_admin_log
4540
4576
  (id, actor, action, tenant_id, scope_id, vertical, before, after, caused_by, on_behalf_of, at)
4541
4577
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
4542
- .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), this.causedBy,
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,
4543
4581
  // #977: set only on an attributed view (`attributed`), never on the host itself.
4544
4582
  this.onBehalfOf === null ? null : JSON.stringify(this.onBehalfOf), at);
4545
4583
  }
@@ -4562,8 +4600,12 @@ export class SqliteScopeHost {
4562
4600
  .all(tenantId).map((r) => ({ key: r.entitlement_key, expired: r.expires_at !== null && r.expires_at <= now }));
4563
4601
  }
4564
4602
  /** #977: this host, with every admin row it writes naming who the actor acted for. */
4565
- attributed(onBehalfOf) {
4566
- return attributedHost(this, onBehalfOf, this.buildAdmin);
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);
4567
4609
  }
4568
4610
  buildAdmin() {
4569
4611
  // #977: an admin whose audit rows say on whose behalf it acts. Built over a VIEW of this
@@ -4808,6 +4850,13 @@ export class SqliteScopeHost {
4808
4850
  const { tenantId } = assignment.node;
4809
4851
  return revokeAndFence(tenantId, assignment.principalId, `role:${assignment.roleKey}`, `tenant:${tenantId}`, (at) => this.recordAdmin(actor, 'unassignRole', { tenantId, scopeId: null }, assignment, null, at));
4810
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));
4811
4860
  // The tenant-level reads the checker makes, synchronous, for the bound inside a unit.
4812
4861
  const directoryReader = directoryTenantReader({
4813
4862
  directory: this.directory,
@@ -6444,14 +6493,13 @@ export class SqliteScopeHost {
6444
6493
  this.recordAccess(actor, 'getOrg', { tenantId }, { orgId }, o ? 1 : 0);
6445
6494
  return o;
6446
6495
  },
6447
- addMember: async (actor, tenantId, principal, orgId) => {
6496
+ addMember: async (actor, tenantId, principal, orgId, opts) => {
6448
6497
  requireOrg(tenantId, orgId);
6449
6498
  // INSERT OR REPLACE, so re-adding a revoked member clears the tombstone —
6450
6499
  // they are a member again. The add/revoke history is not lost: it lives in
6451
6500
  // the append-only admin log, which is where "what happened" belongs. The
6452
6501
  // tuple carries "what is true now" plus enough to explain a live proof.
6453
- writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${orgId}`);
6454
- this.recordAdmin(actor, 'addMember', { tenantId }, null, { principal, orgId });
6502
+ joinOrg(actor, tenantId, principal, orgId, opts?.expiresAt ?? null);
6455
6503
  },
6456
6504
  applyMembership: async (actor, change) => {
6457
6505
  // ONE directory transaction, synchronous throughout (#1184): the fence (an add's), the
@@ -6473,10 +6521,24 @@ export class SqliteScopeHost {
6473
6521
  const bound = tenantCoverage(directoryReader, tenantId, boundedBy, role.permissions);
6474
6522
  if (!bound.covered)
6475
6523
  return { applied: false, missing: bound.missing };
6476
- if (change.op === 'remove')
6477
- return { applied: true, changed: revokeTenantRoleAndFence(actor, assignment) };
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
+ }
6478
6538
  writeTenantTuple(tenantId, `principal:${principal}`, `role:${roleKey}`, `tenant:${tenantId}`);
6479
6539
  this.recordAdmin(actor, 'assignRole', { tenantId, scopeId: null }, null, assignment);
6540
+ if (org?.bounded)
6541
+ joinOrg(actor, tenantId, principal, org.orgId, org.expiresAt);
6480
6542
  return { applied: true };
6481
6543
  })();
6482
6544
  },
@@ -6485,12 +6547,12 @@ export class SqliteScopeHost {
6485
6547
  // Tombstone (K-21), never DELETE: a repeat revoke neither moves the timestamp nor writes
6486
6548
  // a second audit row. One transaction with the removal fence (#1184), which a no-op
6487
6549
  // raises too.
6488
- this.directory.transaction(() => revokeAndFence(tenantId, principal, 'member', `org:${orgId}`, (at) => this.recordAdmin(actor, 'removeMember', { tenantId }, { principal, orgId }, null, at)))();
6550
+ this.directory.transaction(() => leaveOrgAndFence(actor, tenantId, principal, orgId))();
6489
6551
  },
6490
6552
  listMembers: async (actor, tenantId, orgId, options) => {
6491
6553
  requireOrg(tenantId, orgId);
6492
6554
  const rows = this.directory
6493
- .prepare(`SELECT subject, revoked_at FROM _substrat_tenant_tuples
6555
+ .prepare(`SELECT subject, revoked_at, expires_at FROM _substrat_tenant_tuples
6494
6556
  WHERE tenant_id = ? AND relation = 'member' AND object = ?
6495
6557
  ${options?.includeRevoked ? '' : 'AND revoked_at IS NULL'}
6496
6558
  ORDER BY subject`)
@@ -6500,6 +6562,7 @@ export class SqliteScopeHost {
6500
6562
  principal: r.subject.slice('principal:'.length),
6501
6563
  orgId,
6502
6564
  revokedAt: r.revoked_at,
6565
+ expiresAt: r.expires_at,
6503
6566
  }));
6504
6567
  },
6505
6568
  createTenant: async (actor, input) => {
@@ -7089,6 +7152,8 @@ export class SqliteScopeHost {
7089
7152
  '_substrat_membership_fences', // #1184: the latest removal, per principal
7090
7153
  '_substrat_peer_switches', // #2029: the peer switch's record, per scope
7091
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
7092
7157
  ];
7093
7158
  const clear = this.directory.transaction(() => {
7094
7159
  for (const table of tables) {
@@ -7590,6 +7655,11 @@ export class SqliteScopeHost {
7590
7655
  const { tenantId, scopeId, ...after } = ownerTransferAudit.parse(entry);
7591
7656
  this.recordAdmin(actor, 'transferOwner', { tenantId, scopeId }, null, after);
7592
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
+ },
7593
7663
  /** #2005: one change to a scope's copy marker, written around the vertical's own change. */
7594
7664
  recordCopyMark: async (actor, entry) => {
7595
7665
  const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
@@ -7732,40 +7802,49 @@ export class SqliteScopeHost {
7732
7802
  recordOpsFailure: async (entry) => {
7733
7803
  const at = new Date().toISOString();
7734
7804
  const fingerprint = opsFailureFingerprint(entry);
7735
- this.directory
7736
- .prepare(`INSERT INTO _substrat_ops_failures
7737
- (id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
7738
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
7739
- .run(ulid(), entry.actor, entry.operation, entry.stage ?? null, entry.tenantId ?? null, entry.scopeId ?? null, entry.vertical ?? null, entry.version ?? null, entry.status ?? null,
7740
- // Bounded here, not trusted from the catch site: one runaway upstream
7741
- // body must not become a runaway directory row (#559).
7742
- entry.message.slice(0, 2000), entry.reference ?? null, entry.origin ?? null, entry.code ?? null, fingerprint, at);
7743
- // Prune-on-write (#559): retention lives here, not in a cron — every insert
7744
- // pays for its own housekeeping, so the table stays bounded even where no
7745
- // scheduled pass runs (this adapter has none).
7746
- const horizon = new Date(Date.now() - OPS_FAILURE_RETENTION_DAYS * 86_400_000).toISOString();
7747
- this.directory.prepare('DELETE FROM _substrat_ops_failures WHERE at < ?').run(horizon);
7748
- // The issues materialization (#1233): the group's counters live on their
7749
- // own row, bumped in the same call, because the evidence self-prunes above
7750
- // and a count must survive its own exemplars. A fresh arrival regresses a
7751
- // resolved issue; an ignored one stays ignored — that is what ignoring means.
7752
- this.directory
7753
- .prepare(`INSERT INTO _substrat_issues
7754
- (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)
7755
- VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
7756
- ON CONFLICT (fingerprint) DO UPDATE SET
7757
- seen_count = seen_count + 1,
7758
- last_seen = excluded.last_seen,
7759
- last_message = excluded.last_message,
7760
- last_tenant_id = excluded.last_tenant_id,
7761
- last_owner_kind = excluded.last_owner_kind,
7762
- last_vertical = COALESCE(excluded.last_vertical, last_vertical),
7763
- last_version = COALESCE(excluded.last_version, last_version),
7764
- origin = COALESCE(excluded.origin, origin),
7765
- status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`)
7766
- .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);
7767
- const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
7768
- this.directory.prepare('DELETE FROM _substrat_issues WHERE last_seen < ?').run(issueHorizon);
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
+ })();
7769
7848
  },
7770
7849
  listOpsFailures: async (actor, filter) => {
7771
7850
  const where = [];
@@ -7847,19 +7926,30 @@ export class SqliteScopeHost {
7847
7926
  }));
7848
7927
  },
7849
7928
  recordSweepRun: async (entry) => {
7850
- this.directory
7851
- .prepare(`INSERT OR IGNORE INTO _substrat_sweep_runs
7852
- (id, kind, unit, outcome, tenant_id, scope_id, vertical, version, operation,
7853
- connection_id, error, elapsed_ms, request_id, event_type, observed_at, platform_requests, at)
7854
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
7855
- .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,
7856
- // Bounded here, not trusted from the sweep: one runaway provider body
7857
- // must not become a runaway directory row (the #559 rule).
7858
- 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), entry.at ?? new Date().toISOString());
7859
- // Prune-on-write, like ops failures and for the same reason: the table stays
7860
- // bounded even on a deployment whose scheduled pass is broken.
7861
- const horizon = new Date(Date.now() - SWEEP_RUN_RETENTION_DAYS * 86_400_000).toISOString();
7862
- this.directory.prepare('DELETE FROM _substrat_sweep_runs WHERE at < ?').run(horizon);
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
+ })();
7863
7953
  },
7864
7954
  listSweepRuns: async (actor, filter) => {
7865
7955
  const where = [];
@@ -7929,6 +8019,8 @@ export class SqliteScopeHost {
7929
8019
  }
7930
8020
  return pruned;
7931
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)),
7932
8024
  listIssues: async (actor, filter) => {
7933
8025
  const where = [];
7934
8026
  const params = [];
@@ -7973,6 +8065,25 @@ export class SqliteScopeHost {
7973
8065
  this.recordAdmin(actor, 'setIssueStatus', { tenantId: null, vertical: after.lastVertical }, { fingerprint, status: existing.status }, { fingerprint, status: after.status });
7974
8066
  return after;
7975
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
+ },
7976
8087
  recordModelUsage: async (input) => {
7977
8088
  const l = input.line;
7978
8089
  // INSERT OR IGNORE on the UNIQUE request id: the drain may replay a settled-then-
@@ -8314,6 +8425,10 @@ export class SqliteScopeHost {
8314
8425
  * can never disagree about who acted (#97). `overrideActor` remains for the
8315
8426
  * system-actor path, where the acting module is the honest answer.
8316
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
+ }
8317
8432
  operationContext(rt, subject, overrideActor,
8318
8433
  /** #458: per-invoke tally of `ctx.requestPlatform` calls; absent for consumer dispatch. */
8319
8434
  signals,
@@ -8343,7 +8458,9 @@ export class SqliteScopeHost {
8343
8458
  * instant its row write used, so the `capability.exercised` event's `occurredAt` and the
8344
8459
  * row's `last_used_at` are the same value — a second clock read could disagree.
8345
8460
  */
8346
- 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) {
8347
8464
  // For a connection, system, capability or peer-vertical subject this carries THAT id so
8348
8465
  // the type holds — it is not a person, and the event actor below says what it is instead.
8349
8466
  const principal = subject.id;
@@ -8351,6 +8468,7 @@ export class SqliteScopeHost {
8351
8468
  const relations = this.relations;
8352
8469
  const searchPlans = this.searchPlans;
8353
8470
  const listPlans = this.listPlans;
8471
+ const statePlans = this.statePlans;
8354
8472
  // K-34: the checks that passed in THIS operation. The context is created per invoke
8355
8473
  // (see buildStub), so this accumulates one operation's authorizations; `emit` snapshots
8356
8474
  // whatever has passed up to that point. A system/override actor is unconditionally
@@ -8472,11 +8590,10 @@ export class SqliteScopeHost {
8472
8590
  // about the process, so it never rides `DomainEvent` for module code to
8473
8591
  // branch on; it exists for the observability joins the column serves.
8474
8592
  this.versionId,
8475
- // #1237: whatever delivery is in flight FOR THIS SCOPE, if any — read off
8476
- // the runtime, not the host, because the host serves every scope at once
8477
- // and this context outlives an `await`. A fact about the surrounding
8478
- // dispatch, never envelope data module code could set or branch on.
8479
- 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,
8480
8597
  // #1237: a fact about the surrounding CALL, like the version above — never
8481
8598
  // envelope data module code could set or branch on.
8482
8599
  rt.invocationId, full.payload === undefined ? null : JSON.stringify(full.payload));
@@ -8485,7 +8602,11 @@ export class SqliteScopeHost {
8485
8602
  tenantId: rt.tenantId,
8486
8603
  scopeId: rt.scopeId,
8487
8604
  principal,
8488
- sql: guardSecrets(guardSqlLimits(scopedSql(rt.db, true)), minted),
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),
8489
8610
  now: () => at,
8490
8611
  // #1746/#1747: the host stamps who and where; module code supplies only the template
8491
8612
  // and its fields. The invocation id is read per call — it is set for the duration of
@@ -8494,7 +8615,7 @@ export class SqliteScopeHost {
8494
8615
  tenantId: rt.tenantId,
8495
8616
  scopeId: rt.scopeId,
8496
8617
  operation: operation ?? null,
8497
- invocationId: () => rt.invocationId ?? rt.unitInvocationId,
8618
+ invocationId: () => rt.invocationId ?? delivery?.invocationId ?? null,
8498
8619
  // A consumer runs under the system override, so it logs as `system`.
8499
8620
  principalKind: overrideActor ? 'system' : subject.kind,
8500
8621
  // The string-safe redaction: `redactSecrets` parses its serialization back, and a
@@ -8555,7 +8676,9 @@ export class SqliteScopeHost {
8555
8676
  const plan = searchPlans.get(entityType);
8556
8677
  if (!plan)
8557
8678
  throw new NotSearchable(entityType);
8558
- 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));
8559
8682
  return rt.db.prepare(q.sql).all(...q.params).map((row) => ({ entityType, id: row.id, rank: row.rank }));
8560
8683
  },
8561
8684
  /**
@@ -8576,13 +8699,15 @@ export class SqliteScopeHost {
8576
8699
  order: params.order,
8577
8700
  cursor: params.cursor,
8578
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),
8579
8704
  });
8580
8705
  const rows = rt.db.prepare(q.sql).all(...q.params);
8581
8706
  // A FULL page may have more; a short one is the end of the walk. Same rule
8582
8707
  // as `pageOf`, applied here because the cursor is a COLUMN's value (plus
8583
8708
  // the tie-break) rather than a field the caller could name.
8584
8709
  const last = rows.length >= limit ? rows[rows.length - 1] : undefined;
8585
- 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);
8586
8711
  const page = { entries: rows, nextCursor };
8587
8712
  if (!params.total)
8588
8713
  return page;
@@ -8661,6 +8786,24 @@ export class SqliteScopeHost {
8661
8786
  emit: (event) => writeEvent(event, 'kernel'),
8662
8787
  assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
8663
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
+ }),
8664
8807
  // #304: the request-time entitlement read. The pure adapter is single-process, so the
8665
8808
  // directory is local — no projection needed; it reads `_substrat_entitlements` straight,
8666
8809
  // with the same expiry-at-read filter as the gate (#33), and the SAME contract the DO
@@ -9049,9 +9192,7 @@ export class SqliteScopeHost {
9049
9192
  actor: new ScopeActor(),
9050
9193
  appliedMigrations,
9051
9194
  mintEventId,
9052
- causedBy: null,
9053
9195
  invocationId: null,
9054
- unitInvocationId: null,
9055
9196
  };
9056
9197
  this.scopes.set(key, created);
9057
9198
  this.scopesById.set(scopeId, created);
@@ -9116,7 +9257,11 @@ function assertTablesWithinColumnLimit(db) {
9116
9257
  if (wide)
9117
9258
  throw new Error(tooManyTableColumns(wide.name, wide.width));
9118
9259
  }
9119
- 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) {
9120
9265
  // #1811: a Durable Object refuses a result set over its column cap, `exec` of a SELECT included.
9121
9266
  // The driver knows the width, so it is read off the prepared statement rather than parsed out
9122
9267
  // of the text (a `SELECT *` is as wide as the tables under it). Module SQL only, like the
@@ -9143,7 +9288,7 @@ function scopedSql(db, judgeWidth = false) {
9143
9288
  assertTablesWithinColumnLimit(db);
9144
9289
  return { changes: info.changes };
9145
9290
  },
9146
- });
9291
+ }, statefulTables, afterDdl);
9147
9292
  }
9148
9293
  const schemaVersion = (db) => db.pragma('schema_version', { simple: true });
9149
9294
  //# sourceMappingURL=index.js.map