@substrat-run/adapter-cloudflare 0.133.0 → 0.135.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,7 @@
1
1
  import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
2
- import { delegatedReadParams, fromWireFailure, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, verticalSlug, entityObjectRef, } from '@substrat-run/contracts';
2
+ import { delegatedReadParams, fromWireFailure, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, verticalSlug, entityObjectRef, } from '@substrat-run/contracts';
3
3
  import { normalizeHostname, toRouteTarget } from './route-resolver.js';
4
- import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, runDueJobRuns, startJobRun, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, } from '@substrat-run/kernel';
4
+ import { attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, runDueJobRuns, startJobRun, attachmentTextJob, assertAttachmentExtractors, assertJobRegistrable, isAttachmentTextRun, searchLimit, searchMatchExpression, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, assertRowLimit, assertRowOffset, } from '@substrat-run/kernel';
5
5
  import { attributedHost } from '@substrat-run/kernel';
6
6
  import { isOrangeToOrange, isUpgradeRequest, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
7
7
  import { tenantStoreDatabaseName } from './d1.js';
@@ -147,6 +147,8 @@ export class CloudflareScopeHost {
147
147
  blobStores;
148
148
  /** Worker-side attachment-bucket resolver (#473); undefined ⇒ attachments() refuses. */
149
149
  attachmentBuckets;
150
+ /** The parsers attachment text is extracted with (K-43); the host's own, never imported here. */
151
+ attachmentExtractors;
150
152
  executors = new Map();
151
153
  /**
152
154
  * `<moduleId>/<job>` → the pass body and its default step policy (#1577). Host
@@ -169,6 +171,8 @@ export class CloudflareScopeHost {
169
171
  */
170
172
  onBehalfOf = null;
171
173
  withdrawn = new Map(); // operation → module
174
+ /** Operation → who binds it (module, SKU flag, its declared schedules) — the §4.3 gate's
175
+ * input, resolved per invoke by the kernel's `requiredEntitlementFor` (#1654). */
172
176
  operationEntitlement = new Map();
173
177
  /** #574: remote connector write-back for scopes served by another deployment. */
174
178
  connectorDelegation;
@@ -208,6 +212,8 @@ export class CloudflareScopeHost {
208
212
  this.tenantStores = options.tenantStores;
209
213
  this.blobStores = options.blobStores;
210
214
  this.attachmentBuckets = options.attachmentBuckets;
215
+ assertAttachmentExtractors(options.attachmentExtractors ?? []);
216
+ this.attachmentExtractors = options.attachmentExtractors ?? [];
211
217
  this.fetchImpl = options.fetch ?? globalFetch;
212
218
  this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
213
219
  this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
@@ -418,6 +424,8 @@ export class CloudflareScopeHost {
418
424
  return this.drainExecutors(tenantId, scopeId, null);
419
425
  }
420
426
  registerJob(moduleId, name, handler, retry) {
427
+ // #1575: the kernel's own jobs are dispatched before this registry is read.
428
+ assertJobRegistrable(moduleId, name);
421
429
  const key = `${moduleId}/${name}`;
422
430
  if (this.jobs.has(key))
423
431
  throw new Error(`job '${key}' is already registered`);
@@ -448,6 +456,23 @@ export class CloudflareScopeHost {
448
456
  recordStep: (runId, name, result, attempts, lastError, at) => stub.jobStepRecord(runId, name, result, attempts, lastError, at),
449
457
  };
450
458
  }
459
+ /**
460
+ * The extraction job's adapter half (#1575). The record and the outcome are RPCs to the
461
+ * scope DO; the bytes are read here, where the per-tenant bucket is bound — the DO never
462
+ * holds them. No permission gate on any of it: extraction is the kernel's own derivation,
463
+ * and the person-facing gate is at search.
464
+ */
465
+ attachmentTextHandler(tenantId, scopeId) {
466
+ const stub = this.scopeStub(scopeId);
467
+ return attachmentTextJob({
468
+ record: (attachmentId) => stub.attachmentTextSource(attachmentId),
469
+ bytes: async (record) => (await (await this.resolveAttachmentStore(tenantId)).get(attachmentBlobKey(scopeId, record.id)))?.body ??
470
+ null,
471
+ write: (attachmentId, outcome) => stub.attachmentTextRecord(attachmentId, outcome),
472
+ },
473
+ // K-43: the host's parsers, handed in — this adapter imports none.
474
+ this.attachmentExtractors);
475
+ }
451
476
  async startJobRun(tenantId, scopeId, input) {
452
477
  await this.validateScopeAccess(tenantId, scopeId);
453
478
  await this.migrateAndRecord(scopeId);
@@ -458,9 +483,12 @@ export class CloudflareScopeHost {
458
483
  // advance, and an archived one's never move again.
459
484
  await this.validateScopeAccess(tenantId, scopeId);
460
485
  await this.migrateAndRecord(scopeId);
486
+ // #1575: the kernel's extraction job is this host's own, bound to this scope — no
487
+ // deployment registers it, and none can shadow it.
488
+ const attachmentText = { handler: this.attachmentTextHandler(tenantId, scopeId) };
461
489
  return runDueJobRuns({
462
490
  store: this.jobStore(scopeId),
463
- handlerFor: (run) => this.jobs.get(`${run.module_id}/${run.job}`),
491
+ handlerFor: (run) => isAttachmentTextRun(run) ? attachmentText : this.jobs.get(`${run.module_id}/${run.job}`),
464
492
  now: () => new Date().toISOString(),
465
493
  openScope: (run) => this.getSystemScope(run.module_id, tenantId, scopeId),
466
494
  maxPasses: options?.maxPasses,
@@ -605,6 +633,15 @@ export class CloudflareScopeHost {
605
633
  async summarizeDenialsLocal(scopeId, filter) {
606
634
  return this.scopeStub(scopeId).summarizeDenials(filter);
607
635
  }
636
+ /**
637
+ * The operator's capability read's CP-less path (#1686) — the denial log's trust line: the
638
+ * directory rows live in the scope's own DO, in the vertical's deployment, reachable only
639
+ * through its platform-gated `/internal/capabilities`; the K-3 check and the K-24 entry are
640
+ * the control plane's, made before it calls. Records only — the DO's query selects no hash.
641
+ */
642
+ async listCapabilitiesLocal(scopeId, filter) {
643
+ return this.scopeStub(scopeId).listCapabilities(filter);
644
+ }
608
645
  /**
609
646
  * Copy one scope's data into a fresh scope DO, entirely within THIS deployment —
610
647
  * the data half of an orchestrated snapshot (preview-and-snapshots.md §9). Like the
@@ -819,9 +856,14 @@ export class CloudflareScopeHost {
819
856
  throw new Error(`${manifest.id} declares operationInputs for unbound operation(s): ` +
820
857
  `${unboundInputs.sort().join(', ')} — a schema on nothing reads as a parse that is not there`);
821
858
  }
859
+ const binding = {
860
+ moduleId: manifest.id,
861
+ entitlementKey: manifest.entitlementKey,
862
+ scheduledOperations: new Set((manifest.schedules ?? []).map((sch) => sch.operation)),
863
+ };
822
864
  for (const name of Object.keys(registration.operations ?? {})) {
823
865
  this.bindOperation(name);
824
- this.operationEntitlement.set(name, manifest.entitlementKey);
866
+ this.operationEntitlement.set(name, binding);
825
867
  }
826
868
  }
827
869
  defineOperation(name, _handler) {
@@ -1088,6 +1130,8 @@ export class CloudflareScopeHost {
1088
1130
  }),
1089
1131
  list: notDelegated('list'),
1090
1132
  remove: notDelegated('remove'),
1133
+ // #1575: undelegated for `list`'s reason — a connector picks a document by id.
1134
+ search: notDelegated('search'),
1091
1135
  };
1092
1136
  }
1093
1137
  await this.migrateAndRecord(scopeId);
@@ -1136,6 +1180,12 @@ export class CloudflareScopeHost {
1136
1180
  remove: async (attachmentId) => unwrapCapabilityReply(await stub.capabilityAttachmentRefuseWrite(hash, tenantId, scopeId, 'attachments.remove', {
1137
1181
  attachmentId,
1138
1182
  })),
1183
+ search: async (term, options) => {
1184
+ // Judged here too, so a short term reaches the caller as `SearchTermTooShort`
1185
+ // rather than as a message that crossed the RPC boundary. The DO judges again.
1186
+ searchMatchExpression(term, 'prefix');
1187
+ return unwrapCapabilityReply(await stub.capabilityAttachmentSearch(term, searchLimit(options?.limit), hash, tenantId, scopeId));
1188
+ },
1139
1189
  };
1140
1190
  }
1141
1191
  const connectionId = 'connectionId' in subject ? subject.connectionId : undefined;
@@ -1156,7 +1206,7 @@ export class CloudflareScopeHost {
1156
1206
  filename: input.filename,
1157
1207
  contentType: input.contentType,
1158
1208
  size: input.body.byteLength,
1159
- sha256: await sha256Hex(input.body),
1209
+ sha256: await attachmentSha256(input.body),
1160
1210
  visibility: input.visibility,
1161
1211
  createdBy,
1162
1212
  createdAt: new Date().toISOString(),
@@ -1183,6 +1233,14 @@ export class CloudflareScopeHost {
1183
1233
  await store.delete(attachmentBlobKey(scopeId, removed.id)).catch(() => { });
1184
1234
  return removed;
1185
1235
  },
1236
+ search: async (term, options) => {
1237
+ if (forEvent) {
1238
+ // The delivery-admitted surface reads the ONE attachment its delivery names.
1239
+ throw new Error('attachments.search is not available on a delivery-scoped surface');
1240
+ }
1241
+ searchMatchExpression(term, 'prefix');
1242
+ return unwrapCapabilityReply(await stub.attachmentSearch(term, searchLimit(options?.limit), asPrincipalId, tenantId, scopeId, connectionId));
1243
+ },
1186
1244
  };
1187
1245
  }
1188
1246
  /**
@@ -1196,7 +1254,7 @@ export class CloudflareScopeHost {
1196
1254
  throw new Error(`attachment ${record.id}: bytes missing from the blob store — the metadata row ` +
1197
1255
  `survived something the object did not (rewind/reap); see the #473 integrity notes`);
1198
1256
  }
1199
- if ((await sha256Hex(obj.body)) !== record.sha256) {
1257
+ if ((await attachmentSha256(obj.body)) !== record.sha256) {
1200
1258
  throw new Error(`attachment ${record.id}: bytes do not match the recorded sha256`);
1201
1259
  }
1202
1260
  return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
@@ -1806,7 +1864,13 @@ export class CloudflareScopeHost {
1806
1864
  // (`cp.tenantHoldsEntitlement`); for a hosted/CP-less scope that call is a trusting
1807
1865
  // no-op, so the SAME `requiredKey` is passed to the DO, which fails closed against
1808
1866
  // its PROJECTED entitlements (#304). One or the other enforces, never neither.
1809
- const requiredKey = operationEntitlement.get(operation);
1867
+ //
1868
+ // EXCEPTION (#1654): a module's own declared schedule, through this system door,
1869
+ // demands no SKU — its `system:<moduleId>` grant is the switch, as it already is for
1870
+ // permissions (#383). `requiredEntitlementFor` holds the whole rule and why; it
1871
+ // answers `undefined` there, so neither the coordinator nor the DO asks. Every other
1872
+ // door passes no `systemModuleId` and is gated exactly as before.
1873
+ const requiredKey = requiredEntitlementFor(operation, operationEntitlement.get(operation), systemModuleId);
1810
1874
  if (requiredKey && !(await cp.tenantHoldsEntitlement(tenantId, requiredKey))) {
1811
1875
  // Required AND held (#691) — a second CP read, but only on the denial path.
1812
1876
  const now = new Date().toISOString();
@@ -2040,7 +2104,7 @@ export class CloudflareScopeHost {
2040
2104
  // lets a LISTED vertical's builder still preview their own new code (marketplace-publish.md
2041
2105
  // §2; issue #509 ask (d)). Every other scope kind keeps the refusal.
2042
2106
  if (v.admission !== 'admitted' && scope.kind !== 'preview') {
2043
- throw new Error(`version ${versionId} is ${v.admission}, not admitted — it cannot be bound to a scope`);
2107
+ throw substratError('conflict', `version ${versionId} is ${v.admission}, not admitted — it cannot be bound to a scope`);
2044
2108
  }
2045
2109
  return { v, scope };
2046
2110
  };
@@ -2831,7 +2895,7 @@ export class CloudflareScopeHost {
2831
2895
  return;
2832
2896
  }
2833
2897
  if (existing.owner_tenant !== parsed.ownerTenant) {
2834
- throw new Error(`vertical '${parsed.slug}' is owned by ${existing.owner_tenant ?? 'the platform'}, not ${parsed.ownerTenant ?? 'the platform'}`);
2898
+ throw substratError('conflict', `vertical '${parsed.slug}' is owned by ${existing.owner_tenant ?? 'the platform'}, not ${parsed.ownerTenant ?? 'the platform'}`);
2835
2899
  }
2836
2900
  throw new Error(`vertical '${parsed.slug}' is already registered as ${existing.source}`);
2837
2901
  }
@@ -2898,7 +2962,7 @@ export class CloudflareScopeHost {
2898
2962
  const prod = await this.cp.readChannel(slug, 'prod');
2899
2963
  const prodVersion = prod ? await this.cp.readVersion(prod.version_id) : undefined;
2900
2964
  if (prodVersion?.admission_note === AUTO_ADMISSION_NOTE) {
2901
- throw new Error(`vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
2965
+ throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
2902
2966
  `a staff admit must vouch for it before listing`);
2903
2967
  }
2904
2968
  }
@@ -2990,7 +3054,7 @@ export class CloudflareScopeHost {
2990
3054
  if (!v)
2991
3055
  throw substratError('not_found', `unknown version ${versionId}`);
2992
3056
  if (v.admission === 'admitted') {
2993
- throw new Error(`version ${versionId} is already admitted — it may be bound`);
3057
+ throw substratError('conflict', `version ${versionId} is already admitted — it may be bound`);
2994
3058
  }
2995
3059
  if (v.admission === 'rejected')
2996
3060
  return;
@@ -3002,10 +3066,10 @@ export class CloudflareScopeHost {
3002
3066
  if (!incoming)
3003
3067
  throw substratError('not_found', `unknown version ${versionId}`);
3004
3068
  if (incoming.vertical_slug !== verticalSlug) {
3005
- throw new Error(`version ${versionId} belongs to '${incoming.vertical_slug}'`);
3069
+ throw substratError('conflict', `version ${versionId} belongs to '${incoming.vertical_slug}'`);
3006
3070
  }
3007
3071
  if (incoming.admission !== 'admitted') {
3008
- throw new Error(`version ${versionId} is ${incoming.admission}, not admitted — it cannot be promoted`);
3072
+ throw substratError('conflict', `version ${versionId} is ${incoming.admission}, not admitted — it cannot be promoted`);
3009
3073
  }
3010
3074
  const current = await this.cp.readChannel(verticalSlug, channel);
3011
3075
  const outgoing = current ? await this.cp.readVersion(current.version_id) : undefined;
@@ -3441,7 +3505,7 @@ export class CloudflareScopeHost {
3441
3505
  moveImportCursor: async (actor, tenantId, scopeId, raw) => this.moveImportCursorAt(actor, tenantId, scopeId, raw),
3442
3506
  readUndrainedEvents: async (actor, tenantId, scopeId, limit) => {
3443
3507
  const record = await this.scopeRecordForRead(tenantId, scopeId);
3444
- const bounded = Math.min(Math.max(limit ?? 200, 1), 1000);
3508
+ const bounded = Math.min(assertRowLimit('limit', limit ?? 200), 1000);
3445
3509
  // #1334: on the shared control plane the scope's outbox is in its vertical's
3446
3510
  // deployment, and this host's own namespace is the module-less placeholder —
3447
3511
  // reading it would construct an empty DO and answer "nothing to ship". The
@@ -3602,6 +3666,9 @@ export class CloudflareScopeHost {
3602
3666
  },
3603
3667
  readScopeTable: async (actor, tenantId, scopeId, input) => {
3604
3668
  await this.scopeRecordForRead(tenantId, scopeId);
3669
+ // Checked here as well as in the ScopeDO, so the refusal keeps its code across the hop.
3670
+ assertRowLimit('limit', input.limit);
3671
+ assertRowOffset('offset', input.offset);
3605
3672
  const page = await this.scopeStub(scopeId).introspectTable(input.table, input.limit, input.offset);
3606
3673
  await this.recordAccess(actor, 'readScopeTable', { tenantId, scopeId }, { table: input.table, limit: page.limit, offset: page.offset }, page.rows.length);
3607
3674
  return page;
@@ -3615,6 +3682,26 @@ export class CloudflareScopeHost {
3615
3682
  await this.recordAccess(actor, 'listDenials', { tenantId, scopeId }, filter ?? null, rows.length);
3616
3683
  return rows;
3617
3684
  },
3685
+ // #1686: the operator's capability read. K-3 on the directory before the DO, like the
3686
+ // denial log. On the shared control plane a scope a vertical's own deployment serves
3687
+ // holds its storage THERE, and this namespace holds a placeholder: the control-plane
3688
+ // API asks the vertical (`listCapabilitiesLocal`), so this branch is the co-located one.
3689
+ listCapabilities: async (actor, tenantId, scopeId, filter) => {
3690
+ // Parsed here too: a typed refusal thrown inside the DO arrives as a bare message.
3691
+ const parsed = capabilityFilter.parse(filter ?? {});
3692
+ const rec = await this.scopeRecordForRead(tenantId, scopeId);
3693
+ this.assertServedHere(rec, scopeId, 'listCapabilities');
3694
+ const page = await this.scopeStub(scopeId).listCapabilities(parsed);
3695
+ await this.recordAccess(actor, 'listCapabilities', { tenantId, scopeId }, filter ?? null, page.entries.length);
3696
+ return page;
3697
+ },
3698
+ listRefusals: async (actor, tenantId, scopeId, filter) => {
3699
+ // #1745: the denial log's discipline — K-3 on the directory before the DO is reached.
3700
+ await this.scopeRecordForRead(tenantId, scopeId);
3701
+ const rows = await this.scopeStub(scopeId).listRefusals(filter);
3702
+ await this.recordAccess(actor, 'listRefusals', { tenantId, scopeId }, filter ?? null, rows.length);
3703
+ return rows;
3704
+ },
3618
3705
  summarizeDenials: async (actor, tenantId, scopeId, filter) => {
3619
3706
  await this.scopeRecordForRead(tenantId, scopeId);
3620
3707
  const summary = await this.scopeStub(scopeId).summarizeDenials(filter);
@@ -3783,7 +3870,20 @@ export class CloudflareScopeHost {
3783
3870
  `_substrat_job_runs or _substrat_job_steps — erasing now would destroy the subject ` +
3784
3871
  `key while leaving their data in the job-run tables. Redeploy the vertical and re-run.`);
3785
3872
  }
3786
- const { events: eventsRedacted, intents: intentsRedacted, jobRuns: jobRunsRedacted } = redacted;
3873
+ // A DO from before the free-text half (#1632): it never looked at the idempotency
3874
+ // ledger or a queued sweep record, and cannot name the intents the directory's drain
3875
+ // failures quote. Refused before the key, for the reason above.
3876
+ if (!('idempotencyResults' in redacted) ||
3877
+ typeof redacted.idempotencyResults !== 'number' ||
3878
+ !Array.isArray(redacted.intentIds)) {
3879
+ throw substratError('unavailable', `scope ${scopeId} runs a ScopeDO whose redaction does not reach _substrat_idempotency ` +
3880
+ `or a queued sweep-runs intent — erasing now would destroy the subject key while ` +
3881
+ `leaving their data in those rows. Redeploy the vertical and re-run.`);
3882
+ }
3883
+ const { events: eventsRedacted, intents: intentsRedacted, jobRuns: jobRunsRedacted, idempotencyResults, intentIds, } = redacted;
3884
+ // The directory's failure text (#1632) — a drain failure quoting one of those intents,
3885
+ // an issue's exemplar, a sweep record's error. Still before the key.
3886
+ await this.cp.redactSubjectText({ tenantId, scopeId, subjectId, intentIds });
3787
3887
  const at = new Date().toISOString();
3788
3888
  const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
3789
3889
  const receipt = subjectShredReceipt.parse({
@@ -3800,7 +3900,7 @@ export class CloudflareScopeHost {
3800
3900
  await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
3801
3901
  // BOTH counts: the access log's number is "how much evidence this destroyed", and
3802
3902
  // an intent payload is a whole event's worth of it.
3803
- await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted);
3903
+ await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted + idempotencyResults);
3804
3904
  return receipt;
3805
3905
  },
3806
3906
  // -- impersonation (K-42, #868) ----------------------------------------
@@ -4195,7 +4295,8 @@ export class CloudflareScopeHost {
4195
4295
  return drained;
4196
4296
  },
4197
4297
  pruneAccessLog: async (actor, limit) => {
4198
- const pruned = await this.cp.pruneAccessLog(limit);
4298
+ // Checked here as well as in the directory, so the refusal keeps its code across the hop.
4299
+ const pruned = await this.cp.pruneAccessLog(assertRowLimit('limit', limit));
4199
4300
  if (pruned > 0) {
4200
4301
  // The payload is the APPLIED state, so it belongs in `after` (contracts'
4201
4302
  // adminLogEntry: before = prior state, after = the applied payload) — the
@@ -4308,6 +4409,8 @@ export class CloudflareScopeHost {
4308
4409
  await this.recordAccess(actor, 'listSweepRuns', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
4309
4410
  return rows.map((r) => sweepRunEntry.parse(r));
4310
4411
  },
4412
+ // Checked here as well as in the directory, so the refusal keeps its code across the hop.
4413
+ pruneTelemetry: async (_actor, limit) => this.cp.pruneTelemetry(assertRowLimit('limit', limit)),
4311
4414
  listIssues: async (actor, filter) => {
4312
4415
  const rows = await this.cp.listIssues({
4313
4416
  status: filter?.status,
@@ -5534,11 +5637,6 @@ export class CloudflareScopeHost {
5534
5637
  return importsOfManifestJson(v.manifest_json);
5535
5638
  }
5536
5639
  }
5537
- /** SHA-256 hex of an attachment's bytes — what `AttachmentRecord.sha256` holds. */
5538
- async function sha256Hex(body) {
5539
- const digest = await crypto.subtle.digest('SHA-256', body);
5540
- return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
5541
- }
5542
5640
  /** A ledger row -> the wire entry, the attribution re-nested (#1054). */
5543
5641
  function modelUsageEntryOf(r) {
5544
5642
  return modelUsageEntry.parse({