@substrat-run/adapter-cloudflare 0.88.0 → 0.90.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/scope-do.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DurableObject } from 'cloudflare:workers';
2
- import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, toWireFailure, grantRefFromProof, principalId, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, platformRequest, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, } from '@substrat-run/contracts';
3
- import { ulid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialBucketRow, PermissionDenied, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, } from '@substrat-run/kernel';
2
+ import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, toWireFailure, grantRefFromProof, principalId, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, platformRequest, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, } from '@substrat-run/contracts';
3
+ import { ulid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialBucketRow, PermissionDenied, assertImpersonationWrites, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, } from '@substrat-run/kernel';
4
4
  import { OperationQueue } from './serialization.js';
5
5
  import { doScopedSql } from './sql.js';
6
6
  import { createDoTupleChecker, createLocalControlPlaneReader } from './checker.js';
@@ -23,6 +23,10 @@ const KERNEL_DDL = `
23
23
  -- K-34: the checks the emitting operation passed (JSON [{permission, grant?}]).
24
24
  -- NULL on rows written before the field existed -- honestly unrecorded, not empty.
25
25
  authorization TEXT,
26
+ -- K-42: the staff actor + session an impersonated operation ran under (JSON
27
+ -- session/by). NULL is the ordinary case -- nobody was impersonating -- and the
28
+ -- actor column above stays the principal the permission model answered about.
29
+ impersonation TEXT,
26
30
  drained_at TEXT
27
31
  );
28
32
  -- platform-intents.md: durable intents a vertical enqueues (ctx.requestPlatform) for the platform
@@ -33,6 +37,8 @@ const KERNEL_DDL = `
33
37
  kind TEXT NOT NULL,
34
38
  payload TEXT NOT NULL,
35
39
  requested_by TEXT NOT NULL,
40
+ -- K-42: the staff actor + session, when the intent was raised under one.
41
+ impersonation TEXT,
36
42
  status TEXT NOT NULL DEFAULT 'pending',
37
43
  attempts INTEGER NOT NULL DEFAULT 0,
38
44
  last_error TEXT,
@@ -57,6 +63,8 @@ const KERNEL_DDL = `
57
63
  tenant_id TEXT NOT NULL,
58
64
  scope_id TEXT,
59
65
  operation TEXT,
66
+ -- K-42: WHICH of the two actors was refused is exactly what this log is for.
67
+ impersonation TEXT,
60
68
  at TEXT NOT NULL,
61
69
  drained_at TEXT
62
70
  );
@@ -215,6 +223,8 @@ const KERNEL_DDL = `
215
223
  -- MAX(id) per (entity_type, entity_id) is the read. The id column sits last so
216
224
  -- SQLite walks to the end of the matched range instead of aggregating over it.
217
225
  ${OUTBOX_ENTITY_INDEX}
