@substrat-run/adapter-cloudflare 0.39.0 → 0.41.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/host.js CHANGED
@@ -1,7 +1,8 @@
1
- import { accessLogEntry, adminLogEntry, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, connectionGrant, connectionSecret, systemGrant, entitlementGrant, entitlementGrantInput, subjectRef, createConnectionInput, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, platformRequest, } from '@substrat-run/contracts';
1
+ import { accessLogEntry, adminLogEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, connectionGrant, connectionSecret, systemGrant, entitlementGrant, entitlementGrantInput, subjectRef, createConnectionInput, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, platformRequest, } from '@substrat-run/contracts';
2
2
  import { normalizeHostname, toRouteTarget } from './route-resolver.js';
3
- import { resolveScopeRecord, ulid, backoffAt, resolveRetryPolicy, unconfiguredSecretBox, } from '@substrat-run/kernel';
3
+ import { attachmentBlobKey, resolveScopeRecord, ulid, backoffAt, resolveRetryPolicy, unconfiguredSecretBox, } from '@substrat-run/kernel';
4
4
  import { tenantStoreDatabaseName } from './d1.js';
5
+ import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
5
6
  /** DO row → contract shape. Never reads the secrets table — that is the split. */
6
7
  const toConnection = (r) => connection.parse({
7
8
  id: r.id,
@@ -69,6 +70,8 @@ export class CloudflareScopeHost {
69
70
  admin;
70
71
  scopeNs;
71
72
  cp;
73
+ /** No control plane bound — `this.cp` is the throwing null object (Phase 3, CP-less). */
74
+ cpLess;
72
75
  /** Project + evaluate permissions scope-locally (scope-local-permissions.md). */
73
76
  scopeLocalPermissions;
74
77
  // Registration-mechanics bookkeeping (validation only — the DO executes).
@@ -85,6 +88,10 @@ export class CloudflareScopeHost {
85
88
  fetchImpl;
86
89
  /** The live D1 client for per-tenant stores (#301); undefined ⇒ refuse loudly. */
87
90
  tenantStores;
91
+ /** The live R2 client for per-tenant blob stores (#473); undefined ⇒ refuse loudly. */
92
+ blobStores;
93
+ /** Worker-side attachment-bucket resolver (#473); undefined ⇒ attachments() refuses. */
94
+ attachmentBuckets;
88
95
  executors = new Map();
89
96
  /**
90
97
  * The event currently being effected, stamped onto admin rows the executor writes.
@@ -109,9 +116,12 @@ export class CloudflareScopeHost {
109
116
  constructor(options) {
110
117
  this.secretBox = options.secretBox ?? unconfiguredSecretBox;
111
118
  this.tenantStores = options.tenantStores;
119
+ this.blobStores = options.blobStores;
120
+ this.attachmentBuckets = options.attachmentBuckets;
112
121
  this.fetchImpl = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
113
122
  this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
114
123
  this.scopeNs = options.scope;
124
+ this.cpLess = !options.controlPlane;
115
125
  this.cp = options.controlPlane
116
126
  ? options.controlPlane.get(options.controlPlane.idFromName('control-plane'))
117
127
  : nullControlPlane();
@@ -576,6 +586,119 @@ export class CloudflareScopeHost {
576
586
  }
577
587
  return this.tenantStores;
578
588
  }
589
+ async provisionBlobStore(actor, input) {
590
+ // Mirror of provisionTenantStore (#301) with R2 in place of D1: fail-closed tenant
591
+ // gate, ledger idempotency, deterministic name so a crashed retry converges, DO
592
+ // first-writer-wins with loser teardown.
593
+ const r2 = this.requireBlobStores(`provisionBlobStore(tenant=${input.tenantId} vertical=${input.vertical} binding=${input.binding})`);
594
+ const tenant = await this.cp.getTenant(input.tenantId);
595
+ if (!tenant) {
596
+ throw new Error(`cannot provision blob store under unknown tenant: ${input.tenantId}`);
597
+ }
598
+ if (tenant.status !== 'active') {
599
+ throw new Error(`cannot provision blob store under non-active tenant (status: ${tenant.status}): ${input.tenantId}`);
600
+ }
601
+ const existing = await this.cp.getBlobStore(input.tenantId, input.vertical, input.binding);
602
+ if (existing)
603
+ return { binding: input.binding, kind: 'blob', ref: existing.ref };
604
+ const name = await blobStoreBucketName(input.tenantId, input.vertical, input.binding);
605
+ const ref = await r2.create(name);
606
+ const stored = await this.cp.putBlobStore({
607
+ tenantId: input.tenantId,
608
+ vertical: input.vertical,
609
+ binding: input.binding,
610
+ kind: 'blob',
611
+ ref,
612
+ createdAt: new Date().toISOString(),
613
+ });
614
+ if (stored.ref !== ref) {
615
+ await r2.remove(ref).catch(() => undefined);
616
+ return { binding: input.binding, kind: 'blob', ref: stored.ref };
617
+ }
618
+ await this.recordAdmin(actor, 'provisionBlobStore', { tenantId: input.tenantId, vertical: input.vertical }, null, { binding: input.binding, kind: 'blob', ref });
619
+ return { binding: input.binding, kind: 'blob', ref };
620
+ }
621
+ /** The injected R2 client, or a loud refusal naming what to configure. */
622
+ requireBlobStores(what) {
623
+ if (!this.blobStores) {
624
+ throw new Error(`per-tenant blob stores are not configured on this host (#473): pass ` +
625
+ `CloudflareScopeHostOptions.blobStores (createR2BlobStores with the platform's ` +
626
+ `Cloudflare credential) — refused ${what}`);
627
+ }
628
+ return this.blobStores;
629
+ }
630
+ async attachments(principal, tenantId, scopeId) {
631
+ // Same fail-closed lifecycle gate + lazy-migration as getScope (#473). The permission
632
+ // gates and metadata facts live in the ScopeDO (per-scope serialization, spine event
633
+ // in the same transaction); bytes go straight to the per-tenant R2 bucket through the
634
+ // binding the vertical's worker resolved — never through the DO.
635
+ await this.cp.validateScopeAccess(tenantId, scopeId);
636
+ await this.migrateAndRecord(scopeId);
637
+ if (!this.attachmentBuckets) {
638
+ throw new Error(`attachments are not configured on this host (#473): pass ` +
639
+ `CloudflareScopeHostOptions.attachmentBuckets — (tenantId) => ` +
640
+ `env[blobStoreBindingName('<BINDING>', tenantId)] for the vertical's declared blob store`);
641
+ }
642
+ const bucket = await this.attachmentBuckets(tenantId);
643
+ if (!bucket) {
644
+ throw new Error(`no attachment bucket resolved for tenant ${tenantId} (#473) — is the per-tenant blob ` +
645
+ `store provisioned and its r2_bucket binding attached to the serving script?`);
646
+ }
647
+ const store = r2TenantBlobStore(bucket);
648
+ const stub = this.scopeStub(scopeId);
649
+ const sha256Hex = async (body) => {
650
+ const digest = await crypto.subtle.digest('SHA-256', body);
651
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
652
+ };
653
+ return {
654
+ upload: async (input) => {
655
+ const id = ulid();
656
+ const key = attachmentBlobKey(scopeId, id);
657
+ const record = attachmentRecord.parse({
658
+ id,
659
+ entity: input.entity,
660
+ filename: input.filename,
661
+ contentType: input.contentType,
662
+ size: input.body.byteLength,
663
+ sha256: await sha256Hex(input.body),
664
+ visibility: input.visibility,
665
+ createdBy: principal,
666
+ createdAt: new Date().toISOString(),
667
+ });
668
+ // Bytes first, row second — a crash between the two leaves an orphaned object
669
+ // (harmless, GC-able), never a row without bytes; a refused gate compensates.
670
+ await store.put(key, input.body, { contentType: input.contentType });
671
+ try {
672
+ return await stub.attachmentAdd(record, principal, tenantId, scopeId);
673
+ }
674
+ catch (err) {
675
+ await store.delete(key).catch(() => { });
676
+ throw err;
677
+ }
678
+ },
679
+ list: (entity) => stub.attachmentList(entity, principal, tenantId, scopeId),
680
+ open: async (attachmentId) => {
681
+ const record = await stub.attachmentAuthorize(attachmentId, 'read', principal, tenantId, scopeId);
682
+ if (!record)
683
+ return null;
684
+ const obj = await store.get(attachmentBlobKey(scopeId, record.id));
685
+ if (!obj) {
686
+ throw new Error(`attachment ${record.id}: bytes missing from the blob store — the metadata row ` +
687
+ `survived something the object did not (rewind/reap); see the #473 integrity notes`);
688
+ }
689
+ if ((await sha256Hex(obj.body)) !== record.sha256) {
690
+ throw new Error(`attachment ${record.id}: bytes do not match the recorded sha256`);
691
+ }
692
+ return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
693
+ },
694
+ remove: async (attachmentId) => {
695
+ const removed = await stub.attachmentRemove(attachmentId, principal, tenantId, scopeId);
696
+ if (removed)
697
+ await store.delete(attachmentBlobKey(scopeId, removed.id)).catch(() => { });
698
+ return removed;
699
+ },
700
+ };
701
+ }
579
702
  async importScope(actor, input, dump) {
580
703
  // Create the destination scope (directory row + DO + lazy migrate); the DO then
581
704
  // replaces its provisioned schema with the dump wholesale (drop-then-replay), so
@@ -678,12 +801,12 @@ export class CloudflareScopeHost {
678
801
  throw err;
679
802
  }
680
803
  }
681
- async getScope(principal, tenantId, scopeId) {
804
+ async getScope(principal, tenantId, scopeId, options) {
682
805
  // Lifecycle gates (control-plane.md §4.1/§4.2), the K-3 fail-closed path,
683
806
  // evaluated durably in the ControlPlaneDO. A throw propagates here.
684
807
  await this.cp.validateScopeAccess(tenantId, scopeId);
685
808
  await this.migrateAndRecord(scopeId);
686
- return this.buildStub(tenantId, scopeId, principal);
809
+ return this.buildStub(tenantId, scopeId, principal, undefined, undefined, options);
687
810
  }
688
811
  /**
689
812
  * A scope stub whose authority is a CONNECTION (#97).
@@ -741,10 +864,15 @@ export class CloudflareScopeHost {
741
864
  if (!schedules || schedules.length === 0)
742
865
  return report;
743
866
  // Only run on a live scope of this tenant; a scope archived between the sweep's
744
- // enumeration and here simply has nothing due.
745
- const rec = await this.cp.getScopeRecord(tenantId, scopeId);
746
- if (!rec || rec.status !== 'active')
747
- return report;
867
+ // enumeration and here simply has nothing due. A CP-less host has no directory
868
+ // to ask (#461) — it already trusts the router-asserted (tenant, scope) for the
869
+ // whole request path, and lifecycle for its scopes lives wherever provisioning
870
+ // does, so the grant check below is the only gate it can and does enforce.
871
+ if (!this.cpLess) {
872
+ const rec = await this.cp.getScopeRecord(tenantId, scopeId);
873
+ if (!rec || rec.status !== 'active')
874
+ return report;
875
+ }
748
876
  const stub = this.scopeStub(scopeId);
749
877
  // The grant IS the switch (#383): run only where the scope holds a live
750
878
  // `system:<moduleId>` grant. Skips a foreign-vertical scope and a per-tenant
@@ -779,7 +907,7 @@ export class CloudflareScopeHost {
779
907
  return report;
780
908
  }
781
909
  /** The stub body, shared by the principal, connection, and system doors. */
782
- buildStub(tenantId, scopeId, principal, connectionId, systemModuleId) {
910
+ buildStub(tenantId, scopeId, principal, connectionId, systemModuleId, options) {
783
911
  const stub = this.scopeStub(scopeId);
784
912
  const cp = this.cp;
785
913
  const operationEntitlement = this.operationEntitlement;
@@ -802,9 +930,13 @@ export class CloudflareScopeHost {
802
930
  if (requiredKey && !(await cp.tenantHoldsEntitlement(tenantId, requiredKey))) {
803
931
  return Promise.reject(new Error(`operation not entitled: ${operation} — tenant does not hold '${requiredKey}'`));
804
932
  }
805
- const result = (await stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId));
933
+ const envelope = await stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId);
806
934
  await this.drainExecutors(tenantId, scopeId);
807
- return result;
935
+ // #458: the operation committed having enqueued platform intents — tell the
936
+ // caller's harness so it can flag the response for the router kick (#381).
937
+ if (envelope.platformRequests > 0)
938
+ options?.onPlatformRequests?.(envelope.platformRequests);
939
+ return envelope.result;
808
940
  },
809
941
  };
810
942
  }
@@ -933,7 +1065,13 @@ export class CloudflareScopeHost {
933
1065
  await this.fanOut(tenantId); // role definitions are projected into the tenant's scopes
934
1066
  },
935
1067
  listRoles: async (actor, filter) => {
936
- const rows = await this.cp.listRoles({ tenantId: filter?.tenantId, source: filter?.source });
1068
+ const rows = await this.cp.listRoles({
1069
+ tenantId: filter?.tenantId,
1070
+ source: filter?.source,
1071
+ limit: filter?.limit,
1072
+ cursor: filter?.cursor,
1073
+ order: filter?.order,
1074
+ });
937
1075
  await this.recordAccess(actor, 'listRoles', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
938
1076
  // Parsed, not cast — the same parse the pure adapter does, which is what
939
1077
  // makes the shared contract suite mean anything.
@@ -1110,6 +1248,9 @@ export class CloudflareScopeHost {
1110
1248
  tenantId: filter?.tenantId,
1111
1249
  scopeId: filter?.scopeId,
1112
1250
  status: filter?.status,
1251
+ limit: filter?.limit,
1252
+ cursor: filter?.cursor,
1253
+ order: filter?.order,
1113
1254
  });
1114
1255
  await this.recordAccess(actor, 'listHostnames', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
1115
1256
  return rows.map(mapHostname);
@@ -1161,9 +1302,9 @@ export class CloudflareScopeHost {
1161
1302
  await this.cp.insertVertical(parsed.slug, parsed.name, parsed.source, parsed.ownerTenant, envSpecJson, installSpecJson, parsed.listed ? 1 : 0, new Date().toISOString());
1162
1303
  await this.recordAdmin(actor, 'registerVertical', { tenantId: null }, null, parsed);
1163
1304
  },
1164
- listVerticals: async (actor) => {
1165
- const rows = await this.cp.listVerticals();
1166
- await this.recordAccess(actor, 'listVerticals', {}, null, rows.length);
1305
+ listVerticals: async (actor, page) => {
1306
+ const rows = await this.cp.listVerticals(page);
1307
+ await this.recordAccess(actor, 'listVerticals', {}, page ?? null, rows.length);
1167
1308
  return rows.map(mapVertical);
1168
1309
  },
1169
1310
  publishVersion: async (actor, input) => {
@@ -1192,8 +1333,8 @@ export class CloudflareScopeHost {
1192
1333
  admission: selfAdmits ? 'admitted' : 'pending',
1193
1334
  });
1194
1335
  },
1195
- listVersions: async (actor, verticalSlug) => {
1196
- const rows = await this.cp.listVersions(verticalSlug);
1336
+ listVersions: async (actor, verticalSlug, page) => {
1337
+ const rows = await this.cp.listVersions(verticalSlug, page);
1197
1338
  await this.recordAccess(actor, 'listVersions', {}, { verticalSlug }, rows.length);
1198
1339
  return rows.map(mapVersion);
1199
1340
  },
@@ -1240,12 +1381,18 @@ export class CloudflareScopeHost {
1240
1381
  const existing = await this.cp.readVertical(slug);
1241
1382
  if (!existing)
1242
1383
  throw new Error(`unknown vertical '${slug}'`);
1243
- // Refuse while any scope is bound: a deleted registry row would strand those
1244
- // scopes' version pins and routing. Deployed dispatch scripts are NOT reaped
1245
- // here — they become orphans for the cleanup script (#248).
1384
+ // Refuse while any restorable scope is bound: a deleted registry row would strand
1385
+ // those scopes' version pins and routing. An `archived` scope (a deleted app) still
1386
+ // blocks — unarchive can bring it back — but the refusal names reap/restore, not
1387
+ // "delete", because the app itself is already gone. `reaped` is terminal history and
1388
+ // never blocks. Deployed dispatch scripts are NOT reaped here — they become orphans
1389
+ // for the cleanup script (#248).
1246
1390
  const bound = await this.cp.countScopesForVertical(slug);
1247
- if (bound > 0) {
1248
- throw new Error(`vertical '${slug}' still backs ${bound} scope(s) — delete or rebind them first`);
1391
+ if (bound.live > 0) {
1392
+ throw new Error(`vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
1393
+ }
1394
+ if (bound.archived > 0) {
1395
+ throw new Error(`vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
1249
1396
  }
1250
1397
  await this.cp.deleteVertical(slug);
1251
1398
  await this.recordAdmin(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.owner_tenant }, null);
@@ -1355,8 +1502,8 @@ export class CloudflareScopeHost {
1355
1502
  }
1356
1503
  await this.recordAdmin(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack });
1357
1504
  },
1358
- listChannels: async (actor, verticalSlug) => {
1359
- const rows = await this.cp.listChannels(verticalSlug);
1505
+ listChannels: async (actor, verticalSlug, page) => {
1506
+ const rows = await this.cp.listChannels(verticalSlug, page);
1360
1507
  // The serving script runs ONE version (#286); surface it on the prod row so a
1361
1508
  // failed in-place serve (channel moved, serve did not) reads honestly instead of
1362
1509
  // claiming the new version is live (#321).
@@ -1370,8 +1517,8 @@ export class CloudflareScopeHost {
1370
1517
  servingVersionId: r.channel === 'prod' ? serving : null,
1371
1518
  }));
1372
1519
  },
1373
- listChannelHistory: async (actor, verticalSlug, channel) => {
1374
- const rows = await this.cp.listChannelHistory(verticalSlug, channel);
1520
+ listChannelHistory: async (actor, verticalSlug, channel, page) => {
1521
+ const rows = await this.cp.listChannelHistory(verticalSlug, channel, page);
1375
1522
  await this.recordAccess(actor, 'listChannelHistory', {}, { verticalSlug, channel }, rows.length);
1376
1523
  return rows.map((r) => channelHistoryEntry.parse({
1377
1524
  id: r.id,
@@ -1547,10 +1694,10 @@ export class CloudflareScopeHost {
1547
1694
  const before = await this.cp.reapTenant(tenantId);
1548
1695
  await this.recordAdmin(actor, 'reapTenant', { tenantId }, { status: before }, { status: 'reaped' });
1549
1696
  },
1550
- listTenants: async (actor) => {
1551
- const tenants = (await this.cp.listTenants()).map((t) => tenantSchema.parse(t));
1697
+ listTenants: async (actor, page) => {
1698
+ const tenants = (await this.cp.listTenants(page)).map((t) => tenantSchema.parse(t));
1552
1699
  // Enumerating every tenant on the platform is the read this log exists for.
1553
- await this.recordAccess(actor, 'listTenants', {}, null, tenants.length);
1700
+ await this.recordAccess(actor, 'listTenants', {}, page ?? null, tenants.length);
1554
1701
  return tenants;
1555
1702
  },
1556
1703
  getTenant: async (actor, tenantId) => {
@@ -1567,6 +1714,9 @@ export class CloudflareScopeHost {
1567
1714
  : [filter.status]
1568
1715
  : undefined,
1569
1716
  vertical: filter?.vertical,
1717
+ limit: filter?.limit,
1718
+ cursor: filter?.cursor,
1719
+ order: filter?.order,
1570
1720
  });
1571
1721
  await this.recordAccess(actor, 'listScopes', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
1572
1722
  return rows.map(mapScope);
@@ -1591,6 +1741,21 @@ export class CloudflareScopeHost {
1591
1741
  createdAt: r.created_at,
1592
1742
  }));
1593
1743
  },
1744
+ listBlobStores: async (actor, filter) => {
1745
+ const rows = await this.cp.listBlobStores({
1746
+ tenantId: filter?.tenantId,
1747
+ vertical: filter?.vertical,
1748
+ });
1749
+ await this.recordAccess(actor, 'listBlobStores', { tenantId: filter?.tenantId ?? null }, filter ?? null, rows.length);
1750
+ return rows.map((r) => ({
1751
+ tenantId: r.tenant_id,
1752
+ vertical: r.vertical,
1753
+ binding: r.binding,
1754
+ kind: 'blob',
1755
+ ref: r.ref,
1756
+ createdAt: r.created_at,
1757
+ }));
1758
+ },
1594
1759
  listScopeTables: async (actor, tenantId, scopeId) => {
1595
1760
  // K-3 cross-check on the shared directory BEFORE reaching the scope DO: a pair
1596
1761
  // that does not resolve is unreachable, never another tenant's database.
@@ -1812,8 +1977,8 @@ export class CloudflareScopeHost {
1812
1977
  return; // idempotent, and a no-op is not audited
1813
1978
  await this.recordAdmin(actor, 'revokeConnection', { tenantId: row.tenant_id, vertical: row.vertical }, { status: row.status }, { id, provider: row.provider, status: 'revoked', revokedAt: now });
1814
1979
  },
1815
- openConnection: async (tenantId, vertical, provider) => {
1816
- const row = await this.cp.readLiveConnection(tenantId, vertical, provider);
1980
+ openConnection: async (tenantId, vertical, provider, externalAccountRef) => {
1981
+ const row = await this.cp.readLiveConnection(tenantId, vertical, provider, externalAccountRef);
1817
1982
  if (!row)
1818
1983
  return undefined;
1819
1984
  const secret = connectionSecret.parse(JSON.parse(await this.secretBox.open({ keyId: row.key_id, ciphertext: row.ciphertext })));
@@ -1889,6 +2054,8 @@ export class CloudflareScopeHost {
1889
2054
  tenantId: filter?.tenantId,
1890
2055
  method: filter?.method,
1891
2056
  limit: filter?.limit,
2057
+ cursor: filter?.cursor,
2058
+ order: filter?.order,
1892
2059
  });
1893
2060
  // Reading the access log is itself a read. Recorded before returning, so
1894
2061
  // the row describing this call is not in its own result.
@@ -2069,6 +2236,22 @@ export class CloudflareScopeHost {
2069
2236
  object: `scope:${input.scopeId}`,
2070
2237
  expires_at: null,
2071
2238
  },
2239
+ // #461: each registered module's SCHEDULE grants (#383) ride the same unit —
2240
+ // the CP-less mirror of `provisionScope`'s writeTuple loop. Without them the
2241
+ // grant-is-the-switch check makes every schedule a silent no-op (`fired: 0`,
2242
+ // no error — the #49 unfalsifiable zero).
2243
+ ...[...this.moduleSchedules].flatMap(([modId, schedules]) => {
2244
+ const perms = new Set();
2245
+ for (const s of schedules)
2246
+ for (const p of s.permissions)
2247
+ perms.add(p);
2248
+ return [...perms].map((perm) => ({
2249
+ subject: `system:${modId}`,
2250
+ relation: `granted:${perm}`,
2251
+ object: `scope:${input.scopeId}`,
2252
+ expires_at: null,
2253
+ }));
2254
+ }),
2072
2255
  ],
2073
2256
  // #406: identity links, delivered by the platform with provisioning exactly as
2074
2257
  // entitlements are (#310). Preserve-on-undefined, so an absent field never wipes