@substrat-run/adapter-cloudflare 0.143.0 → 0.145.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":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AA4DnD,OAAO,EA2DL,KAAK,SAAS,EAsBd,KAAK,kBAAkB,EAEvB,KAAK,gBAAgB,EAqGtB,MAAM,sBAAsB,CAAC;AAmD9B,OAAO,EAAW,KAAK,OAAO,EAAE,MAAM,eAAe,CAAC;AAGtD,OAAO,EAkBL,kBAAkB,EAsEnB,MAAM,sBAAsB,CAAC;AA0B9B;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAoFD,oFAAoF;AACpF,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;AAsjB5E;;;GAGG;AACH,MAAM,MAAM,yBAAyB,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC;AAmBtD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,SAAS,CAOxD;AAED,gHAAgH;AAChH,OAAO,EAAE,kBAAkB,EAAE,CAAC;AA2B9B,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,CAAC,UAAU,CAAC,CA46N7E"}
1
+ {"version":3,"file":"scope-do.d.ts","sourceRoot":"","sources":["../src/scope-do.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AA4DnD,OAAO,EA2DL,KAAK,SAAS,EAsBd,KAAK,kBAAkB,EAEvB,KAAK,gBAAgB,EAqGtB,MAAM,sBAAsB,CAAC;AAoD9B,OAAO,EAAW,KAAK,OAAO,EAAE,MAAM,eAAe,CAAC;AAGtD,OAAO,EAkBL,kBAAkB,EA4EnB,MAAM,sBAAsB,CAAC;AA0B9B;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAoFD,oFAAoF;AACpF,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;AA+jB5E;;;GAGG;AACH,MAAM,MAAM,yBAAyB,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC;AAmBtD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,SAAS,CAOxD;AAED,gHAAgH;AAChH,OAAO,EAAE,kBAAkB,EAAE,CAAC;AA2B9B,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,CAAC,UAAU,CAAC,CA2jO7E"}
package/dist/scope-do.js CHANGED
@@ -7,7 +7,7 @@ import { isUpgradeRequest, readSubscription, LIVE_DECIDE_ATTEMPTS, LIVE_FANOUT_L
7
7
  import { replyOf } from './do-reply.js';
8
8
  import { OperationQueue } from './serialization.js';
9
9
  import { assertNoWorkerdStorage, doScopedSql, doBuiltColumnsOf, doRedactionSql, doSpineSql } from './sql.js';
10
- import { actorOf, admitPeer, collectPeers, switchPeer, peerSubjectRef, subjectGrantState, assertNoSecret, CAPABILITY_DDL, COPY_ORIGIN_DDL, ENTITY_STATE_MOVES_DDL, SWITCH_FENCES_DDL, TABLE_OWNERS_DDL, assertMigrationLeavesLedgerAlone, recordOwnershipSteps, runMigrationStatements, splitSqlStatements, executableSqlStatements, blankSqlComments, CAPABILITY_EXCHANGE_OPERATION, CAPABILITY_BECOME_MINT_OPERATION, becomeMintCheck, mintBecomeCapabilityAsPrincipal, revokeBecomeCapabilityAsPrincipal, readBecomeLinkStates, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, CONTINUATION_POSITION_CAP, visibleContinuation, searchStateWhere, uncheckedView, addStatePlans, assertNoCallerPurge, isUnreachableParent, PURGE_BATCH, heldPurgePass, lifecycleRefusal, purgeHeldBy, purgeDueOf, purgeOnlyKeysOf, runPurgePass, refuseTrashedTarget, assertBoundOperations, registerTrashTargets, withheldKeysFor, statefulTablesOf, afterMigration, afterRuntimeDdl, derivesAnything, repairDerivedObjects, StateColumnLost, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, asyncInvocationId, asyncLinePass, resolveCapabilitySession, revokeCapabilityAsPlatform, readCapabilityPage, domainEventOf, facetEvents, readDeadLetters, readLifecycleFlow, readOperationSeries, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
10
+ import { actorOf, admitPeer, collectPeers, switchPeer, peerSubjectRef, subjectGrantState, assertNoSecret, CAPABILITY_DDL, COPY_ORIGIN_DDL, ENTITY_STATE_MOVES_DDL, SWITCH_FENCES_DDL, TABLE_OWNERS_DDL, assertMigrationLeavesLedgerAlone, recordOwnershipSteps, runMigrationStatements, splitSqlStatements, executableSqlStatements, blankSqlComments, CAPABILITY_EXCHANGE_OPERATION, CAPABILITY_BECOME_MINT_OPERATION, becomeMintCheck, mintBecomeCapabilityAsPrincipal, revokeBecomeCapabilityAsPrincipal, readBecomeLinkStates, ENTITY_UNBIND_OPERATION, allowedFor, unbindEntityHolderBounded, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, CONTINUATION_POSITION_CAP, visibleContinuation, searchStateWhere, uncheckedView, addStatePlans, addUpdatedStampPlans, watchUpdatedStamps, assertNoCallerPurge, isUnreachableParent, PURGE_BATCH, heldPurgePass, lifecycleRefusal, purgeHeldBy, purgeDueOf, purgeOnlyKeysOf, runPurgePass, refuseTrashedTarget, assertBoundOperations, registerTrashTargets, withheldKeysFor, statefulTablesOf, afterMigration, afterRuntimeDdl, derivesAnything, repairDerivedObjects, StateColumnLost, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, asyncInvocationId, asyncLinePass, resolveCapabilitySession, revokeCapabilityAsPlatform, readCapabilityPage, domainEventOf, facetEvents, readDeadLetters, readLifecycleFlow, readOperationSeries, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
11
11
  import { COPY_RESTORE_FENCE_LAPSED, copyRestoreFenceLapsed } from '@substrat-run/kernel';
12
12
  import { createDoTupleChecker, createLocalControlPlaneReader, scopeTupleReader } from './checker.js';
13
13
  import { settlePlatformRequestIn } from '@substrat-run/kernel';
@@ -352,9 +352,9 @@ const KERNEL_DDL = `
352
352
  -- #1575: attachment text and its FTS5 index, shared from @substrat-run/kernel. After
353
353
  -- _substrat_attachments, whose delete trigger it adds.
354
354
  ${ATTACHMENT_TEXT_DDL}
355
- -- #901: an entity's version is the ULID of the last event about it, so
356
- -- MAX(id) per (entity_type, entity_id) is the read. The id column sits last so
357
- -- SQLite walks to the end of the matched range instead of aggregating over it.
355
+ -- #901: an entity's version is the ULID of the last event about it (a state move
356
+ -- excepted, #2191), so the newest id per (entity_type, entity_id) is the read. The id
357
+ -- column sits last so SQLite walks the matched range backwards instead of aggregating.
358
358
  ${OUTBOX_ENTITY_INDEX}
359
359
  -- #116: the request-dedupe table, kernel-owned so no vertical migrates for it.
360
360
  ${IDEMPOTENCY_DDL}
@@ -561,6 +561,15 @@ function toRpcError(err) {
561
561
  }
562
562
  return new Error(String(err));
563
563
  }
564
+ /** A throwing RPC kept for a coordinator from before #113: `fn`'s refusal, as `toRpcError` rebuilds it. */
565
+ async function thrownForRpc(fn) {
566
+ try {
567
+ return await fn();
568
+ }
569
+ catch (err) {
570
+ throw toRpcError(err);
571
+ }
572
+ }
564
573
  /** The check subject for an attachment gate (#476): the connection when the connector door
565
574
  * set one, else the principal — mirrors the invoke path's subject selection. */
566
575
  /**
@@ -737,6 +746,8 @@ export function defineScopeDO(modules, bareOps) {
737
746
  listPlans = new Map();
738
747
  /** #119: entity type → its archive/trash plan, from every registered module. */
739
748
  statePlans = new Map();
749
+ /** #2166: entity type → the updated stamps the host holds, from every registered module. */
750
+ updatedStampPlans = new Map();
740
751
  /** #2068: each registered module's erasure, in registration order — what `redactSubject` runs. */
741
752
  erasurePlans = [];
742
753
  /** entityType → the declared attachment gate (#473): read key + write key (default: read). */
@@ -860,6 +871,7 @@ export function defineScopeDO(modules, bareOps) {
860
871
  }
861
872
  // #119: the archive/trash plans, refusing a second owner of one entity type.
862
873
  addStatePlans(this.statePlans, manifest.id, manifest.entityStates, manifest.permissions);
874
+ addUpdatedStampPlans(this.updatedStampPlans, manifest.id, manifest.updatedStamps);
863
875
  // #811: the list indexes `lists` declares, same placement and same reason.
864
876
  for (const plan of listIndexPlans(manifest.id, manifest.lists, manifest.entityStates)) {
865
877
  const existing = this.listPlans.get(plan.entityType);
@@ -1358,7 +1370,10 @@ export function defineScopeDO(modules, bareOps) {
1358
1370
  // As the producer's principal: real checks against the grants this vertical's
1359
1371
  // `peers` gave it; its emits carry `{ vertical, scope }` and what they passed.
1360
1372
  // What it emits was emitted BECAUSE of the producer's event (#1237).
1361
- await imp.handler(this.importContext(tenantId, scopeId, peerSubject, { causedBy: e.id, invocationId: unit.invocationId }), event);
1373
+ const ctx = this.importContext(tenantId, scopeId, peerSubject, { causedBy: e.id, invocationId: unit.invocationId });
1374
+ const stamps = watchUpdatedStamps(doSpineSql(this.sql), this.updatedStampPlans);
1375
+ await imp.handler(ctx, event);
1376
+ stamps?.assertHeld(ctx, `${imp.moduleId} consuming ${e.type}`);
1362
1377
  this.sql.exec(`INSERT INTO _substrat_deliveries (event_id, consumer_module, delivered_at, invocation_id)
1363
1378
  VALUES (?, ?, ?, NULL)`, e.id, imp.moduleId, new Date().toISOString());
1364
1379
  });
@@ -1675,14 +1690,16 @@ export function defineScopeDO(modules, bareOps) {
1675
1690
  return { ...one, done: one.next === null };
1676
1691
  }
1677
1692
  let [toppedUp, retired, retirementsLeftOpen, from] = [0, 0, 0, null];
1693
+ let boundEver;
1678
1694
  do {
1679
1695
  const one = await pass(from);
1680
1696
  toppedUp += one.toppedUp;
1681
1697
  retired += one.retired;
1682
1698
  retirementsLeftOpen += one.retirementsLeftOpen;
1683
1699
  from = one.next;
1700
+ boundEver = one.boundEver;
1684
1701
  } while (from);
1685
- return { toppedUp, retired, retirementsLeftOpen, next: null, done: true };
1702
+ return { toppedUp, retired, retirementsLeftOpen, next: null, done: true, ...(boundEver ? { boundEver } : {}) };
1686
1703
  }
1687
1704
  /**
1688
1705
  * The dispatch capability's admission (#726 remedy B): this delivery may read the
@@ -2052,9 +2069,12 @@ export function defineScopeDO(modules, bareOps) {
2052
2069
  : undefined;
2053
2070
  // #119: the host's trash refusal, before the guards and the handler — the pure
2054
2071
  // adapter's, through the same kernel function.
2055
- await refuseTrashedTarget({ sql: doSpineSql(this.sql), plans: this.statePlans, check: ctx.check }, operation, target, parsed, purging ? { now: new Date().toISOString(), gate: this.purgeGateFacts(systemDoor.moduleId, tenantId) } : undefined);
2072
+ // #2166: what the guards and the handler emit about a stamped entity, held below.
2073
+ const stamps = watchUpdatedStamps(doSpineSql(this.sql), this.updatedStampPlans);
2074
+ await refuseTrashedTarget({ sql: doSpineSql(this.sql), plans: this.statePlans, check: ctx.check, now: ctx.now() }, operation, target, parsed, purging ? { gate: this.purgeGateFacts(systemDoor.moduleId, tenantId) } : undefined);
2056
2075
  await this.runGuards(operation, ctx, parsed);
2057
2076
  result = await handler(ctx, parsed);
2077
+ stamps?.assertHeld(ctx, operation);
2058
2078
  if (guardedRef && invokeOptions?.ifMatch !== undefined) {
2059
2079
  assertIfMatch(guardedRef, invokeOptions.ifMatch, seen ?? null);
2060
2080
  }
@@ -2555,6 +2575,12 @@ export function defineScopeDO(modules, bareOps) {
2555
2575
  * principal holding that root in several tabs is one check, fanned out to each socket.
2556
2576
  */
2557
2577
  const rootChecks = new Map();
2578
+ /**
2579
+ * A row beneath a checked root that the key reaches only through a record whose shape
2580
+ * protects it (#2177): decided by the subscriber's own check of the key on the row, asked
2581
+ * once per (principal, key, row) per pass, under the same epoch rules as `rootChecks`.
2582
+ */
2583
+ const rowChecks = new Map();
2558
2584
  const ancestors = new Map();
2559
2585
  const openEpoch = (now) => ({
2560
2586
  writes: this.revision.statementsWritten,
@@ -2568,17 +2594,26 @@ export function defineScopeDO(modules, bareOps) {
2568
2594
  if (!holds(epoch, now)) {
2569
2595
  epoch = openEpoch(now);
2570
2596
  rootChecks.clear();
2597
+ rowChecks.clear();
2571
2598
  ancestors.clear();
2572
2599
  }
2573
2600
  return epoch;
2574
2601
  };
2575
- const reaches = async (row, root, now) => {
2576
- let up = ancestors.get(row.id);
2602
+ /**
2603
+ * Is the row beneath the root? With a `key` (#2177), only along a path the key's own check
2604
+ * would climb: it stops at a record whose shape protects the key. Memoised per (row, key),
2605
+ * since the stop depends on it.
2606
+ */
2607
+ const reaches = async (row, root, now, key) => {
2608
+ const memo = `${row.id}\n${key ?? ''}`;
2609
+ let up = ancestors.get(memo);
2577
2610
  if (!up) {
2578
2611
  // A walk that cannot answer is a frame not sent — the same fail-closed rule as the check.
2579
2612
  // Walked at the decision's own instant; remembered only while its epoch holds.
2580
- up = ancestorsWithin(parents, { entityType: row.entity_type, entityId: row.entity_id }, now).catch(() => new Set());
2581
- ancestors.set(row.id, up);
2613
+ up = Promise.resolve()
2614
+ .then(() => ancestorsWithin(parents, { entityType: row.entity_type, entityId: row.entity_id }, now, key))
2615
+ .catch(() => new Set());
2616
+ ancestors.set(memo, up);
2582
2617
  }
2583
2618
  return (await up).has(`${root.entityType}:${root.entityId}`);
2584
2619
  };
@@ -2646,7 +2681,13 @@ export function defineScopeDO(modules, bareOps) {
2646
2681
  rootVerdict = undefined;
2647
2682
  // Narrowing first: it is memoised across sockets, and a row outside the root
2648
2683
  // is out whatever the principal holds.
2649
- if (within && !(await reaches(row, within, now)))
2684
+ // A checked root walks with its key first (#2177). The keyed walk is the plain walk with
2685
+ // stops, so whatever it reaches the plain walk reaches too: a hit settles "beneath the
2686
+ // root" in one walk, and only a miss needs the plain walk to tell a row outside the root
2687
+ // from one the key reaches only through a record whose shape protects it.
2688
+ const key = within?.checked;
2689
+ const keyed = within && key !== undefined ? await reaches(row, within, now, key) : false;
2690
+ if (within && !keyed && !(await reaches(row, within, now)))
2650
2691
  return 'skip';
2651
2692
  if (within?.checked !== undefined) {
2652
2693
  if (rootVerdict === undefined) {
@@ -2664,6 +2705,27 @@ export function defineScopeDO(modules, bareOps) {
2664
2705
  }
2665
2706
  if (!rootVerdict.allowed)
2666
2707
  return 'revoked';
2708
+ // The root check speaks for a row the key reaches through the root. A row it reaches
2709
+ // only through a record whose shape protects the key (#2177) is the subscriber's own
2710
+ // check on the row: a holder by role or by the record's shape is nudged, a holder by a
2711
+ // grant above the record is not — the answer `ctx.check` gives, by asking it.
2712
+ if (!keyed) {
2713
+ const ask = () => context()
2714
+ .check(within.checked, { entityType: row.entity_type, entityId: row.entity_id })
2715
+ .then((d) => d.allowed, () => false);
2716
+ let allowed;
2717
+ if (e.remote) {
2718
+ allowed = ask();
2719
+ }
2720
+ else {
2721
+ const memo = `${subscriber.principal}\n${key}\n${row.id}`;
2722
+ allowed = rowChecks.get(memo);
2723
+ if (!allowed)
2724
+ rowChecks.set(memo, (allowed = ask()));
2725
+ }
2726
+ if (!(await allowed))
2727
+ return 'skip';
2728
+ }
2667
2729
  }
2668
2730
  if (within?.vouched !== undefined || within?.checked !== undefined) {
2669
2731
  // The root was vouched for or checked, so the walk above was the row filter —
@@ -2758,8 +2820,42 @@ export function defineScopeDO(modules, bareOps) {
2758
2820
  .toArray()[0];
2759
2821
  return row ? attachmentRecordOfRow(row) : null;
2760
2822
  }
2823
+ // The attachment verbs (#473) each have two doors onto one private body. The `…Reply`
2824
+ // door answers a refusal as DATA, so its code and extensions survive the hop (#113); the
2825
+ // plain one throws it as the bare `Error` `toRpcError` rebuilds, and is kept for a
2826
+ // coordinator from before #113.
2827
+ attachmentAdd(...args) {
2828
+ return thrownForRpc(() => this.addAttachment(...args));
2829
+ }
2830
+ attachmentAddReply(...args) {
2831
+ return replyOf(() => this.addAttachment(...args));
2832
+ }
2833
+ attachmentList(...args) {
2834
+ return thrownForRpc(() => this.listAttachments(...args));
2835
+ }
2836
+ attachmentListReply(...args) {
2837
+ return replyOf(() => this.listAttachments(...args));
2838
+ }
2839
+ attachmentAuthorize(...args) {
2840
+ return thrownForRpc(() => this.authorizeAttachment(...args));
2841
+ }
2842
+ attachmentAuthorizeReply(...args) {
2843
+ return replyOf(() => this.authorizeAttachment(...args));
2844
+ }
2845
+ systemAttachmentAuthorize(...args) {
2846
+ return thrownForRpc(() => this.authorizeSystemAttachment(...args));
2847
+ }
2848
+ systemAttachmentAuthorizeReply(...args) {
2849
+ return replyOf(() => this.authorizeSystemAttachment(...args));
2850
+ }
2851
+ attachmentRemove(...args) {
2852
+ return thrownForRpc(() => this.removeAttachment(...args));
2853
+ }
2854
+ attachmentRemoveReply(...args) {
2855
+ return replyOf(() => this.removeAttachment(...args));
2856
+ }
2761
2857
  /** Record an uploaded attachment: write gate + row + `attachment.added`, one txn. */
2762
- async attachmentAdd(record, principal, tenantId, scopeId,
2858
+ async addAttachment(record, principal, tenantId, scopeId,
2763
2859
  // #476: when set, the gate is checked as this CONNECTION (not `principal`, which is
2764
2860
  // then only the ctx.principal placeholder the connector door passes).
2765
2861
  connectionId) {
@@ -2795,7 +2891,7 @@ export function defineScopeDO(modules, bareOps) {
2795
2891
  if (err instanceof PermissionDenied) {
2796
2892
  this.recordDenial(attachSubject(principal, connectionId), tenantId, 'attachments.upload', err, null);
2797
2893
  }
2798
- throw toRpcError(err);
2894
+ throw err;
2799
2895
  }
2800
2896
  // #1525: null. An attachment RPC carries no invocation, exactly as its denial
2801
2897
  // records none — so the deliveries it drains name no call rather than a wrong one.
@@ -2804,7 +2900,7 @@ export function defineScopeDO(modules, bareOps) {
2804
2900
  });
2805
2901
  }
2806
2902
  /** Records for one entity, newest first — read gate first. */
2807
- async attachmentList(entity, principal, tenantId, scopeId, connectionId) {
2903
+ async listAttachments(entity, principal, tenantId, scopeId, connectionId) {
2808
2904
  await this.ensureMigrations();
2809
2905
  const gate = this.attachmentGate(entity.entityType);
2810
2906
  try {
@@ -2815,7 +2911,7 @@ export function defineScopeDO(modules, bareOps) {
2815
2911
  if (err instanceof PermissionDenied) {
2816
2912
  this.recordDenial(attachSubject(principal, connectionId), tenantId, 'attachments.list', err, null);
2817
2913
  }
2818
- throw toRpcError(err);
2914
+ throw err;
2819
2915
  }
2820
2916
  const rows = this.sql
2821
2917
  .exec(`SELECT id FROM _substrat_attachments WHERE entity_type = ? AND entity_id = ?
@@ -2827,7 +2923,7 @@ export function defineScopeDO(modules, bareOps) {
2827
2923
  * Authorize one attachment (`read` before bytes are served, `write` before a remove is
2828
2924
  * attempted elsewhere) and return its record — null for an id this scope does not know.
2829
2925
  */
2830
- async attachmentAuthorize(attachmentId, mode, principal, tenantId, scopeId, connectionId, forEventId) {
2926
+ async authorizeAttachment(attachmentId, mode, principal, tenantId, scopeId, connectionId, forEventId) {
2831
2927
  await this.ensureMigrations();
2832
2928
  const record = this.attachmentRow(attachmentId);
2833
2929
  if (!record)
@@ -2846,7 +2942,7 @@ export function defineScopeDO(modules, bareOps) {
2846
2942
  if (err instanceof PermissionDenied) {
2847
2943
  this.recordDenial(attachSubject(principal, connectionId), tenantId, 'attachments.open', err, null);
2848
2944
  }
2849
- throw toRpcError(err);
2945
+ throw err;
2850
2946
  }
2851
2947
  return record;
2852
2948
  }
@@ -2859,12 +2955,12 @@ export function defineScopeDO(modules, bareOps) {
2859
2955
  if (err instanceof PermissionDenied) {
2860
2956
  this.recordDenial(attachSubject(principal, connectionId), tenantId, 'attachments.open', err, null);
2861
2957
  }
2862
- throw toRpcError(err);
2958
+ throw err;
2863
2959
  }
2864
2960
  return record;
2865
2961
  }
2866
2962
  /** Authorize a job's read as its registered module, never as a person. */
2867
- async systemAttachmentAuthorize(attachmentId, moduleId, tenantId, scopeId,
2963
+ async authorizeSystemAttachment(attachmentId, moduleId, tenantId, scopeId,
2868
2964
  /** #1834: the instance the system door's gate read; see `invoke`. */
2869
2965
  systemDoorInstance) {
2870
2966
  await this.ensureMigrations();
@@ -2875,10 +2971,10 @@ export function defineScopeDO(modules, bareOps) {
2875
2971
  catch (err) {
2876
2972
  if (err instanceof SystemDoorMovedError)
2877
2973
  return SYSTEM_DOOR_MOVED;
2878
- throw toRpcError(err);
2974
+ throw err;
2879
2975
  }
2880
2976
  if (!this.modules.has(moduleId)) {
2881
- throw toRpcError(substratError('not_found', `module not registered in this scope: ${moduleId}`));
2977
+ throw substratError('not_found', `module not registered in this scope: ${moduleId}`);
2882
2978
  }
2883
2979
  const record = this.attachmentRow(attachmentId);
2884
2980
  if (!record)
@@ -2892,12 +2988,12 @@ export function defineScopeDO(modules, bareOps) {
2892
2988
  if (err instanceof PermissionDenied) {
2893
2989
  this.recordDenial({ kind: 'system', id: moduleId }, tenantId, 'attachments.open', err, null);
2894
2990
  }
2895
- throw toRpcError(err);
2991
+ throw err;
2896
2992
  }
2897
2993
  return record;
2898
2994
  }
2899
2995
  /** Remove an attachment's metadata fact: write gate + delete + `attachment.removed`. */
2900
- async attachmentRemove(attachmentId, principal, tenantId, scopeId, connectionId) {
2996
+ async removeAttachment(attachmentId, principal, tenantId, scopeId, connectionId) {
2901
2997
  await this.ensureMigrations();
2902
2998
  return this.queue.enqueue(async () => {
2903
2999
  const record = this.attachmentRow(attachmentId);
@@ -2924,7 +3020,7 @@ export function defineScopeDO(modules, bareOps) {
2924
3020
  if (err instanceof PermissionDenied) {
2925
3021
  this.recordDenial(attachSubject(principal, connectionId), tenantId, 'attachments.remove', err, null);
2926
3022
  }
2927
- throw toRpcError(err);
3023
+ throw err;
2928
3024
  }
2929
3025
  // #1525: null. An attachment RPC carries no invocation, exactly as its denial
2930
3026
  // records none — so the deliveries it drains name no call rather than a wrong one.
@@ -3530,6 +3626,7 @@ export function defineScopeDO(modules, bareOps) {
3530
3626
  holdings: this.checker.holdings
3531
3627
  ? (principal) => this.checker.holdings({ kind: 'principal', id: principal }, { tenantId, scopeId })
3532
3628
  : undefined,
3629
+ allowed: (principal, permission, entity) => allowedFor(this.checker, principal, { tenantId, scopeId })(permission, entity),
3533
3630
  }, secret, mode);
3534
3631
  });
3535
3632
  if (outcome)
@@ -3548,7 +3645,7 @@ export function defineScopeDO(modules, bareOps) {
3548
3645
  const minter = principalId.parse(caller);
3549
3646
  const parsed = principalBecomeCapabilityInput.parse(input);
3550
3647
  return await this.queue.enqueue(async () => {
3551
- const checked = await becomeMintCheck({ sql: doSpineSql(this.sql), checker: this.checker }, minter, parsed.principal, { tenantId, scopeId });
3648
+ const checked = await becomeMintCheck({ sql: doSpineSql(this.sql), checker: this.checker }, minter, parsed.principal, { tenantId, scopeId }, parsed.bind);
3552
3649
  if (!checked.ok)
3553
3650
  return checked;
3554
3651
  const liveSince = this.liveHighWaterMark();
@@ -3565,6 +3662,32 @@ export function defineScopeDO(modules, bareOps) {
3565
3662
  return { ok: true, minted: minted };
3566
3663
  });
3567
3664
  }
3665
+ /**
3666
+ * Unbind a binding shape's record (#1686) — its column cleared and the shape its person held
3667
+ * there through it taken back — as `caller`, who must hold the binding's key on the record. The
3668
+ * kernel's bound and the write in ONE queued body and one storage transaction; a refusal writes
3669
+ * nothing. On the spine as `entity.holder-unbound`, the caller its actor.
3670
+ */
3671
+ async unbindEntityHolderBoundedFor(tenantId, scopeId, caller, entity) {
3672
+ await this.ensureMigrations();
3673
+ const by = principalId.parse(caller);
3674
+ return await this.queue.enqueue(async () => {
3675
+ const liveSince = this.liveHighWaterMark();
3676
+ const now = instant.parse(new Date().toISOString());
3677
+ let answer = { ok: true, unbound: [] };
3678
+ await this.revision.transaction(async () => {
3679
+ answer = await unbindEntityHolderBounded({
3680
+ sql: doSpineSql(this.sql),
3681
+ now,
3682
+ allowed: allowedFor(this.checker, by, { tenantId, scopeId }),
3683
+ emit: (event) => kernelEmit(this.operationContext(by, tenantId, scopeId, undefined, undefined, undefined, undefined, undefined, ENTITY_UNBIND_OPERATION, undefined, [], now), event),
3684
+ }, entity);
3685
+ });
3686
+ if (answer.ok && answer.unbound.length > 0)
3687
+ await this.settleCommitted(tenantId, scopeId, liveSince, null);
3688
+ return answer;
3689
+ });
3690
+ }
3568
3691
  /** Where each named `become` link stands (#1686) — a pending-invite list's read. */
3569
3692
  async becomeLinkStatesFor(tenantId, scopeId, ids) {
3570
3693
  await this.ensureMigrations();
@@ -4510,12 +4633,7 @@ export function defineScopeDO(modules, bareOps) {
4510
4633
  /** A bounded page of one table. Unknown table names throw — never queried blind. Kept for a
4511
4634
  * coordinator from before #113; the current one calls `introspectTableReply`. */
4512
4635
  introspectTable(table, limit, offset) {
4513
- try {
4514
- return this.tablePage(table, limit, offset);
4515
- }
4516
- catch (e) {
4517
- throw toRpcError(e);
4518
- }
4636
+ return thrownForRpc(async () => this.tablePage(table, limit, offset));
4519
4637
  }
4520
4638
  /** `introspectTable`, its refusal answered as DATA so its code survives the hop (#113). */
4521
4639
  introspectTableReply(table, limit, offset) {
@@ -4547,13 +4665,8 @@ export function defineScopeDO(modules, bareOps) {
4547
4665
  * at SCOPE_QUERY_ROW_MAX with `truncated` set, never an error.
4548
4666
  * Kept for a coordinator from before #113; the current one calls `introspectQueryReply`.
4549
4667
  */
4550
- async introspectQuery(sql) {
4551
- try {
4552
- return await this.readOnlyQuery(sql);
4553
- }
4554
- catch (e) {
4555
- throw toRpcError(e);
4556
- }
4668
+ introspectQuery(sql) {
4669
+ return thrownForRpc(() => this.readOnlyQuery(sql));
4557
4670
  }
4558
4671
  /** `introspectQuery`, its refusal answered as DATA so its code survives the hop (#113). */
4559
4672
  introspectQueryReply(sql) {
@@ -4711,6 +4824,8 @@ export function defineScopeDO(modules, bareOps) {
4711
4824
  'ALTER TABLE _substrat_capabilities ADD COLUMN target_digest TEXT',
4712
4825
  // #1686: why the kernel revoked a link itself; NULL for every revoke already there.
4713
4826
  'ALTER TABLE _substrat_capabilities ADD COLUMN revoked_reason TEXT',
4827
+ // #1686: the action a become carries; NULL on every row already there — none carries one.
4828
+ 'ALTER TABLE _substrat_capabilities ADD COLUMN action TEXT',
4714
4829
  // #1632: legacy runs retain an unknown subject; no content-based backfill.
4715
4830
  'ALTER TABLE _substrat_job_runs ADD COLUMN subject_id TEXT',
4716
4831
  // #2034: the lease. NULL = nobody holds the run, which is right for every row already there.
@@ -5479,7 +5594,9 @@ export function defineScopeDO(modules, bareOps) {
5479
5594
  causedBy: event.id,
5480
5595
  invocationId: unit.invocationId,
5481
5596
  });
5597
+ const stamps = watchUpdatedStamps(doSpineSql(this.sql), this.updatedStampPlans);
5482
5598
  await consumer.handler(ctx, event);
5599
+ stamps?.assertHeld(ctx, `${mod.id} consuming ${event.type}`);
5483
5600
  this.sql.exec(`INSERT INTO _substrat_deliveries
5484
5601
  (event_id, consumer_module, delivered_at, invocation_id)
5485
5602
  VALUES (?, ?, ?, ?)`, event.id, mod.id, new Date().toISOString(), invocationId);