226
+ -- #116: the request-dedupe table, kernel-owned so no vertical migrates for it.
227
+ ${IDEMPOTENCY_DDL}
218
228
  `;
219
229
  /**
220
230
  * Workers RPC carries a plain `Error`'s MESSAGE faithfully and nothing else. A custom
@@ -284,6 +294,7 @@ function rowToPlatformRequest(r) {
284
294
  kind: r.kind,
285
295
  payload: JSON.parse(r.payload),
286
296
  requestedBy: JSON.parse(r.requested_by),
297
+ impersonation: r.impersonation == null ? null : JSON.parse(r.impersonation),
287
298
  status: r.status,
288
299
  attempts: r.attempts,
289
300
  lastError: r.last_error,
@@ -405,6 +416,8 @@ export function defineScopeDO(modules, bareOps) {
405
416
  operationInput = new Map();
406
417
  /** #129: name → the entity whose version an `If-Match` is compared against. */
407
418
  operationConcurrency = new Map();
419
+ /** #116: the operations that declared `idempotency: false` — refusals, not participants. */
420
+ operationIdempotencyOptOut = new Set();
408
421
  modules = new Map();
409
422
  guards = new Map();
410
423
  predicates = new Map();
@@ -451,6 +464,13 @@ export function defineScopeDO(modules, bareOps) {
451
464
  // intent settled before this column reads as "nobody classified this" rather
452
465
  // than claiming an origin the drain never decided.
453
466
  'ALTER TABLE _substrat_platform_requests ADD COLUMN last_failure TEXT',
467
+ // K-42: the two-actor stamp on the three spine tables that record who did
468
+ // what, for a scope DO created before impersonation existed. Nullable
469
+ // everywhere, and the null means "nobody was impersonating" rather than
470
+ // "unrecorded" — every one of those rows predates the possibility.
471
+ 'ALTER TABLE _substrat_outbox ADD COLUMN impersonation TEXT',
472
+ 'ALTER TABLE _substrat_platform_requests ADD COLUMN impersonation TEXT',
473
+ 'ALTER TABLE _substrat_denials ADD COLUMN impersonation TEXT',
454
474
  ]) {
455
475
  try {
456
476
  this.sql.exec(alter);
@@ -559,6 +579,15 @@ export function defineScopeDO(modules, bareOps) {
559
579
  throw new Error(`${manifest.id} declares operationConcurrency for unbound operation(s): ` +
560
580
  `${unguarded.sort().join(', ')} — a precondition on nothing reads as a guard that is not there`);
561
581
  }
582
+ // #116: same rule again for an opt-out — one on an unbound name reads as a
583
+ // deliberate exclusion of an operation that is not there.
584
+ const declaredOptOuts = registration.operationIdempotencyOptOuts ?? [];
585
+ const unboundOptOuts = declaredOptOuts.filter((name) => !ownOps.has(name));
586
+ if (unboundOptOuts.length > 0) {
587
+ throw new Error(`${manifest.id} declares idempotency: false for unbound operation(s): ` +
588
+ `${[...unboundOptOuts].sort().join(', ')} — an opt-out on nothing reads as an ` +
589
+ 'exclusion someone decided, of an operation that does not exist');
590
+ }
562
591
  for (const [name, handler] of Object.entries(registration.operations ?? {})) {
563
592
  this.defineOperation(name, handler);
564
593
  // The schema follows the HANDLER, not the name: a withdrawn operation
@@ -569,6 +598,9 @@ export function defineScopeDO(modules, bareOps) {
569
598
  const guarded = declaredConcurrency[name];
570
599
  if (guarded && this.operations.has(name))
571
600
  this.operationConcurrency.set(name, guarded);
601
+ if (declaredOptOuts.includes(name) && this.operations.has(name)) {
602
+ this.operationIdempotencyOptOut.add(name);
603
+ }
572
604
  }
573
605
  }
574
606
  defineOperation(name, handler) {
@@ -813,19 +845,27 @@ export function defineScopeDO(modules, bareOps) {
813
845
  * sent an `If-Match` and does not see it refuses the success. An old DO
814
846
  * cannot set the acknowledgement, which is exactly how the skew is caught.
815
847
  */
816
- invokeOptions) {
848
+ invokeOptions,
849
+ /**
850
+ * K-42: the session this call runs under, resolved by the coordinator from
851
+ * the directory (which this DO cannot read) and passed WHOLE rather than as
852
+ * an id. The coordinator has already checked it is live, unexpired and for
853
+ * this scope; the DO uses it for the two-actor stamp and for the read-only
854
+ * bound, and nothing here can be reached without it having been minted.
855
+ */
856
+ impersonation) {
817
857
  if (!failureEnvelope) {
818
858
  // Legacy path, byte-for-byte what it was: rewrapped so a non-plain error (a
819
859
  // ZodError, whose `message` is a getter) still arrives with its message.
820
860
  try {
821
- return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions);
861
+ return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation);
822
862
  }
823
863
  catch (err) {
824
864
  throw toRpcError(err);
825
865
  }
826
866
  }
827
867
  try {
828
- return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions);
868
+ return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation);
829
869
  }
830
870
  catch (err) {
831
871
  // The ONE place the error keeps its structure: flattened here, rebuilt by the
@@ -835,11 +875,16 @@ export function defineScopeDO(modules, bareOps) {
835
875
  }
836
876
  }
837
877
  /** The operation path itself. Throws; `invoke` decides how that reaches the caller. */
838
- async invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions) {
878
+ async invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions,
879
+ /** K-42: the session, resolved coordinator-side. See `invoke` above. */
880
+ impersonation) {
839
881
  await this.ensureMigrations();
840
882
  const handler = this.operations.get(operation);
883
+ // `not_found`, not a bare throw (#113): every vertical hand-matched this message
884
+ // to reach a 404, because the platform's own refusals were as untyped as anything
885
+ // else. Naming the code once here is what lets those patterns go.
841
886
  if (!handler)
842
- throw new Error(`unknown operation: ${operation}`);
887
+ throw substratError('not_found', `unknown operation: ${operation}`);
843
888
  // #304 entitlement gate, scope-local path: fail closed on the projected view exactly as
844
889
  // the coordinator fails closed against the CP. Only active once entitlements have been
845
890
  // projected (the `entitlements_enforced` marker) — before that the scope trusts upstream,
@@ -857,7 +902,7 @@ export function defineScopeDO(modules, bareOps) {
857
902
  .exec(`SELECT entitlement_key, expires_at FROM _substrat_entitlements
