@substrat-run/control-plane-api 0.135.0 → 0.136.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 (59) hide show
  1. package/dist/aggregate-reads.d.ts.map +1 -1
  2. package/dist/aggregate-reads.js +4 -1
  3. package/dist/aggregate-reads.js.map +1 -1
  4. package/dist/aggregate-source.d.ts +17 -1
  5. package/dist/aggregate-source.d.ts.map +1 -1
  6. package/dist/aggregate-source.js +16 -10
  7. package/dist/aggregate-source.js.map +1 -1
  8. package/dist/api.d.ts.map +1 -1
  9. package/dist/api.js +1016 -65
  10. package/dist/api.js.map +1 -1
  11. package/dist/cf-observability.d.ts +6 -0
  12. package/dist/cf-observability.d.ts.map +1 -1
  13. package/dist/cf-observability.js +125 -33
  14. package/dist/cf-observability.js.map +1 -1
  15. package/dist/connect-url.d.ts +2 -2
  16. package/dist/connect-url.d.ts.map +1 -1
  17. package/dist/connect-url.js +7 -1
  18. package/dist/connect-url.js.map +1 -1
  19. package/dist/connection-relay.d.ts +4 -2
  20. package/dist/connection-relay.d.ts.map +1 -1
  21. package/dist/connection-relay.js +10 -1
  22. package/dist/connection-relay.js.map +1 -1
  23. package/dist/deploy.d.ts +3 -0
  24. package/dist/deploy.d.ts.map +1 -1
  25. package/dist/deploy.js.map +1 -1
  26. package/dist/failure-attribution.js +1 -1
  27. package/dist/failure-attribution.js.map +1 -1
  28. package/dist/index.d.ts +6 -5
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +5 -3
  31. package/dist/index.js.map +1 -1
  32. package/dist/observability.d.ts +20 -1
  33. package/dist/observability.d.ts.map +1 -1
  34. package/dist/observability.js +11 -1
  35. package/dist/observability.js.map +1 -1
  36. package/dist/platform-drain.d.ts +24 -2
  37. package/dist/platform-drain.d.ts.map +1 -1
  38. package/dist/platform-drain.js +36 -3
  39. package/dist/platform-drain.js.map +1 -1
  40. package/dist/platform-entry.d.ts +62 -6
  41. package/dist/platform-entry.d.ts.map +1 -1
  42. package/dist/platform-entry.generated.d.ts +8 -1
  43. package/dist/platform-entry.generated.d.ts.map +1 -1
  44. package/dist/platform-entry.generated.js +10 -3
  45. package/dist/platform-entry.generated.js.map +1 -1
  46. package/dist/platform-entry.js +151 -9
  47. package/dist/platform-entry.js.map +1 -1
  48. package/dist/provider-error.d.ts +9 -0
  49. package/dist/provider-error.d.ts.map +1 -0
  50. package/dist/provider-error.js +9 -0
  51. package/dist/provider-error.js.map +1 -0
  52. package/dist/tenant-request-query.d.ts.map +1 -1
  53. package/dist/tenant-request-query.js +3 -1
  54. package/dist/tenant-request-query.js.map +1 -1
  55. package/dist/vertical-client.d.ts +177 -40
  56. package/dist/vertical-client.d.ts.map +1 -1
  57. package/dist/vertical-client.js +350 -171
  58. package/dist/vertical-client.js.map +1 -1
  59. package/package.json +7 -7
@@ -1,6 +1,23 @@
1
- import { PREVIEW_CLIENT_PATH, attachmentRecord, denialFilterParams, capabilityFilterParams, capabilityPage, mintedPreviewClient, ownerSeat, ownerClaimLink, ownerTransferResult, previewClientClaim, retiredPreviewClients, peerGrantsEntry, peerSwitchOutcome, systemSwitchOutcome, systemScheduleEntry, switchedOffInUnit, exportedBatch, importResult, importCursorMoved, importState, substratError, } from '@substrat-run/contracts';
2
- import { CONNECTOR_ATTACHMENT_RECORD_HEADER, PLATFORM_SECRET_HEADER, undrainedEventsOf } from '@substrat-run/kernel';
1
+ import { PREVIEW_CLIENT_PATH, attachmentRecord, denialFilterParams, capabilityFilterParams, capabilityPage, mintedPreviewClient, ownerSeat, ownerClaimLink, ownerTransferResult, previewClientClaim, retiredPreviewClients, peerGrantsEntry, peerSwitchOutcome, systemSwitchOutcome, lifecycleDelivery, systemScheduleEntry, switchedOffInUnit, exportedBatch, importResult, importCursorMoved, importState, substratError, CONNECTOR_ATTACHMENT_RECORD_HEADER, LOAD_STAMP_HEADER, PLATFORM_SECRET_HEADER, WRITE_REVISION_HEADER, } from '@substrat-run/contracts';
2
+ import { undrainedEventsOf } from '@substrat-run/kernel';
3
3
  import { ControlPlaneError } from '@substrat-run/control-plane-client';
