@substrat-run/control-plane-api 0.159.0 → 0.161.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.
Files changed (68) hide show
  1. package/dist/api.d.ts +33 -1
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +674 -63
  4. package/dist/api.js.map +1 -1
  5. package/dist/browser.d.ts +2 -2
  6. package/dist/browser.d.ts.map +1 -1
  7. package/dist/browser.js +2 -1
  8. package/dist/browser.js.map +1 -1
  9. package/dist/cf-observability.d.ts +30 -0
  10. package/dist/cf-observability.d.ts.map +1 -1
  11. package/dist/cf-observability.js +304 -99
  12. package/dist/cf-observability.js.map +1 -1
  13. package/dist/custom-hostnames.d.ts +30 -0
  14. package/dist/custom-hostnames.d.ts.map +1 -1
  15. package/dist/custom-hostnames.js +25 -2
  16. package/dist/custom-hostnames.js.map +1 -1
  17. package/dist/deploy.d.ts +3 -0
  18. package/dist/deploy.d.ts.map +1 -1
  19. package/dist/deploy.js.map +1 -1
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +3 -1
  22. package/dist/errors.js.map +1 -1
  23. package/dist/index.d.ts +7 -5
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +6 -4
  26. package/dist/index.js.map +1 -1
  27. package/dist/observability.d.ts +29 -2
  28. package/dist/observability.d.ts.map +1 -1
  29. package/dist/observability.js +8 -0
  30. package/dist/observability.js.map +1 -1
  31. package/dist/platform-drain.d.ts +26 -2
  32. package/dist/platform-drain.d.ts.map +1 -1
  33. package/dist/platform-drain.js +297 -119
  34. package/dist/platform-drain.js.map +1 -1
  35. package/dist/platform-entry.generated.d.ts +2 -2
  36. package/dist/platform-entry.generated.d.ts.map +1 -1
  37. package/dist/platform-entry.generated.js +5 -5
  38. package/dist/platform-entry.generated.js.map +1 -1
  39. package/dist/platform-wire.d.ts +51 -0
  40. package/dist/platform-wire.d.ts.map +1 -0
  41. package/dist/platform-wire.js +163 -0
  42. package/dist/platform-wire.js.map +1 -0
  43. package/dist/scope-copy-backfill.d.ts.map +1 -1
  44. package/dist/scope-copy-backfill.js +22 -9
  45. package/dist/scope-copy-backfill.js.map +1 -1
  46. package/dist/scope-copy-cleanup.js +1 -1
  47. package/dist/scope-copy-cleanup.js.map +1 -1
  48. package/dist/staff-client.d.ts +38 -2
  49. package/dist/staff-client.d.ts.map +1 -1
  50. package/dist/staff-client.js +24 -1
  51. package/dist/staff-client.js.map +1 -1
  52. package/dist/vertical-client.d.ts +116 -4
  53. package/dist/vertical-client.d.ts.map +1 -1
  54. package/dist/vertical-client.js +366 -25
  55. package/dist/vertical-client.js.map +1 -1
  56. package/dist/wire-settings.d.ts +77 -0
  57. package/dist/wire-settings.d.ts.map +1 -0
  58. package/dist/wire-settings.js +155 -0
  59. package/dist/wire-settings.js.map +1 -0
  60. package/dist/wire-upload.d.ts +100 -0
  61. package/dist/wire-upload.d.ts.map +1 -0
  62. package/dist/wire-upload.js +141 -0
  63. package/dist/wire-upload.js.map +1 -0
  64. package/dist/wire-walk.d.ts +140 -0
  65. package/dist/wire-walk.d.ts.map +1 -0
  66. package/dist/wire-walk.js +339 -0
  67. package/dist/wire-walk.js.map +1 -0
  68. package/package.json +10 -10
@@ -1,6 +1,6 @@
1
- import { INERT_SCOPE_REASON, isPrimaryScope, lifecycleRefusal, platformIntentFailureMessage, ulid, } from '@substrat-run/kernel';
1
+ import { INERT_SCOPE_REASON, isPrimaryScope, lifecycleRefusal, platformIntentFailureMessage, resolveScopeRecord, ulid, withheldOpsFailure, withheldStaffFailure, } from '@substrat-run/kernel';
2
2
  import { isTerminalDispatchFailure } from './provider-error.js';