858
903
  WHERE tenant_id = ? ORDER BY entitlement_key`, tenantId)
859
904
  .toArray();
860
- throw new Error(entitlementDenial(operation, requiredEntitlement, all.map((r) => ({
905
+ throw substratError('not_found', entitlementDenial(operation, requiredEntitlement, all.map((r) => ({
861
906
  key: r.entitlement_key,
862
907
  expired: r.expires_at !== null && r.expires_at <= now,
863
908
  }))));
@@ -878,20 +923,73 @@ export function defineScopeDO(modules, bareOps) {
878
923
  'nothing would have been compared. Declare it, or drop the header');
879
924
  }
880
925
  const guardedRef = guarded ? concurrencyRefOf(operation, guarded, parsed) : undefined;
926
+ // #116. Refused rather than ignored, for the reason two paragraphs up: an
927
+ // operation that opted out never records its response, so a caller who sent
928
+ // a key and got a 200 believes a retry is safe when it would execute again.
929
+ const idempotencyKey = invokeOptions?.idempotencyKey;
930
+ if (idempotencyKey !== undefined) {
931
+ if (this.operationIdempotencyOptOut.has(operation)) {
932
+ throw new Error(idempotencyOptedOutMessage(operation));
933
+ }
934
+ assertIdempotencyKey(idempotencyKey);
935
+ }
936
+ // The subject a key is scoped to — the same three-way read `recordDenial`
937
+ // makes below, hoisted because both need it. A key belongs to whoever sent
938
+ // it: two principals choosing `1` must not reach each other's response.
939
+ const idempotencySubjectRef = systemModuleId
940
+ ? { kind: 'system', id: systemModuleId }
941
+ : connectionId
942
+ ? { kind: 'connection', id: connectionId }
943
+ : { kind: 'principal', id: principal };
944
+ // Fingerprinted from the PARSED input (defaults applied), before the queue:
945
+ // a pure hash of what the caller sent has no business inside a transaction.
946
+ const fingerprint = idempotencyKey === undefined ? undefined : await requestFingerprint(operation, parsed);
881
947
  return this.queue.enqueue(async () => {
882
948
  let result;
883
949
  let committedVersion = null;
950
+ // #116: set when this invocation was answered from a recording rather
951
+ // than run. Read after the transaction, where it decides both the
952
+ // envelope's acknowledgement and whether there is anything to dispatch.
953
+ let replayed = false;
884
954
  // #458: how many platform intents THIS invoke enqueued. Counted inside the
885
955
  // transaction, reported only after commit — a rolled-back intent is no signal.
886
956
  // The envelope return (below) is the DO↔coordinator wire for it; both sides
887
957
  // live in this package and deploy as one script, so the shape never skews.
888
958
  const signals = { platformRequests: 0 };
959
+ /**
960
+ * K-42: how a read-only session is made to BE read-only.
961
+ *
962
+ * Thrown on success, so the transaction never commits — the same
963
+ * mechanism `queryScope`'s console uses, for the same reason: the DO's
964
+ * `exec` exposes no read-only flag, so refusing to commit is the only
965
+ * enforcement that survives a handler writing rows with plain SQL.
966
+ * `ctx.emit` and the other effecting verbs refuse outright as well; that
967
+ * is the half a support engineer sees, this is the half that holds.
968
+ */
969
+ const rollback = new Error('read-only impersonation rollback');
889
970
  // The async transaction is the K-4 boundary: guards + handler + emits
890
971
  // commit together, or a throw (from either) rolls domain writes AND
891
972
  // emitted events back as one — verified across `await` in workerd.
892
973
  try {
893
974
  await this.ctx.storage.transaction(async () => {
894
- const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals);
975
+ const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals, impersonation);
976
+ // #116: a retry is answered from the recording, and nothing else runs
977
+ // — not the guards, not the handler, not the permission check inside
978
+ // it. Keyed by SUBJECT, so a caller only ever reaches its own
979
+ // responses; `idempotency.ts` states what that does not promise.
980
+ if (idempotencyKey !== undefined && fingerprint !== undefined) {
981
+ const lookup = idempotencyLookupQuery(idempotencySubjectRef, idempotencyKey);
982
+ const prior = this.sql.exec(lookup.sql, ...lookup.params).toArray()[0];
983
+ if (prior) {
984
+ const replay = replayFor(idempotencyKey, fingerprint, prior);
985
+ result = replay.result;
986
+ // Only a guarded operation may report a tag (#129).
987
+ if (guardedRef)
988
+ committedVersion = replay.entityVersion;
989
+ replayed = true;
990
+ return;
991
+ }
992
+ }
895
993
  // #129: snapshot the version BEFORE the handler, compare AFTER it.
896
994
  // Before, because the handler's own `emit` moves it; after, because the
897
995
  // permission check lives inside the handler and must answer first — a
@@ -910,9 +1008,47 @@ export function defineScopeDO(modules, bareOps) {
910
1008
  // the handler and still inside the transaction.
911
1009
  if (guardedRef)
912
1010
  committedVersion = this.versionAt(guardedRef);
1011
+ // #116: recorded INSIDE the transaction, which is what makes a failed
1012
+ // request retried rather than replayed — it rolls back with the writes
1013
+ // it describes. The prune rides along, on the only path that adds a row.
1014
+ if (idempotencyKey !== undefined && fingerprint !== undefined) {
1015
+ const at = new Date().toISOString();
1016
+ const record = idempotencyRecordStatement(idempotencySubjectRef, idempotencyKey, operation, fingerprint, result, committedVersion, at);
1017
+ this.sql.exec(record.sql, ...record.params);
1018
+ const prune = idempotencyPruneStatement(at);
1019
+ this.sql.exec(prune.sql, ...prune.params);
1020
+ }
1021
+ if (impersonation?.mode === 'read-only')
1022
+ throw rollback;
913
1023
  });
914
1024
  }
915
1025
  catch (err) {
1026
+ // The read-only unwind, not a failure: `result` was assigned before the
1027
+ // throw and is the answer, while every row the handler wrote is gone.
1028
+ //
1029
+ // The acknowledgements ride along unchanged, because they are about
1030
+ // whether this DO UNDERSTOOD the arguments, not about what committed —
1031
+ // and a coordinator that sent `If-Match` to a read-only session must not
1032
+ // be told the host is too old to have evaluated it. The version reported
1033
+ // is the rolled-back one, which is the honest answer: nothing moved.
1034
+ if (err === rollback) {
1035
+ return {
1036
+ result,
1037
+ platformRequests: 0,
1038
+ impersonation: { honoured: true },
1039
+ ...(idempotencyKey !== undefined
1040
+ ? { idempotency: { keyHonoured: true, replayed } }
1041
+ : {}),
1042
+ ...(guardedRef
1043
+ ? {
1044
+ concurrency: {
1045
+ version: committedVersion,
1046
+ ifMatchChecked: invokeOptions?.ifMatch !== undefined,
1047
+ },
1048
+ }
1049
+ : {}),
1050
+ };
1051
+ }
916
1052
  // K-35: the transaction has rolled back; record a refused check now, as its own
917
1053
  // write (outside that transaction), so the denial survives the rollback.
918
1054
  if (err instanceof PermissionDenied) {
@@ -920,7 +1056,7 @@ export function defineScopeDO(modules, bareOps) {
920
1056
  ? { kind: 'system', id: systemModuleId }
921
1057
  : connectionId
922
1058
  ? { kind: 'connection', id: connectionId }
923
- : { kind: 'principal', id: principal }, tenantId, operation, err);
1059
+ : { kind: 'principal', id: principal }, tenantId, operation, err, impersonation);
924
1060
  }
925
1061
  // The ORIGINAL error, deliberately: `invoke` flattens it for the envelope
926
1062
  // (which keeps its code and extensions) or rewraps it for the legacy throw
@@ -928,10 +1064,22 @@ export function defineScopeDO(modules, bareOps) {
928
1064
  throw err;
929
1065
  }
930
1066
  // Post-commit: drain the outbox to consumers, each delivery its own txn.
931
- await this.dispatch(tenantId, scopeId);
1067
+ // Skipped on a replay: nothing was written, so there is nothing this
1068
+ // invocation added to drain. Anything the ORIGINAL left undrained is the
1069
+ // outbox's own retry backstop, which is what that backstop is for.
1070
+ if (!replayed)
1071
+ await this.dispatch(tenantId, scopeId);
932
1072
  return {
933
1073
  result,
934
1074
  platformRequests: signals.platformRequests,
1075
+ ...(impersonation ? { impersonation: { honoured: true } } : {}),
1076
+ // The acknowledgement the coordinator's skew check reads (#116), on the
1077
+ // same reasoning as `ifMatchChecked` below and with a sharper failure: a
1078
+ // DO too old to know about keys would EXECUTE THE OPERATION AGAIN and
1079
+ // return 200, which is the duplicate the header was sent to prevent.
1080
+ ...(idempotencyKey !== undefined
1081
+ ? { idempotency: { keyHonoured: true, replayed } }
1082
+ : {}),
935
1083
  // The acknowledgement the coordinator's skew check reads. Present only
936
1084
  // for a guarded operation, so an unguarded one costs nothing.
937
1085
  ...(guardedRef
@@ -1333,9 +1481,18 @@ export function defineScopeDO(modules, bareOps) {
1333
1481
  throw new Error(`too many pending platform requests (${pending}); the delivery retries once some have drained`);
1334
1482
  }
1335
1483
  const id = platformRequestId.parse(ulid());
1484
+ // K-42: the intent inherits the SOURCE EVENT's stamp rather than being written
1485
+ // unstamped. Nobody is impersonating at this moment — the drain runs long after
1486
+ // the session's invoke returned — but the intent exists BECAUSE of an event that
1487
+ // was raised under one, and the platform drain is where an operator asks who
1488
+ // caused an outbound effect. Read here rather than passed over the RPC: the DO
1489
+ // owns the outbox, so a coordinator cannot claim a stamp or drop one.
1490
+ const source = this.sql
1491
+ .exec('SELECT impersonation FROM _substrat_outbox WHERE id = ?', eventId)
1492
+ .toArray()[0];
1336
1493
  this.sql.exec(`INSERT INTO _substrat_platform_requests