4
+ /** `routeAnswer`'s word for a deployment that predates the route it was asked (#1722, #2010). */
5
+ const PREDATES = Symbol('predates');
6
+ /** #1722's kept-copy and fenced-wipe verbs: a host without the method answers a 501. */
7
+ const FENCED = { legacy501: true, lost: 'it may or may not have acted' };
8
+ /**
9
+ * What a 200 whose body is not JSON says, when it is an HTML page (#2010, Codex #2014 r1). An old
10
+ * deployment's SPA fallback answers its own app's `index.html` for a path it does not route, and a
11
+ * proxy or error page in between answers HTML too. Nothing the platform controls marks the first
12
+ * (the page is the vertical's, served by the asset layer before any platform code runs, and the
13
+ * deployments in question predate any marker that could be added now), so the two cannot be told
14
+ * apart, and an HTML page is never read as "predates the route".
15
+ */
16
+ function htmlNote(res) {
17
+ return (res.headers.get('content-type') ?? '').toLowerCase().includes('text/html')
18
+ ? " (an HTML page: an old deployment's app shell cannot be told from an error page in between)"
19
+ : '';
20
+ }
4
21
  /**
5
22
  * The query string for both internal denial reads. The filter's own fields come from
6
23
  * the ONE encoder in contracts (#971) — this branch answers the same two routes as
@@ -319,6 +336,30 @@ export class VerticalClient {
319
336
  async connectorGrant(input) {
320
337
  await this.postInternal('/internal/connector-grant', input, 'connector-grant');
321
338
  }
339
+ /**
340
+ * Deliver one scope's lifecycle to the deployment serving it (#1713). Throws on anything but
341
+ * a parsed answer, a deployment built before the route (404/501) included, so the caller
342
+ * records no receipt and the heal sweep asks again.
343
+ */
344
+ async setLifecycle(input) {
345
+ const verb = 'lifecycle';
346
+ const base = this.options.baseUrl ?? 'https://vertical.invalid';
347
+ const res = await this.reach(verb, () => this.options.fetch(`${base}/internal/lifecycle`, {
348
+ method: 'POST',
349
+ headers: { 'content-type': 'application/json', [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
350
+ body: JSON.stringify(input),
351
+ }));
352
+ if (res.status === 404) {
353
+ throw new ControlPlaneError(501, `the deployment serving scope ${input.scopeId} predates the lifecycle delivery (#1713) — redeploy the vertical`);
354
+ }
355
+ if (!res.ok)
356
+ throw await this.refusal(verb, res);
357
+ const parsed = lifecycleDelivery.safeParse(await res.json().catch(() => null));
358
+ if (!parsed.success) {
359
+ throw new ControlPlaneError(502, `vertical answered ${verb} for scope ${input.scopeId} with an unexpected shape`);
360
+ }
361
+ return parsed.data;
362
+ }
322
363
  /**
323
364
  * Move one module's schedule kill switch in the deployment serving the scope (#1666) —
324
365
  * the far end of `revokeFromSystem` / `restoreToSystem` for a hosted scope, whose
@@ -327,114 +368,83 @@ export class VerticalClient {
327
368
  * ONE shape is normalized to "redeploy the vertical", and only because it is the
328
369
  * deployment's own proof that it cannot have acted: a script built before the route
329
370
  * answers a **404** (the path does not exist — the route itself answers a module it holds
330
- * nothing for with a 200 `held: false`, never a 404) or its **SPA shell** (a 200 that is
331
- * not JSON). "Nothing was switched" is true of exactly those.
371
+ * nothing for with a 200 `held: false`, never a 404). "Nothing was switched" is true of exactly
372
+ * that. An HTML 200 is NOT that proof (`routeAnswer`): an old deployment's app shell cannot be
373
+ * told from an error page after a switch that moved.
332
374
  *
333
375
  * Everything else surfaces as the failure it is, and never claims that: a transport
334
- * failure (`reach` → 502 "unreachable"), a genuine 5xx from the far end, and a 200 JSON of
335
- * the wrong shape. The request may have landed and the switch may have moved before the
336
- * answer was lost, so the only honest instruction is to confirm the position first.
376
+ * failure (`reach` → 502 "unreachable"), a genuine 5xx from the far end, a 200 whose body
377
+ * is truncated or fails to read (#2010), and a 200 JSON of the wrong shape. The request may
378
+ * have landed and the switch may have moved before the answer was lost, so the only honest
379
+ * instruction is to confirm the position first.
337
380
  */
338
381
  async systemSwitch(input) {
339
382
  const verb = 'system-switch';
340
- const predates = () => new ControlPlaneError(501, `the deployment serving scope ${input.scopeId} predates the schedule switch (#1666) — ` +
341
- `redeploy the vertical, then retry. Nothing was switched.`);
383
+ const lost = `the switch on scope ${input.scopeId} may or may not have moved. ` +
384
+ `Confirm its position (read the scope's schedule status) before retrying.`;
342
385
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
343
- const res = await this.reach(verb, () => this.options.fetch(`${base}/internal/system-switch`, {
386
+ const rule = {
387
+ legacy501: false,
388
+ lost,
389
+ predates: `the deployment serving scope ${input.scopeId} predates the schedule switch (#1666) — ` +
390
+ `redeploy the vertical, then retry. Nothing was switched.`,
391
+ shape: `vertical answered ${verb} with an unexpected shape — ${lost}`,
392
+ };
393
+ return this.parsedAnswer(verb, rule, systemSwitchOutcome, () => this.options.fetch(`${base}/internal/system-switch`, {
344
394
  method: 'POST',
345
395
  headers: { 'content-type': 'application/json', [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
346
396
  body: JSON.stringify(input),
347
397
  }));
348
- if (res.status === 404)
349
- throw predates();
350
- if (!res.ok)
351
- throw await this.refusal(verb, res);
352
- const text = await res.text();
353
- let raw;
354
- try {
355
- raw = JSON.parse(text);
356
- }
357
- catch {
358
- throw predates();
359
- }
360
- const parsed = systemSwitchOutcome.safeParse(raw);
361
- if (!parsed.success) {
362
- throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape — the switch on scope ${input.scopeId} may ` +
363
- `or may not have moved. Confirm its position (the scope's SQL console) before retrying.`);
364
- }
365
- return parsed.data;
366
398
  }
367
399
  /**
368
400
  * The read half of the schedule kill switch's status (#1674): every module the
369
401
  * deployment serving this scope holds or has held system authority for, and where each
370
- * stands. Mirrors `systemSwitch`'s seam and its skew rule exactly (#1666's "only the
371
- * explicit legacy signal reads as redeploy"): a 404 (the route does not exist) or an SPA
372
- * shell (a 200 that is not JSON) are the deployment's own proof it predates this read,
373
- * and become a 501 that says so. A 200 of the wrong-shaped JSON is NOT that proof — it
374
- * is a failure this client cannot explain, so it surfaces as the 502 it is rather than a
375
- * guess at "redeploy". The far end's own 501 (the route exists, the method doesn't)
376
- * passes through `refusal` verbatim, unchanged either way.
402
+ * stands. Mirrors `systemSwitch`'s seam and its skew rule exactly (`routeAnswer`): a 404
403
+ * is the deployment's own proof it predates this read, and becomes a 501 that says so. A
404
+ * truncated, unreadable or HTML 200 (#2010) and a 200 of the wrong-shaped JSON are NOT that
405
+ * proof — they are a failed read, a 502, rather than a guess at
406
+ * "redeploy". The far end's own 501 (the route exists, the method doesn't) passes through
407
+ * `refusal` verbatim, unchanged either way.
377
408
  */
378
409
  async systemGrantsStatus(input) {
379
410
  const verb = 'system-grants';
380
- const predates = () => new ControlPlaneError(501, `the deployment serving scope ${input.scopeId} predates the schedule switch's status read (#1674) — ` +
381
- `redeploy the vertical, then retry.`);
411
+ const lost = `the status of scope ${input.scopeId} could not be read. Nothing was changed; retry the read.`;
382
412
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
383
- const res = await this.reach(verb, () => this.options.fetch(`${base}/internal/system-grants?scopeId=${encodeURIComponent(input.scopeId)}`, {
413
+ const rule = {
414
+ legacy501: false,
415
+ lost,
416
+ predates: `the deployment serving scope ${input.scopeId} predates the schedule switch's status read (#1674) — ` +
417
+ `redeploy the vertical, then retry.`,
418
+ shape: `vertical answered ${verb} with an unexpected shape for scope ${input.scopeId}.`,
419
+ };
420
+ return this.parsedAnswer(verb, rule, systemScheduleEntry.array(), () => this.options.fetch(`${base}/internal/system-grants?scopeId=${encodeURIComponent(input.scopeId)}`, {
384
421
  method: 'GET',
385
422
  headers: { [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
386
423
  }));
387
- if (res.status === 404)
388
- throw predates();
389
- if (!res.ok)
390
- throw await this.refusal(verb, res);
391
- const text = await res.text();
392
- let raw;
393
- try {
394
- raw = JSON.parse(text);
395
- }
396
- catch {
397
- throw predates();
398
- }
399
- const parsed = systemScheduleEntry.array().safeParse(raw);
400
- if (!parsed.success) {
401
- throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape for scope ${input.scopeId}.`);
402
- }
403
- return parsed.data;
404
424
  }
405
425
  /**
406
426
  * The read half of the peer kill switch's status (#1706): every peer the deployment
407
427
  * serving this scope holds or has held grants for, and where each stands. Mirrors
408
- * `peerSwitch`'s seam and `systemGrantsStatus`'s skew rule exactly — a 404 or an SPA
409
- * shell are the deployment's own proof it predates this read and become a 501 that says
410
- * to redeploy; a wrong-shaped 200 is not that proof and surfaces as the 502 it is.
428
+ * `peerSwitch`'s seam and `systemGrantsStatus`'s skew rule exactly — a 404 is the
429
+ * deployment's own proof it predates this read and becomes a 501 that says to redeploy; a
430
+ * truncated, unreadable, HTML or wrong-shaped 200 is not that proof and surfaces as the 502
431
+ * it is.
411
432
  */
412
433
  async peerGrantsStatus(input) {
413
434
  const verb = 'peer-grants';
414
- const predates = () => new ControlPlaneError(501, `the deployment serving scope ${input.scopeId} predates the peer switch's status read (#1706) — ` +
415
- `redeploy the vertical, then retry.`);
435
+ const lost = `the peer status of scope ${input.scopeId} could not be read. Nothing was changed; retry the read.`;
416
436
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
417
- const res = await this.reach(verb, () => this.options.fetch(`${base}/internal/peer-grants?scopeId=${encodeURIComponent(input.scopeId)}`, {
437
+ const rule = {
438
+ legacy501: false,
439
+ lost,
440
+ predates: `the deployment serving scope ${input.scopeId} predates the peer switch's status read (#1706) — ` +
441
+ `redeploy the vertical, then retry.`,
442
+ shape: `vertical answered ${verb} with an unexpected shape for scope ${input.scopeId}.`,
443
+ };
444
+ return this.parsedAnswer(verb, rule, peerGrantsEntry.array(), () => this.options.fetch(`${base}/internal/peer-grants?scopeId=${encodeURIComponent(input.scopeId)}`, {
418
445
  method: 'GET',
419
446
  headers: { [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
420
447
  }));
421
- if (res.status === 404)
422
- throw predates();
423
- if (!res.ok)
424
- throw await this.refusal(verb, res);
425
- const text = await res.text();
426
- let raw;
427
- try {
428
- raw = JSON.parse(text);
429
- }
430
- catch {
431
- throw predates();
432
- }
433
- const parsed = peerGrantsEntry.array().safeParse(raw);
434
- if (!parsed.success) {
435
- throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape for scope ${input.scopeId}.`);
436
- }
437
- return parsed.data;
438
448
  }
439
449
  /**
440
450
  * Move one PEER's kill switch in the deployment serving the scope (#1706) — the far end
@@ -442,40 +452,30 @@ export class VerticalClient {
442
452
  * live there and nowhere the shared control plane can reach.
443
453
  *
444
454
  * The seam, the skew rule and the honesty rule are `systemSwitch`'s, for the same reasons:
445
- * a **404** (no such route) or an **SPA shell** (a 200 that is not JSON) are the
446
- * deployment's own proof that it predates this route and therefore cannot have switched
447
- * anything — the route itself answers a peer the scope holds nothing for with a 200
448
- * `held: false`, never a 404. Everything else surfaces as the failure it is, because the
449
- * request may have landed and the switch may have moved before the answer was lost.
455
+ * a **404** (no such route) is the deployment's own proof that it
456
+ * predates this route and therefore cannot have switched anything — the route itself
457
+ * answers a peer the scope holds nothing for with a 200 `held: false`, never a 404.
458
+ * Everything else, a truncated or unreadable 200 included (#2010), surfaces as the failure
459
+ * it is, because the request may have landed and the switch may have moved before the
460
+ * answer was lost.
450
461
  */
451
462
  async peerSwitch(input) {
452
463
  const verb = 'peer-switch';
453
- const predates = () => new ControlPlaneError(501, `the deployment serving scope ${input.scopeId} predates the peer kill switch (#1706) — ` +
454
- `redeploy the vertical, then retry. Nothing was switched.`);
464
+ const lost = `the switch for peer '${input.vertical}' on scope ${input.scopeId} may or may not have moved. ` +
465
+ `Confirm its position (read the scope's peer status) before retrying.`;
455
466
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
456
- const res = await this.reach(verb, () => this.options.fetch(`${base}/internal/peer-switch`, {
467
+ const rule = {
468
+ legacy501: false,
469
+ lost,
470
+ predates: `the deployment serving scope ${input.scopeId} predates the peer kill switch (#1706) — ` +
471
+ `redeploy the vertical, then retry. Nothing was switched.`,
472
+ shape: `vertical answered ${verb} with an unexpected shape — ${lost}`,
473
+ };
474
+ return this.parsedAnswer(verb, rule, peerSwitchOutcome, () => this.options.fetch(`${base}/internal/peer-switch`, {
457
475
  method: 'POST',
458
476
  headers: { 'content-type': 'application/json', [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
459
477
  body: JSON.stringify(input),
460
478
  }));
461
- if (res.status === 404)
462
- throw predates();
463
- if (!res.ok)
464
- throw await this.refusal(verb, res);
465
- const text = await res.text();
466
- let raw;
467
- try {
468
- raw = JSON.parse(text);
469
- }
470
- catch {
471
- throw predates();
472
- }
473
- const parsed = peerSwitchOutcome.safeParse(raw);
474
- if (!parsed.success) {
475
- throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape — the switch for peer '${input.vertical}' on ` +
476
- `scope ${input.scopeId} may or may not have moved. Confirm its position before retrying.`);
477
- }
478
- return parsed.data;
479
479
  }
480
480
  /**
481
481
  * A preview's own client at a team auth-server (#1704, `@substrat-run/contracts`'
@@ -483,13 +483,14 @@ export class VerticalClient {
483
483
  * here, delete the preview's clients. Addressed to the ISSUER's deployment (this client),
484
484
  * never the preview's.
485
485
  *
486
- * The skew rule is `systemSwitch`'s, widened by one status: a deployment built before these
486
+ * The skew rule is `routeAnswer`'s with `legacy501`: a deployment built before these
487
487
  * routes answers its `/internal/*` fallback — a JSON **501** on the auth-server — or a
488
- * **404**, or an SPA shell (a 200 that is not JSON). All three are the deployment's own
489
- * proof it cannot have acted, and become a 501 that says to redeploy the auth server. The
490
- * caller must never read that as "the parent does not sign in here". A 200 of the wrong
491
- * shape is not that proof and surfaces as a 502; a refusal (403 another tenant's issuer,
492
- * 409 a parent it does not claim) passes through verbatim.
488
+ * **404**. Both are the deployment's own proof it cannot have acted,
489
+ * and become a 501 that says to redeploy the auth server. The caller must never read that
490
+ * as "the parent does not sign in here". A truncated or unreadable 200 (#2010) and a 200 of
491
+ * the wrong shape are not that proof and surface as a 502: a mint or a retire may have run.
492
+ * A refusal (403 another tenant's issuer, 409 a parent it does not claim) passes through
493
+ * verbatim.
493
494
  *
494
495
  * The mint's answer carries a client SECRET. It is returned to the caller and nothing here
495
496
  * keeps, logs or quotes it: no error message on this path includes a response body that
@@ -505,31 +506,19 @@ export class VerticalClient {
505
506
  return this.previewClientCall('DELETE', PREVIEW_CLIENT_PATH, input, retiredPreviewClients, 'preview-client retire', input.scopeId);
506
507
  }
507
508
  async previewClientCall(method, path, body, schema, verb, issuerScopeId) {
508
- const predates = () => new ControlPlaneError(501, `the auth server serving scope ${issuerScopeId} predates preview clients (#1704) — redeploy it, ` +
509
- `then re-run. Nothing was minted or deleted there.`);
510
509
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
511
- const res = await this.reach(verb, () => this.options.fetch(`${base}${path}`, {
510
+ const rule = {
511
+ legacy501: true,
512
+ lost: `the auth server serving scope ${issuerScopeId} may or may not have acted on it.`,
513
+ predates: `the auth server serving scope ${issuerScopeId} predates preview clients (#1704) — redeploy it, ` +
514
+ `then re-run. Nothing was minted or deleted there.`,
515
+ shape: `auth server answered ${verb} with an unexpected shape for scope ${issuerScopeId}.`,
516
+ };
517
+ return this.parsedAnswer(verb, rule, schema, () => this.options.fetch(`${base}${path}`, {
512
518
  method,
513
519
  headers: { 'content-type': 'application/json', [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
514
520
  body: JSON.stringify(body),
515
521
  }));
516
- if (res.status === 404 || res.status === 501)
517
- throw predates();
518
- if (!res.ok)
519
- throw await this.refusal(verb, res);
520
- const text = await res.text();
521
- let raw;
522
- try {
523
- raw = JSON.parse(text);
524
- }
525
- catch {
526
- throw predates();
527
- }
528
- const parsed = schema.safeParse(raw);
529
- if (!parsed.success) {
530
- throw new ControlPlaneError(502, `auth server answered ${verb} with an unexpected shape for scope ${issuerScopeId}.`);
531
- }
532
- return parsed.data;
533
522
  }
534
523
  /** Journal a platform-request outcome back in the vertical after the platform ran it. */
535
524
  async settlePlatformRequest(tenantId, scopeId, id, outcome) {
@@ -562,6 +551,19 @@ export class VerticalClient {
562
551
  async exportScope(scopeId) {
563
552
  return this.getInternal(`/internal/export?scopeId=${encodeURIComponent(scopeId)}`);
564
553
  }
554
+ /**
555
+ * `exportScope` with the store's load stamp (#1722), read by the vertical in the same call as
556
+ * the dump and handed over in a header: what a carry's fenced wipe of the copy it leaves
557
+ * expects. Null from a deployment that predates the stamp, which cannot fence a wipe either.
558
+ * A store nothing has stamped is stamped by this read.
559
+ */
560
+ async exportScopeStamped(scopeId) {
561
+ const path = `/internal/export?scopeId=${encodeURIComponent(scopeId)}&stamp=1`;
562
+ const res = await this.getInternalResponse(path);
563
+ const loadStamp = res.headers.get(LOAD_STAMP_HEADER) || null;
564
+ const revision = res.headers.get(WRITE_REVISION_HEADER) || null;
565
+ return { tables: await this.parseInternal('introspection', path, res), loadStamp, revision };
566
+ }
565
567
  /**
566
568
  * The write half of `exportScope` — load a dump into one existing scope in this
567
569
  * deployment (drop-then-replay), for the governed restore/backout. The control-plane
@@ -576,7 +578,12 @@ export class VerticalClient {
576
578
  /** #1742: the recorded-off modules, switched off on THIS scope in the restore's own event.
577
579
  * #1869: `sourceScopeId`, the scope the tables were captured from, so the vertical re-points
578
580
  * exactly that scope's grants, and `exact` when the platform exported them itself. A
579
- * vertical that predates the fields ignores them. */
581
+ * vertical that predates the fields ignores them. #1722: `loadStamp`, the stamp a carry
582
+ * leaves on the copy it lands, which a later fenced wipe of that copy expects, and `expect`,
583
+ * the marker the carry read here (`loadMarker`): the load is refused if the store moved since.
584
+ * #2005: `markCopy` — the directory's classification of this scope, sent when it is not
585
+ * primary, so the vertical marks it a copy in its own storage (and refuses a primary one).
586
+ * A vertical that predates the field ignores it. */
580
587
  opts) {
581
588
  // `exact` vouches for a named source; the vertical refuses it without one, so say so here.
582
589
  if (opts?.exact && !opts.sourceScopeId) {
@@ -589,9 +596,191 @@ export class VerticalClient {
589
596
  ...(opts?.switchedOff ? { switchedOff: opts.switchedOff } : {}),
590
597
  ...(opts?.sourceScopeId ? { sourceScopeId: opts.sourceScopeId } : {}),
591
598
  ...(opts?.exact ? { exact: true } : {}),
599
+ ...(opts?.loadStamp ? { loadStamp: opts.loadStamp } : {}),
600
+ ...(opts?.expect ? { expect: opts.expect } : {}),
601
+ ...(opts?.markCopy ? { markCopy: opts.markCopy } : {}),
592
602
  }, 'restore');
593
603
  return { tables: count, ...switchedOffFrom(switchedOff) };
594
604
  }
605
+ /**
606
+ * Wipe the copy a carry left in this deployment (#1722), only if nothing was loaded into the
607
+ * scope since `expectLoadStamp` (null: a store no load has stamped). `wiped: false` is the
608
+ * vertical's refusal: something was restored there since, and it stays.
609
+ *
610
+ * `'unfenced'` is a deployment that cannot compare the stamp, and the only answer that lets
611
+ * the caller fall back to an unconditional wipe: a 404 (built before the route), a 501 (a host
612
+ * without the method) are the deployment's own proof that it wiped nothing (`routeAnswer`).
613
+ * Everything else, a truncated or malformed JSON answer and any HTML page included, surfaces as
614
+ * the failure it is: the wipe may or may not have run.
615
+ */
616
+ async wipeCarriedCopy(input) {
617
+ const verb = 'wipe-carried';
618
+ const base = this.options.baseUrl ?? 'https://vertical.invalid';
619
+ const answer = await this.routeAnswer(verb, FENCED, () => this.options.fetch(`${base}/internal/wipe-carried`, {
620
+ method: 'POST',
621
+ headers: { 'content-type': 'application/json', [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
622
+ body: JSON.stringify(input),
623
+ }));
624
+ if (answer === PREDATES)
625
+ return 'unfenced';
626
+ const wiped = answer?.wiped;
627
+ if (typeof wiped !== 'boolean') {
628
+ throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape — the copy of scope ${input.scopeId} may or may not be wiped`);
629
+ }
630
+ return { wiped };
631
+ }
632
+ /**
633
+ * What a carry's restore into this scope expects to find unchanged (#1722): the load stamp and
634
+ * the store's write revision. `'unfenced'` on `wipeCarriedCopy`'s terms: only the
635
+ * deployment's own answer that it predates the route; everything else is a failure.
636
+ */
637
+ async loadMarker(scopeId) {
638
+ const verb = 'load-marker';
639
+ const base = this.options.baseUrl ?? 'https://vertical.invalid';
640
+ const answer = await this.routeAnswer(verb, FENCED, () => this.options.fetch(`${base}/internal/load-marker?scopeId=${encodeURIComponent(scopeId)}`, {
641
+ headers: { [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
642
+ }));
643
+ if (answer === PREDATES)
644
+ return 'unfenced';
645
+ const { loadStamp, revision } = (answer ?? {});
646
+ const field = (v) => v === null || (typeof v === 'string' && v.length > 0);
647
+ if (!field(loadStamp) || !field(revision)) {
648
+ throw new ControlPlaneError(502, `vertical answered ${verb} for scope ${scopeId} with an unexpected shape`);
649
+ }
650
+ return { loadStamp: loadStamp, revision: revision };
651
+ }
652
+ /**
653
+ * The kept copy of a scope in this deployment (#1722), or null. A deployment built before the
654
+ * fenced wipe cannot have made one, so its "predates" answer reads as null too.
655
+ */
656
+ async keptCopy(scopeId) {
657
+ const verb = 'kept-copy';
658
+ const base = this.options.baseUrl ?? 'https://vertical.invalid';
659
+ const answer = await this.routeAnswer(verb, FENCED, () => this.options.fetch(`${base}/internal/kept-copy?scopeId=${encodeURIComponent(scopeId)}`, {
660
+ headers: { [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
661
+ }));
662
+ if (answer === PREDATES)
663
+ return null;
664
+ const kept = answer?.kept;
665
+ if (kept === null)
666
+ return null;
667
+ const k = kept;
668
+ if (!k || typeof k.carriedTo !== 'string' || typeof k.keptAt !== 'string' || !(k.revision === null || typeof k.revision === 'string')) {
669
+ throw new ControlPlaneError(502, `vertical answered ${verb} for scope ${scopeId} with an unexpected shape`);
670
+ }
671
+ return {
672
+ carriedTo: k.carriedTo,
673
+ keptAt: k.keptAt,
674
+ revision: k.revision,
675
+ ...(k.leftAgain && typeof k.leftAgain.to === 'string' && typeof k.leftAgain.at === 'string'
676
+ ? { leftAgain: { to: k.leftAgain.to, at: k.leftAgain.at } }
677
+ : {}),
678
+ ...(k.clearedOnly && (k.clearedOnly.revision === null || typeof k.clearedOnly.revision === 'string')
679
+ ? { clearedOnly: { revision: k.clearedOnly.revision } }
680
+ : {}),
681
+ };
682
+ }
683
+ /** Release the marker of a kept copy that is the live store after all (#1722), at the revision read. */
684
+ async releaseKeptCopy(input) {
685
+ const verb = 'kept-copy-release';
686
+ const base = this.options.baseUrl ?? 'https://vertical.invalid';
687
+ const answer = await this.routeAnswer(verb, FENCED, () => this.options.fetch(`${base}/internal/kept-copy/release`, {
688
+ method: 'POST',
689
+ headers: { 'content-type': 'application/json', [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
690
+ body: JSON.stringify(input),
691
+ }));
692
+ if (answer === PREDATES) {
693
+ throw new ControlPlaneError(501, `the deployment holding scope ${input.scopeId} keeps no copies (#1722)`);
694
+ }
695
+ const a = answer;
696
+ if (a?.released === true)
697
+ return { released: true };
698
+ if (a?.refused === 'changed' || a?.refused === 'not-kept')
699
+ return { refused: a.refused };
700
+ throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape — the marker may or may not be cleared`);
701
+ }
702
+ /** Discard the kept copy of a scope in this deployment (#1722), at the revision the operator read. */
703
+ async discardKeptCopy(input) {
704
+ const verb = 'kept-copy-discard';
705
+ const base = this.options.baseUrl ?? 'https://vertical.invalid';
706
+ const answer = await this.routeAnswer(verb, FENCED, () => this.options.fetch(`${base}/internal/kept-copy/discard`, {
707
+ method: 'POST',
708
+ headers: { 'content-type': 'application/json', [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
709
+ body: JSON.stringify(input),
710
+ }));
711
+ if (answer === PREDATES) {
712
+ throw new ControlPlaneError(501, `the deployment holding scope ${input.scopeId} keeps no copies (#1722)`);
713
+ }
714
+ const a = answer;
715
+ if (a?.discarded === true)
716
+ return { discarded: true };
717
+ if (a?.refused === 'changed' || a?.refused === 'not-kept')
718
+ return { refused: a.refused };
719
+ throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape — the copy may or may not be discarded`);
720
+ }
721
+ /**
722
+ * The ONE skew rule for a route a deployment may predate (#1722, #2010): `PREDATES` only for a
723
+ * status that says the route is not there — a 404, and a 501 where `rule.legacy501` says the far
724
+ * end's own fallback answers one. No body is that proof. Everything else is a failure, never
725
+ * `PREDATES`, because the request may have landed: a transport failure, a refusal, a body that
726
+ * fails to read, and a body that is not valid JSON — a truncated `{"held":` from a deployment
727
+ * that DID act, or an HTML page (`htmlNote`), which an old deployment's app shell and an error
728
+ * page after a switch that moved both are. Those last ones are a 502 that ends with
729
+ * `rule.lost`, the verb's own sentence for what a lost answer leaves unknown.
730
+ */
731
+ async routeAnswer(verb, rule, request) {
732
+ const res = await this.reach(verb, request);
733
+ if (res.status === 404 || (rule.legacy501 && res.status === 501))
734
+ return PREDATES;
735
+ if (!res.ok)
736
+ throw await this.refusal(verb, res);
737
+ return this.readAnswer(verb, res, rule.lost);
738
+ }
739
+ /**
740
+ * `routeAnswer` for a verb whose "predates" is always a 501 and whose answer has a schema:
741
+ * the parsed answer, or the 501 `rule.predates`, or the 502 `rule.shape` for a wrong shape.
742
+ */
743
+ async parsedAnswer(verb, rule, schema, request) {
744
+ const answer = await this.routeAnswer(verb, rule, request);
745
+ if (answer === PREDATES)
746
+ throw new ControlPlaneError(501, rule.predates);
747
+ const parsed = schema.safeParse(answer);
748
+ if (!parsed.success)
749
+ throw new ControlPlaneError(502, rule.shape);
750
+ return parsed.data;
751
+ }
752
+ /**
753
+ * A 200's body, read under #2010's rule: the JSON it carries. A body that fails to read, or is
754
+ * not JSON (an HTML page included, `htmlNote`), is a 502 that names `subject` and ends with
755
+ * `lost`, because the deployment that answered may have acted.
756
+ */
757
+ async readAnswer(subject, res, lost) {
758
+ let text;
759
+ try {
760
+ text = await res.text();
761
+ }
762
+ catch (e) {
763
+ throw new ControlPlaneError(502, `reading the vertical's answer to ${subject} failed (${e instanceof Error ? e.message : String(e)}) — ${lost}`);
764
+ }
765
+ try {
766
+ return JSON.parse(text);
767
+ }
768
+ catch {
769
+ throw new ControlPlaneError(502, `vertical answered ${subject} with a body that is not JSON${htmlNote(res)} — ${lost}`);
770
+ }
771
+ }
772
+ /** Mark one scope a copy in the vertical's own storage (#2005), given the directory's
773
+ * classification of it; whether this call stamped it. */
774
+ async markCopy(scopeId, lineage) {
775
+ return this.postInternal('/internal/mark-copy', { scopeId, lineage }, 'mark-copy');
776
+ }
777
+ /** Remove a mistaken copy marker (#2005), given the directory's classification; whether it did. */
778
+ async clearCopyMark(scopeId, lineage,
779
+ /** #1722 (Codex #2008 r12–r13): clear only the store with this load stamp and revision; 412
780
+ * otherwise. */
781
+ opts = {}) {
782
+ return this.postInternal('/internal/clear-copy-mark', { scopeId, lineage, ...(opts.expect ? { expect: opts.expect } : {}) }, 'clear-copy-mark');
783
+ }
595
784
  /** Facets over one scope's outbox (#1239) — through the vertical that holds the data. */
596
785
  async facetEvents(scopeId, input) {
597
786
  const q = new URLSearchParams({ scopeId });
@@ -787,21 +976,16 @@ export class VerticalClient {
787
976
  : `vertical refused ${verb}: ${res.status} ${res.statusText}`);
788
977
  }
789
978
  /**
790
- * A 200 whose body is not JSON is a script that does not SERVE this route: an old
791
- * worker build falls through to its SPA fallback and answers the app shell (200,
792
- * `<!doctype …`) for any `/internal/*` path it predates. Surface that as the
793
- * diagnosis instead of an unhandled SyntaxError → 500 (#389: a pre-#236 script has
794
- * no `/internal/export`, so a carried rebind cannot dump it).
979
+ * A 200 that is not JSON is a 502 that names the verb, instead of an unhandled SyntaxError →
980
+ * 500 (#389: a pre-#236 script has no `/internal/export`, and its SPA fallback answered the app
981
+ * shell). That shell cannot be told from an error page in between (`htmlNote`), and a truncated
982
+ * answer from a deployment that has the route may follow a write that ran (#2010), so the
983
+ * message says the call may or may not have acted, and names redeploying only as the remedy
984
+ * for the case it cannot prove.
795
985
  */
796
986
  async parseInternal(verb, path, res) {
797
- const text = await res.text();
798
- try {
799
- return JSON.parse(text);
800
- }
801
- catch {
802
- throw new ControlPlaneError(502, `vertical answered ${verb} (${path}) with non-JSON — its deployed script predates this ` +
803
- `surface. Redeploy the vertical (or, for a rebind, use abandonData).`);
804
- }
987
+ return (await this.readAnswer(`${verb} (${path})`, res, 'it may or may not have acted. If this deployment answers its app for /internal/*, it predates ' +
988
+ 'this surface: redeploy the vertical (or, for a rebind, use abandonData).'));
805
989
  }
806
990
  /** A platform-authenticated POST to the vertical's `/internal/*` surface. */
807
991
  /**
@@ -827,10 +1011,11 @@ export class VerticalClient {
827
1011
  *
828
1012
  * The skew rule is `systemSwitch`'s, and here it is the whole point: an EMPTY batch is a
829
1013
  * real answer (nothing new), so a deployment that cannot answer must never produce one. A
830
- * 404 (the route does not exist) or a 200 that is not JSON (the SPA shell of a script that
831
- * predates the route) is that deployment's own proof, and becomes a 501 saying to redeploy.
1014
+ * 404 (the route does not exist) is that deployment's own proof (`routeAnswer`), and becomes a
1015
+ * 501 saying to redeploy.
832
1016
  * The far end's own 501 (the route exists, the host method does not) passes through
833
- * verbatim. A JSON 200 of the wrong shape is a 502, never a guess.
1017
+ * verbatim. A truncated or unreadable 200 (#2010) and a JSON 200 of the wrong shape are a
1018
+ * 502, never a guess.
834
1019
  */
835
1020
  async exportedEvents(input) {
836
1021
  return this.crossVerticalCall('exported-events', '/internal/exported-events', input.scopeId, exportedBatch, input);
@@ -856,8 +1041,8 @@ export class VerticalClient {
856
1041
  /**
857
1042
  * The replay lever's far end (#1705 PR 3): move the watermark of the consumer scope this
858
1043
  * deployment serves. The same skew rule, plus the write's one difference: a JSON 200 of the
859
- * wrong shape may follow a move that happened, so the 502 says to read the edge before
860
- * pulling the lever again. A 404 or an SPA shell proves the route is absent, so nothing moved.
1044
+ * wrong shape, or one truncated or unreadable (#2010), may follow a move that happened, so the
1045
+ * 502 says to read the edge before pulling the lever again. A 404 proves the route is absent, so nothing moved.
861
1046
  */
862
1047
  async importCursorMove(input) {
863
1048
  return this.crossVerticalCall('import-cursor', '/internal/import-cursor', input.scopeId, importCursorMoved, input, {
@@ -869,31 +1054,21 @@ export class VerticalClient {
869
1054
  * of `body` when there is one, a GET otherwise.
870
1055
  */
871
1056
  async crossVerticalCall(verb, path, scopeId, schema, body, opts = {}) {
872
- const predates = () => new ControlPlaneError(501, `the deployment serving scope ${scopeId} predates cross-vertical events (#1705) — redeploy the vertical. ` +
873
- `Nothing was read or delivered; the edge's watermark holds.`);
1057
+ const lost = opts.write
1058
+ ? 'the write may have taken effect: read the edge before retrying.'
1059
+ : 'it may or may not have acted; repeating it is safe.';
874
1060
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
875
1061
  const secret = { [PLATFORM_SECRET_HEADER]: this.options.platformSecret };
876
- const res = await this.reach(verb, () => this.options.fetch(`${base}${path}`, body === undefined
1062
+ const rule = {
1063
+ legacy501: false,
1064
+ lost,
1065
+ predates: `the deployment serving scope ${scopeId} predates cross-vertical events (#1705) — redeploy the vertical. ` +
1066
+ `Nothing was read or delivered; the edge's watermark holds.`,
1067
+ shape: `vertical answered ${verb} with an unexpected shape for scope ${scopeId}` + (opts.write ? ` — ${lost}` : '.'),
1068
+ };
1069
+ return this.parsedAnswer(verb, rule, schema, () => this.options.fetch(`${base}${path}`, body === undefined
877
1070
  ? { method: 'GET', headers: secret }
878
1071
  : { method: 'POST', headers: { ...secret, 'content-type': 'application/json' }, body: JSON.stringify(body) }));
879
- if (res.status === 404)
880
- throw predates();
881
- if (!res.ok)
882
- throw await this.refusal(verb, res);
883
- const text = await res.text();
884
- let raw;
885
- try {
886
- raw = JSON.parse(text);
887
- }
888
- catch {
889
- throw predates();
890
- }
891
- const parsed = schema.safeParse(raw);
892
- if (!parsed.success) {
893
- throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape for scope ${scopeId}.` +
894
- (opts.write ? ' The write may have taken effect: read the edge before retrying.' : ''));
895
- }
896
- return parsed.data;
897
1072
  }
898
1073
  async postInternal(path, body, verb) {
899
1074
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
@@ -911,13 +1086,17 @@ export class VerticalClient {
911
1086
  }
912
1087
  /** A platform-authenticated GET to the vertical's `/internal/*` surface. */
913
1088
  async getInternal(path) {
1089
+ return this.parseInternal('introspection', path, await this.getInternalResponse(path));
1090
+ }
1091
+ /** `getInternal`'s request, answering the response itself, for a caller that reads a header too. */
1092
+ async getInternalResponse(path) {
914
1093
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
915
1094
  const res = await this.reach('introspection', () => this.options.fetch(`${base}${path}`, {
916
1095
  headers: { [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
917
1096
  }));
918
1097
  if (!res.ok)
919
1098
  throw await this.refusal('introspection', res);
920
- return this.parseInternal('introspection', path, res);
1099
+ return res;
921
1100
  }
922
1101
  }
923
1102
  //# sourceMappingURL=vertical-client.js.map