@substrat-run/adapter-cloudflare 0.132.1 → 0.134.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
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';
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, 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';
@@ -110,6 +110,7 @@ export class CloudflareScopeHost {
110
110
  admin;
111
111
  scopeNs;
112
112
  cp;
113
+ servicePrincipals;
113
114
  /** No control plane bound — `this.cp` is the throwing null object (Phase 3, CP-less). */
114
115
  cpLess;
115
116
  /** Project + evaluate permissions scope-locally (scope-local-permissions.md). */
@@ -146,6 +147,8 @@ export class CloudflareScopeHost {
146
147
  blobStores;
147
148
  /** Worker-side attachment-bucket resolver (#473); undefined ⇒ attachments() refuses. */
148
149
  attachmentBuckets;
150
+ /** The parsers attachment text is extracted with (K-43); the host's own, never imported here. */
151
+ attachmentExtractors;
149
152
  executors = new Map();
150
153
  /**
151
154
  * `<moduleId>/<job>` → the pass body and its default step policy (#1577). Host
@@ -202,10 +205,13 @@ export class CloudflareScopeHost {
202
205
  * request after that returned 1101 in production.
203
206
  */
204
207
  constructor(options) {
208
+ this.servicePrincipals = options.servicePrincipals;
205
209
  this.secretBox = options.secretBox ?? unconfiguredSecretBox;
206
210
  this.tenantStores = options.tenantStores;
207
211
  this.blobStores = options.blobStores;
208
212
  this.attachmentBuckets = options.attachmentBuckets;
213
+ assertAttachmentExtractors(options.attachmentExtractors ?? []);
214
+ this.attachmentExtractors = options.attachmentExtractors ?? [];
209
215
  this.fetchImpl = options.fetch ?? globalFetch;
210
216
  this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
211
217
  this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
@@ -416,6 +422,8 @@ export class CloudflareScopeHost {
416
422
  return this.drainExecutors(tenantId, scopeId, null);
417
423
  }
418
424
  registerJob(moduleId, name, handler, retry) {
425
+ // #1575: the kernel's own jobs are dispatched before this registry is read.
426
+ assertJobRegistrable(moduleId, name);
419
427
  const key = `${moduleId}/${name}`;
420
428
  if (this.jobs.has(key))
421
429
  throw new Error(`job '${key}' is already registered`);
@@ -446,6 +454,23 @@ export class CloudflareScopeHost {
446
454
  recordStep: (runId, name, result, attempts, lastError, at) => stub.jobStepRecord(runId, name, result, attempts, lastError, at),
447
455
  };
448
456
  }
457
+ /**
458
+ * The extraction job's adapter half (#1575). The record and the outcome are RPCs to the
459
+ * scope DO; the bytes are read here, where the per-tenant bucket is bound — the DO never
460
+ * holds them. No permission gate on any of it: extraction is the kernel's own derivation,
461
+ * and the person-facing gate is at search.
462
+ */
463
+ attachmentTextHandler(tenantId, scopeId) {
464
+ const stub = this.scopeStub(scopeId);
465
+ return attachmentTextJob({
466
+ record: (attachmentId) => stub.attachmentTextSource(attachmentId),
467
+ bytes: async (record) => (await (await this.resolveAttachmentStore(tenantId)).get(attachmentBlobKey(scopeId, record.id)))?.body ??
468
+ null,
469
+ write: (attachmentId, outcome) => stub.attachmentTextRecord(attachmentId, outcome),
470
+ },
471
+ // K-43: the host's parsers, handed in — this adapter imports none.
472
+ this.attachmentExtractors);
473
+ }
449
474
  async startJobRun(tenantId, scopeId, input) {
450
475
  await this.validateScopeAccess(tenantId, scopeId);
451
476
  await this.migrateAndRecord(scopeId);
@@ -456,9 +481,12 @@ export class CloudflareScopeHost {
456
481
  // advance, and an archived one's never move again.
457
482
  await this.validateScopeAccess(tenantId, scopeId);
458
483
  await this.migrateAndRecord(scopeId);
484
+ // #1575: the kernel's extraction job is this host's own, bound to this scope — no
485
+ // deployment registers it, and none can shadow it.
486
+ const attachmentText = { handler: this.attachmentTextHandler(tenantId, scopeId) };
459
487
  return runDueJobRuns({
460
488
  store: this.jobStore(scopeId),
461
- handlerFor: (run) => this.jobs.get(`${run.module_id}/${run.job}`),
489
+ handlerFor: (run) => isAttachmentTextRun(run) ? attachmentText : this.jobs.get(`${run.module_id}/${run.job}`),
462
490
  now: () => new Date().toISOString(),
463
491
  openScope: (run) => this.getSystemScope(run.module_id, tenantId, scopeId),
464
492
  maxPasses: options?.maxPasses,
@@ -1086,6 +1114,8 @@ export class CloudflareScopeHost {
1086
1114
  }),
1087
1115
  list: notDelegated('list'),
1088
1116
  remove: notDelegated('remove'),
1117
+ // #1575: undelegated for `list`'s reason — a connector picks a document by id.
1118
+ search: notDelegated('search'),
1089
1119
  };
1090
1120
  }
1091
1121
  await this.migrateAndRecord(scopeId);
@@ -1134,6 +1164,12 @@ export class CloudflareScopeHost {
1134
1164
  remove: async (attachmentId) => unwrapCapabilityReply(await stub.capabilityAttachmentRefuseWrite(hash, tenantId, scopeId, 'attachments.remove', {
1135
1165
  attachmentId,
1136
1166
  })),
1167
+ search: async (term, options) => {
1168
+ // Judged here too, so a short term reaches the caller as `SearchTermTooShort`
1169
+ // rather than as a message that crossed the RPC boundary. The DO judges again.
1170
+ searchMatchExpression(term, 'prefix');
1171
+ return unwrapCapabilityReply(await stub.capabilityAttachmentSearch(term, searchLimit(options?.limit), hash, tenantId, scopeId));
1172
+ },
1137
1173
  };
1138
1174
  }
1139
1175
  const connectionId = 'connectionId' in subject ? subject.connectionId : undefined;
@@ -1154,7 +1190,7 @@ export class CloudflareScopeHost {
1154
1190
  filename: input.filename,
1155
1191
  contentType: input.contentType,
1156
1192
  size: input.body.byteLength,
1157
- sha256: await sha256Hex(input.body),
1193
+ sha256: await attachmentSha256(input.body),
1158
1194
  visibility: input.visibility,
1159
1195
  createdBy,
1160
1196
  createdAt: new Date().toISOString(),
@@ -1181,6 +1217,14 @@ export class CloudflareScopeHost {
1181
1217
  await store.delete(attachmentBlobKey(scopeId, removed.id)).catch(() => { });
1182
1218
  return removed;
1183
1219
  },
1220
+ search: async (term, options) => {
1221
+ if (forEvent) {
1222
+ // The delivery-admitted surface reads the ONE attachment its delivery names.
1223
+ throw new Error('attachments.search is not available on a delivery-scoped surface');
1224
+ }
1225
+ searchMatchExpression(term, 'prefix');
1226
+ return unwrapCapabilityReply(await stub.attachmentSearch(term, searchLimit(options?.limit), asPrincipalId, tenantId, scopeId, connectionId));
1227
+ },
1184
1228
  };
1185
1229
  }
1186
1230
  /**
@@ -1194,7 +1238,7 @@ export class CloudflareScopeHost {
1194
1238
  throw new Error(`attachment ${record.id}: bytes missing from the blob store — the metadata row ` +
1195
1239
  `survived something the object did not (rewind/reap); see the #473 integrity notes`);
1196
1240
  }
1197
- if ((await sha256Hex(obj.body)) !== record.sha256) {
1241
+ if ((await attachmentSha256(obj.body)) !== record.sha256) {
1198
1242
  throw new Error(`attachment ${record.id}: bytes do not match the recorded sha256`);
1199
1243
  }
1200
1244
  return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
@@ -2038,7 +2082,7 @@ export class CloudflareScopeHost {
2038
2082
  // lets a LISTED vertical's builder still preview their own new code (marketplace-publish.md
2039
2083
  // §2; issue #509 ask (d)). Every other scope kind keeps the refusal.
2040
2084
  if (v.admission !== 'admitted' && scope.kind !== 'preview') {
2041
- throw new Error(`version ${versionId} is ${v.admission}, not admitted — it cannot be bound to a scope`);
2085
+ throw substratError('conflict', `version ${versionId} is ${v.admission}, not admitted — it cannot be bound to a scope`);
2042
2086
  }
2043
2087
  return { v, scope };
2044
2088
  };
@@ -2829,7 +2873,7 @@ export class CloudflareScopeHost {
2829
2873
  return;
2830
2874
  }
2831
2875
  if (existing.owner_tenant !== parsed.ownerTenant) {
2832
- throw new Error(`vertical '${parsed.slug}' is owned by ${existing.owner_tenant ?? 'the platform'}, not ${parsed.ownerTenant ?? 'the platform'}`);
2876
+ throw substratError('conflict', `vertical '${parsed.slug}' is owned by ${existing.owner_tenant ?? 'the platform'}, not ${parsed.ownerTenant ?? 'the platform'}`);
2833
2877
  }
2834
2878
  throw new Error(`vertical '${parsed.slug}' is already registered as ${existing.source}`);
2835
2879
  }
@@ -2896,7 +2940,7 @@ export class CloudflareScopeHost {
2896
2940
  const prod = await this.cp.readChannel(slug, 'prod');
2897
2941
  const prodVersion = prod ? await this.cp.readVersion(prod.version_id) : undefined;
2898
2942
  if (prodVersion?.admission_note === AUTO_ADMISSION_NOTE) {
2899
- throw new Error(`vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
2943
+ throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
2900
2944
  `a staff admit must vouch for it before listing`);
2901
2945
  }
2902
2946
  }
@@ -3003,7 +3047,7 @@ export class CloudflareScopeHost {
3003
3047
  throw new Error(`version ${versionId} belongs to '${incoming.vertical_slug}'`);
3004
3048
  }
3005
3049
  if (incoming.admission !== 'admitted') {
3006
- throw new Error(`version ${versionId} is ${incoming.admission}, not admitted — it cannot be promoted`);
3050
+ throw substratError('conflict', `version ${versionId} is ${incoming.admission}, not admitted — it cannot be promoted`);
3007
3051
  }
3008
3052
  const current = await this.cp.readChannel(verticalSlug, channel);
3009
3053
  const outgoing = current ? await this.cp.readVersion(current.version_id) : undefined;
@@ -3114,6 +3158,9 @@ export class CloudflareScopeHost {
3114
3158
  },
3115
3159
  bindScopeVersion: async (actor, tenantId, scopeId, versionId, opts) => {
3116
3160
  const { v, scope } = await bindTarget(tenantId, scopeId, versionId);
3161
+ if (opts?.expectedVersionId !== undefined && scope.vertical_version_id !== opts.expectedVersionId) {
3162
+ throw substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
3163
+ }
3117
3164
  const ack = bindAcknowledgement.parse(opts?.acknowledge ?? {});
3118
3165
  // #1756: an export an app in this tenant imports, dropped or re-versioned by what this
3119
3166
  // scope would run. Before the snapshot, so a refused bind leaves nothing behind.
@@ -3130,9 +3177,10 @@ export class CloudflareScopeHost {
3130
3177
  await this.snapshotScope(actor, tenantId, scopeId);
3131
3178
  }
3132
3179
  }
3133
- await this.cp.bindScopeVersion(scopeId, versionId, v.vertical_slug);
3180
+ await this.cp.bindScopeVersion(scopeId, versionId, v.vertical_slug, opts?.expectedVersionId);
3134
3181
  await this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, null, {
3135
3182
  versionId, vertical: v.vertical_slug, version: v.version,
3183
+ ...(opts?.expectedVersionId !== undefined ? { expectedVersionId: opts.expectedVersionId } : {}),
3136
3184
  ...(ack.exportBreak ? { acknowledged: ack } : {}),
3137
3185
  });
3138
3186
  },
@@ -3435,7 +3483,7 @@ export class CloudflareScopeHost {
3435
3483
  moveImportCursor: async (actor, tenantId, scopeId, raw) => this.moveImportCursorAt(actor, tenantId, scopeId, raw),
3436
3484
  readUndrainedEvents: async (actor, tenantId, scopeId, limit) => {
3437
3485
  const record = await this.scopeRecordForRead(tenantId, scopeId);
3438
- const bounded = Math.min(Math.max(limit ?? 200, 1), 1000);
3486
+ const bounded = Math.min(assertRowLimit('limit', limit ?? 200), 1000);
3439
3487
  // #1334: on the shared control plane the scope's outbox is in its vertical's
3440
3488
  // deployment, and this host's own namespace is the module-less placeholder —
3441
3489
  // reading it would construct an empty DO and answer "nothing to ship". The
@@ -3596,6 +3644,9 @@ export class CloudflareScopeHost {
3596
3644
  },
3597
3645
  readScopeTable: async (actor, tenantId, scopeId, input) => {
3598
3646
  await this.scopeRecordForRead(tenantId, scopeId);
3647
+ // Checked here as well as in the ScopeDO, so the refusal keeps its code across the hop.
3648
+ assertRowLimit('limit', input.limit);
3649
+ assertRowOffset('offset', input.offset);
3599
3650
  const page = await this.scopeStub(scopeId).introspectTable(input.table, input.limit, input.offset);
3600
3651
  await this.recordAccess(actor, 'readScopeTable', { tenantId, scopeId }, { table: input.table, limit: page.limit, offset: page.offset }, page.rows.length);
3601
3652
  return page;
@@ -3777,7 +3828,20 @@ export class CloudflareScopeHost {
3777
3828
  `_substrat_job_runs or _substrat_job_steps — erasing now would destroy the subject ` +
3778
3829
  `key while leaving their data in the job-run tables. Redeploy the vertical and re-run.`);
3779
3830
  }
3780
- const { events: eventsRedacted, intents: intentsRedacted, jobRuns: jobRunsRedacted } = redacted;
3831
+ // A DO from before the free-text half (#1632): it never looked at the idempotency
3832
+ // ledger or a queued sweep record, and cannot name the intents the directory's drain
3833
+ // failures quote. Refused before the key, for the reason above.
3834
+ if (!('idempotencyResults' in redacted) ||
3835
+ typeof redacted.idempotencyResults !== 'number' ||
3836
+ !Array.isArray(redacted.intentIds)) {
3837
+ throw substratError('unavailable', `scope ${scopeId} runs a ScopeDO whose redaction does not reach _substrat_idempotency ` +
3838
+ `or a queued sweep-runs intent — erasing now would destroy the subject key while ` +
3839
+ `leaving their data in those rows. Redeploy the vertical and re-run.`);
3840
+ }
3841
+ const { events: eventsRedacted, intents: intentsRedacted, jobRuns: jobRunsRedacted, idempotencyResults, intentIds, } = redacted;
3842
+ // The directory's failure text (#1632) — a drain failure quoting one of those intents,
3843
+ // an issue's exemplar, a sweep record's error. Still before the key.
3844
+ await this.cp.redactSubjectText({ tenantId, scopeId, subjectId, intentIds });
3781
3845
  const at = new Date().toISOString();
3782
3846
  const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
3783
3847
  const receipt = subjectShredReceipt.parse({
@@ -3794,7 +3858,7 @@ export class CloudflareScopeHost {
3794
3858
  await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
3795
3859
  // BOTH counts: the access log's number is "how much evidence this destroyed", and
3796
3860
  // an intent payload is a whole event's worth of it.
3797
- await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted);
3861
+ await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted + idempotencyResults);
3798
3862
  return receipt;
3799
3863
  },
3800
3864
  // -- impersonation (K-42, #868) ----------------------------------------
@@ -4189,7 +4253,8 @@ export class CloudflareScopeHost {
4189
4253
  return drained;
4190
4254
  },
4191
4255
  pruneAccessLog: async (actor, limit) => {
4192
- const pruned = await this.cp.pruneAccessLog(limit);
4256
+ // Checked here as well as in the directory, so the refusal keeps its code across the hop.
4257
+ const pruned = await this.cp.pruneAccessLog(assertRowLimit('limit', limit));
4193
4258
  if (pruned > 0) {
4194
4259
  // The payload is the APPLIED state, so it belongs in `after` (contracts'
4195
4260
  // adminLogEntry: before = prior state, after = the applied payload) — the
@@ -4302,6 +4367,8 @@ export class CloudflareScopeHost {
4302
4367
  await this.recordAccess(actor, 'listSweepRuns', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
4303
4368
  return rows.map((r) => sweepRunEntry.parse(r));
4304
4369
  },
4370
+ // Checked here as well as in the directory, so the refusal keeps its code across the hop.
4371
+ pruneTelemetry: async (_actor, limit) => this.cp.pruneTelemetry(assertRowLimit('limit', limit)),
4305
4372
  listIssues: async (actor, filter) => {
4306
4373
  const rows = await this.cp.listIssues({
4307
4374
  status: filter?.status,
@@ -5067,6 +5134,7 @@ export class CloudflareScopeHost {
5067
5134
  * from its own storage. No tenant-level tuples, no control plane.
5068
5135
  */
5069
5136
  async provisionScopeLocal(input) {
5137
+ const services = await this.servicePrincipals?.(input.tenantId, input.scopeId);
5070
5138
  const stub = this.scopeStub(input.scopeId);
5071
5139
  await this.migrateAndRecord(input.scopeId); // create the module tables (setMigrationState no-ops on a null CP)
5072
5140
  const switchedOff = await stub.applyProjection(input.tenantId, input.roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source })), [], // no tenant-level tuples — a CP-less vertical grants at scope level only
@@ -5163,7 +5231,7 @@ export class CloudflareScopeHost {
5163
5231
  // scope this call provisions — the one the seat's tuples name.
5164
5232
  input.switchedOff
5165
5233
  ? { scopeId: input.scopeId, moduleIds: input.switchedOff, at: new Date().toISOString() }
5166
- : undefined);
5234
+ : undefined, services?.map((id) => `principal:${id}`));
5167
5235
  return input.switchedOff ? { switchedOff } : {};
5168
5236
  }
5169
5237
  /**
@@ -5527,11 +5595,6 @@ export class CloudflareScopeHost {
5527
5595
  return importsOfManifestJson(v.manifest_json);
5528
5596
  }
5529
5597
  }
5530
- /** SHA-256 hex of an attachment's bytes — what `AttachmentRecord.sha256` holds. */
5531
- async function sha256Hex(body) {
5532
- const digest = await crypto.subtle.digest('SHA-256', body);
5533
- return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
5534
- }
5535
5598
  /** A ledger row -> the wire entry, the attribution re-nested (#1054). */
5536
5599
  function modelUsageEntryOf(r) {
5537
5600
  return modelUsageEntry.parse({