@substrat-run/adapter-cloudflare 0.138.0 → 0.139.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,10 +1,10 @@
1
1
  import { env as ambientEnv } from 'cloudflare:workers';
2
2
  import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
3
3
  import { isSystemDoorMoved, SYSTEM_DOOR_MOVED, SYSTEM_DOOR_REGATES } from './system-door.js';
4
- import { delegatedReadParams, operationSeriesCount, 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, mintedCapability, 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, copyMarkAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
4
+ import { delegatedReadParams, operationSeriesCount, fromWireFailure, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, 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, mintedCapability, 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, memberChangeAudit, copyMarkAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
5
5
  import { normalizeHostname, toRouteTarget } from './route-resolver.js';
6
- import { attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, assertLeaseMs, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, assertJobRegistrable, isAttachmentTextRun, searchLimit, searchMatchExpression, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, } from '@substrat-run/kernel';
7
- import { attributedHost } from '@substrat-run/kernel';
6
+ import { attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, assertLeaseMs, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, attachmentTextBackfillJob, searchLimit, searchMatchExpression, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, memberAddedAudit, } from '@substrat-run/kernel';
7
+ import { attributedView } from '@substrat-run/kernel';
8
8
  import { isOrangeToOrange, isUpgradeRequest, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, encodeLiveWithin, liveWithinOf, } from './live-reads.js';
9
9
  import { tenantStoreDatabaseName } from './d1.js';
10
10
  import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
@@ -275,6 +275,7 @@ export class CloudflareScopeHost {
275
275
  attachmentBuckets;
276
276
  /** The parsers attachment text is extracted with (K-43); the host's own, never imported here. */
277
277
  attachmentExtractors;
278
+ attachmentTextBounds;
278
279
  executors = new Map();
279
280
  /**
280
281
  * `<moduleId>/<job>` → the pass body and its default step policy (#1577). Host
@@ -285,10 +286,10 @@ export class CloudflareScopeHost {
285
286
  */
286
287
  jobs = new Map();
287
288
  /**
288
- * The event currently being effected, stamped onto admin rows the executor writes.
289
- * Ambient rather than threaded through every HostAdmin signature: set and cleared
290
- * around one await, with executors running sequentially, so there is no window
291
- * where it belongs to a different event.
289
+ * The event an executor is effecting, stamped onto the admin rows it writes (K-22). Like
290
+ * `onBehalfOf`, only ever set on a VIEW (`causedByView`, or `attributed` with a
291
+ * `causedBy`), never on the host: a field set around the handler's `await` stamped every
292
+ * admin call the host served meanwhile — a staff call included — with that event (#2055).
292
293
  */
293
294
  causedBy = null;
294
295
  /**
@@ -342,6 +343,7 @@ export class CloudflareScopeHost {
342
343
  this.attachmentBuckets = options.attachmentBuckets ?? ambientAttachmentBucket;
343
344
  assertAttachmentExtractors(options.attachmentExtractors ?? []);
344
345
  this.attachmentExtractors = options.attachmentExtractors ?? [];
346
+ this.attachmentTextBounds = resolveAttachmentTextBounds(options.attachmentTextBounds);
345
347
  this.fetchImpl = options.fetch ?? globalFetch;
346
348
  this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
347
349
  this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
@@ -571,7 +573,8 @@ export class CloudflareScopeHost {
571
573
  lines.write({ ...unitOf(event.id, attempt), startedAt, outcome: 'inert' });
572
574
  continue;
573
575
  }
574
- this.causedBy = event.id;
576
+ // #2055: the handler writes through a view bound to its event, never through the host.
577
+ const caused = this.causedByView(event.id);
575
578
  try {
576
579
  if (executor.kind === 'connector' && this.cpLess) {
577
580
  // #574 phase 3: this host cannot run a connector — no connection
@@ -589,14 +592,14 @@ export class CloudflareScopeHost {
589
592
  lines.write({ ...unitOf(event.id, 1), startedAt, outcome: 'routed' });
590
593
  }
591
594
  else if (executor.kind === 'connector') {
592
- await executor.handler(await this.connectorContext(tenantId, scopeId, executor.timeoutMs, event.id), event);
595
+ await executor.handler(await caused.connectorContext(tenantId, scopeId, executor.timeoutMs, event.id), event);
593
596
  const attempt = await stub.recordExecutorAttempt(event.id, deliveryId, null, null, invocationId);
594
597
  report.delivered += 1;
595
598
  outcomeOf(event, 'delivered');
596
599
  lines.write({ ...unitOf(event.id, attempt), startedAt, outcome: 'delivered' });
597
600
  }
598
601
  else {
599
- const result = await executor.handler(this.admin, event, scope);
602
+ const result = await executor.handler(caused.admin, event, scope);
600
603
  // #1184: a refusal is the handler's own terminal decision — journaled with its
601
604
  // reason, no next attempt, listed by `executorDeadLetters` beside an exhausted one.
602
605
  const refused = isDeliveryRefusal(result) ? result : null;
@@ -634,9 +637,6 @@ export class CloudflareScopeHost {
634
637
  error: err,
635
638
  });
636
639
  }
637
- finally {
638
- this.causedBy = null;
639
- }
640
640
  }
641
641
  }
642
642
  }
@@ -701,7 +701,7 @@ export class CloudflareScopeHost {
701
701
  write: (attachmentId, outcome) => stub.attachmentTextRecord(attachmentId, outcome),
702
702
  },
703
703
  // K-43: the host's parsers, handed in — this adapter imports none.
704
- this.attachmentExtractors);
704
+ this.attachmentExtractors, this.attachmentTextBounds);
705
705
  }
706
706
  async startJobRun(tenantId, scopeId, input) {
707
707
  await this.assertLive(tenantId, scopeId);
@@ -713,12 +713,18 @@ export class CloudflareScopeHost {
713
713
  // advance, and an archived one's never move again.
714
714
  await this.assertLive(tenantId, scopeId);
715
715
  await this.migrateAndRecord(scopeId);
716
- // #1575: the kernel's extraction job is this host's own, bound to this scope — no
717
- // deployment registers it, and none can shadow it.
718
- const attachmentText = { handler: this.attachmentTextHandler(tenantId, scopeId) };
716
+ // #1575: the kernel's extraction jobs are this host's own, bound to this scope — no
717
+ // deployment registers them, and none can shadow them. The backfill starts here, on
718
+ // the drive, so a scope's first request never pays for attachments that predate it.
719
+ const stub = this.scopeStub(scopeId);
720
+ await stub.attachmentTextBackfillStart();
721
+ const kernelJobs = {
722
+ [ATTACHMENT_TEXT_JOB]: this.attachmentTextHandler(tenantId, scopeId),
723
+ [ATTACHMENT_TEXT_BACKFILL_JOB]: attachmentTextBackfillJob({ queueBatch: (after) => stub.attachmentTextBackfillBatch(after) }),
724
+ };
719
725
  return runDueJobRuns({
720
726
  store: this.jobStore(scopeId),
721
- handlerFor: (run) => isAttachmentTextRun(run) ? attachmentText : this.jobs.get(`${run.module_id}/${run.job}`),
727
+ handlerFor: (run) => kernelJobFor(run, kernelJobs) ?? this.jobs.get(`${run.module_id}/${run.job}`),
722
728
  now: () => new Date().toISOString(),
723
729
  // #1834: the door is opened FOR this pass, so its "not now" is tied to this pass alone.
724
730
  openScope: async (run, pass) => this.buildStub(tenantId, scopeId, undefined, undefined, await this.openSystemDoor(run.module_id, tenantId, scopeId, undefined, pass)),
@@ -740,13 +746,9 @@ export class CloudflareScopeHost {
740
746
  // ran it. On a CP-less host `connectorContext` throws from the null control plane:
741
747
  // fail closed, exactly the hole routing exists to avoid.
742
748
  await this.assertLive(tenantId, scopeId);
743
- this.causedBy = event.id;
744
- try {
745
- await handler(await this.connectorContext(tenantId, scopeId, options?.timeoutMs ?? 30_000, event.id), event);
746
- }
747
- finally {
748
- this.causedBy = null;
749
- }
749
+ // Built on a view bound to the event, so its admin rows carry it and nothing else the
750
+ // host serves does (#2055).
751
+ await handler(await this.causedByView(event.id).connectorContext(tenantId, scopeId, options?.timeoutMs ?? 30_000, event.id), event);
750
752
  }
751
753
  async executorDeadLetters(tenantId, scopeId) {
752
754
  await this.validateScopeAccess(tenantId, scopeId);
@@ -2167,6 +2169,32 @@ export class CloudflareScopeHost {
2167
2169
  throw unknownRoleError(roleKey);
2168
2170
  return coverage.parse(bound);
2169
2171
  }
2172
+ async listScopeRoleHolders(tenantId, scopeId, principal) {
2173
+ await this.scopeRoleGate(tenantId, scopeId, 'listScopeRoleHolders');
2174
+ return this.scopeStub(scopeId).scopeRoleHoldersFor(scopeId, principal === undefined ? undefined : principalId.parse(principal));
2175
+ }
2176
+ async changeScopeRoleBounded(tenantId, scopeId, caller, principal, from, to) {
2177
+ await this.scopeRoleGate(tenantId, scopeId, 'changeScopeRoleBounded');
2178
+ const target = principalId.parse(principal);
2179
+ const bound = await this.scopeStub(scopeId).changeScopeRoleBoundedFor(tenantId, scopeId, principalId.parse(caller), target, from, to);
2180
+ if (bound === 'not-held')
2181
+ throw substratError('conflict', `${target} does not hold '${from}' at this scope`);
2182
+ if (bound === 'unknown-to')
2183
+ throw unknownRoleError(to);
2184
+ return coverage.parse(bound);
2185
+ }
2186
+ async revokeScopeRolesBounded(tenantId, scopeId, caller, principal) {
2187
+ await this.scopeRoleGate(tenantId, scopeId, 'revokeScopeRolesBounded');
2188
+ const result = await this.scopeStub(scopeId).revokeScopeRolesBoundedFor(tenantId, scopeId, principalId.parse(caller), principalId.parse(principal));
2189
+ return { coverage: coverage.parse(result.coverage), revoked: result.revoked };
2190
+ }
2191
+ /** The (tenant, scope) gate the scope-role verbs share — `assignScopeRoleBounded`'s two checks. */
2192
+ async scopeRoleGate(tenantId, scopeId, verb) {
2193
+ if (this.cpLess && !(await this.scopeStub(scopeId).servesTenant(tenantId))) {
2194
+ throw unknownScopeForTenant(tenantId, scopeId);
2195
+ }
2196
+ await this.peerScopeGate(tenantId, scopeId, verb);
2197
+ }
2170
2198
  /**
2171
2199
  * Where a peer verb (#1706) may reach a scope: its own ScopeDO, after the ordinary pair and
2172
2200
  * lifecycle gate. Refused, loudly, on the SHARED control plane for a scope bound to a
@@ -2651,8 +2679,12 @@ export class CloudflareScopeHost {
2651
2679
  }
2652
2680
  // -- admin surface --------------------------------------------------------
2653
2681
  /** #977: this host, with every admin row it writes naming who the actor acted for. */
2654
- attributed(onBehalfOf) {
2655
- return attributedHost(this, onBehalfOf, this.buildAdmin);
2682
+ attributed(onBehalfOf, options) {
2683
+ return attributedView(this, { onBehalfOf, causedBy: options?.causedBy }, this.buildAdmin);
2684
+ }
2685
+ /** #2055: this host, with every admin row it writes naming `eventId` as its cause. */
2686
+ causedByView(eventId) {
2687
+ return attributedView(this, { causedBy: eventId }, this.buildAdmin);
2656
2688
  }
2657
2689
  buildAdmin() {
2658
2690
  // The directory row → the `scope` contract. Parsed, not cast: the columns are
@@ -4156,21 +4188,25 @@ export class CloudflareScopeHost {
4156
4188
  await this.recordAccess(actor, 'getOrg', { tenantId }, { orgId }, r ? 1 : 0);
4157
4189
  return r ? mapOrg(r) : undefined;
4158
4190
  },
4159
- addMember: async (actor, tenantId, principal, orgId) => {
4191
+ addMember: async (actor, tenantId, principal, orgId, opts) => {
4160
4192
  await requireOrg(tenantId, orgId);
4161
- await this.cp.writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${orgId}`, null);
4162
- await this.recordAdmin(actor, 'addMember', { tenantId }, null, { principal, orgId });
4193
+ const expiresAt = opts?.expiresAt ?? null;
4194
+ await this.cp.writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${orgId}`, expiresAt);
4195
+ await this.recordAdmin(actor, 'addMember', { tenantId }, null, memberAddedAudit(principal, orgId, expiresAt));
4163
4196
  await this.fanOut(tenantId); // membership is a tenant-level tuple
4164
4197
  },
4165
4198
  applyMembership: async (actor, change) => {
4166
4199
  // The row is minted here, where attribution and `causedBy` live, and written by the
4167
4200
  // ControlPlaneDO in the same synchronous method as the fence, the bound and the tuple
4168
4201
  // (#1184): one DO unit, so nothing lands between the check and the write.
4169
- const { tenantId, principal, roleKey, op } = change;
4202
+ const { tenantId, principal, roleKey, op, orgId } = change;
4170
4203
  const assignment = { principalId: principal, roleKey, node: { tenantId, scopeId: null } };
4171
4204
  const result = await this.cp.applyMembership(change, op === 'add'
4172
4205
  ? this.adminEntry(actor, 'assignRole', { tenantId, scopeId: null }, null, assignment)
4173
- : this.adminEntry(actor, 'unassignRole', { tenantId, scopeId: null }, assignment, null));
4206
+ : this.adminEntry(actor, 'unassignRole', { tenantId, scopeId: null }, assignment, null),
4207
+ // #2047: the org's own row, when the change joins or leaves one. The unit fills in its
4208
+ // `before`/`after` and writes it only for what it does — a join's carries the expiry.
4209
+ orgId ? this.adminEntry(actor, op === 'add' ? 'addMember' : 'removeMember', { tenantId }, null, null) : undefined);
4174
4210
  // The tenant-level tuple, or its tombstone, reaches the projections.
4175
4211
  if (result.applied && (op === 'add' || result.changed))
4176
4212
  await this.fanOut(tenantId);
@@ -4192,6 +4228,7 @@ export class CloudflareScopeHost {
4192
4228
  principal: r.subject.slice('principal:'.length),
4193
4229
  orgId,
4194
4230
  revokedAt: r.revoked_at,
4231
+ expiresAt: r.expires_at,
4195
4232
  }));
4196
4233
  },
4197
4234
  createTenant: async (actor, input) => {
@@ -5085,6 +5122,11 @@ export class CloudflareScopeHost {
5085
5122
  const { tenantId, scopeId, ...after } = ownerTransferAudit.parse(entry);
5086
5123
  await this.recordAdmin(actor, 'transferOwner', { tenantId, scopeId }, null, after);
5087
5124
  },
5125
+ /** #1150: one phase of a dashboard member change, written around the vertical's own. */
5126
+ recordMemberChange: async (actor, entry) => {
5127
+ const { tenantId, scopeId, ...after } = memberChangeAudit.parse(entry);
5128
+ await this.recordAdmin(actor, 'manageScopeMember', { tenantId, scopeId }, null, after);
5129
+ },
5088
5130
  /** #2005: one change to a scope's copy marker, written around the vertical's own change. */
5089
5131
  recordCopyMark: async (actor, entry) => {
5090
5132
  const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
@@ -5241,6 +5283,8 @@ export class CloudflareScopeHost {
5241
5283
  },
5242
5284
  // Checked here as well as in the directory, so the refusal keeps its code across the hop.
5243
5285
  pruneTelemetry: async (_actor, limit) => this.cp.pruneTelemetry(assertRowLimit('limit', limit)),
5286
+ // #1748: the DO writes each stale resolution's audit row in the same unit, from this one.
5287
+ pruneFindings: async (actor, limit) => this.cp.pruneFindings(assertRowLimit('limit', limit), this.adminEntry(actor, 'resolveStaleFinding', { tenantId: null }, null, null)),
5244
5288
  listIssues: async (actor, filter) => {
5245
5289
  const rows = await this.cp.listIssues({
5246
5290
  status: filter?.status,
@@ -5262,6 +5306,33 @@ export class CloudflareScopeHost {
5262
5306
  await this.recordAdmin(actor, 'setIssueStatus', { tenantId: null, vertical: after.lastVertical }, { fingerprint, status: before.status }, { fingerprint, status: after.status });
5263
5307
  return after;
5264
5308
  },
5309
+ listFindings: async (actor, filter) => {
5310
+ const rows = await this.cp.listFindings({ ...filter });
5311
+ await this.recordAccess(actor, 'listFindings', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
5312
+ return rows.map((r) => findingEntry.parse(r));
5313
+ },
5314
+ // #1748: each findings mutation writes its audit row in the DO's own unit, from the row
5315
+ // minted here (actor, attribution) — so a failed audit write rolls the mutation back.
5316
+ setFindingStatus: async (actor, tenantId, id, status) => {
5317
+ const audit = this.adminEntry(actor, 'setFindingStatus', { tenantId }, null, null);
5318
+ const change = await this.cp.setFindingStatus(tenantId, id, status, new Date().toISOString(), audit);
5319
+ return change ? findingEntry.parse(change.after) : undefined;
5320
+ },
5321
+ createFindingRule: async (actor, tenantId, input) => {
5322
+ const audit = this.adminEntry(actor, 'createFindingRule', { tenantId }, null, null);
5323
+ const created = await this.directory('createFindingRule', tenantId, input, actor, new Date().toISOString(), audit);
5324
+ return { rule: findingRuleEntry.parse(created.rule), suppressed: created.suppressed.length };
5325
+ },
5326
+ revokeFindingRule: async (actor, tenantId, ruleId) => {
5327
+ const audit = this.adminEntry(actor, 'revokeFindingRule', { tenantId }, null, null);
5328
+ const change = await this.cp.revokeFindingRule(tenantId, ruleId, new Date().toISOString(), audit);
5329
+ return change ? findingRuleEntry.parse(change.after) : undefined;
5330
+ },
5331
+ listFindingRules: async (actor, tenantId, filter) => {
5332
+ const rows = await this.cp.listFindingRules(tenantId, filter?.active ? new Date().toISOString() : undefined, filter?.limit);
5333
+ await this.recordAccess(actor, 'listFindingRules', { tenantId }, filter, rows.length);
5334
+ return rows.map((r) => findingRuleEntry.parse(r));
5335
+ },
5265
5336
  recordModelUsage: async (input) => {
5266
5337
  const l = input.line;
5267
5338
  return this.cp.recordModelUsage({