3
- import { principalId as principalIdSchema, scopeId as scopeIdSchema, tenantId as tenantIdSchema, provisionSiblingPayload, archiveScopePayload, callsDeclares, peerInvokePayload, undeclaredCallMessage, provisionTenantPayload, setEntitlementsPayload, connectorDispatchPayload, CONNECTOR_DISPATCH_KIND_PREFIX, modelUsageLine, MODEL_USAGE_KIND, SWEEP_RUNS_KIND, isUndecodablePlatformRequest, platformRequestId, sweepRunsPayload, } from '@substrat-run/contracts';
3
+ import { principalId as principalIdSchema, scopeId as scopeIdSchema, tenantId as tenantIdSchema, provisionSiblingPayload, archiveScopePayload, callsDeclares, peerInvokePayload, undeclaredCallMessage, provisionTenantPayload, setEntitlementsPayload, connectorDispatchPayload, CONNECTOR_DISPATCH_KIND_PREFIX, modelUsageLine, MODEL_USAGE_KIND, SWEEP_RUNS_KIND, isUndecodablePlatformRequest, platformRequestId, createTenantInput, errorCodeOf, sweepRunsPayload, neutralUpstreamMarkers, withoutUpstreamReference, } from '@substrat-run/contracts';
4
4
  import { ControlPlaneError } from '@substrat-run/control-plane-client';
5
5
  import { attributeFailure, terminalFailureNote } from './failure-attribution.js';
6
6
  import { connectionGrantsForScope } from './vertical-client.js';