1337
- (id, kind, payload, requested_by, status, attempts, requested_at)
1338
- VALUES (?, ?, ?, ?, 'pending', 0, ?)`, id, kind, payload, requestedBy, instant.parse(new Date().toISOString()));
1494
+ (id, kind, payload, requested_by, impersonation, status, attempts, requested_at)
1495
+ VALUES (?, ?, ?, ?, ?, 'pending', 0, ?)`, id, kind, payload, requestedBy, source?.impersonation ?? null, instant.parse(new Date().toISOString()));
1339
1496
  this.recordExecutorAttempt(eventId, deliveryId, null, null);
1340
1497
  return id;
1341
1498
  }
@@ -1682,7 +1839,8 @@ export function defineScopeDO(modules, bareOps) {
1682
1839
  * catch after the storage transaction has rolled back, so this write is its own and
1683
1840
  * survives — the whole point, since the denial is the write the operation could not make.
1684
1841
  */
1685
- recordDenial(subject, tenantId, operation, err) {
1842
+ /** K-42: the session a refused call ran under travels with the denial row. */
1843
+ recordDenial(subject, tenantId, operation, err, impersonation) {
1686
1844
  // Only an ENFORCED denial (assertAllowed, which attaches the checked permission +
1687
1845
  // node) is recorded. A module's own hand-thrown `new PermissionDenied('…')` carries
1688
1846
  // no permission key and is left to the module.
@@ -1694,8 +1852,8 @@ export function defineScopeDO(modules, bareOps) {
1694
1852
  ? { connection: subject.id }
1695
1853
  : subject.id;
1696
1854
  this.sql.exec(`INSERT INTO _substrat_denials
1697
- (id, actor, permission, tenant_id, scope_id, operation, at)
1698
- VALUES (?, ?, ?, ?, ?, ?, ?)`, ulid(), JSON.stringify(actor), err.permission, err.node.tenantId, err.node.scopeId ?? null, operation, new Date().toISOString());
1855
+ (id, actor, permission, tenant_id, scope_id, operation, impersonation, at)
1856
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ulid(), JSON.stringify(actor), err.permission, err.node.tenantId, err.node.scopeId ?? null, operation, impersonation ? JSON.stringify(impersonationStampOf(impersonation)) : null, new Date().toISOString());
1699
1857
  }
1700
1858
  parseOutboxRow(row) {
1701
1859
  return domainEvent.parse({
@@ -1710,13 +1868,26 @@ export function defineScopeDO(modules, bareOps) {
1710
1868
  piiClass: row.pii_class,
1711
1869
  ...(row.subject_id ? { subjectId: row.subject_id } : {}),
1712
1870
  ...(row.authorization ? { authorization: JSON.parse(row.authorization) } : {}),
1871
+ // K-42: the stamp survives the read, so a consumer's event and an executor's
1872
+ // are the same fact the stored row is. Absent rather than null when nobody
1873
+ // was impersonating, because `DomainEvent.impersonation` is optional — the
1874
+ // shape module code never sees is also the shape it cannot branch on.
1875
+ ...(row.impersonation ? { impersonation: JSON.parse(row.impersonation) } : {}),
1713
1876
  payload: row.payload === null ? undefined : JSON.parse(row.payload),
1714
1877
  });
1715
1878
  }
1716
1879
  // -- operation context (port of operationContext) -------------------------
1717
1880
  operationContext(principal, tenantId, scopeId, systemActor, connectionId, systemModuleId,
1718
1881
  /** #458: per-invoke tally of `ctx.requestPlatform` calls; absent for consumer dispatch. */
1719
- signals) {
1882
+ signals,
1883
+ /**
1884
+ * K-42: the session this operation runs under, resolved by the coordinator
1885
+ * from the directory and handed down. Read here and nowhere module code can
1886
+ * reach — a vertical that could see it could branch on it, and "hide this row
1887
+ * when support is looking" is the one behaviour this feature must make
1888
+ * impossible.
1889
+ */
1890
+ impersonation) {
1720
1891
  const checker = this.checker;
1721
1892
  const relations = this.relations;
1722
1893
  const searchPlans = this.searchPlans;
@@ -1751,6 +1922,10 @@ export function defineScopeDO(modules, bareOps) {
1751
1922
  // invoke, so this does not leak across operations). `emit` snapshots it; a
1752
1923
  // system/override actor is unconditionally allowed, so its checks are not recorded.
1753
1924
  const passed = [];
1925
+ // K-42: the two-actor stamp, computed once per operation. Every record this
1926
+ // operation writes about who did what carries it, and module code can
1927
+ // neither add it nor drop it.
1928
+ const stamp = impersonation ? impersonationStampOf(impersonation) : undefined;
1754
1929
  /**
1755
1930
  * The scope host's half of `ctx.atomic` (#770) — everything else is the
1756
1931
  * kernel's (`createAtomic`).
@@ -1797,6 +1972,7 @@ export function defineScopeDO(modules, bareOps) {
1797
1972
  sql: doScopedSql(sql),
1798
1973
  now: () => at,
1799
1974
  emit: (event) => {
1975
+ assertImpersonationWrites(impersonation, 'ctx.emit');
1800
1976
  const parsed = domainEventInput.parse(event);
1801
1977
  const full = domainEvent.parse({
1802
1978
  ...parsed,
@@ -1806,13 +1982,16 @@ export function defineScopeDO(modules, bareOps) {
1806
1982
  scopeId,
1807
1983
  actor: systemActor ?? derivedActor,
1808
1984
  ...(passed.length ? { authorization: passed.map((p) => ({ ...p })) } : {}),
1985
+ ...(stamp ? { impersonation: stamp } : {}),
1809
1986
  });
1810
1987
  sql.exec(`INSERT INTO _substrat_outbox
1811
1988
  (id, type, schema_version, occurred_at, tenant_id, scope_id, actor,
1812
- entity_type, entity_id, pii_class, subject_id, authorization, payload)
1813
- 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.payload === undefined ? null : JSON.stringify(full.payload));
1989
+ entity_type, entity_id, pii_class, subject_id, authorization,
1990
+ impersonation, payload)
1991
+ 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.payload === undefined ? null : JSON.stringify(full.payload));
1814
1992
  },
1815
1993
  requestPlatform: (request) => {
1994
+ assertImpersonationWrites(impersonation, 'ctx.requestPlatform');
1816
1995
  const input = platformRequestInput.parse(request);
1817
1996
  // Backpressure (platform-intents.md): refuse when the scope already holds too many pending
1818
1997
  // intents, so a stuck or runaway vertical cannot flood the platform drain.
@@ -1825,8 +2004,8 @@ export function defineScopeDO(modules, bareOps) {
1825
2004
  const id = platformRequestId.parse(ulid());
1826
2005
  const requestedBy = systemActor ?? derivedActor;
1827
2006
  sql.exec(`INSERT INTO _substrat_platform_requests
1828
- (id, kind, payload, requested_by, status, attempts, requested_at)
1829
- VALUES (?, ?, ?, ?, 'pending', 0, ?)`, id, input.kind, JSON.stringify(input.payload ?? null), JSON.stringify(requestedBy), at);
2007
+ (id, kind, payload, requested_by, impersonation, status, attempts, requested_at)
2008
+ VALUES (?, ?, ?, ?, ?, 'pending', 0, ?)`, id, input.kind, JSON.stringify(input.payload ?? null), JSON.stringify(requestedBy), stamp ? JSON.stringify(stamp) : null, at);
1830
2009
  if (signals)
1831
2010
  signals.platformRequests += 1;
1832
2011
  return id;
@@ -1895,6 +2074,7 @@ export function defineScopeDO(modules, bareOps) {
1895
2074
  * than elevates.
1896
2075
  */
1897
2076
  grant: async (principal, permission, entity) => {
2077
+ assertImpersonationWrites(impersonation, 'ctx.grant');
1898
2078
  const held = await runCheck(permission, entity);
1899
2079
  if (!held.allowed) {
1900
2080
  throw new PermissionDenied(`cannot grant '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
@@ -1903,6 +2083,7 @@ export function defineScopeDO(modules, bareOps) {
1903
2083
  sql.exec(`INSERT OR IGNORE INTO _substrat_tuples (subject, relation, object) VALUES (?, ?, ?)`, `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
1904
2084
  },
1905
2085
  revoke: async (principal, permission, entity) => {
2086
+ assertImpersonationWrites(impersonation, 'ctx.revoke');
1906
2087
  const held = await runCheck(permission, entity);
1907
2088
  if (!held.allowed) {
1908
2089
  throw new PermissionDenied(`cannot revoke '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
@@ -1912,6 +2093,7 @@ export function defineScopeDO(modules, bareOps) {
1912
2093
  },
1913
2094
  atomic: createAtomic(runSub, { passed, signals }),
1914
2095
  link: (child, parent) => {
2096
+ assertImpersonationWrites(impersonation, 'ctx.link');
1915
2097
  const allowed = relations.get(child.entityType);
1916
2098
  if (!allowed?.has(parent.entityType)) {
1917
2099
  throw new Error(`undeclared entity relation: ${child.entityType} → ${parent.entityType} ` +