@substrat-run/adapter-cloudflare 0.125.0 → 0.126.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.
@@ -1 +1 @@
1
- {"version":3,"file":"scope-do.d.ts","sourceRoot":"","sources":["../src/scope-do.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAYL,KAAK,WAAW,EAqCjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAmCL,KAAK,SAAS,EAmBd,KAAK,kBAAkB,EAEvB,KAAK,gBAAgB,EAoEtB,MAAM,sBAAsB,CAAC;AAiF9B;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAwaD;;;GAGG;AACH,MAAM,MAAM,yBAAyB,CAAC,CAAC,IAAI;IAAE,KAAK,EAAE,CAAC,CAAC;IAAC,OAAO,CAAC,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,WAAW,CAAA;CAAE,CAAC;AAgDxG;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,SAAS,CAOxD;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAwDxD;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,kBAAkB,EAAE,EAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,GACxD,KAAK,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,UAAU,KAAK,aAAa,CA86JjE"}
1
+ {"version":3,"file":"scope-do.d.ts","sourceRoot":"","sources":["../src/scope-do.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAcL,KAAK,WAAW,EAqCjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAmCL,KAAK,SAAS,EAmBd,KAAK,kBAAkB,EAEvB,KAAK,gBAAgB,EAoEtB,MAAM,sBAAsB,CAAC;AAmF9B;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAsbD;;;GAGG;AACH,MAAM,MAAM,yBAAyB,CAAC,CAAC,IAAI;IAAE,KAAK,EAAE,CAAC,CAAC;IAAC,OAAO,CAAC,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,WAAW,CAAA;CAAE,CAAC;AAgDxG;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,SAAS,CAOxD;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAwDxD;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,kBAAkB,EAAE,EAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,GACxD,KAAK,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,UAAU,KAAK,aAAa,CAs7JjE"}
package/dist/scope-do.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { REWIND_REFUSED } from './rewind-refusal.js';
2
2
  import { DurableObject } from 'cloudflare:workers';
3
- import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
3
+ import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
4
4
  import { ulid, DO_SQL_LIMITS, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, IDEMPOTENCY_DDL, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, jobRunListLimit, } from '@substrat-run/kernel';
5
5
  import { isUpgradeRequest, readSubscription, LIVE_FANOUT_LIMIT, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
6
6
  import { OperationQueue } from './serialization.js';
7
7
  import { doScopedSql, doSpineSql } from './sql.js';
8
- import { actorOf, admitPeer, collectPeers, switchPeer, assertNoSecret, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, resolveCapabilitySession, revokeCapabilityAsPlatform, domainEventOf, facetEvents, readDeadLetters, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
8
+ import { actorOf, admitPeer, collectPeers, switchPeer, assertNoSecret, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, resolveCapabilitySession, revokeCapabilityAsPlatform, domainEventOf, facetEvents, readDeadLetters, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
9
9
  import { createDoTupleChecker, createLocalControlPlaneReader } from './checker.js';
10
+ import { repointScopeGrants } from '@substrat-run/kernel';
10
11
  /**
11
12
  * The key marking a scope DO whose storage was destroyed (`destroyStorage`).
12
13
  * Written after `deleteAll()` so it survives the wipe, and never cleared: `reaped`
@@ -341,6 +342,19 @@ const KERNEL_DDL = `
341
342
  * In-process — the SQLite adapter, a handler in the same isolate — the real class
342
343
  * arrives and `errorCodeOf` reads it directly.
343
344
  */
345
+ /**
346
+ * #1864: the kernel's own events (attachments, capability exchanges) written through the
347
+ * context an operation runs in, WITHOUT `ctx.emit`'s refusal of kernel-authored types. Keyed
348
+ * by the context object and never exported, so module code — which can reach `ctx` but not
349
+ * this map — cannot write one. Registered as each context is built.
350
+ */
351
+ const kernelEmitters = new WeakMap();
352
+ function kernelEmit(ctx, event) {
353
+ const write = kernelEmitters.get(ctx);
354
+ if (!write)
355
+ throw new Error('kernelEmit: not an operation context this host built');
356
+ write(event);
357
+ }
344
358
  function toRpcError(err) {
345
359
  if (err instanceof Error) {
346
360
  return err.constructor === Error ? err : new Error(err.message);
@@ -1245,16 +1259,17 @@ export function defineScopeDO(modules, bareOps) {
1245
1259
  // BOTH stores, because a scope check consults both: rule 2 inheritance makes a
1246
1260
  // tenant-level grant enforceable here exactly as a scope-level one is, and the
1247
1261
  // projected `_substrat_tenant_tuples` is where this DO holds them. A read-back
1248
- // that disagreed with enforcement would be worse than none.
1262
+ // that disagreed with enforcement would be worse than none. GLOB, not LIKE (#1869):
1263
+ // case-sensitive, as the checker's match is.
1249
1264
  const rows = [
1250
1265
  ...this.sql
1251
1266
  .exec(`SELECT subject, relation, expires_at FROM _substrat_tuples
1252
- WHERE subject LIKE 'connection:%' AND relation LIKE 'granted:%'
1267
+ WHERE subject GLOB 'connection:*' AND relation GLOB 'granted:*'
1253
1268
  AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`, now)
1254
1269
  .toArray(),
1255
1270
  ...this.sql
1256
1271
  .exec(`SELECT subject, relation, expires_at FROM _substrat_tenant_tuples
1257
- WHERE subject LIKE 'connection:%' AND relation LIKE 'granted:%'
1272
+ WHERE subject GLOB 'connection:*' AND relation GLOB 'granted:*'
1258
1273
  AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`, now)
1259
1274
  .toArray(),
1260
1275
  ];
@@ -2089,7 +2104,7 @@ export function defineScopeDO(modules, bareOps) {
2089
2104
  (id, entity_type, entity_id, filename, content_type, size, sha256,
2090
2105
  visibility, created_by, created_at)
2091
2106
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, parsed.id, parsed.entity.entityType, parsed.entity.entityId, parsed.filename, parsed.contentType, parsed.size, parsed.sha256, parsed.visibility, parsed.createdBy, parsed.createdAt);
2092
- ctx.emit({
2107
+ kernelEmit(ctx, {
2093
2108
  type: ATTACHMENT_ADDED,
2094
2109
  schemaVersion: 1,
2095
2110
  entity: parsed.entity,
@@ -2185,7 +2200,7 @@ export function defineScopeDO(modules, bareOps) {
2185
2200
  const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, undefined, undefined, undefined, 'attachments.remove');
2186
2201
  assertAllowed(await ctx.check(gate.write, record.entity));
2187
2202
  this.sql.exec('DELETE FROM _substrat_attachments WHERE id = ?', attachmentId);
2188
- ctx.emit({
2203
+ kernelEmit(ctx, {
2189
2204
  type: ATTACHMENT_REMOVED,
2190
2205
  schemaVersion: 1,
2191
2206
  entity: record.entity,
@@ -2592,7 +2607,7 @@ export function defineScopeDO(modules, bareOps) {
2592
2607
  // Stamped `{ capability }` by the ordinary emit path, under the exchange's own
2593
2608
  // pseudo-operation name — the actor is the capability, as on every event it
2594
2609
  // goes on to cause.
2595
- emit: (capability, event) => this.operationContext(principalId.parse(ulid()), tenantId, scopeId, undefined, undefined, undefined, undefined, undefined, CAPABILITY_EXCHANGE_OPERATION, capability, [], now).emit(event),
2610
+ emit: (capability, event) => kernelEmit(this.operationContext(principalId.parse(ulid()), tenantId, scopeId, undefined, undefined, undefined, undefined, undefined, CAPABILITY_EXCHANGE_OPERATION, capability, [], now), event),
2596
2611
  }, secret, mode);
2597
2612
  });
2598
2613
  if (outcome)
@@ -3404,11 +3419,7 @@ export function defineScopeDO(modules, bareOps) {
3404
3419
  this.sql.exec(stmt);
3405
3420
  });
3406
3421
  }
3407
- async importDump(tables, destScopeId,
3408
- /** The directory's recorded-off modules (#1742), switched off on `destScopeId` right after
3409
- * the replay re-points the grants, in the same event: a dump from before the switch was
3410
- * pulled carries the grants live and no marker. Needs `destScopeId`, the scope restored. */
3411
- switchOff) {
3422
+ async importDump(tables, destScopeId, { switchOff, sourceScopeId, exact, } = {}) {
3412
3423
  // The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
3413
3424
  //
3414
3425
  // Two distinct FK hazards, and both need the deferral — the second is why it cannot
@@ -3437,6 +3448,10 @@ export function defineScopeDO(modules, bareOps) {
3437
3448
  // only the first statement, so the text itself has to be the one statement.
3438
3449
  // Pure input validation, so it runs before the first DROP: a refused dump touches nothing.
3439
3450
  assertReplayableDump(replayable, { maxColumns: DO_SQL_LIMITS.columns });
3451
+ // #1869: `exact` vouches for a named source; without one it would silently fall back.
3452
+ if (exact && !sourceScopeId) {
3453
+ throw substratError('validation_failed', 'restore refused: `exact` needs the scope the dump came from');
3454
+ }
3440
3455
  const switched = await this.ctx.storage.transaction(async () => {
3441
3456
  this.sql.exec('PRAGMA defer_foreign_keys = ON');
3442
3457
  // Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
@@ -3487,7 +3502,7 @@ export function defineScopeDO(modules, bareOps) {
3487
3502
  // Re-point the restored grants at THIS scope (after the spine exists, so a dump
3488
3503
  // that carried no tuples table still finds one here).
3489
3504
  if (destScopeId)
3490
- this.rewriteScopeTuples(destScopeId);
3505
+ this.rewriteScopeTuples(destScopeId, sourceScopeId && { scopeId: sourceScopeId, exact });
3491
3506
  // #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
3492
3507
  // the spine and the re-point. A switch that throws rolls the whole restore back, so
3493
3508
  // the dump's grants never commit live without the switch that should cover them.
@@ -3557,14 +3572,14 @@ export function defineScopeDO(modules, bareOps) {
3557
3572
  * `_substrat_tenant_tuples`, which this does not attempt — a cross-tenant restore is a
3558
3573
  * governed copy, not a repair.
3559
3574
  *
3575
+ * Which rows move is `repointScopeGrants`'s (#1869): the dump's source scope, exactly.
3576
+ *
3560
3577
  * `UPDATE OR REPLACE` because (subject, relation, object) is the primary key: if the
3561
3578
  * dump already held a tuple for the destination scope, the rewritten row collapses
3562
3579
  * onto it instead of failing the whole restore.
3563
3580
  */
3564
- rewriteScopeTuples(destScopeId) {
3565
- this.sql.exec(`UPDATE OR REPLACE _substrat_tuples
3566
- SET object = ?
3567
- WHERE object LIKE 'scope:%' AND object <> ?`, `scope:${destScopeId}`, `scope:${destScopeId}`);
3581
+ rewriteScopeTuples(destScopeId, source) {
3582
+ repointScopeGrants(this.switchSql(), destScopeId, source);
3568
3583
  }
3569
3584
  /**
3570
3585
  * Wipe this scope's storage — the reap half of deleteSnapshot (preview-and-
@@ -3936,6 +3951,51 @@ export function defineScopeDO(modules, bareOps) {
3936
3951
  }
3937
3952
  return checker.covers(subject, role.permissions, { tenantId, scopeId });
3938
3953
  };
3954
+ // ctx.emit's writer, and the kernel's own events' (#1864): one path to the outbox, with the
3955
+ // reserved-type refusal applied to module code only. Never handed to module code as-is.
3956
+ const writeEvent = (event, author) => {
3957
+ assertImpersonationWrites(impersonation, 'ctx.emit');
3958
+ const parsed = domainEventInput.parse(event);
3959
+ // #1864: a kernel-authored type is the kernel's to write — module code cannot forge one.
3960
+ if (author === 'module')
3961
+ assertModuleEmittableType(parsed.type);
3962
+ else
3963
+ assertKernelAuthoredType(parsed.type); // a new kernel event must join the reserved set
3964
+ // #1672: the COMPLETE parsed event — entity id, type and subject as well as payload.
3965
+ assertNoSecret('ctx.emit', parsed, minted);
3966
+ const full = domainEvent.parse({
3967
+ ...parsed,
3968
+ // #956: from the operation's instant, not a second reading of the clock.
3969
+ // `ORDER BY id` is how the outbox and every timeline page, so an id whose
3970
+ // timestamp disagreed with its own `occurredAt` sorted the log by a clock
3971
+ // nothing else in the operation used.
3972
+ id: eventId.parse(mintEventId(Date.parse(at))),
3973
+ occurredAt: at,
3974
+ tenantId,
3975
+ scopeId,
3976
+ actor: systemActor ?? derivedActor,
3977
+ ...(passed.length ? { authorization: passed.map((p) => ({ ...p })) } : {}),
3978
+ ...(stamp ? { impersonation: stamp } : {}),
3979
+ // #1231: kernel-stamped like the two above — `domainEventInput.parse`
3980
+ // already stripped anything module code tried to smuggle under this key.
3981
+ ...(operation ? { operation } : {}),
3982
+ });
3983
+ sql.exec(`INSERT INTO _substrat_outbox
3984
+ (id, type, schema_version, occurred_at, tenant_id, scope_id, actor,
3985
+ entity_type, entity_id, pii_class, subject_id, authorization,
3986
+ impersonation, operation, version, caused_by, invocation_id, payload)
3987
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, full.id, full.type, full.schemaVersion, full.occurredAt, full.tenantId, full.scopeId, JSON.stringify(full.actor), full.entity.entityType, full.entity.entityId, full.piiClass, full.subjectId ?? null, full.authorization ? JSON.stringify(full.authorization) : null, full.impersonation ? JSON.stringify(full.impersonation) : null, full.operation ?? null,
3988
+ // #1242: script configuration, not envelope data — the version is a fact
3989
+ // about the deploy, so it never rides `DomainEvent` for module code to
3990
+ // branch on; it exists for the observability joins the column serves.
3991
+ this.env.SUBSTRAT_VERSION_ID ?? null,
3992
+ // #1237: whatever delivery is in flight, if any — read off the DO the same
3993
+ // way the version is read off its env. A fact about the surrounding
3994
+ // dispatch, never envelope data module code could set or branch on.
3995
+ this.causedBy,
3996
+ // #1237: a fact about the surrounding CALL, like the version above.
3997
+ this.invocationId, full.payload === undefined ? null : JSON.stringify(full.payload));
3998
+ };
3939
3999
  const ctxRef = {
3940
4000
  tenantId,
3941
4001
  scopeId,
@@ -3957,44 +4017,7 @@ export function defineScopeDO(modules, bareOps) {
3957
4017
  // log's text is not JSON.
3958
4018
  redact: (text) => redactSecretText(text, minted),
3959
4019
  }),
3960
- emit: (event) => {
3961
- assertImpersonationWrites(impersonation, 'ctx.emit');
3962
- const parsed = domainEventInput.parse(event);
3963
- // #1672: the COMPLETE parsed event — entity id, type and subject as well as payload.
3964
- assertNoSecret('ctx.emit', parsed, minted);
3965
- const full = domainEvent.parse({
3966
- ...parsed,
3967
- // #956: from the operation's instant, not a second reading of the clock.
3968
- // `ORDER BY id` is how the outbox and every timeline page, so an id whose
3969
- // timestamp disagreed with its own `occurredAt` sorted the log by a clock
3970
- // nothing else in the operation used.
3971
- id: eventId.parse(mintEventId(Date.parse(at))),
3972
- occurredAt: at,
3973
- tenantId,
3974
- scopeId,
3975
- actor: systemActor ?? derivedActor,
3976
- ...(passed.length ? { authorization: passed.map((p) => ({ ...p })) } : {}),
3977
- ...(stamp ? { impersonation: stamp } : {}),
3978
- // #1231: kernel-stamped like the two above — `domainEventInput.parse`
3979
- // already stripped anything module code tried to smuggle under this key.
3980
- ...(operation ? { operation } : {}),
3981
- });
3982
- sql.exec(`INSERT INTO _substrat_outbox
3983
- (id, type, schema_version, occurred_at, tenant_id, scope_id, actor,
3984
- entity_type, entity_id, pii_class, subject_id, authorization,
3985
- impersonation, operation, version, caused_by, invocation_id, payload)
3986
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, full.id, full.type, full.schemaVersion, full.occurredAt, full.tenantId, full.scopeId, JSON.stringify(full.actor), full.entity.entityType, full.entity.entityId, full.piiClass, full.subjectId ?? null, full.authorization ? JSON.stringify(full.authorization) : null, full.impersonation ? JSON.stringify(full.impersonation) : null, full.operation ?? null,
3987
- // #1242: script configuration, not envelope data — the version is a fact
3988
- // about the deploy, so it never rides `DomainEvent` for module code to
3989
- // branch on; it exists for the observability joins the column serves.
3990
- this.env.SUBSTRAT_VERSION_ID ?? null,
3991
- // #1237: whatever delivery is in flight, if any — read off the DO the same
3992
- // way the version is read off its env. A fact about the surrounding
3993
- // dispatch, never envelope data module code could set or branch on.
3994
- this.causedBy,
3995
- // #1237: a fact about the surrounding CALL, like the version above.
3996
- this.invocationId, full.payload === undefined ? null : JSON.stringify(full.payload));
3997
- },
4020
+ emit: (event) => writeEvent(event, 'module'),
3998
4021
  requestPlatform: (request) => {
3999
4022
  assertImpersonationWrites(impersonation, 'ctx.requestPlatform');
4000
4023
  const input = platformRequestInput.parse(request);
@@ -4122,23 +4145,19 @@ export function defineScopeDO(modules, bareOps) {
4122
4145
  subject: systemActor ? { kind: 'system', id: systemActor.system } : subject,
4123
4146
  now: at,
4124
4147
  check: runCheck,
4125
- emit: (event) => ctxRef.emit(event),
4148
+ emit: (event) => writeEvent(event, 'kernel'),
4126
4149
  isOperation: (name) => this.operations.has(name),
4127
4150
  assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
4128
4151
  minted,
4129
4152
  }),
4130
- link: (child, parent) => {
4131
- assertImpersonationWrites(impersonation, 'ctx.link');
4132
- entityObjectRef(child, 'ctx.link'); // #1856: an edge the walk can read back
4133
- entityObjectRef(parent, 'ctx.link');
4134
- const allowed = relations.get(child.entityType);
4135
- if (!allowed?.has(parent.entityType)) {
4136
- throw new Error(`undeclared entity relation: ${child.entityType} → ${parent.entityType} ` +
4137
- `(declare it in a module manifest's entityRelations)`);
4138
- }
4139
- sql.exec(`INSERT OR IGNORE INTO _substrat_tuples (subject, relation, object)
4140
- VALUES (?, 'parent', ?)`, `${child.entityType}:${child.entityId}`, `${parent.entityType}:${parent.entityId}`);
4141
- },
4153
+ // K-16 / #1864: link and relink, written once in the kernel over the raw spine seam.
4154
+ ...createEntityEdgeVerbs({
4155
+ sql: doSpineSql(sql),
4156
+ relations,
4157
+ now: at,
4158
+ emit: (event) => writeEvent(event, 'kernel'),
4159
+ assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
4160
+ }),
4142
4161
  entitlement: async (key) => {
4143
4162
  const held = await entitlementReader().listEntitlements(tenantId);
4144
4163
  return held.find((e) => e.key === key) ?? null;
@@ -4162,6 +4181,7 @@ export function defineScopeDO(modules, bareOps) {
4162
4181
  return sealTo({ keyId: row.key_id, publicKey: row.public_key }, plaintext);
4163
4182
  },
4164
4183
  };
4184
+ kernelEmitters.set(ctxRef, (event) => writeEvent(event, 'kernel'));
4165
4185
  return ctxRef;
4166
4186
  }
4167
4187
  /** True once this scope has had entitlements projected at least once (#304) — the switch