@@ -32,6 +32,92 @@ const undecodedRefusal = (decodeError) => `not executed: the intent row could no
32
32
  * fault, short enough that the proposer learns the truth while it still matters.
33
33
  */
34
34
  export const MAX_PLATFORM_REQUEST_ATTEMPTS = 100;
35
+ /**
36
+ * The ops-failure row of an intent the drain gave up on or settled `failed` (#570, #618). #2554:
37
+ * an intent's error is whatever its handler met, a provider's answer or another vertical's
38
+ * deployment among them, so the scope's tenant's row says only what became of the intent, under
39
+ * the platform's reference, and the error is staff's row under the same one (`withheldOpsFailure`).
40
+ */
41
+ function intentFailure(ctx, kind, stage, outcome, raw, answered, reference) {
42
+ return withheldOpsFailure({
43
+ operation: `intent.${kind}`,
44
+ stage,
45
+ tenantId: ctx.tenantId,
46
+ scopeId: ctx.scopeId,
47
+ vertical: ctx.vertical,
48
+ version: ctx.versionId ?? null,
49
+ // The attribution the settle journals (#841) rides onto the fleet row too (#1233).
50
+ origin: outcome.failure?.origin ?? null,
51
+ code: outcome.failure?.code ?? null,
52
+ }, raw, answered, reference);
53
+ }
54
+ /** The stage of staff's row for a withheld failure on a pass that left the intent pending (#2556). */
55
+ const WITHHELD_TRANSIENT_STAGE = 'transient';
56
+ /**
57
+ * How long staff's row for a withheld transient failure stands for the same intent (#2556). Every
58
+ * pass of a stuck intent journals the same reference meanwhile, and files nothing: the staff digest
59
+ * mails every row since its watermark, so a row per pass would repeat one stuck intent every quarter
60
+ * hour. A condition that lasts is filed again after this, under a new reference.
61
+ */
62
+ export const WITHHELD_REFILE_MS = 24 * 60 * 60_000;
63
+ /** The stage of staff's note that a withheld transient reference could not be looked up (#2556). */
64
+ const WITHHELD_LOOKUP_STAGE = 'transient-lookup';
65
+ /** How a journaled withheld sentence ends: the platform's reference staff's row is filed under. */
66
+ const JOURNALED_REFERENCE = /^(.*) \(reference ([0-9A-HJKMNP-TV-Z]{26})\)$/s;
67
+ /**
68
+ * How staff's row for a withheld transient failure opens: the intent, and the sentence its scope was
69
+ * told. A sentence may quote the payload (a peer call's operation, a tenant slug), so the opening is
70
+ * stored after any upstream marker, which is read from the raw text alone, and neutralised as the
71
+ * row stores it (`withUpstreamReference`), which is also the form the next pass compares.
72
+ */
73
+ const withheldTransientOpening = (id, withheld) => neutralUpstreamMarkers(platformIntentFailureMessage(id, `pending (${withheld}): `));
74
+ /**
75
+ * The reference a withheld failure on a pass that left the intent pending is journaled under
76
+ * (#2556), keyed on the intent AND the sentence: a handler that gets one step further on a later
77
+ * pass is a different failure, with its own row. The previous settle journaled `<sentence>
78
+ * (reference R)` in the intent's `lastError`; when that sentence is this pass's, R is reused with
79
+ * nothing filed, once one ledger read by R confirms staff's row for this intent and sentence stands
80
+ * in the drained scope within `WITHHELD_REFILE_MS`. Otherwise a new reference is minted and staff's
81
+ * row filed under it, with no tenant, in the drained scope, so a subject erasure of the intent
82
+ * reaches it; the row opens with the sentence (`withheldTransientOpening`), which is what the next
83
+ * pass matches, never the raw text, whose upstream handles vary per call. A ledger that cannot be
84
+ * read files anew, since a second row is the safer error than a reference nothing resolves, and
85
+ * leaves staff a note saying the earlier reference went unconfirmed.
86
+ */
87
+ async function withheldTransientReference(ctx, kind, id, previousError, withheld, outcome, opts) {
88
+ const operation = `intent.${kind}`;
89
+ const opening = withheldTransientOpening(id, withheld);
90
+ const entry = {
91
+ operation,
92
+ scopeId: ctx.scopeId,
93
+ vertical: ctx.vertical,
94
+ version: ctx.versionId ?? null,
95
+ origin: outcome.failure?.origin ?? null,
96
+ code: outcome.failure?.code ?? null,
97
+ };
98
+ const journaled = JOURNALED_REFERENCE.exec(previousError ?? '');
99
+ const previous = journaled?.[1] === withheld ? journaled[2] : null;
100
+ if (previous !== null && opts?.listFailures) {
101
+ const since = new Date((opts.now?.() ?? new Date()).getTime() - WITHHELD_REFILE_MS).toISOString();
102
+ try {
103
+ const rows = await opts.listFailures({ scopeId: ctx.scopeId, operation, reference: previous, since, limit: 10 });
104
+ const filed = rows.some((r) => r.tenantId === null && r.stage === WITHHELD_TRANSIENT_STAGE && withoutUpstreamReference(r.message).startsWith(opening));
105
+ if (filed)
106
+ return previous;
107
+ }
108
+ catch (e) {
109
+ // Filed anew below; the note is staff's, and names only the reference and the ledger's own error.
110
+ opts.recordFailure?.(withheldStaffFailure({ ...entry, stage: WITHHELD_LOOKUP_STAGE }, platformIntentFailureMessage(id, `pending: reference ${previous} could not be confirmed in the failure ledger, so this pass files anew: ${e instanceof Error ? e.message : String(e)}`), ulid()));
111
+ }
112
+ }
113
+ const reference = ulid();
114
+ opts?.recordFailure?.(withheldStaffFailure({ ...entry, stage: WITHHELD_TRANSIENT_STAGE }, outcome.error ?? 'unknown', reference, opening));
115
+ return reference;
116
+ }
117
+ /** What a withheld failure's settle journals: its origin and code, never a permission read from the text. */
118
+ function withheldAttribution(failure) {
119
+ return failure ? { ...failure, permission: null } : null;
120
+ }
35
121
  /**
36
122
  * Drain one scope's pending platform intents: list them from the vertical, dispatch each to the
37
123
  * handler for its `kind`, and settle the outcome back in the vertical. An unknown kind settles
@@ -118,30 +204,30 @@ export async function drainScopePlatformRequests(client, ctx, handlers, opts) {
118
204
  // lands an ops-failure row (#559) so a six-day retry loop is an operator's headline,
119
205
  // not a spine-table archaeology find.
120
206
  const maxAttempts = opts?.maxAttempts ?? MAX_PLATFORM_REQUEST_ATTEMPTS;
207
+ // #2556: a withheld error is journaled as its sentence under the platform's reference, the
208
+ // reference staff's row is filed under (terminal and give-up: the pair below; a pass that
209
+ // leaves the intent pending: `withheldTransientReference`).
210
+ const withheld = outcome.withheld;
211
+ let journaled = outcome.error ?? null;
121
212
  if (outcome.status === 'pending' && attempts + 1 >= maxAttempts) {
213
+ const gaveUp = `gave up after ${attempts + 1} drain attempts`;
122
214
  outcome = {
123
215
  status: 'failed',
124
216
  result: outcome.result,
125
- error: `gave up after ${attempts + 1} drain attempts — last error: ${outcome.error ?? 'unknown'}`,
217
+ error: `${gaveUp} — last error: ${outcome.error ?? 'unknown'}`,
126
218
  // The give-up inherits the last pass's attribution rather than inventing one: what
127
219
  // refused a hundred times is what refused on the last attempt.
128
220
  failure: outcome.failure,
221
+ ...(withheld === undefined ? {} : { withheld }),
129
222
  };
130
- opts?.recordFailure?.({
131
- operation: `intent.${kind}`,
132
- stage: 'attempt-ceiling',
133
- tenantId: ctx.tenantId,
134
- scopeId: ctx.scopeId,
135
- vertical: ctx.vertical,
136
- version: ctx.versionId ?? null,
137
- // The attribution the settle journals (#841) rides onto the fleet row too —
138
- // until now the drain had {origin, code} in hand and dropped it here (#1233).
139
- origin: outcome.failure?.origin ?? null,
140
- code: outcome.failure?.code ?? null,
141
- message: platformIntentFailureMessage(id, outcome.error ?? 'unknown'),
142
- });
223
+ const reference = ulid();
224
+ journaled = withheld === undefined ? outcome.error : `${gaveUp} — last error: ${withheld} (reference ${reference})`;
225
+ opts?.recordFailure?.(intentFailure(ctx, kind, 'attempt-ceiling', outcome, platformIntentFailureMessage(id, outcome.error ?? 'unknown'), (r) => platformIntentFailureMessage(id, `${gaveUp} (reference ${r})`), reference));
143
226
  }
144
227
  else if (outcome.status === 'failed' && !inert) {
228
+ const reference = ulid();
229
+ if (withheld !== undefined)
230
+ journaled = `${withheld} (reference ${reference})`;
145
231
  // An inert settle (#2005) is expected, not an operator's headline; the settle below
146
232
  // still journals its reason and attribution.
147
233
  //
@@ -149,17 +235,11 @@ export async function drainScopePlatformRequests(client, ctx, handlers, opts) {
149
235
  // over, nobody is coming back to it, and until now its only trace was a `last_error`
150
236
  // column in the vertical's own DO. An unknown kind, a refused payload, a provider's 4xx:
151
237
  // each is an operator's headline for the same reason a give-up is.
152
- opts?.recordFailure?.({
153
- operation: `intent.${kind}`,
154
- stage: 'terminal',
155
- tenantId: ctx.tenantId,
156
- scopeId: ctx.scopeId,
157
- vertical: ctx.vertical,
158
- version: ctx.versionId ?? null,
159
- origin: outcome.failure?.origin ?? null,
160
- code: outcome.failure?.code ?? null,
161
- message: platformIntentFailureMessage(id, `failed: ${outcome.error ?? 'unknown'}`),
162
- });
238
+ opts?.recordFailure?.(intentFailure(ctx, kind, 'terminal', outcome, platformIntentFailureMessage(id, `failed: ${outcome.error ?? 'unknown'}`), (r) => platformIntentFailureMessage(id, `failed (reference ${r})`), reference));
239
+ }
240
+ else if (outcome.status === 'pending' && withheld !== undefined) {
241
+ const previousError = isUndecodablePlatformRequest(request) ? null : request.lastError;
242
+ journaled = `${withheld} (reference ${await withheldTransientReference(ctx, kind, id, previousError, withheld, outcome, opts)})`;
163
243
  }
164
244
  // No special case for a refused row: every row that reaches here has an id the settle
165
245
  // route accepts (#1637 left the rest out above), so its settle is refused for the same
@@ -170,8 +250,8 @@ export async function drainScopePlatformRequests(client, ctx, handlers, opts) {
170
250
  await client.settlePlatformRequest(ctx.tenantId, ctx.scopeId, id, {
171
251
  status,
172
252
  result: outcome.result,
173
- lastError: outcome.error ?? null,
174
- failure: outcome.failure ?? null,
253
+ lastError: journaled,
254
+ failure: withheld === undefined ? (outcome.failure ?? null) : withheldAttribution(outcome.failure),
175
255
  ...(outcome.event ? { event: outcome.event } : {}),
176
256
  });
177
257
  report[status]++;
@@ -223,6 +303,7 @@ export async function provisionSiblingScope(deps, input) {
223
303
  slug: input.slug,
224
304
  name: input.name,
225
305
  vertical: parent.vertical,
306
+ verticalSource: 'copy',
226
307
  jurisdiction: parent.jurisdiction ?? undefined,
227
308
  });
228
309
  const created = await adoptServingForProvisioning(host, actor, await admin.getScopeRecord(actor, input.tenantId, input.scopeId));
@@ -273,11 +354,23 @@ export async function provisionSiblingScope(deps, input) {
273
354
  */
274
355
  export function provisionSiblingHandler(deps) {
275
356
  return async (ctx, request) => {
276
- const payload = provisionSiblingPayload.parse(request.payload);
357
+ const parsed = provisionSiblingPayload.safeParse(request.payload);
358
+ if (!parsed.success)
359
+ return malformedPayload('provision-sibling', parsed.error);
360
+ const payload = parsed.data;
277
361
  const prior = request.result;
278
- const scopeId = prior && typeof prior === 'object' && 'scopeId' in prior
279
- ? scopeIdSchema.parse(prior.scopeId)
280
- : scopeIdSchema.parse(ulid());
362
+ // The sibling's own input, checked before `provisionSiblingScope` writes its row, by the rules
363
+ // that write applies: one it would refuse settles here and leaves no row behind.
364
+ const checked = checkedBeforeWrites(() => {
365
+ const scopeId = prior && typeof prior === 'object' && 'scopeId' in prior
366
+ ? scopeIdSchema.parse(prior.scopeId)
367
+ : scopeIdSchema.parse(ulid());
368
+ resolveScopeRecord({ tenantId: ctx.tenantId, scopeId, slug: payload.slug, name: payload.name });
369
+ return scopeId;
370
+ });
371
+ if (!checked.ok)
372
+ return checked.refusal;
373
+ const scopeId = checked.value;
281
374
  try {
282
375
  const r = await provisionSiblingScope(deps, {
283
376
  tenantId: ctx.tenantId,
@@ -292,12 +385,58 @@ export function provisionSiblingHandler(deps) {
292
385
  : { status: 'failed', result: { scopeId }, error: r.error };
293
386
  }
294
387
  catch (e) {
388
+ // After the writes have begun, a throw is classified as it always was: a vertical's refusal
389
+ // is transient, and anything else leaves the request pending. A refusal settled terminal here
390
+ // could leave a half-provisioned sibling that no later pass finishes.
295
391
  if (e instanceof ControlPlaneError)
296
392
  return { status: 'pending', result: { scopeId }, error: e.message };
297
393
  throw e;
298
394
  }
299
395
  };
300
396
  }
397
+ /** The attribution of a request refused for its own input: ours, and the input's to fix. */
398
+ const INPUT_REFUSED = { origin: 'platform', code: 'validation_failed', permission: null };
399
+ /**
400
+ * #2556: a throw whose text is not the drained scope's to read (`PlatformRequestOutcome.withheld`),
401
+ * kept pending as the drain's own catch would keep it, attributed as that catch would, and
402
+ * journaled as `sentence`.
403
+ */
404
+ function withheldThrow(e, sentence, result) {
405
+ return {
406
+ status: 'pending',
407
+ ...(result === undefined ? {} : { result }),
408
+ error: e instanceof Error ? e.message : String(e),
409
+ failure: attributeFailure(e),
410
+ withheld: sentence,
411
+ };
412
+ }
413
+ /** A payload that does not parse, settled `failed` at once: it never will parse. */
414
+ function malformedPayload(kind, error) {
415
+ const fields = error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
416
+ return { status: 'failed', error: `${kind} payload is malformed: ${fields}`, failure: INPUT_REFUSED };
417
+ }
418
+ /**
419
+ * A request's input, checked before its handler writes anything: `check` parses and resolves, and
420
+ * performs no I/O. A `validation_failed` from it (a parse failure reads as one) refuses the input
421
+ * itself, which is refused the same way on every pass, so it settles `failed` at once rather than
422
+ * spending attempts. This is the ONLY place such a refusal is terminal: once a handler has begun
423
+ * writing, a throw keeps its old classification, so a parse failing on a stored row mid-provision
424
+ * leaves the request pending to be finished, not a half-written tenant or scope settled for good.
425
+ * Anything else `check` throws is rethrown.
426
+ */
427
+ function checkedBeforeWrites(check) {
428
+ try {
429
+ return { ok: true, value: check() };
430
+ }
431
+ catch (e) {
432
+ if (errorCodeOf(e) !== 'validation_failed')
433
+ throw e;
434
+ return {
435
+ ok: false,
436
+ refusal: { status: 'failed', error: e instanceof Error ? e.message : String(e), failure: INPUT_REFUSED },
437
+ };
438
+ }
439
+ }
301
440
  /**
302
441
  * The `archive-scope` platform-request handler — archives a sibling scope named in the intent.
303
442
  * The scope being drained proves the tenant; the target must be under that same tenant and run the
@@ -425,17 +564,25 @@ export function peerInvokeHandler(deps) {
425
564
  if (!client) {
426
565
  return { status: 'failed', error: `no deployment serves '${payload.vertical}' in this tenant` };
427
566
  }
428
- const result = await client.verticalInvoke({
429
- // `tenantSlug`: the directory's, for the tenant this intent was drained in (#2252), so a
430
- // pinned caller meets a bare `peers` entry at the door (`peerCallerAt`).
431
- caller: { vertical: ctx.vertical, scope: ctx.scopeId, tenantSlug: tenant.slug },
432
- tenantId: ctx.tenantId,
433
- scopeId: resolution.instance.scopeId,
434
- operation: payload.operation,
435
- ...(payload.input === undefined ? {} : { input: payload.input }),
436
- // The intent id: a redelivery reaches the recorded response instead of running twice.
437
- idempotencyKey: request.id,
438
- });
567
+ let result;
568
+ try {
569
+ result = await client.verticalInvoke({
570
+ // `tenantSlug`: the directory's, for the tenant this intent was drained in (#2252), so a
571
+ // pinned caller meets a bare `peers` entry at the door (`peerCallerAt`).
572
+ caller: { vertical: ctx.vertical, scope: ctx.scopeId, tenantSlug: tenant.slug },
573
+ tenantId: ctx.tenantId,
574
+ scopeId: resolution.instance.scopeId,
575
+ operation: payload.operation,
576
+ ...(payload.input === undefined ? {} : { input: payload.input }),
577
+ // The intent id: a redelivery reaches the recorded response instead of running twice.
578
+ idempotencyKey: request.id,
579
+ });
580
+ }
581
+ catch (e) {
582
+ // #2556: the target's answer is its deployment's text, possibly another builder's code, so
583
+ // the caller's journal says only which call did not complete. Transient, as it always was.
584
+ return withheldThrow(e, `the call to '${payload.vertical}' (operation '${payload.operation}') did not complete at its deployment`);
585
+ }
439
586
  return { status: 'done', result: { invoked: payload.operation, result: result ?? null } };
440
587
  };
441
588
  }
@@ -492,14 +639,39 @@ async function admitManager(deps, ctx, requestedKeys, targetVertical) {
492
639
  */
493
640
  export function provisionTenantHandler(deps) {
494
641
  return async (ctx, request) => {
495
- const payload = provisionTenantPayload.parse(request.payload);
642
+ // Terminal, like the other refusals here: a payload that does not parse never will, and a
643
+ // throw would leave it pending and retried every pass until the attempt ceiling.
644
+ const parsed = provisionTenantPayload.safeParse(request.payload);
645
+ if (!parsed.success)
646
+ return malformedPayload('provision-tenant', parsed.error);
647
+ const payload = parsed.data;
496
648
  const admitted = await admitManager(deps, ctx, payload.entitlements.map((e) => e.key), payload.instance.vertical);
497
649
  if ('refusal' in admitted)
498
650
  return admitted.refusal;
651
+ // The whole input, checked BEFORE anything is written, by the same rules the writes below
652
+ // apply: an input they would refuse settles here and creates nothing, rather than leaving a
653
+ // tenant and its entitlements behind a scope that is never provisioned. After the capability
654
+ // check above, so a manager that may not provision at all is told that (#455), not what is
655
+ // wrong with a payload it could not have sent anyway.
656
+ const checked = checkedBeforeWrites(() => {
657
+ const tenantId = tenantIdSchema.parse(payload.tenant.id);
658
+ const scopeId = scopeIdSchema.parse(payload.instance.scopeId);
659
+ createTenantInput.parse({ id: tenantId, slug: payload.tenant.slug, name: payload.tenant.name, provisionedByTenant: ctx.tenantId });
660
+ const scopeInput = {
661
+ tenantId,
662
+ scopeId,
663
+ slug: payload.instance.slug,
664
+ name: payload.instance.name,
665
+ vertical: payload.instance.vertical,
666
+ };
667
+ resolveScopeRecord(scopeInput);
668
+ return { tenantId, scopeId, scopeInput };
669
+ });
670
+ if (!checked.ok)
671
+ return checked.refusal;
672
+ const { tenantId, scopeId, scopeInput } = checked.value;
499
673
  const { host, actor } = deps;
500
674
  const admin = host.admin;
501
- const tenantId = tenantIdSchema.parse(payload.tenant.id);
502
- const scopeId = scopeIdSchema.parse(payload.instance.scopeId);
503
675
  try {
504
676
  await admin.createTenant(actor, {
505
677
  id: tenantId,
@@ -515,49 +687,54 @@ export function provisionTenantHandler(deps) {
515
687
  const message = e instanceof Error ? e.message : String(e);
516
688
  // Both adapters fail closed on a slug owned by a DIFFERENT tenant id with this
517
689
  // phrasing — a retry can never converge, so settle terminal instead of pending.
690
+ // The text names only the payload's own slug (#2556), so it is journaled as it is.
518
691
  if (message.includes('slugs are unique'))
519
692
  return { status: 'failed', error: message };
520
693
  throw e;
521
694
  }
522
- // Granted BEFORE provisionInstance so the #310 projection carries them on day one.
523
- for (const grant of payload.entitlements) {
524
- await admin.grantEntitlement(actor, tenantId, grant.key, { plan: grant.plan });
525
- }
526
- await host.provisionScope(actor, {
527
- tenantId,
528
- scopeId,
529
- slug: payload.instance.slug,
530
- name: payload.instance.name,
531
- vertical: payload.instance.vertical,
532
- });
533
- const created = await adoptServingForProvisioning(host, actor, await admin.getScopeRecord(actor, tenantId, scopeId));
534
- const vertical = created ? await deps.resolveVerticalForScope(created) : undefined;
535
- if (!vertical) {
536
- return {
537
- status: 'failed',
538
- result: { tenantId, scopeId },
539
- error: `no deployment is bound for vertical '${payload.instance.vertical}'`,
540
- };
541
- }
542
- const entitlements = await admin.listEntitlements(actor, tenantId);
543
- // #301: a NEW tenant's first install is exactly the minting path — the stores are
544
- // per (tenant, target vertical), keyed by the PAYLOAD's vertical, not the manager's.
545
- const tenantStores = await collectTenantStoreHandles({
546
- host,
547
- actor,
548
- slug: payload.instance.vertical,
549
- tenantId,
550
- patchBindings: deps.patchScriptBindings,
551
- });
552
- // #473: same blob-store minting on the manager-driven provision-tenant path.
553
- await collectBlobStoreHandles({
554
- host,
555
- actor,
556
- slug: payload.instance.vertical,
557
- tenantId,
558
- patchBindings: deps.patchScriptBindings,
559
- });
695
+ // #2556: from here every write and call concerns the tenant being provisioned and the
696
+ // payload vertical's deployment, neither of them the manager's, so a throw's text is withheld
697
+ // from the manager's journal: it learns the step, and staff read the text under the reference.
698
+ let step = 'granting its entitlements';
560
699
  try {
700
+ // Granted BEFORE provisionInstance so the #310 projection carries them on day one.
701
+ for (const grant of payload.entitlements) {
702
+ await admin.grantEntitlement(actor, tenantId, grant.key, { plan: grant.plan });
703
+ }
704
+ // Past the first write, so a throw keeps its old classification (pending, retried): the
705
+ // tenant exists, and a later pass is what finishes it.
706
+ step = 'provisioning its scope';
707
+ await host.provisionScope(actor, scopeInput);
708
+ const created = await adoptServingForProvisioning(host, actor, await admin.getScopeRecord(actor, tenantId, scopeId));
709
+ const vertical = created ? await deps.resolveVerticalForScope(created) : undefined;
710
+ if (!vertical) {
711
+ return {
712
+ status: 'failed',
713
+ result: { tenantId, scopeId },
714
+ error: `no deployment is bound for vertical '${payload.instance.vertical}'`,
715
+ };
716
+ }
717
+ step = 'collecting its stores';
718
+ const entitlements = await admin.listEntitlements(actor, tenantId);
719
+ // #301: a NEW tenant's first install is exactly the minting path — the stores are
720
+ // per (tenant, target vertical), keyed by the PAYLOAD's vertical, not the manager's.
721
+ const tenantStores = await collectTenantStoreHandles({
722
+ host,
723
+ actor,
724
+ slug: payload.instance.vertical,
725
+ tenantId,
726
+ patchBindings: deps.patchScriptBindings,
727
+ });
728
+ // #473: same blob-store minting on the manager-driven provision-tenant path.
729
+ await collectBlobStoreHandles({
730
+ host,
731
+ actor,
732
+ slug: payload.instance.vertical,
733
+ tenantId,
734
+ patchBindings: deps.patchScriptBindings,
735
+ });
736
+ // A refusal from the vertical is transient (`pending`), as is anything else thrown here.
737
+ step = `provisioning its '${payload.instance.vertical}' instance`;
561
738
  // #1674: a re-drain reuses the proposed ids, so this can re-seat a wiped, switched-off scope.
562
739
  await reconcileThenReassert(admin, actor, { tenantId, scopeId }, (carry) => vertical.provisionInstance({
563
740
  tenantId,
@@ -570,6 +747,7 @@ export function provisionTenantHandler(deps) {
570
747
  ...carry,
571
748
  }));
572
749
  if (payload.config && Object.keys(payload.config).length) {
750
+ step = `configuring its '${payload.instance.vertical}' instance`;
573
751
  try {
574
752
  await vertical.configureInstance({
575
753
  tenantId,
@@ -584,14 +762,12 @@ export function provisionTenantHandler(deps) {
584
762
  throw e;
585
763
  }
586
764
  }
765
+ step = 'activating its scope';
766
+ await admin.activateScope(actor, tenantId, scopeId);
587
767
  }
588
768
  catch (e) {
589
- if (e instanceof ControlPlaneError) {
590
- return { status: 'pending', result: { tenantId, scopeId }, error: e.message };
591
- }
592
- throw e;
769
+ return withheldThrow(e, `provisioning tenant '${payload.tenant.slug}' did not complete while ${step}`, { tenantId, scopeId });
593
770
  }
594
- await admin.activateScope(actor, tenantId, scopeId);
595
771
  return { status: 'done', result: { tenantId, scopeId } };
596
772
  };
597
773
  }
@@ -664,34 +840,38 @@ export function setEntitlementsHandler(deps) {
664
840
  if (!scope) {
665
841
  return { status: 'failed', error: `unknown scope for tenant: (${tenantId}, ${scopeId})` };
666
842
  }
667
- const target = new Map(payload.entitlements.map((e) => [e.key, e.plan]));
668
- for (const key of admitted.declared) {
669
- if (target.has(key)) {
670
- await admin.grantEntitlement(actor, tenantId, key, { plan: target.get(key) });
843
+ // #2556: the writes and the reconcile concern the managed tenant and its scope's deployment,
844
+ // neither of them the manager's, so a throw's text is withheld from the manager's journal.
845
+ let step = 'reconciling its entitlements';
846
+ try {
847
+ const target = new Map(payload.entitlements.map((e) => [e.key, e.plan]));
848
+ for (const key of admitted.declared) {
849
+ if (target.has(key)) {
850
+ await admin.grantEntitlement(actor, tenantId, key, { plan: target.get(key) });
851
+ }
852
+ else {
853
+ await admin.revokeEntitlement(actor, tenantId, key);
854
+ }
671
855
  }
672
- else {
673
- await admin.revokeEntitlement(actor, tenantId, key);
856
+ const vertical = await deps.resolveVerticalForScope(scope);
857
+ if (!vertical) {
858
+ // Named by the payload's own ids, not by what the managed tenant's scope runs (#2556).
859
+ return { status: 'failed', error: `no deployment is bound for scope ${scopeId} of tenant ${tenantId}` };
674
860
  }
675
- }
676
- const vertical = await deps.resolveVerticalForScope(scope);
677
- if (!vertical) {
678
- return {
679
- status: 'failed',
680
- error: `no deployment is bound for vertical '${scope.vertical ?? 'none'}'`,
681
- };
682
- }
683
- const entitlements = await admin.listEntitlements(actor, tenantId);
684
- const identityLinks = (await admin.listIdentityLinks(actor, tenantId)).map(({ tenantId: _tenantId, ...link }) => link);
685
- // #592: every reconcile re-delivers the tenant's live connection grants, whoever
686
- // triggered it — this drain-side reconcile must not silently wipe or omit them.
687
- const connectionGrants = connectionGrantsForScope(await admin.listConnectionGrants(actor, tenantId), scope.vertical, scopeId);
688
- // #687: sealing keys ride every reconcile too, for the same reason grants do — a
689
- // drain-side reconcile must not leave a scope unable to seal to a connector it could
690
- // seal to a moment ago.
691
- const connectionKeys = scope.vertical
692
- ? await admin.connectionSealingKeys(tenantId, scope.vertical)
693
- : [];
694
- try {
861
+ step = 'gathering what its instance is reconciled with';
862
+ const entitlements = await admin.listEntitlements(actor, tenantId);
863
+ const identityLinks = (await admin.listIdentityLinks(actor, tenantId)).map(({ tenantId: _tenantId, ...link }) => link);
864
+ // #592: every reconcile re-delivers the tenant's live connection grants, whoever
865
+ // triggered it — this drain-side reconcile must not silently wipe or omit them.
866
+ const connectionGrants = connectionGrantsForScope(await admin.listConnectionGrants(actor, tenantId), scope.vertical, scopeId);
867
+ // #687: sealing keys ride every reconcile too, for the same reason grants do — a
868
+ // drain-side reconcile must not leave a scope unable to seal to a connector it could
869
+ // seal to a moment ago.
870
+ const connectionKeys = scope.vertical
871
+ ? await admin.connectionSealingKeys(tenantId, scope.vertical)
872
+ : [];
873
+ // A refusal from the vertical is transient (`pending`), as is anything else thrown here.
874
+ step = 'reconciling its instance';
695
875
  await reconcileThenReassert(admin, actor, { tenantId, scopeId }, (carry) => vertical.reconcileInstance({
696
876
  tenantId,
697
877
  scopeId,
@@ -703,9 +883,7 @@ export function setEntitlementsHandler(deps) {
703
883
  }));
704
884
  }
705
885
  catch (e) {
706
- if (e instanceof ControlPlaneError)
707
- return { status: 'pending', error: e.message };
708
- throw e;
886
+ return withheldThrow(e, `setting the entitlements of tenant ${tenantId} did not complete while ${step}`);
709
887
  }
710
888
  return { status: 'done', result: { tenantId, plan: payload.plan } };
711
889
  };