@substrat-run/adapter-cloudflare 0.138.0 → 0.140.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/README.md +2 -2
- package/dist/checker.d.ts.map +1 -1
- package/dist/checker.js +4 -0
- package/dist/checker.js.map +1 -1
- package/dist/control-plane-do.d.ts +95 -26
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +290 -35
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +55 -14
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +347 -69
- package/dist/host.js.map +1 -1
- package/dist/live-reads.d.ts +30 -7
- package/dist/live-reads.d.ts.map +1 -1
- package/dist/live-reads.js +43 -9
- package/dist/live-reads.js.map +1 -1
- package/dist/route-resolver.d.ts +1 -1
- package/dist/route-resolver.d.ts.map +1 -1
- package/dist/route-resolver.js +7 -0
- package/dist/route-resolver.js.map +1 -1
- package/dist/scope-do.d.ts +29 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +811 -257
- package/dist/scope-do.js.map +1 -1
- package/dist/sql.d.ts +7 -1
- package/dist/sql.d.ts.map +1 -1
- package/dist/sql.js +29 -3
- package/dist/sql.js.map +1 -1
- package/package.json +12 -10
package/dist/host.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
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, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, copyMarkAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, peerInstanceResolution, peerBinding as peerBindingSchema, 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,
|
|
7
|
-
import {
|
|
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';
|
|
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, recordAuditOutcome, auditWarningOf, 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, assertNoCallerPurge, purgeReportOf, purgeStillDue, registerTrashTargets, memberAddedAudit, shapeTopUpBatch, } from '@substrat-run/kernel';
|
|
7
|
+
import { attributedView, isModuleErasureCounts, moduleErasurePlan, moduleRowsErased } from '@substrat-run/kernel';
|
|
8
|
+
import { isOrangeToOrange, isUpgradeRequest, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, LIVE_EXPIRES_HEADER, liveInstant, encodeLiveWithin, liveWithinOf, } from './live-reads.js';
|
|
9
9
|
import { tenantStoreDatabaseName } from './d1.js';
|
|
10
10
|
import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
|
|
11
11
|
import { unwrapReply } from './do-reply.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
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
717
|
-
// deployment registers
|
|
718
|
-
|
|
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) =>
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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);
|
|
@@ -775,6 +777,12 @@ export class CloudflareScopeHost {
|
|
|
775
777
|
async settlePlatformRequest(tenantId, scopeId, id, outcome) {
|
|
776
778
|
await this.validateScopeAccess(tenantId, scopeId);
|
|
777
779
|
await this.migrateAndRecord(scopeId);
|
|
780
|
+
// #2102: an outcome event travels by the verb that writes it; every other settle keeps the
|
|
781
|
+
// positional one, so it still reaches a DO class that predates the event.
|
|
782
|
+
if (outcome.event) {
|
|
783
|
+
await this.scopeStub(scopeId).settlePlatformRequestWithEvent(tenantId, scopeId, id, outcome);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
778
786
|
await this.scopeStub(scopeId).settlePlatformRequest(id, outcome.status, outcome.result === undefined ? null : JSON.stringify(outcome.result), outcome.lastError ?? null, outcome.failure == null ? null : JSON.stringify(outcome.failure));
|
|
779
787
|
}
|
|
780
788
|
migrationFrontier() {
|
|
@@ -1217,6 +1225,9 @@ export class CloudflareScopeHost {
|
|
|
1217
1225
|
if (this.moduleIds.has(manifest.id)) {
|
|
1218
1226
|
throw new Error(`module already registered: ${manifest.id}`);
|
|
1219
1227
|
}
|
|
1228
|
+
// #2068: the same refusal the ScopeDO applies at code time, here at registration, so a
|
|
1229
|
+
// misdeclared erasure fails where it is registered on both hosts.
|
|
1230
|
+
moduleErasurePlan(registration);
|
|
1220
1231
|
const migrations = registration.migrations ?? [];
|
|
1221
1232
|
const seen = new Set();
|
|
1222
1233
|
for (const m of migrations) {
|
|
@@ -1239,6 +1250,9 @@ export class CloudflareScopeHost {
|
|
|
1239
1250
|
}
|
|
1240
1251
|
this.predicateNames.set(name, manifest.id);
|
|
1241
1252
|
}
|
|
1253
|
+
// #119: the trash rules, refused here too so a bad module fails at construction, not at its
|
|
1254
|
+
// scope's first wake. The scope holds the targets; the coordinator only needs the verdict.
|
|
1255
|
+
registerTrashTargets(manifest.id, new Set(Object.keys(registration.operations ?? {})), registration.operationInputs, manifest.entityStates, manifest.schedules);
|
|
1242
1256
|
// #1705: after the checks above, so a refused module leaves nothing registered here.
|
|
1243
1257
|
this.crossVertical.register(manifest, registration.imports);
|
|
1244
1258
|
this.moduleIds.add(manifest.id);
|
|
@@ -2167,6 +2181,32 @@ export class CloudflareScopeHost {
|
|
|
2167
2181
|
throw unknownRoleError(roleKey);
|
|
2168
2182
|
return coverage.parse(bound);
|
|
2169
2183
|
}
|
|
2184
|
+
async listScopeRoleHolders(tenantId, scopeId, principal) {
|
|
2185
|
+
await this.scopeRoleGate(tenantId, scopeId, 'listScopeRoleHolders');
|
|
2186
|
+
return this.scopeStub(scopeId).scopeRoleHoldersFor(scopeId, principal === undefined ? undefined : principalId.parse(principal));
|
|
2187
|
+
}
|
|
2188
|
+
async changeScopeRoleBounded(tenantId, scopeId, caller, principal, from, to) {
|
|
2189
|
+
await this.scopeRoleGate(tenantId, scopeId, 'changeScopeRoleBounded');
|
|
2190
|
+
const target = principalId.parse(principal);
|
|
2191
|
+
const bound = await this.scopeStub(scopeId).changeScopeRoleBoundedFor(tenantId, scopeId, principalId.parse(caller), target, from, to);
|
|
2192
|
+
if (bound === 'not-held')
|
|
2193
|
+
throw substratError('conflict', `${target} does not hold '${from}' at this scope`);
|
|
2194
|
+
if (bound === 'unknown-to')
|
|
2195
|
+
throw unknownRoleError(to);
|
|
2196
|
+
return coverage.parse(bound);
|
|
2197
|
+
}
|
|
2198
|
+
async revokeScopeRolesBounded(tenantId, scopeId, caller, principal) {
|
|
2199
|
+
await this.scopeRoleGate(tenantId, scopeId, 'revokeScopeRolesBounded');
|
|
2200
|
+
const result = await this.scopeStub(scopeId).revokeScopeRolesBoundedFor(tenantId, scopeId, principalId.parse(caller), principalId.parse(principal));
|
|
2201
|
+
return { coverage: coverage.parse(result.coverage), revoked: result.revoked };
|
|
2202
|
+
}
|
|
2203
|
+
/** The (tenant, scope) gate the scope-role verbs share — `assignScopeRoleBounded`'s two checks. */
|
|
2204
|
+
async scopeRoleGate(tenantId, scopeId, verb) {
|
|
2205
|
+
if (this.cpLess && !(await this.scopeStub(scopeId).servesTenant(tenantId))) {
|
|
2206
|
+
throw unknownScopeForTenant(tenantId, scopeId);
|
|
2207
|
+
}
|
|
2208
|
+
await this.peerScopeGate(tenantId, scopeId, verb);
|
|
2209
|
+
}
|
|
2170
2210
|
/**
|
|
2171
2211
|
* Where a peer verb (#1706) may reach a scope: its own ScopeDO, after the ordinary pair and
|
|
2172
2212
|
* lifecycle gate. Refused, loudly, on the SHARED control plane for a scope bound to a
|
|
@@ -2422,22 +2462,41 @@ export class CloudflareScopeHost {
|
|
|
2422
2462
|
const startedAt = Date.now();
|
|
2423
2463
|
let emitted;
|
|
2424
2464
|
let failure;
|
|
2465
|
+
// #119, #2096: more of a purge horizon's lap is due — the schedule stays due, so the next pass continues it.
|
|
2466
|
+
let stillDue = false;
|
|
2425
2467
|
try {
|
|
2426
2468
|
// The gate above already answered for this pass; a fire that meets a restarted scope
|
|
2427
2469
|
// is gated again by the door (#1834).
|
|
2428
2470
|
door ??= await this.openSystemDoor(moduleId, tenantId, scopeId, gate);
|
|
2429
2471
|
const scope = this.buildStub(tenantId, scopeId, undefined, undefined, door);
|
|
2430
|
-
|
|
2472
|
+
if (schedule.purge) {
|
|
2473
|
+
// #119: a purge horizon's schedule runs its operation once per due entity, each its own
|
|
2474
|
+
// call and transaction — and the SCOPE decides which are due and runs them, so purge
|
|
2475
|
+
// authority is never an argument any stub can pass (`runPurgeSweep`).
|
|
2476
|
+
const pass = await door.through((instance) => stub.runPurgeSweep(schedule.operation, tenantId, scopeId, instance));
|
|
2477
|
+
// The purges' events, delivered the way each invoke's own tail delivers them.
|
|
2478
|
+
await this.drainExecutors(tenantId, scopeId, null);
|
|
2479
|
+
stillDue = purgeStillDue(pass);
|
|
2480
|
+
const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
|
|
2481
|
+
report.errors.push(...outcome.errors);
|
|
2482
|
+
if (outcome.failure)
|
|
2483
|
+
throw outcome.failure;
|
|
2484
|
+
}
|
|
2485
|
+
else {
|
|
2486
|
+
await scope.invoke(schedule.operation, schedule.input, { invocationId, onEmitted: (r) => (emitted = r) });
|
|
2487
|
+
}
|
|
2431
2488
|
report.fired += 1;
|
|
2432
2489
|
}
|
|
2433
2490
|
catch (err) {
|
|
2434
2491
|
status = 'failed';
|
|
2435
2492
|
failure = { error: err };
|
|
2436
2493
|
report.failed += 1;
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2494
|
+
if (!schedule.purge) {
|
|
2495
|
+
report.errors.push({
|
|
2496
|
+
operation: schedule.operation,
|
|
2497
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2498
|
+
});
|
|
2499
|
+
}
|
|
2441
2500
|
}
|
|
2442
2501
|
lines.write({
|
|
2443
2502
|
kind: 'schedule',
|
|
@@ -2452,10 +2511,12 @@ export class CloudflareScopeHost {
|
|
|
2452
2511
|
latenessMs: lastRun === null ? null : now - dueAt,
|
|
2453
2512
|
...(emitted ? { emitted } : {}),
|
|
2454
2513
|
});
|
|
2514
|
+
report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
|
|
2515
|
+
if (stillDue)
|
|
2516
|
+
continue;
|
|
2455
2517
|
// #1288: 'schedule', whatever this operation happens to be called — including
|
|
2456
2518
|
// `freshness:<something>`, which is exactly the row the evaluator no longer eats.
|
|
2457
2519
|
await stub.recordScheduleRun(schedule.operation, new Date(now).toISOString(), status, 'schedule', invocationId);
|
|
2458
|
-
report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
|
|
2459
2520
|
}
|
|
2460
2521
|
lines.end();
|
|
2461
2522
|
return report;
|
|
@@ -2520,6 +2581,9 @@ export class CloudflareScopeHost {
|
|
|
2520
2581
|
? 'vertical'
|
|
2521
2582
|
: undefined,
|
|
2522
2583
|
invoke: async (operation, input, invokeOptions) => {
|
|
2584
|
+
// #119: purge authority is never an invoke option — only the scope's own `runPurgeSweep`
|
|
2585
|
+
// purges — and an options object naming a cutoff is refused outright.
|
|
2586
|
+
assertNoCallerPurge(invokeOptions);
|
|
2523
2587
|
// Entitlement gate (§4.3): a module loads for a tenant only if the tenant holds its
|
|
2524
2588
|
// SKU flag. The COORDINATOR gates the console-managed path against the shared CP
|
|
2525
2589
|
// (`cp.tenantHoldsEntitlement`); for a hosted/CP-less scope that call is a trusting
|
|
@@ -2651,8 +2715,12 @@ export class CloudflareScopeHost {
|
|
|
2651
2715
|
}
|
|
2652
2716
|
// -- admin surface --------------------------------------------------------
|
|
2653
2717
|
/** #977: this host, with every admin row it writes naming who the actor acted for. */
|
|
2654
|
-
attributed(onBehalfOf) {
|
|
2655
|
-
return
|
|
2718
|
+
attributed(onBehalfOf, options) {
|
|
2719
|
+
return attributedView(this, { onBehalfOf, causedBy: options?.causedBy }, this.buildAdmin);
|
|
2720
|
+
}
|
|
2721
|
+
/** #2055: this host, with every admin row it writes naming `eventId` as its cause. */
|
|
2722
|
+
causedByView(eventId) {
|
|
2723
|
+
return attributedView(this, { causedBy: eventId }, this.buildAdmin);
|
|
2656
2724
|
}
|
|
2657
2725
|
buildAdmin() {
|
|
2658
2726
|
// The directory row → the `scope` contract. Parsed, not cast: the columns are
|
|
@@ -2918,11 +2986,21 @@ export class CloudflareScopeHost {
|
|
|
2918
2986
|
// admin log are separate, so no order makes the pair atomic; this one fails toward
|
|
2919
2987
|
// "an intent with no recorded outcome" and never toward "a switch that moved with no
|
|
2920
2988
|
// audit row". A retry after a crash re-audits even though it answers `changed: false`.
|
|
2989
|
+
//
|
|
2990
|
+
// #2089: every outcome row goes through the kernel's `recordAuditOutcome`, as the control
|
|
2991
|
+
// plane's audited changes do. One that cannot be written is logged with the operation id,
|
|
2992
|
+
// never swallowed; the call still answers with its own error (or, applied, with success and
|
|
2993
|
+
// `auditWarning`), and the scheduled settle closes the intent as `unknown`.
|
|
2921
2994
|
const operationId = ulid();
|
|
2922
2995
|
const target = { tenantId, scopeId, vertical };
|
|
2923
2996
|
const base = { operationId, ...switchAuditSubject(kind, key, to) };
|
|
2924
2997
|
await this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason });
|
|
2925
2998
|
const errorOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
2999
|
+
const recordOutcome = (row) => recordAuditOutcome(() => this.recordAdmin(actor, action, target, null, { ...base, ...row }), {
|
|
3000
|
+
flow: `${kind}-switch`,
|
|
3001
|
+
operationId,
|
|
3002
|
+
phase: row.phase,
|
|
3003
|
+
}, (message, fields) => console.error(message, fields));
|
|
2926
3004
|
// #2045 (Codex r3): the deployment's fence is attested BEFORE anything is recorded or moved. An
|
|
2927
3005
|
// ON on a deployment built before it is refused here, with nothing written anywhere but the
|
|
2928
3006
|
// audit. An OFF goes on: the kill switch must work on every deployment (see `UnattestedSwitch`).
|
|
@@ -2932,7 +3010,7 @@ export class CloudflareScopeHost {
|
|
|
2932
3010
|
}
|
|
2933
3011
|
}
|
|
2934
3012
|
catch (err) {
|
|
2935
|
-
await
|
|
3013
|
+
await recordOutcome({ phase: 'refused', error: errorOf(err) });
|
|
2936
3014
|
throw err;
|
|
2937
3015
|
}
|
|
2938
3016
|
// The directory's record (#1674; #2029 for a peer), written BEFORE the scope moves, both
|
|
@@ -2950,13 +3028,13 @@ export class CloudflareScopeHost {
|
|
|
2950
3028
|
// Nothing has moved: fail the call here, audited. An ON must not switch a scope on
|
|
2951
3029
|
// whose record still says off (the next reconcile would switch it back off), and an
|
|
2952
3030
|
// OFF must not leave a scope off that the record does not know of (#1823).
|
|
2953
|
-
await
|
|
3031
|
+
await recordOutcome({ phase: 'failed', error: errorOf(err) });
|
|
2954
3032
|
throw err;
|
|
2955
3033
|
}
|
|
2956
3034
|
// #2045: a newer call on this subject has recorded its position already: this one writes
|
|
2957
3035
|
// nothing, here or in the scope, and says so.
|
|
2958
3036
|
if (recordWriteSuperseded(prior, record)) {
|
|
2959
|
-
await
|
|
3037
|
+
await recordOutcome({ phase: 'refused', superseded: true });
|
|
2960
3038
|
throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
|
|
2961
3039
|
}
|
|
2962
3040
|
let outcome;
|
|
@@ -2978,13 +3056,12 @@ export class CloudflareScopeHost {
|
|
|
2978
3056
|
// readback can settle the scope and nothing is put back: the record is this call's position
|
|
2979
3057
|
// under the newest fence, and its write-ahead mark makes the next re-assert move the scope
|
|
2980
3058
|
// to it, under that fence, whichever way it lies now.
|
|
2981
|
-
await
|
|
2982
|
-
...base,
|
|
3059
|
+
await recordOutcome({
|
|
2983
3060
|
phase: err instanceof UnattestedSwitch ? 'refused' : 'failed',
|
|
2984
3061
|
error: errorOf(err),
|
|
2985
3062
|
recordKept: true,
|
|
2986
3063
|
reassertOwed: true,
|
|
2987
|
-
})
|
|
3064
|
+
});
|
|
2988
3065
|
if (err instanceof UnattestedSwitch)
|
|
2989
3066
|
throw substratError('precondition_failed', err.message);
|
|
2990
3067
|
throw err;
|
|
@@ -2993,7 +3070,7 @@ export class CloudflareScopeHost {
|
|
|
2993
3070
|
// record is that newer call's too (its write overwrote this one's, mark included), so nothing
|
|
2994
3071
|
// is undone and no mark is this call's to clear.
|
|
2995
3072
|
if (outcome.superseded) {
|
|
2996
|
-
await
|
|
3073
|
+
await recordOutcome({ phase: 'refused', superseded: true });
|
|
2997
3074
|
throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
|
|
2998
3075
|
}
|
|
2999
3076
|
// A call that held nothing moved nothing, so its record write is undone: left `off`, it
|
|
@@ -3020,8 +3097,7 @@ export class CloudflareScopeHost {
|
|
|
3020
3097
|
else if (!unfenced) {
|
|
3021
3098
|
owedError = await this.cp.clearSwitchOwed(kind, tenantId, scopeId, key, operationId).then(() => null, errorOf);
|
|
3022
3099
|
}
|
|
3023
|
-
|
|
3024
|
-
...base,
|
|
3100
|
+
const unrecorded = await recordOutcome({
|
|
3025
3101
|
phase: outcome.held ? 'applied' : 'refused',
|
|
3026
3102
|
changed: outcome.changed,
|
|
3027
3103
|
permissions: outcome.permissions,
|
|
@@ -3033,30 +3109,32 @@ export class CloudflareScopeHost {
|
|
|
3033
3109
|
throw substratError('not_found', switchNotFoundMessage(kind, scopeId, key, to) +
|
|
3034
3110
|
(recordError ? `; and its directory record could not be put back (${recordError})` : ''));
|
|
3035
3111
|
}
|
|
3036
|
-
return { operationId, outcome };
|
|
3112
|
+
return { operationId, outcome, ...(unrecorded !== null ? { auditWarning: auditWarningOf('the switch', unrecorded) } : {}) };
|
|
3037
3113
|
};
|
|
3038
3114
|
/** #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`. */
|
|
3039
3115
|
const switchSystem = async (actor, raw, to) => {
|
|
3040
3116
|
const input = systemSwitch.parse(raw);
|
|
3041
|
-
const { operationId, outcome } = await switchSubjectAt(actor, 'system', input.node, input.moduleId, input.reason, to);
|
|
3117
|
+
const { operationId, outcome, auditWarning } = await switchSubjectAt(actor, 'system', input.node, input.moduleId, input.reason, to);
|
|
3042
3118
|
return {
|
|
3043
3119
|
operationId,
|
|
3044
3120
|
moduleId: input.moduleId,
|
|
3045
3121
|
schedules: to,
|
|
3046
3122
|
changed: outcome.changed,
|
|
3047
3123
|
permissions: outcome.permissions,
|
|
3124
|
+
...(auditWarning ? { auditWarning } : {}),
|
|
3048
3125
|
};
|
|
3049
3126
|
};
|
|
3050
3127
|
/** #1706: move one PEER's kill switch on one scope — see `HostAdmin.revokeFromPeer`. */
|
|
3051
3128
|
const switchPeerAt = async (actor, raw, to) => {
|
|
3052
3129
|
const input = peerSwitch.parse(raw);
|
|
3053
|
-
const { operationId, outcome } = await switchSubjectAt(actor, 'peer', input.node, input.vertical, input.reason, to);
|
|
3130
|
+
const { operationId, outcome, auditWarning } = await switchSubjectAt(actor, 'peer', input.node, input.vertical, input.reason, to);
|
|
3054
3131
|
return {
|
|
3055
3132
|
operationId,
|
|
3056
3133
|
vertical: input.vertical,
|
|
3057
3134
|
calls: to,
|
|
3058
3135
|
changed: outcome.changed,
|
|
3059
3136
|
permissions: outcome.permissions,
|
|
3137
|
+
...(auditWarning ? { auditWarning } : {}),
|
|
3060
3138
|
};
|
|
3061
3139
|
};
|
|
3062
3140
|
/**
|
|
@@ -3323,6 +3401,8 @@ export class CloudflareScopeHost {
|
|
|
3323
3401
|
return result;
|
|
3324
3402
|
};
|
|
3325
3403
|
return {
|
|
3404
|
+
// #2069: a view of the view this admin was built over — the person added, its event kept.
|
|
3405
|
+
attributed: (onBehalfOf) => this.attributed(onBehalfOf).admin,
|
|
3326
3406
|
// #603: fixed at construction — a worker deployed without SECRET_BOX_KEY can never
|
|
3327
3407
|
// store a credential, and saying so is what lets a transport answer 503 instead of 500.
|
|
3328
3408
|
canStoreSecrets: isSecretBoxConfigured(this.secretBox),
|
|
@@ -3395,6 +3475,17 @@ export class CloudflareScopeHost {
|
|
|
3395
3475
|
if (!grant.node.scopeId)
|
|
3396
3476
|
await this.fanOut(grant.node.tenantId);
|
|
3397
3477
|
},
|
|
3478
|
+
grantEntityShape: async (actor, grant) => {
|
|
3479
|
+
await this.grantEntityShapeLocal(grant.node.scopeId, grant.principalId, grant.entity, grant.permissions);
|
|
3480
|
+
await this.recordAdmin(actor, 'grantEntityShape', grant.node, null, grant);
|
|
3481
|
+
},
|
|
3482
|
+
reconcileEntityGrantShapes: async (actor, node, shapes, opts) => {
|
|
3483
|
+
const { toppedUp, retired } = await this.topUpEntityGrantShapesLocal(node.tenantId, node.scopeId, shapes, opts?.batch);
|
|
3484
|
+
if (toppedUp > 0 || retired > 0) {
|
|
3485
|
+
await this.recordAdmin(actor, 'reconcileEntityGrantShapes', node, null, { shapes, toppedUp, retired });
|
|
3486
|
+
}
|
|
3487
|
+
return { toppedUp, retired };
|
|
3488
|
+
},
|
|
3398
3489
|
grantToConnection: async (actor, raw) => {
|
|
3399
3490
|
const grant = connectionGrant.parse(raw);
|
|
3400
3491
|
const conn = await this.cp.readConnection(grant.connectionId);
|
|
@@ -3651,6 +3742,23 @@ export class CloudflareScopeHost {
|
|
|
3651
3742
|
// #1706: the directory's answer to "vertical Y in tenant T", in the ControlPlaneDO, by
|
|
3652
3743
|
// the kernel's one rule. No actor, not logged — `resolveHostname`'s machine-path reason.
|
|
3653
3744
|
resolveVerticalInstance: async (tenantId, vertical) => verticalResolution.parse(await this.cp.resolveVerticalInstance(tenantId, verticalSlug.parse(vertical))),
|
|
3745
|
+
resolvePeerInstance: async (tenantId, callerScopeId, vertical) => peerInstanceResolution.parse(await this.cp.resolvePeerInstance(tenantId, callerScopeId, verticalSlug.parse(vertical))),
|
|
3746
|
+
peerBinding: async (actor, tenantId, callerScopeId, vertical) => {
|
|
3747
|
+
const row = await this.cp.peerBinding(tenantId, callerScopeId, verticalSlug.parse(vertical));
|
|
3748
|
+
await this.recordAccess(actor, 'peerBinding', { tenantId, scopeId: callerScopeId }, { vertical }, row ? 1 : 0);
|
|
3749
|
+
return row ? peerBindingSchema.parse({ tenantId, callerScopeId, vertical, targetScopeId: row.target_scope_id, invalidated: row.invalidated === 1 }) : undefined;
|
|
3750
|
+
},
|
|
3751
|
+
peerBindings: async (actor, tenantId, callerScopeId) => {
|
|
3752
|
+
const rows = await this.cp.peerBindings(tenantId, callerScopeId);
|
|
3753
|
+
await this.recordAccess(actor, 'peerBindings', { tenantId, scopeId: callerScopeId }, null, rows.length);
|
|
3754
|
+
return rows.map((r) => peerBindingSchema.parse({ tenantId, callerScopeId, vertical: r.vertical, targetScopeId: r.target_scope_id, invalidated: r.invalidated === 1 }));
|
|
3755
|
+
},
|
|
3756
|
+
setPeerBinding: async (_actor, tenantId, callerScopeId, vertical, targetScopeId) => {
|
|
3757
|
+
const answer = await this.cp.setPeerBinding(tenantId, callerScopeId, verticalSlug.parse(vertical), targetScopeId);
|
|
3758
|
+
if (!answer.ok)
|
|
3759
|
+
throw substratError(answer.code, answer.message);
|
|
3760
|
+
return { changed: answer.changed, previous: answer.previous };
|
|
3761
|
+
},
|
|
3654
3762
|
resolveHostname: async (raw) =>
|
|
3655
3763
|
// The router's per-request read. No actor, not logged — the same machine-path
|
|
3656
3764
|
// carve-out resolveIdentity has (K-24). Shares its mapping with the router's
|
|
@@ -3676,6 +3784,9 @@ export class CloudflareScopeHost {
|
|
|
3676
3784
|
// Declared email-sender intent (#303) — the request half; the grant is its own column.
|
|
3677
3785
|
if (parsed.sendsEmail)
|
|
3678
3786
|
installSpec.sendsEmail = parsed.sendsEmail;
|
|
3787
|
+
// Where the scope lifecycle is held (#1713) — read by the lifecycle delivery's targets.
|
|
3788
|
+
if (parsed.lifecycle)
|
|
3789
|
+
installSpec.lifecycle = parsed.lifecycle;
|
|
3679
3790
|
const installSpecJson = Object.keys(installSpec).length ? JSON.stringify(installSpec) : null;
|
|
3680
3791
|
const existing = await this.cp.readVertical(parsed.slug);
|
|
3681
3792
|
if (existing) {
|
|
@@ -4156,21 +4267,25 @@ export class CloudflareScopeHost {
|
|
|
4156
4267
|
await this.recordAccess(actor, 'getOrg', { tenantId }, { orgId }, r ? 1 : 0);
|
|
4157
4268
|
return r ? mapOrg(r) : undefined;
|
|
4158
4269
|
},
|
|
4159
|
-
addMember: async (actor, tenantId, principal, orgId) => {
|
|
4270
|
+
addMember: async (actor, tenantId, principal, orgId, opts) => {
|
|
4160
4271
|
await requireOrg(tenantId, orgId);
|
|
4161
|
-
|
|
4162
|
-
await this.
|
|
4272
|
+
const expiresAt = opts?.expiresAt ?? null;
|
|
4273
|
+
await this.cp.writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${orgId}`, expiresAt);
|
|
4274
|
+
await this.recordAdmin(actor, 'addMember', { tenantId }, null, memberAddedAudit(principal, orgId, expiresAt));
|
|
4163
4275
|
await this.fanOut(tenantId); // membership is a tenant-level tuple
|
|
4164
4276
|
},
|
|
4165
4277
|
applyMembership: async (actor, change) => {
|
|
4166
4278
|
// The row is minted here, where attribution and `causedBy` live, and written by the
|
|
4167
4279
|
// ControlPlaneDO in the same synchronous method as the fence, the bound and the tuple
|
|
4168
4280
|
// (#1184): one DO unit, so nothing lands between the check and the write.
|
|
4169
|
-
const { tenantId, principal, roleKey, op } = change;
|
|
4281
|
+
const { tenantId, principal, roleKey, op, orgId } = change;
|
|
4170
4282
|
const assignment = { principalId: principal, roleKey, node: { tenantId, scopeId: null } };
|
|
4171
4283
|
const result = await this.cp.applyMembership(change, op === 'add'
|
|
4172
4284
|
? this.adminEntry(actor, 'assignRole', { tenantId, scopeId: null }, null, assignment)
|
|
4173
|
-
: this.adminEntry(actor, 'unassignRole', { tenantId, scopeId: null }, assignment, null)
|
|
4285
|
+
: this.adminEntry(actor, 'unassignRole', { tenantId, scopeId: null }, assignment, null),
|
|
4286
|
+
// #2047: the org's own row, when the change joins or leaves one. The unit fills in its
|
|
4287
|
+
// `before`/`after` and writes it only for what it does — a join's carries the expiry.
|
|
4288
|
+
orgId ? this.adminEntry(actor, op === 'add' ? 'addMember' : 'removeMember', { tenantId }, null, null) : undefined);
|
|
4174
4289
|
// The tenant-level tuple, or its tombstone, reaches the projections.
|
|
4175
4290
|
if (result.applied && (op === 'add' || result.changed))
|
|
4176
4291
|
await this.fanOut(tenantId);
|
|
@@ -4192,6 +4307,7 @@ export class CloudflareScopeHost {
|
|
|
4192
4307
|
principal: r.subject.slice('principal:'.length),
|
|
4193
4308
|
orgId,
|
|
4194
4309
|
revokedAt: r.revoked_at,
|
|
4310
|
+
expiresAt: r.expires_at,
|
|
4195
4311
|
}));
|
|
4196
4312
|
},
|
|
4197
4313
|
createTenant: async (actor, input) => {
|
|
@@ -4673,6 +4789,12 @@ export class CloudflareScopeHost {
|
|
|
4673
4789
|
// Both spine copies (#1600): the outbox row AND any platform intent this event was
|
|
4674
4790
|
// routed into. One RPC, so a crash cannot land half of it.
|
|
4675
4791
|
const redacted = await this.scopeStub(scopeId).redactSubject(subjectId);
|
|
4792
|
+
// A refusal answered as data (#2068) — a module's `onSubjectErased` hook threw or reached
|
|
4793
|
+
// past its own tables, and the DO rolled the whole redaction back. Rethrown with its code,
|
|
4794
|
+
// before the key.
|
|
4795
|
+
if (typeof redacted === 'object' && 'failure' in redacted && redacted.failure) {
|
|
4796
|
+
throw fromWireFailure(redacted.failure);
|
|
4797
|
+
}
|
|
4676
4798
|
// An OLD ScopeDO answers with a bare number — it redacted the outbox and never
|
|
4677
4799
|
// looked at the intent journal. Refused here, BEFORE the key is destroyed, and
|
|
4678
4800
|
// that order is the whole point: the key is the irreversible half, so proceeding
|
|
@@ -4705,7 +4827,16 @@ export class CloudflareScopeHost {
|
|
|
4705
4827
|
`or a queued sweep-runs intent — erasing now would destroy the subject key while ` +
|
|
4706
4828
|
`leaving their data in those rows. Redeploy the vertical and re-run.`);
|
|
4707
4829
|
}
|
|
4708
|
-
|
|
4830
|
+
// A DO from before the module half (#2068): it redacted the spine and never ran a
|
|
4831
|
+
// module's declared erasure or its `onSubjectErased` hook. Refused before the key, for
|
|
4832
|
+
// the reason above — its reply read as "no module rows" would receipt an erasure that
|
|
4833
|
+
// left the person in the vertical's own tables, with the key already gone.
|
|
4834
|
+
if (!('vertical' in redacted) || !isModuleErasureCounts(redacted.vertical)) {
|
|
4835
|
+
throw substratError('unavailable', `scope ${scopeId} runs a ScopeDO from before #2068, whose redaction does not reach a module's ` +
|
|
4836
|
+
`own tables — erasing now would destroy the subject key while leaving their data in the ` +
|
|
4837
|
+
`vertical's rows. Redeploy the vertical and re-run.`);
|
|
4838
|
+
}
|
|
4839
|
+
const { events: eventsRedacted, intents: intentsRedacted, jobRuns: jobRunsRedacted, idempotencyResults, intentIds, vertical, } = redacted;
|
|
4709
4840
|
// The directory's failure text (#1632) — a drain failure quoting one of those intents,
|
|
4710
4841
|
// an issue's exemplar, a sweep record's error. Still before the key.
|
|
4711
4842
|
await this.cp.redactSubjectText({ tenantId, scopeId, subjectId, intentIds });
|
|
@@ -4716,6 +4847,7 @@ export class CloudflareScopeHost {
|
|
|
4716
4847
|
eventsRedacted,
|
|
4717
4848
|
intentsRedacted,
|
|
4718
4849
|
jobRunsRedacted,
|
|
4850
|
+
...vertical,
|
|
4719
4851
|
keyDestroyed: existed,
|
|
4720
4852
|
tombstoned: true,
|
|
4721
4853
|
});
|
|
@@ -4725,7 +4857,7 @@ export class CloudflareScopeHost {
|
|
|
4725
4857
|
await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
|
|
4726
4858
|
// BOTH counts: the access log's number is "how much evidence this destroyed", and
|
|
4727
4859
|
// an intent payload is a whole event's worth of it.
|
|
4728
|
-
await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted + idempotencyResults);
|
|
4860
|
+
await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted + idempotencyResults + moduleRowsErased(vertical));
|
|
4729
4861
|
return receipt;
|
|
4730
4862
|
},
|
|
4731
4863
|
// -- impersonation (K-42, #868) ----------------------------------------
|
|
@@ -4968,12 +5100,12 @@ export class CloudflareScopeHost {
|
|
|
4968
5100
|
throw new Error(`connection not found: ${id}`);
|
|
4969
5101
|
const sealed = await this.secretBox.seal(JSON.stringify(connectionSecret.parse(secret)));
|
|
4970
5102
|
const now = new Date().toISOString();
|
|
4971
|
-
await this.cp.updateConnectionSecret(id, sealed.keyId, sealed.ciphertext, expiresAt
|
|
5103
|
+
await this.cp.updateConnectionSecret(id, sealed.keyId, sealed.ciphertext, expiresAt === undefined ? row.expires_at : expiresAt, now);
|
|
4972
5104
|
await this.recordAdmin(actor, 'updateConnectionSecret', { tenantId: row.tenant_id, vertical: row.vertical }, null, {
|
|
4973
5105
|
id,
|
|
4974
5106
|
provider: row.provider,
|
|
4975
5107
|
rotatedAt: now,
|
|
4976
|
-
expiresAt: expiresAt
|
|
5108
|
+
expiresAt: expiresAt === undefined ? row.expires_at : expiresAt,
|
|
4977
5109
|
// §3.5.1's attribution, rotate-side: the authorizing tenant principal,
|
|
4978
5110
|
// never laundered into the actor column.
|
|
4979
5111
|
...(opts?.rotatedBy ? { rotatedBy: opts.rotatedBy } : {}),
|
|
@@ -4989,6 +5121,55 @@ export class CloudflareScopeHost {
|
|
|
4989
5121
|
return; // idempotent, and a no-op is not audited
|
|
4990
5122
|
await this.recordAdmin(actor, 'revokeConnection', { tenantId: row.tenant_id, vertical: row.vertical }, { status: row.status }, { id, provider: row.provider, status: 'revoked', revokedAt: now });
|
|
4991
5123
|
},
|
|
5124
|
+
// -- a vertical's mailed connect links (connections.md §3.5.4) --------------
|
|
5125
|
+
// Parsed here, run in the ControlPlaneDO as the kernel's statements (shared with
|
|
5126
|
+
// the pure adapter). Each move writes its audit row in the DO's own unit, from the
|
|
5127
|
+
// row minted here (actor, attribution) — so a failed audit write rolls the move back.
|
|
5128
|
+
// The DO has no clock of its own on this path — `now` is the coordinator's, the same
|
|
5129
|
+
// wall clock every other directory write uses.
|
|
5130
|
+
mintConnectLink: async (actor, raw) => {
|
|
5131
|
+
const input = mintConnectLinkInput.parse(raw);
|
|
5132
|
+
return this.cp.insertConnectLink({
|
|
5133
|
+
id: ulid(),
|
|
5134
|
+
tenantId: input.tenantId,
|
|
5135
|
+
scopeId: input.scopeId,
|
|
5136
|
+
vertical: input.vertical,
|
|
5137
|
+
provider: input.provider,
|
|
5138
|
+
createdBy: input.createdBy,
|
|
5139
|
+
subjectRef: input.subjectRef ?? null,
|
|
5140
|
+
returnUrl: input.returnUrl ?? null,
|
|
5141
|
+
createdAt: new Date().toISOString(),
|
|
5142
|
+
expiresAt: input.expiresAt,
|
|
5143
|
+
}, this.adminEntry(actor, 'mintConnectLink', { tenantId: input.tenantId }, null, null));
|
|
5144
|
+
},
|
|
5145
|
+
getConnectLink: async (actor, raw) => {
|
|
5146
|
+
const key = connectLinkKey.parse(raw);
|
|
5147
|
+
const link = await this.cp.readConnectLink(key);
|
|
5148
|
+
await this.recordAccess(actor, 'getConnectLink', key, { id: key.id }, link ? 1 : 0);
|
|
5149
|
+
return link;
|
|
5150
|
+
},
|
|
5151
|
+
listConnectLinks: async (actor, raw) => {
|
|
5152
|
+
const filter = connectLinkFilter.parse(raw);
|
|
5153
|
+
const links = await this.cp.listConnectLinks(filter, new Date().toISOString());
|
|
5154
|
+
await this.recordAccess(actor, 'listConnectLinks', filter, filter, links.length);
|
|
5155
|
+
return links;
|
|
5156
|
+
},
|
|
5157
|
+
revokeConnectLink: async (actor, raw) => {
|
|
5158
|
+
const key = connectLinkKey.parse(raw);
|
|
5159
|
+
// Idempotent, and a no-op is not audited (the statement hands back no row for one).
|
|
5160
|
+
const audit = this.adminEntry(actor, 'revokeConnectLink', { tenantId: key.tenantId }, null, null);
|
|
5161
|
+
return (await this.cp.revokeConnectLink(key, audit))?.link;
|
|
5162
|
+
},
|
|
5163
|
+
consumeConnectLink: async (actor, raw) => {
|
|
5164
|
+
const input = consumeConnectLinkInput.parse(raw);
|
|
5165
|
+
const audit = this.adminEntry(actor, 'consumeConnectLink', { tenantId: input.tenantId }, null, null);
|
|
5166
|
+
return this.cp.consumeConnectLink(input, new Date().toISOString(), audit);
|
|
5167
|
+
},
|
|
5168
|
+
restoreConnectLink: async (actor, raw) => {
|
|
5169
|
+
const key = connectLinkKey.parse(raw);
|
|
5170
|
+
const audit = this.adminEntry(actor, 'restoreConnectLink', { tenantId: key.tenantId }, null, null);
|
|
5171
|
+
return (await this.cp.restoreConnectLink(key, new Date().toISOString(), audit)) !== undefined;
|
|
5172
|
+
},
|
|
4992
5173
|
openConnection: async (tenantId, vertical, provider, externalAccountRef) => {
|
|
4993
5174
|
const row = await this.cp.readLiveConnection(tenantId, vertical, provider, externalAccountRef);
|
|
4994
5175
|
if (!row)
|
|
@@ -5085,6 +5266,23 @@ export class CloudflareScopeHost {
|
|
|
5085
5266
|
const { tenantId, scopeId, ...after } = ownerTransferAudit.parse(entry);
|
|
5086
5267
|
await this.recordAdmin(actor, 'transferOwner', { tenantId, scopeId }, null, after);
|
|
5087
5268
|
},
|
|
5269
|
+
/** #1150: one phase of a dashboard member change, written around the vertical's own. */
|
|
5270
|
+
recordMemberChange: async (actor, entry) => {
|
|
5271
|
+
const { tenantId, scopeId, ...after } = memberChangeAudit.parse(entry);
|
|
5272
|
+
await this.recordAdmin(actor, 'manageScopeMember', { tenantId, scopeId }, null, after);
|
|
5273
|
+
},
|
|
5274
|
+
recordPeerBindingChange: async (actor, entry) => {
|
|
5275
|
+
const { tenantId, scopeId, ...after } = peerBindingAudit.parse(entry);
|
|
5276
|
+
await this.recordAdmin(actor, 'setPeerBinding', { tenantId, scopeId }, null, after);
|
|
5277
|
+
},
|
|
5278
|
+
/** #2064: an audited operation's rows, by the operation-id index, in bounded batches. */
|
|
5279
|
+
auditedOperations: async (actor, refs) => {
|
|
5280
|
+
const rows = await this.cp.auditedOperations([...refs]);
|
|
5281
|
+
await this.recordAccess(actor, 'auditedOperations', {}, { operations: refs.length }, rows.length);
|
|
5282
|
+
return rows;
|
|
5283
|
+
},
|
|
5284
|
+
/** #2064: settle an intent with no outcome — one unit in the directory DO. */
|
|
5285
|
+
settleUnrecordedOutcome: (actor, input) => this.cp.settleUnrecordedOutcome({ actor, intentId: input.intentId, error: input.error }),
|
|
5088
5286
|
/** #2005: one change to a scope's copy marker, written around the vertical's own change. */
|
|
5089
5287
|
recordCopyMark: async (actor, entry) => {
|
|
5090
5288
|
const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
|
|
@@ -5241,6 +5439,8 @@ export class CloudflareScopeHost {
|
|
|
5241
5439
|
},
|
|
5242
5440
|
// Checked here as well as in the directory, so the refusal keeps its code across the hop.
|
|
5243
5441
|
pruneTelemetry: async (_actor, limit) => this.cp.pruneTelemetry(assertRowLimit('limit', limit)),
|
|
5442
|
+
// #1748: the DO writes each stale resolution's audit row in the same unit, from this one.
|
|
5443
|
+
pruneFindings: async (actor, limit) => this.cp.pruneFindings(assertRowLimit('limit', limit), this.adminEntry(actor, 'resolveStaleFinding', { tenantId: null }, null, null)),
|
|
5244
5444
|
listIssues: async (actor, filter) => {
|
|
5245
5445
|
const rows = await this.cp.listIssues({
|
|
5246
5446
|
status: filter?.status,
|
|
@@ -5262,6 +5462,33 @@ export class CloudflareScopeHost {
|
|
|
5262
5462
|
await this.recordAdmin(actor, 'setIssueStatus', { tenantId: null, vertical: after.lastVertical }, { fingerprint, status: before.status }, { fingerprint, status: after.status });
|
|
5263
5463
|
return after;
|
|
5264
5464
|
},
|
|
5465
|
+
listFindings: async (actor, filter) => {
|
|
5466
|
+
const rows = await this.cp.listFindings({ ...filter });
|
|
5467
|
+
await this.recordAccess(actor, 'listFindings', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
|
|
5468
|
+
return rows.map((r) => findingEntry.parse(r));
|
|
5469
|
+
},
|
|
5470
|
+
// #1748: each findings mutation writes its audit row in the DO's own unit, from the row
|
|
5471
|
+
// minted here (actor, attribution) — so a failed audit write rolls the mutation back.
|
|
5472
|
+
setFindingStatus: async (actor, tenantId, id, status) => {
|
|
5473
|
+
const audit = this.adminEntry(actor, 'setFindingStatus', { tenantId }, null, null);
|
|
5474
|
+
const change = await this.cp.setFindingStatus(tenantId, id, status, new Date().toISOString(), audit);
|
|
5475
|
+
return change ? findingEntry.parse(change.after) : undefined;
|
|
5476
|
+
},
|
|
5477
|
+
createFindingRule: async (actor, tenantId, input) => {
|
|
5478
|
+
const audit = this.adminEntry(actor, 'createFindingRule', { tenantId }, null, null);
|
|
5479
|
+
const created = await this.directory('createFindingRule', tenantId, input, actor, new Date().toISOString(), audit);
|
|
5480
|
+
return { rule: findingRuleEntry.parse(created.rule), suppressed: created.suppressed.length };
|
|
5481
|
+
},
|
|
5482
|
+
revokeFindingRule: async (actor, tenantId, ruleId) => {
|
|
5483
|
+
const audit = this.adminEntry(actor, 'revokeFindingRule', { tenantId }, null, null);
|
|
5484
|
+
const change = await this.cp.revokeFindingRule(tenantId, ruleId, new Date().toISOString(), audit);
|
|
5485
|
+
return change ? findingRuleEntry.parse(change.after) : undefined;
|
|
5486
|
+
},
|
|
5487
|
+
listFindingRules: async (actor, tenantId, filter) => {
|
|
5488
|
+
const rows = await this.cp.listFindingRules(tenantId, filter?.active ? new Date().toISOString() : undefined, filter?.limit);
|
|
5489
|
+
await this.recordAccess(actor, 'listFindingRules', { tenantId }, filter, rows.length);
|
|
5490
|
+
return rows.map((r) => findingRuleEntry.parse(r));
|
|
5491
|
+
},
|
|
5265
5492
|
recordModelUsage: async (input) => {
|
|
5266
5493
|
const l = input.line;
|
|
5267
5494
|
return this.cp.recordModelUsage({
|
|
@@ -5653,7 +5880,7 @@ export class CloudflareScopeHost {
|
|
|
5653
5880
|
}));
|
|
5654
5881
|
}
|
|
5655
5882
|
async writeScopeTuple(scopeId, subject, relation, object, expiresAt) {
|
|
5656
|
-
await this.scopeStub(scopeId).writeTuple(subject, relation, object, expiresAt);
|
|
5883
|
+
unwrapReply(await this.scopeStub(scopeId).writeTuple(subject, relation, object, expiresAt));
|
|
5657
5884
|
}
|
|
5658
5885
|
/**
|
|
5659
5886
|
* The connection's CURRENT public sealing key, minted on first ask (#687).
|
|
@@ -6044,11 +6271,17 @@ export class CloudflareScopeHost {
|
|
|
6044
6271
|
* arrive as a different class of answer just because the caller asked for a socket.
|
|
6045
6272
|
*/
|
|
6046
6273
|
liveReads = {
|
|
6047
|
-
subscribe: async ({ tenantId, scopeId, principal, request, within }) => {
|
|
6274
|
+
subscribe: async ({ tenantId, scopeId, principal, request, within, expiresAt }) => {
|
|
6048
6275
|
// Decided before anything else, and thrown: a `within` that is neither a plain
|
|
6049
6276
|
// EntityRef nor a value `vouchedWithin` built is a caller bug, and the two ways of
|
|
6050
6277
|
// guessing at it both open a feed wider than was asked for (#1853).
|
|
6051
6278
|
const narrowed = liveWithinOf(within);
|
|
6279
|
+
// The same for an expiry that is not an instant: read as "none", it would keep the
|
|
6280
|
+
// socket open past the session it was meant to end with (#938).
|
|
6281
|
+
const expires = expiresAt === undefined ? undefined : liveInstant(expiresAt);
|
|
6282
|
+
if (expiresAt !== undefined && !expires) {
|
|
6283
|
+
throw substratError('validation_failed', 'live reads: `expiresAt` must be an ISO 8601 instant');
|
|
6284
|
+
}
|
|
6052
6285
|
if (!isUpgradeRequest(request)) {
|
|
6053
6286
|
return new Response('live reads are a WebSocket surface', {
|
|
6054
6287
|
status: 426,
|
|
@@ -6110,6 +6343,10 @@ export class CloudflareScopeHost {
|
|
|
6110
6343
|
headers.set(LIVE_WITHIN_HEADER, encodeLiveWithin(narrowed));
|
|
6111
6344
|
else
|
|
6112
6345
|
headers.delete(LIVE_WITHIN_HEADER);
|
|
6346
|
+
if (expires)
|
|
6347
|
+
headers.set(LIVE_EXPIRES_HEADER, expires);
|
|
6348
|
+
else
|
|
6349
|
+
headers.delete(LIVE_EXPIRES_HEADER);
|
|
6113
6350
|
const forwarded = new Request(new URL(LIVE_SUBSCRIBE_PATH, 'https://scope.substrat.internal'),
|
|
6114
6351
|
// `new Request(url, { …, headers })` rather than `new Request(request, …)`:
|
|
6115
6352
|
// the DO is addressed by its own path, not the client's, and the upgrade
|
|
@@ -6367,6 +6604,8 @@ export class CloudflareScopeHost {
|
|
|
6367
6604
|
// #1742: the recorded-off modules and (#2029) peers, switched off after the seat in the
|
|
6368
6605
|
// same unit, on the scope this call provisions — the one the seat's tuples name.
|
|
6369
6606
|
carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined, services?.map((id) => `principal:${id}`)));
|
|
6607
|
+
if (input.entityGrants)
|
|
6608
|
+
await this.topUpEntityGrantShapesLocal(input.tenantId, input.scopeId, input.entityGrants);
|
|
6370
6609
|
return carry ? { switchedOff } : {};
|
|
6371
6610
|
}
|
|
6372
6611
|
/**
|
|
@@ -6392,7 +6631,7 @@ export class CloudflareScopeHost {
|
|
|
6392
6631
|
* already projected (via `provisionScopeLocal`), or the local checker expands it to nothing.
|
|
6393
6632
|
*/
|
|
6394
6633
|
async assignScopeRole(scopeId, principal, roleKey) {
|
|
6395
|
-
await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null);
|
|
6634
|
+
unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null));
|
|
6396
6635
|
}
|
|
6397
6636
|
/**
|
|
6398
6637
|
* Take a scope-level role back — the counterpart `assignScopeRole` went without (#1161).
|
|
@@ -6451,8 +6690,41 @@ export class CloudflareScopeHost {
|
|
|
6451
6690
|
* (writeTuple is INSERT OR REPLACE), so it is safe to re-issue on every link.
|
|
6452
6691
|
*/
|
|
6453
6692
|
async grantEntityLocal(scopeId, principal, permission, entity) {
|
|
6454
|
-
await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, entityObjectRef(entity, 'grantEntityLocal'), // #1856
|
|
6455
|
-
null);
|
|
6693
|
+
unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, entityObjectRef(entity, 'grantEntityLocal'), // #1856
|
|
6694
|
+
null));
|
|
6695
|
+
}
|
|
6696
|
+
/**
|
|
6697
|
+
* Give a person a declared entity-grant SHAPE on one entity in a CP-less vertical (#2071) —
|
|
6698
|
+
* `grantEntityLocal` for every key of the shape, plus the marker that makes them a holder of
|
|
6699
|
+
* it, in one unit. What a vertical calls where it used to loop `grantEntityLocal` over its
|
|
6700
|
+
* `ENTITY_GRANTS` keys: a key the shape gains later then reaches this person at the next
|
|
6701
|
+
* provision or reconcile (`provisionScopeLocal`'s `entityGrants`). Idempotent, and explicit
|
|
6702
|
+
* like `grantEntityLocal`: it brings back a key or a marker a revoke tombstoned.
|
|
6703
|
+
*/
|
|
6704
|
+
async grantEntityShapeLocal(scopeId, principal, entity, permissions) {
|
|
6705
|
+
await this.scopeStub(scopeId).grantEntityShape(principal, entity, permissions);
|
|
6706
|
+
}
|
|
6707
|
+
/**
|
|
6708
|
+
* Top every holder of each declared shape up to the shape as it is now (#2071), in bounded
|
|
6709
|
+
* passes — at most `batch` rows of work per scope transaction (default 500, at most 5000;
|
|
6710
|
+
* anything else is `validation_failed`), repeated until a pass finishes. Never re-grants a revoked
|
|
6711
|
+
* key; takes back only a key the shape declares `retired` (#2082). Returns how many
|
|
6712
|
+
* (person, entity) it topped up, and how many it took retired keys from.
|
|
6713
|
+
*/
|
|
6714
|
+
async topUpEntityGrantShapesLocal(tenantId, scopeId, shapes, batch) {
|
|
6715
|
+
const limit = shapeTopUpBatch(batch);
|
|
6716
|
+
let toppedUp = 0;
|
|
6717
|
+
let retired = 0;
|
|
6718
|
+
if (shapes.length === 0)
|
|
6719
|
+
return { toppedUp, retired };
|
|
6720
|
+
const stub = this.scopeStub(scopeId);
|
|
6721
|
+
for (let done = false; !done;) {
|
|
6722
|
+
const pass = await stub.topUpEntityGrantShapes(tenantId, scopeId, shapes, limit);
|
|
6723
|
+
toppedUp += pass.toppedUp;
|
|
6724
|
+
retired += pass.retired;
|
|
6725
|
+
done = pass.done;
|
|
6726
|
+
}
|
|
6727
|
+
return { toppedUp, retired };
|
|
6456
6728
|
}
|
|
6457
6729
|
// -- the connector write-back's far end (#574) -----------------------------
|
|
6458
6730
|
// A CP-less dispatch vertical cannot run a connector, so the shared control
|
|
@@ -6651,10 +6923,21 @@ export class CloudflareScopeHost {
|
|
|
6651
6923
|
if (!delegation)
|
|
6652
6924
|
this.assertServedHere(rec, scopeId, 'moveImportCursor');
|
|
6653
6925
|
await this.validateScopeAccess(tenantId, scopeId);
|
|
6926
|
+
// #2114: the replay's id is also its audit `operationId`, so the scheduled settle pairs and
|
|
6927
|
+
// closes the lever's rows exactly as it does the kill switches'.
|
|
6654
6928
|
const replayId = ulid();
|
|
6655
6929
|
const target = { tenantId, scopeId, vertical: rec.vertical };
|
|
6656
|
-
const base = { replayId, mode: move.mode, from: move.from, source: source.scopeId };
|
|
6930
|
+
const base = { operationId: replayId, replayId, mode: move.mode, from: move.from, source: source.scopeId };
|
|
6657
6931
|
await this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, phase: 'intent', reason: move.reason });
|
|
6932
|
+
// #2114: every outcome row goes through the kernel's `recordAuditOutcome`, as the kill
|
|
6933
|
+
// switches' do (#2089). One that cannot be written is logged with the replay id, never
|
|
6934
|
+
// swallowed; the call still answers with the move's own error (or, applied, with the move and
|
|
6935
|
+
// `auditWarning`), and the scheduled settle closes the intent as `unknown`.
|
|
6936
|
+
const recordOutcome = (row) => recordAuditOutcome(() => this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, ...row }), {
|
|
6937
|
+
flow: 'import-cursor',
|
|
6938
|
+
operationId: replayId,
|
|
6939
|
+
phase: row.phase,
|
|
6940
|
+
}, (message, fields) => console.error(message, fields));
|
|
6658
6941
|
const at = { move, source: source, replayId };
|
|
6659
6942
|
let moved;
|
|
6660
6943
|
try {
|
|
@@ -6663,21 +6946,16 @@ export class CloudflareScopeHost {
|
|
|
6663
6946
|
: await this.moveInScope(scopeId, at);
|
|
6664
6947
|
}
|
|
6665
6948
|
catch (err) {
|
|
6666
|
-
await
|
|
6667
|
-
...base,
|
|
6668
|
-
phase: 'failed',
|
|
6669
|
-
error: err instanceof Error ? err.message : String(err),
|
|
6670
|
-
}).catch(() => undefined);
|
|
6949
|
+
await recordOutcome({ phase: 'failed', error: err instanceof Error ? err.message : String(err) });
|
|
6671
6950
|
throw err;
|
|
6672
6951
|
}
|
|
6673
|
-
|
|
6674
|
-
...base,
|
|
6952
|
+
const unrecorded = await recordOutcome({
|
|
6675
6953
|
phase: 'applied',
|
|
6676
6954
|
previous: moved.previous,
|
|
6677
6955
|
cursor: moved.cursor,
|
|
6678
6956
|
archived: moved.archived,
|
|
6679
6957
|
});
|
|
6680
|
-
return moved;
|
|
6958
|
+
return unrecorded === null ? moved : { ...moved, auditWarning: auditWarningOf('the move', unrecorded) };
|
|
6681
6959
|
}
|
|
6682
6960
|
/** #1705 PR 3: the promote gate's question, over two stored manifests (`exportBreaksOf`). */
|
|
6683
6961
|
exportBreaksBetween(actor, producer, outgoingManifest, incomingManifest) {
|