@substrat-run/kernel 0.116.0 → 0.117.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 (60) hide show
  1. package/dist/capability.d.ts +220 -0
  2. package/dist/capability.d.ts.map +1 -0
  3. package/dist/capability.js +537 -0
  4. package/dist/capability.js.map +1 -0
  5. package/dist/check-key.d.ts +30 -0
  6. package/dist/check-key.d.ts.map +1 -0
  7. package/dist/check-key.js +37 -0
  8. package/dist/check-key.js.map +1 -0
  9. package/dist/denial-query.d.ts +32 -1
  10. package/dist/denial-query.d.ts.map +1 -1
  11. package/dist/denial-query.js +66 -28
  12. package/dist/denial-query.js.map +1 -1
  13. package/dist/index.d.ts +19 -4
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +14 -3
  16. package/dist/index.js.map +1 -1
  17. package/dist/outbox-event.d.ts +131 -0
  18. package/dist/outbox-event.d.ts.map +1 -0
  19. package/dist/outbox-event.js +185 -0
  20. package/dist/outbox-event.js.map +1 -0
  21. package/dist/permission-checker.d.ts +8 -1
  22. package/dist/permission-checker.d.ts.map +1 -1
  23. package/dist/permission-checker.js +18 -0
  24. package/dist/permission-checker.js.map +1 -1
  25. package/dist/permission-eval.d.ts +8 -0
  26. package/dist/permission-eval.d.ts.map +1 -1
  27. package/dist/permission-eval.js +164 -91
  28. package/dist/permission-eval.js.map +1 -1
  29. package/dist/platform-request-query.d.ts +58 -1
  30. package/dist/platform-request-query.d.ts.map +1 -1
  31. package/dist/platform-request-query.js +61 -1
  32. package/dist/platform-request-query.js.map +1 -1
  33. package/dist/platform-sweep.d.ts +123 -8
  34. package/dist/platform-sweep.d.ts.map +1 -1
  35. package/dist/platform-sweep.js +158 -34
  36. package/dist/platform-sweep.js.map +1 -1
  37. package/dist/row-decode.d.ts +107 -0
  38. package/dist/row-decode.d.ts.map +1 -0
  39. package/dist/row-decode.js +93 -0
  40. package/dist/row-decode.js.map +1 -0
  41. package/dist/scope-host.d.ts +211 -9
  42. package/dist/scope-host.d.ts.map +1 -1
  43. package/dist/scope-host.js +33 -0
  44. package/dist/scope-host.js.map +1 -1
  45. package/dist/scope-tuple-seat.d.ts +72 -0
  46. package/dist/scope-tuple-seat.d.ts.map +1 -0
  47. package/dist/scope-tuple-seat.js +93 -0
  48. package/dist/scope-tuple-seat.js.map +1 -0
  49. package/dist/subject-redaction.d.ts +160 -0
  50. package/dist/subject-redaction.d.ts.map +1 -0
  51. package/dist/subject-redaction.js +210 -0
  52. package/dist/subject-redaction.js.map +1 -0
  53. package/dist/system-switch.d.ts +108 -0
  54. package/dist/system-switch.d.ts.map +1 -0
  55. package/dist/system-switch.js +145 -0
  56. package/dist/system-switch.js.map +1 -0
  57. package/dist/timeline.d.ts.map +1 -1
  58. package/dist/timeline.js +109 -57
  59. package/dist/timeline.js.map +1 -1
  60. package/package.json +2 -2
@@ -1,4 +1,4 @@
1
- import { type PlatformRequestFilter } from '@substrat-run/contracts';
1
+ import { type Actor, type PlatformRequest, type PlatformRequestFilter } from '@substrat-run/contracts';
2
2
  /**
3
3
  * The one SELECT behind every read of a scope's intent journal (#618).
4
4
  *
@@ -16,4 +16,61 @@ export declare function platformRequestHistoryQuery(filter?: PlatformRequestFilt
16
16
  sql: string;
17
17
  params: (string | number)[];
18
18
  };
19
+ /** A stored `_substrat_platform_requests` row — snake_case, the JSON columns still text. */
20
+ export interface PlatformRequestRawRow {
21
+ id: string;
22
+ kind: string;
23
+ payload: string;
24
+ requested_by: string;
25
+ impersonation: string | null;
26
+ status: string;
27
+ attempts: number;
28
+ last_error: string | null;
29
+ last_failure: string | null;
30
+ result: string | null;
31
+ requested_at: string;
32
+ settled_at: string | null;
33
+ }
34
+ /**
35
+ * What `requestedBy` reads as when the stored actor did not decode. The field is required and
36
+ * an actor has no empty value, so this is a marker that names itself rather than a plausible
37
+ * requester: a guessed principal would be the one thing worse than admitting nobody can tell.
38
+ * The same marker every tolerant spine read uses (#1636), so it is one value, not four.
39
+ */
40
+ export declare const UNDECODED_REQUESTER: Actor;
41
+ /**
42
+ * A stored row → the `PlatformRequest` contract shape — TOLERANTLY, and saying so (#1588).
43
+ *
44
+ * Every read of the journal returns a LIST, and this decode used to be strict: one row whose
45
+ * JSON would not parse threw out of the map and took every other intent on the scope with it.
46
+ * That disabled the drain's own queue, and `listPlatformRequestHistory` too — the read that
47
+ * exists so a settled intent's failure is legible afterwards (#618), switched off by the row
48
+ * that failed. Each field is now decoded against its own contract field, and whatever did not
49
+ * decode is named, by column, in `decodeError`, with the field EMPTY in its place:
50
+ *
51
+ * - a JSON column comes back `null`, or {@link UNDECODED_REQUESTER} for the one that cannot be
52
+ * null. Never the raw text: a raw `payload` would read as a string payload that was never
53
+ * sent, which is a guess dressed as a fact.
54
+ * - a nullable scalar (`last_error`, `settled_at`) comes back `null`.
55
+ *
56
+ * **Every value this returns satisfies the contract.** A field is only ever replaced by a value
57
+ * its own schema accepts, so the type is not asserting something the published schema would
58
+ * refuse — a consumer switching on `status` or passing `id` on as a branded id can trust both.
59
+ * The price is the REQUIRED scalars (`id`, `kind`, `status`, `attempts`, `requested_at`): they
60
+ * have no empty value, so a row that breaks one of them cannot be a `PlatformRequest` without
61
+ * lying, and it throws, naming every column it broke — the strict decode this replaced, kept
62
+ * for exactly the part it cannot honestly be relaxed for. JSON is what a foreign dump actually
63
+ * gets wrong; representing a row whose identity itself is corrupt needs a variant beside
64
+ * `PlatformRequest` on every read, which is a contract change of its own.
65
+ *
66
+ * A row the kernel wrote decodes whole and carries no `decodeError` at all, so a healthy list
67
+ * is exactly what it was. This is the READ's half of #1587's rule — strict where work happens,
68
+ * tolerant where evidence is read; the work half is the drain, which refuses any row carrying
69
+ * `decodeError` rather than run a handler on it.
70
+ *
71
+ * Reachable without a forge: `importDump` replays a dump's rows verbatim, so a dump from
72
+ * another world, or one edited by hand, is enough. The field-by-field mechanics are
73
+ * `rowDecoder`'s, shared with the history and denial reads (#1636).
74
+ */
75
+ export declare function platformRequestOf(row: PlatformRequestRawRow): PlatformRequest;
19
76
  //# sourceMappingURL=platform-request-query.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"platform-request-query.d.ts","sourceRoot":"","sources":["../src/platform-request-query.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,qBAAqB,EAC3B,MAAM,yBAAyB,CAAC;AAEjC;;;;;;;;;;GAUG;AAEH,0FAA0F;AAC1F,eAAO,MAAM,wBAAwB,iIAC2F,CAAC;AAEjI,wBAAgB,2BAA2B,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG;IAC3E,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;CAC7B,CAoBA"}
1
+ {"version":3,"file":"platform-request-query.d.ts","sourceRoot":"","sources":["../src/platform-request-query.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,KAAK,EACV,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC3B,MAAM,yBAAyB,CAAC;AAGjC;;;;;;;;;;GAUG;AAEH,0FAA0F;AAC1F,eAAO,MAAM,wBAAwB,iIAC2F,CAAC;AAEjI,wBAAgB,2BAA2B,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG;IAC3E,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;CAC7B,CAoBA;AAED,4FAA4F;AAC5F,MAAM,WAAW,qBAAqB;IACpC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,EAAE,KAAuB,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,qBAAqB,GAAG,eAAe,CAiB7E"}
@@ -1,4 +1,5 @@
1
- import { DEFAULT_PLATFORM_REQUEST_HISTORY_LIMIT, platformRequestFilter, } from '@substrat-run/contracts';
1
+ import { DEFAULT_PLATFORM_REQUEST_HISTORY_LIMIT, platformRequest, platformRequestFilter, } from '@substrat-run/contracts';
2
+ import { rowDecoder, UNDECODED_ACTOR } from './row-decode.js';
2
3
  /**
3
4
  * The one SELECT behind every read of a scope's intent journal (#618).
4
5
  *
@@ -32,4 +33,63 @@ export function platformRequestHistoryQuery(filter) {
32
33
  params,
33
34
  };
34
35
  }
36
+ /**
37
+ * What `requestedBy` reads as when the stored actor did not decode. The field is required and
38
+ * an actor has no empty value, so this is a marker that names itself rather than a plausible
39
+ * requester: a guessed principal would be the one thing worse than admitting nobody can tell.
40
+ * The same marker every tolerant spine read uses (#1636), so it is one value, not four.
41
+ */
42
+ export const UNDECODED_REQUESTER = UNDECODED_ACTOR;
43
+ /**
44
+ * A stored row → the `PlatformRequest` contract shape — TOLERANTLY, and saying so (#1588).
45
+ *
46
+ * Every read of the journal returns a LIST, and this decode used to be strict: one row whose
47
+ * JSON would not parse threw out of the map and took every other intent on the scope with it.
48
+ * That disabled the drain's own queue, and `listPlatformRequestHistory` too — the read that
49
+ * exists so a settled intent's failure is legible afterwards (#618), switched off by the row
50
+ * that failed. Each field is now decoded against its own contract field, and whatever did not
51
+ * decode is named, by column, in `decodeError`, with the field EMPTY in its place:
52
+ *
53
+ * - a JSON column comes back `null`, or {@link UNDECODED_REQUESTER} for the one that cannot be
54
+ * null. Never the raw text: a raw `payload` would read as a string payload that was never
55
+ * sent, which is a guess dressed as a fact.
56
+ * - a nullable scalar (`last_error`, `settled_at`) comes back `null`.
57
+ *
58
+ * **Every value this returns satisfies the contract.** A field is only ever replaced by a value
59
+ * its own schema accepts, so the type is not asserting something the published schema would
60
+ * refuse — a consumer switching on `status` or passing `id` on as a branded id can trust both.
61
+ * The price is the REQUIRED scalars (`id`, `kind`, `status`, `attempts`, `requested_at`): they
62
+ * have no empty value, so a row that breaks one of them cannot be a `PlatformRequest` without
63
+ * lying, and it throws, naming every column it broke — the strict decode this replaced, kept
64
+ * for exactly the part it cannot honestly be relaxed for. JSON is what a foreign dump actually
65
+ * gets wrong; representing a row whose identity itself is corrupt needs a variant beside
66
+ * `PlatformRequest` on every read, which is a contract change of its own.
67
+ *
68
+ * A row the kernel wrote decodes whole and carries no `decodeError` at all, so a healthy list
69
+ * is exactly what it was. This is the READ's half of #1587's rule — strict where work happens,
70
+ * tolerant where evidence is read; the work half is the drain, which refuses any row carrying
71
+ * `decodeError` rather than run a handler on it.
72
+ *
73
+ * Reachable without a forge: `importDump` replays a dump's rows verbatim, so a dump from
74
+ * another world, or one edited by hand, is enough. The field-by-field mechanics are
75
+ * `rowDecoder`'s, shared with the history and denial reads (#1636).
76
+ */
77
+ export function platformRequestOf(row) {
78
+ const shape = platformRequest.shape;
79
+ const d = rowDecoder(`platform request row ${JSON.stringify(row.id)}`, 'PlatformRequest');
80
+ return d.finish({
81
+ id: d.required('id', shape.id, row.id),
82
+ kind: d.required('kind', shape.kind, row.kind),
83
+ payload: d.json('payload', shape.payload, row.payload, null),
84
+ requestedBy: d.json('requested_by', shape.requestedBy, row.requested_by, UNDECODED_REQUESTER),
85
+ impersonation: d.json('impersonation', shape.impersonation, row.impersonation, null),
86
+ status: d.required('status', shape.status, row.status),
87
+ attempts: d.required('attempts', shape.attempts, row.attempts),
88
+ lastError: d.nullable('last_error', shape.lastError, row.last_error),
89
+ failure: d.json('last_failure', shape.failure, row.last_failure, null),
90
+ result: d.json('result', shape.result, row.result, null),
91
+ requestedAt: d.required('requested_at', shape.requestedAt, row.requested_at),
92
+ settledAt: d.nullable('settled_at', shape.settledAt, row.settled_at),
93
+ });
94
+ }
35
95
  //# sourceMappingURL=platform-request-query.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"platform-request-query.js","sourceRoot":"","sources":["../src/platform-request-query.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sCAAsC,EACtC,qBAAqB,GAEtB,MAAM,yBAAyB,CAAC;AAEjC;;;;;;;;;;GAUG;AAEH,0FAA0F;AAC1F,MAAM,CAAC,MAAM,wBAAwB,GACnC,8HAA8H,CAAC;AAEjI,MAAM,UAAU,2BAA2B,CAAC,MAA8B;IAIxE,MAAM,CAAC,GAAG,qBAAqB,CAAC,KAAK,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC;IACpD,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,MAAM,MAAM,GAAwB,EAAE,CAAC;IACvC,IAAI,CAAC,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QACzB,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACvB,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IACtB,CAAC;IACD,IAAI,CAAC,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;QAC3B,KAAK,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QACzB,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;IACxB,CAAC;IACD,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,sCAAsC,CAAC,CAAC;IAC/D,OAAO;QACL,GAAG,EACD,UAAU,wBAAwB,mCAAmC;YACrE,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,UAAU,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACrD,2BAA2B;QAC7B,MAAM;KACP,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"platform-request-query.js","sourceRoot":"","sources":["../src/platform-request-query.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import type { AccessLogEntry, ConnectionId, MigrationProgress, MigrationStraggler, PlatformActorId, ScopeId, TenantId, DrainedEvent } from '@substrat-run/contracts';
1
+ import type { AccessLogEntry, ConnectionId, MigrationProgress, MigrationStraggler, PlatformActorId, Scope, ScopeId, TenantId, DrainedEvent } from '@substrat-run/contracts';
2
2
  import type { ExecutorDrainReport, FetchLike, ScopeHost, SweepRunInput } from './scope-host.js';
3
3
  /**
4
4
  * A connector's reconcile sweep — the unit `runPlatformSweep` calls per live
@@ -118,11 +118,45 @@ export interface PlatformSweepOptions {
118
118
  * What makes the phase necessary: a vertical's `onProvision` runs ONCE per scope, at
119
119
  * install. Anything the vertical mints for itself there — a service principal, a site
120
120
  * registration — therefore never reaches an install that predates it. The new code
121
- * deploys, and the thing it depends on was never created. Comparing the scope's bound
122
- * version against the one its provision last ran against is how the platform sees that,
123
- * and this fn is how it fixes it.
121
+ * deploys, and the thing it depends on was never created. Comparing the version whose
122
+ * code RUNS on the scope (`runningVersionOf`, #1653) against the one its provision last
123
+ * ran against is how the platform sees that, and this fn is how it fixes it.
124
+ *
125
+ * `expected` is the version the phase will record on success — the one running on the
126
+ * scope. A fn whose deployment for the scope runs a DIFFERENT version (its resolver fell
127
+ * back from the serving script to the bound version's, say) must throw rather than
128
+ * resolve: the receipt would otherwise name a hook that never ran, and the scope would
129
+ * read as repaired while the code it runs was never provisioned for. A throw leaves it
130
+ * unmarked and counted `failed`, which is where it belongs.
131
+ *
132
+ * Resolves `'unsupported'` when the vertical answered that it implements no reconcile
133
+ * (a 501 from `/internal/reconcile`). That is neither a success nor a failure: no
134
+ * receipt is written, since nothing ran, and it is counted apart from `failed` so a
135
+ * vertical that simply lacks the route does not drown the refusals somebody must read.
136
+ */
137
+ reconcileScopeFn?: (tenantId: TenantId, scopeId: ScopeId, expected: string) => Promise<void | 'unsupported'>;
138
+ /**
139
+ * The most scopes the #1172 phase reconciles in ONE pass (#1653). Default
140
+ * {@link PROVISION_RECONCILE_BATCH}.
141
+ *
142
+ * A promote of a LISTED vertical puts every install of it behind at once — each one is
143
+ * another tenant's scope, and a popular vertical has thousands. Reconciling them all in
144
+ * one tick is how a cron becomes an incident, so the phase takes at most this many and
145
+ * the rest wait for the next pass (`deferred`). `0` reconciles nothing: the phase still
146
+ * counts who is behind, which makes it the pause switch.
147
+ */
148
+ provisionReconcileBatch?: number;
149
+ /**
150
+ * Where the #1172 window starts, as a number in [0, 1) — `Math.random` unless given.
151
+ *
152
+ * The behind scopes are ordered by id and the pass takes a contiguous window of
153
+ * `provisionReconcileBatch` from a random starting point, wrapping. A FIXED start would
154
+ * let installs that fail on every pass hold the same slots forever and starve every
155
+ * healthy one behind them; a random start gives each behind scope a chance of
156
+ * batch/behind per pass however many of the others keep failing. Injectable so that
157
+ * property is tested rather than hoped for.
124
158
  */
125
- reconcileScopeFn?: (tenantId: TenantId, scopeId: ScopeId) => Promise<void>;
159
+ provisionReconcileRng?: () => number;
126
160
  /**
127
161
  * Also reap expired snapshots (preview-and-snapshots.md §3/§9): any FORK
128
162
  * (`forkedFrom` set) whose `expiresAt` has passed is hard-deleted via
@@ -332,13 +366,68 @@ export interface ScheduleSweepReport {
332
366
  }
333
367
  /** What the #1172 phase did — see `reconcileScopeFn`. */
334
368
  export interface ProvisionReconcileReport {
335
- /** Scopes whose bound version was ahead of their provisioned one this pass. */
369
+ /**
370
+ * Scopes whose running version differed from the one their provision last ran against
371
+ * this pass. Always `reconciled + failed + unsupported + deferred`.
372
+ */
336
373
  behind: number;
337
374
  /** Of those, the ones whose reconcile succeeded and were marked. */
338
375
  reconciled: number;
339
376
  /** Of those, the ones whose reconcile threw. They stay behind and retry next pass. */
340
377
  failed: number;
378
+ /**
379
+ * Of those, the ones whose vertical implements no reconcile (a 501). Unmarked like a
380
+ * failure, so they are asked again next pass; counted apart so they cannot pass for one.
381
+ * `scopeIds` is capped at {@link PROVISION_RECONCILE_REPORTED_IDS}; `count` is exact.
382
+ */
383
+ unsupported: {
384
+ count: number;
385
+ scopeIds: string[];
386
+ };
387
+ /** Of those, the ones past this pass's `provisionReconcileBatch`. Next pass's work. */
388
+ deferred: number;
341
389
  }
390
+ /** The #1172 phase's per-pass bound when `provisionReconcileBatch` is unset (#1653). */
391
+ export declare const PROVISION_RECONCILE_BATCH = 50;
392
+ /** How many `unsupported` scope ids one report carries — the count stays exact. */
393
+ export declare const PROVISION_RECONCILE_REPORTED_IDS = 50;
394
+ /**
395
+ * A scope the platform may treat as the REAL install, rather than a copy of one (#1172,
396
+ * #1653). The directory is the only oracle for this, and it takes BOTH tests, not one.
397
+ *
398
+ * A fork is an archive or a preview of somebody else's data, and re-provisioning one
399
+ * runs the vertical's install-side hook against a copy, minting a second set of whatever
400
+ * it mints — or, since #1656, putting a copy of production on a sweeper that reaps and
401
+ * assigns. But a CLEAN-ROOM preview (#509) is an empty scope with no source to copy, so
402
+ * it carries `kind: 'preview'` and NO `forkedFrom` — the reap sweep keys on `kind` for
403
+ * exactly that reason. Filtering on lineage alone would let those through, which is the
404
+ * one shape of scope where the hook's effects are least wanted.
405
+ */
406
+ export declare function isPrimaryScope(scope: Pick<Scope, 'forkedFrom' | 'kind'>): boolean;
407
+ /** What a vertical's stable serving script runs (#286) — `Vertical.servingRef`/`servingVersionId`. */
408
+ export interface ServingPointer {
409
+ ref: string;
410
+ versionId: string;
411
+ }
412
+ /**
413
+ * The version whose code actually executes for `scope` (#1653) — which is not always the
414
+ * one it is bound to.
415
+ *
416
+ * A scope on its vertical's stable serving script (`servingRef`, #286) runs whatever that
417
+ * script serves, and a promote re-uploads the script in place: the code changes under
418
+ * the scope at that moment, whatever its version pointer says. For a PRIVATE vertical
419
+ * the promote moves the pointer too, so the two agree. For a LISTED one it does not —
420
+ * the pointer is the tenant's, and moves only on their Update — so an install can run
421
+ * version N+1 with its provision state still at N. Measuring against the running version
422
+ * is what lets the reconcile follow the code instead of the pointer.
423
+ *
424
+ * Falls back to the bound version whenever the serving script cannot be named: a scope
425
+ * with no `servingRef` (legacy per-version dispatch, where the bound version's own
426
+ * script IS what runs), a vertical with nothing served in place yet, or a `servingRef`
427
+ * that is not the vertical's current one. The fallback is exactly the pre-#1653 answer,
428
+ * so an unknown never manufactures a reconcile.
429
+ */
430
+ export declare function runningVersionOf(scope: Pick<Scope, 'verticalVersionId' | 'servingRef'>, serving: ServingPointer | null | undefined): string | null;
342
431
  export interface PlatformSweepReport {
343
432
  /** Active scopes `drainDue` ran on. */
344
433
  scopesDrained: number;
@@ -357,8 +446,9 @@ export interface PlatformSweepReport {
357
446
  /** Platform-intent drain outcomes summed across scopes (platform-intents.md). */
358
447
  platformRequestTotals: PlatformRequestDrainTotals;
359
448
  /**
360
- * Scopes whose provision was re-run because their bound version had moved past the one
361
- * it last ran against (#1172), or null when no `reconcileScopeFn` was supplied.
449
+ * Scopes whose provision was re-run because the version running on them had moved past
450
+ * the one it last ran against (#1172, #1653), or null when no `reconcileScopeFn` was
451
+ * supplied.
362
452
  *
363
453
  * Null and `{ reconciled: 0 }` are different facts, as everywhere else in this report:
364
454
  * the first is "nobody looked".
@@ -406,6 +496,31 @@ export interface EventDrainReport {
406
496
  * it. Reported rather than looped, so one busy scope cannot starve the pass.
407
497
  */
408
498
  incomplete: number;
499
+ /**
500
+ * Undrained rows a scope's read stepped over because they would not decode (#1636), plus
501
+ * any event this sweep refused itself because the published `drainedEvent` schema did not
502
+ * accept it (#1641 — what an older vertical, which does not validate, can still send),
503
+ * one entry per scope that had any — ABSENT when no scope did.
504
+ *
505
+ * These rows are never shipped and never stamped: the lake is append-only, so a row
506
+ * built from stand-ins could not be taken back, and the stamp is the only record of
507
+ * what left. So they are missing from the lake for as long as they stay undecodable,
508
+ * and every pass reads them again and reports them again. The event is still in
509
+ * Tier 1, where `readHistory` returns it with a `decodeError`.
510
+ *
511
+ * Not an `errors` entry: nothing about this pass failed, and the failure digest mails
512
+ * every error — a standing condition would be re-sent on every tick.
513
+ */
514
+ skipped?: EventDrainSkipped[];
515
+ }
516
+ /** One scope's share of `EventDrainReport.skipped`. */
517
+ export interface EventDrainSkipped {
518
+ tenantId: TenantId;
519
+ scopeId: ScopeId;
520
+ /** Exact. */
521
+ count: number;
522
+ /** The first of them, oldest first; capped (`UNDRAINED_SKIPPED_IDS`), so `count` may be larger. */
523
+ eventIds: string[];
409
524
  }
410
525
  export interface AccessLogSweepReport {
411
526
  /** Rows handed to the sink and confirmed durable. */
@@ -1 +1 @@
1
- {"version":3,"file":"platform-sweep.d.ts","sourceRoot":"","sources":["../src/platform-sweep.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"platform-sweep.d.ts","sourceRoot":"","sources":["../src/platform-sweep.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,cAAc,EACd,YAAY,EACZ,iBAAiB,EACjB,kBAAkB,EAClB,eAAe,EACf,KAAK,EACL,OAAO,EACP,QAAQ,EACR,YAAY,EACb,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,mBAAmB,EAAE,SAAS,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAWhG;;;;;;;GAOG;AACH,MAAM,WAAW,gBAAgB;IAC/B,CAAC,IAAI,EAAE,SAAS,EAAE,YAAY,EAAE,YAAY,EAAE,IAAI,EAAE;QAAE,KAAK,EAAE,SAAS,CAAA;KAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC7F;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,IAAI,CAAC,OAAO,EAAE,cAAc,EAAE,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC3D;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,SAAS;IACxB;;;;OAIG;IACH,IAAI,CAAC,KAAK,EAAE;QAAE,QAAQ,EAAE,QAAQ,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,EAAE,MAAM,EAAE,YAAY,EAAE,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACzG;AAQD,MAAM,WAAW,oBAAoB;IACnC;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAChD,wFAAwF;IACxF,KAAK,EAAE,eAAe,CAAC;IACvB,0DAA0D;IAC1D,KAAK,EAAE,SAAS,CAAC;IACjB,+FAA+F;IAC/F,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IAC3C,yEAAyE;IACzE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;OAMG;IACH,uBAAuB,CAAC,EAAE,CACxB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,OAAO,KACb,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjF;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,GAAG,aAAa,CAAC,CAAC;IAC7G;;;;;;;;;OASG;IACH,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC;;;;;;;;;OASG;IACH,qBAAqB,CAAC,EAAE,MAAM,MAAM,CAAC;IACrC;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3E;;;;;;;;;OASG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACtE;;;;;;;;;;OAUG;IACH,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B;;;;OAIG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,sDAAsD;IACtD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,uDAAuD;IACvD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;;;OAQG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;;;;OASG;IACH,gBAAgB,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACjE,uEAAuE;IACvE,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC;;;;;;;OAOG;IACH,mBAAmB,CAAC,EAAE,CAAC,OAAO,EAAE,kBAAkB,EAAE,KAAK,IAAI,CAAC;CAC/D;AAED;;;;GAIG;AACH,MAAM,WAAW,oBAAqB,SAAQ,iBAAiB;IAC7D,sEAAsE;IACtE,SAAS,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,QAAQ,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,CAAC;IACjB,oGAAoG;IACpG,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,GAAG,UAAU,GAAG,WAAW,CAAC;AAEzD;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,4+CAwB9B,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,eAAO,MAAM,sBAAsB,QAYlC,CAAC;AAEF;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE9D;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,kEAAkE;IAClE,MAAM,EAAE,MAAM,CAAC;IACf,sDAAsD;IACtD,KAAK,EAAE,MAAM,CAAC;IACd,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAC;IAChB,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,yDAAyD;AACzD,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,oEAAoE;IACpE,UAAU,EAAE,MAAM,CAAC;IACnB,sFAAsF;IACtF,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC;IACnD,uFAAuF;IACvF,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,wFAAwF;AACxF,eAAO,MAAM,yBAAyB,KAAK,CAAC;AAE5C,mFAAmF;AACnF,eAAO,MAAM,gCAAgC,KAAK,CAAC;AAEnD;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAAC,GAAG,OAAO,CAEjF;AAED,sGAAsG;AACtG,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,mBAAmB,GAAG,YAAY,CAAC,EACtD,OAAO,EAAE,cAAc,GAAG,IAAI,GAAG,SAAS,GACzC,MAAM,GAAG,IAAI,CAGf;AAeD,MAAM,WAAW,mBAAmB;IAClC,uCAAuC;IACvC,aAAa,EAAE,MAAM,CAAC;IACtB,2CAA2C;IAC3C,WAAW,EAAE,mBAAmB,CAAC;IACjC,qCAAqC;IACrC,gBAAgB,EAAE,MAAM,CAAC;IACzB,kFAAkF;IAClF,kBAAkB,EAAE,MAAM,CAAC;IAC3B,0DAA0D;IAC1D,eAAe,EAAE,MAAM,CAAC;IACxB,2EAA2E;IAC3E,oBAAoB,EAAE,MAAM,CAAC;IAC7B,+DAA+D;IAC/D,aAAa,EAAE,MAAM,CAAC;IACtB,iFAAiF;IACjF,qBAAqB,EAAE,0BAA0B,CAAC;IAClD;;;;;;;OAOG;IACH,kBAAkB,EAAE,wBAAwB,GAAG,IAAI,CAAC;IACpD;;;;;;OAMG;IACH,UAAU,EAAE,oBAAoB,GAAG,IAAI,CAAC;IACxC;;;;;OAKG;IACH,SAAS,EAAE,mBAAmB,GAAG,IAAI,CAAC;IACtC;;;;OAIG;IACH,SAAS,EAAE,oBAAoB,GAAG,IAAI,CAAC;IACvC,kFAAkF;IAClF,UAAU,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACpC,oFAAoF;IACpF,MAAM,EAAE;QACN,IAAI,EACA,OAAO,GACP,OAAO,GACP,IAAI,GACJ,MAAM,GACN,aAAa,GACb,SAAS,GACT,kBAAkB,GAClB,qBAAqB,GACrB,UAAU,GACV,WAAW,GACX,YAAY,GAEZ,aAAa,CAAC;QAClB,EAAE,EAAE,MAAM,CAAC;QACX,KAAK,EAAE,MAAM,CAAC;KACf,EAAE,CAAC;CACL;AAED,sEAAsE;AACtE,4DAA4D;AAC5D,MAAM,WAAW,gBAAgB;IAC/B,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,EAAE,iBAAiB,EAAE,CAAC;CAC/B;AAED,uDAAuD;AACvD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,QAAQ,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa;IACb,KAAK,EAAE,MAAM,CAAC;IACd,mGAAmG;IACnG,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,oBAAoB;IACnC,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,8EAA8E;IAC9E,MAAM,EAAE,MAAM,CAAC;IACf,yDAAyD;IACzD,MAAM,EAAE,MAAM,CAAC;IACf,kFAAkF;IAClF,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB;AAED,sEAAsE;AACtE,MAAM,WAAW,0BAA0B;IACzC,0DAA0D;IAC1D,MAAM,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,6BAA6B;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,MAAM,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,OAAO,EAAE,MAAM,CAAC;CACjB;AAsBD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,SAAS,EACf,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,mBAAmB,CAAC,CAmiB9B;AA8HD,wFAAwF;AACxF,MAAM,WAAW,qBAAqB;IACpC,IAAI,IAAI,IAAI,CAAC;CACd;AAED,MAAM,WAAW,2BAA4B,SAAQ,oBAAoB;IACvE;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB,sFAAsF;IACtF,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,mBAAmB,GAAG;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACpE,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC;IACnD,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;CACxC;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,SAAS,EACf,OAAO,EAAE,2BAA2B,GACnC,qBAAqB,CAuBvB"}
@@ -1,6 +1,7 @@
1
- import { instant } from '@substrat-run/contracts';
1
+ import { drainedEvent, instant } from '@substrat-run/contracts';
2
2
  import { backoffAt } from './scope-host.js';
3
3
  import { MIGRATION_FLAG_THRESHOLD, migrationFleet, migrationProgress, scopeMigrationState } from './migration-progress.js';
4
+ import { UNDRAINED_SKIPPED_IDS } from './outbox-event.js';
4
5
  /** How many access rows one pass ships, and one pass prunes, by default. */
5
6
  const ACCESS_LOG_BATCH = 500;
6
7
  /** How many events one pass drains PER SCOPE by default. */
@@ -90,6 +91,62 @@ export const SCHEDULE_STATE_REBUILD = `
90
91
  export function scheduleStateHasKind(tableSql) {
91
92
  return tableSql.includes('PRIMARY KEY (kind, schedule_op)');
92
93
  }
94
+ /** The #1172 phase's per-pass bound when `provisionReconcileBatch` is unset (#1653). */
95
+ export const PROVISION_RECONCILE_BATCH = 50;
96
+ /** How many `unsupported` scope ids one report carries — the count stays exact. */
97
+ export const PROVISION_RECONCILE_REPORTED_IDS = 50;
98
+ /**
99
+ * A scope the platform may treat as the REAL install, rather than a copy of one (#1172,
100
+ * #1653). The directory is the only oracle for this, and it takes BOTH tests, not one.
101
+ *
102
+ * A fork is an archive or a preview of somebody else's data, and re-provisioning one
103
+ * runs the vertical's install-side hook against a copy, minting a second set of whatever
104
+ * it mints — or, since #1656, putting a copy of production on a sweeper that reaps and
105
+ * assigns. But a CLEAN-ROOM preview (#509) is an empty scope with no source to copy, so
106
+ * it carries `kind: 'preview'` and NO `forkedFrom` — the reap sweep keys on `kind` for
107
+ * exactly that reason. Filtering on lineage alone would let those through, which is the
108
+ * one shape of scope where the hook's effects are least wanted.
109
+ */
110
+ export function isPrimaryScope(scope) {
111
+ return !scope.forkedFrom && scope.kind !== 'preview';
112
+ }
113
+ /**
114
+ * The version whose code actually executes for `scope` (#1653) — which is not always the
115
+ * one it is bound to.
116
+ *
117
+ * A scope on its vertical's stable serving script (`servingRef`, #286) runs whatever that
118
+ * script serves, and a promote re-uploads the script in place: the code changes under
119
+ * the scope at that moment, whatever its version pointer says. For a PRIVATE vertical
120
+ * the promote moves the pointer too, so the two agree. For a LISTED one it does not —
121
+ * the pointer is the tenant's, and moves only on their Update — so an install can run
122
+ * version N+1 with its provision state still at N. Measuring against the running version
123
+ * is what lets the reconcile follow the code instead of the pointer.
124
+ *
125
+ * Falls back to the bound version whenever the serving script cannot be named: a scope
126
+ * with no `servingRef` (legacy per-version dispatch, where the bound version's own
127
+ * script IS what runs), a vertical with nothing served in place yet, or a `servingRef`
128
+ * that is not the vertical's current one. The fallback is exactly the pre-#1653 answer,
129
+ * so an unknown never manufactures a reconcile.
130
+ */
131
+ export function runningVersionOf(scope, serving) {
132
+ if (scope.servingRef && serving && scope.servingRef === serving.ref)
133
+ return serving.versionId;
134
+ return scope.verticalVersionId;
135
+ }
136
+ /**
137
+ * This pass's share of the behind scopes: at most `batch`, as a contiguous window over
138
+ * the id order, starting at `rng()` of the way round and wrapping. Deterministic for a
139
+ * given `rng`, which is what makes the fairness claim testable.
140
+ */
141
+ function reconcileWindow(behind, batch, rng) {
142
+ if (behind.length <= batch)
143
+ return [...behind];
144
+ if (batch <= 0)
145
+ return [];
146
+ const ordered = [...behind].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
147
+ const start = Math.min(ordered.length - 1, Math.max(0, Math.floor(rng() * ordered.length)));
148
+ return Array.from({ length: batch }, (_, i) => ordered[(start + i) % ordered.length]);
149
+ }
93
150
  const message = (err) => (err instanceof Error ? err.message : String(err));
94
151
  /**
95
152
  * Run `fn` over `items` with at most `limit` in flight. `fn` owns its errors —
@@ -283,53 +340,81 @@ export async function runPlatformSweep(host, options) {
283
340
  }
284
341
  });
285
342
  }
286
- // -- provision reconcile (#1172) --------------------------------------------
343
+ // -- provision reconcile (#1172, #1653) -------------------------------------
287
344
  // A vertical's `onProvision` runs once per scope, at install. So a scope serving code
288
345
  // whose provision hook never ran against it is missing whatever that hook creates —
289
346
  // and no other path will ever deliver it. This phase is what makes a push repair its
290
- // own installs: bound version != provisioned version ⇒ reconcile once, then record it.
347
+ // own installs: running version != provisioned version ⇒ reconcile once, then record it.
348
+ //
349
+ // RUNNING, not bound (#1653): a listed vertical's promote re-serves every install in
350
+ // place and moves none of their pointers, so comparing against the pointer never saw
351
+ // those installs at all. See `runningVersionOf`.
291
352
  //
292
353
  // AFTER the migration phase and skipping what failed there, like every phase that
293
354
  // touches a vertical: a fail-closed scope would only re-throw its migration error.
294
355
  if (options.reconcileScopeFn) {
295
356
  const reconcile = options.reconcileScopeFn;
296
- const provisionReconcile = { behind: 0, reconciled: 0, failed: 0 };
297
- /**
298
- * Primaries only — and that takes BOTH tests, not one.
299
- *
300
- * A fork is an archive or a preview of somebody else's data, and re-provisioning one
301
- * runs the vertical's install-side hook against a copy, minting a second set of
302
- * whatever it mints. But a CLEAN-ROOM preview (#509) is an empty scope with no source
303
- * to copy, so it carries `kind: 'preview'` and NO `forkedFrom` — the reap sweep keys
304
- * on `kind` for exactly that reason. Filtering on lineage alone would let those
305
- * through, which is the one shape of scope where the hook's effects are least wanted.
306
- */
307
- const scopes = (await host.admin.listScopes(options.actor, { status: 'active' })).filter((s) => !s.forkedFrom && s.kind !== 'preview');
308
- await mapBounded(scopes, concurrency, async (s) => {
309
- if (failedThisPass.has(s.id))
310
- return;
311
- // No bound version ⇒ nothing to compare, and nothing a reconcile could target.
312
- if (!s.verticalVersionId)
313
- return;
357
+ const provisionReconcile = {
358
+ behind: 0,
359
+ reconciled: 0,
360
+ failed: 0,
361
+ unsupported: { count: 0, scopeIds: [] },
362
+ deferred: 0,
363
+ };
364
+ const scopes = (await host.admin.listScopes(options.actor, { status: 'active' })).filter((s) => isPrimaryScope(s) && !failedThisPass.has(s.id));
365
+ // One directory read for every vertical's serving pointer — and only when some scope
366
+ // is actually on a serving script, since nothing else can differ from its binding.
367
+ const serving = new Map();
368
+ if (scopes.some((s) => s.servingRef)) {
369
+ for (const v of await host.admin.listVerticals(options.actor)) {
370
+ if (v.servingRef && v.servingVersionId) {
371
+ serving.set(v.slug, { ref: v.servingRef, versionId: v.servingVersionId });
372
+ }
373
+ }
374
+ }
375
+ const behind = [];
376
+ for (const s of scopes) {
377
+ const running = runningVersionOf(s, s.vertical ? serving.get(s.vertical) : null);
378
+ // No running version ⇒ nothing to compare, and nothing a reconcile could target.
379
+ if (!running)
380
+ continue;
314
381
  // A null receipt is "unknown", NOT "up to date": a scope provisioned before the
315
382
  // platform recorded this has no evidence either way, and guessing the optimistic
316
383
  // answer leaves exactly the broken installs this phase exists to heal. It costs
317
384
  // one reconcile per pre-existing scope, once, and then the receipt is there.
318
- if (s.provisionedVersionId === s.verticalVersionId)
319
- return;
320
- provisionReconcile.behind += 1;
321
- const target = s.verticalVersionId;
385
+ if (s.provisionedVersionId === running)
386
+ continue;
387
+ behind.push({ id: s.id, tenantId: s.tenantId, target: running });
388
+ }
389
+ provisionReconcile.behind = behind.length;
390
+ const batch = options.provisionReconcileBatch ?? PROVISION_RECONCILE_BATCH;
391
+ const window = reconcileWindow(behind, batch, options.provisionReconcileRng ?? Math.random);
392
+ provisionReconcile.deferred = behind.length - window.length;
393
+ await mapBounded(window, concurrency, async (s) => {
322
394
  try {
323
- await reconcile(s.tenantId, s.id);
395
+ const outcome = await reconcile(s.tenantId, s.id, s.target);
396
+ if (outcome === 'unsupported') {
397
+ // Nothing ran, so there is nothing true to record: left unmarked, like a
398
+ // failure, and asked again next pass. Not an error — the vertical answered
399
+ // exactly, and it is not the kind of refusal a person has to act on per pass.
400
+ provisionReconcile.unsupported.count += 1;
401
+ if (provisionReconcile.unsupported.scopeIds.length < PROVISION_RECONCILE_REPORTED_IDS) {
402
+ provisionReconcile.unsupported.scopeIds.push(s.id);
403
+ }
404
+ return;
405
+ }
324
406
  // Marked with the version we RECONCILED against, read before the call — not
325
- // whatever the scope happens to be bound to now. A push that lands mid-pass must
326
- // not have its new version marked by a reconcile that ran against the old one.
327
- await host.admin.markScopeProvisioned(options.actor, s.tenantId, s.id, target);
407
+ // whatever the scope runs now. A promote that lands mid-pass must not have its
408
+ // new version marked by a reconcile that ran against the old one. The serving
409
+ // pointer is written only after its upload succeeds, so the version read here is
410
+ // one the script was already running when the pass read it.
411
+ await host.admin.markScopeProvisioned(options.actor, s.tenantId, s.id, s.target);
328
412
  provisionReconcile.reconciled += 1;
329
413
  }
330
414
  catch (err) {
331
415
  // Left unmarked on purpose: it stays behind and is retried next pass, which is
332
- // the whole difference between a backstop and a one-shot.
416
+ // the whole difference between a backstop and a one-shot. One scope's failure
417
+ // is its own: the rest of the window carries on.
333
418
  provisionReconcile.failed += 1;
334
419
  report.errors.push({ kind: 'provision-reconcile', id: s.id, error: message(err) });
335
420
  }
@@ -585,11 +670,14 @@ export async function runPlatformSweep(host, options) {
585
670
  const drainable = await host.admin.listScopes(options.actor, { status: 'active' });
586
671
  await mapBounded(drainable, concurrency, async (s) => {
587
672
  try {
588
- const shipped = await drainScopeEvents(host, options, sink, {
673
+ const { shipped, skipped } = await drainScopeEvents(host, options, sink, {
589
674
  tenantId: s.tenantId,
590
675
  scopeId: s.id,
591
676
  budget,
592
677
  });
678
+ if (skipped) {
679
+ (report.eventDrain.skipped ??= []).push({ tenantId: s.tenantId, scopeId: s.id, ...skipped });
680
+ }
593
681
  if (shipped === 0)
594
682
  return;
595
683
  report.eventDrain.scopes += 1;
@@ -643,14 +731,50 @@ export async function runPlatformSweep(host, options) {
643
731
  *
644
732
  * Nothing is pruned. The outbox is still read by consumers, replay and
645
733
  * `readHistory`; what the stamp buys today is knowing what has left.
734
+ *
735
+ * What the read stepped over (#1636) comes back beside the count, for the report —
736
+ * and ONLY for the report: those rows were never in `events`, so nothing here can
737
+ * ship or stamp them. A host too old to say leaves it undefined.
738
+ *
739
+ * **And every event is parsed here, by the published `drainedEvent`, before the sink sees
740
+ * it** (#1641). This is the last point before an append-only lake, and it is the control
741
+ * plane's: a hosted scope's events arrive over HTTP from the vertical's own deployment,
742
+ * decoded by whatever adapter version that vertical was pushed with. A vertical older
743
+ * than #1636 decodes the envelope and copies its lifted columns unvalidated, so without
744
+ * this parse a corrupt one would still ship — the invariant would hold only for verticals
745
+ * new enough to hold it themselves. An event that fails is treated exactly as the read's
746
+ * own skips are: not shipped, not stamped, and folded into `skipped`.
646
747
  */
647
748
  async function drainScopeEvents(host, options, sink, input) {
648
- const events = await host.admin.readUndrainedEvents(options.actor, input.tenantId, input.scopeId, input.budget);
749
+ const read = await host.admin.readUndrainedEvents(options.actor, input.tenantId, input.scopeId, input.budget);
750
+ // A plain array to the sink, of the published schema's own output: nothing typed as a
751
+ // DrainedEvent reaches the lake unless the schema accepted it here.
752
+ const events = [];
753
+ const refused = [];
754
+ for (const event of read) {
755
+ const parsed = drainedEvent.safeParse(event);
756
+ if (parsed.success)
757
+ events.push(parsed.data);
758
+ else
759
+ refused.push(String(event?.id));
760
+ }
761
+ const skipped = skippedOf(read.skipped, refused);
649
762
  if (events.length === 0)
650
- return 0;
763
+ return { shipped: 0, skipped };
651
764
  await sink.ship({ tenantId: input.tenantId, scopeId: input.scopeId }, events);
652
765
  await host.admin.markEventsDrained(options.actor, input.tenantId, input.scopeId, events.map((e) => e.id));
653
- return events.length;
766
+ return { shipped: events.length, skipped };
767
+ }
768
+ /**
769
+ * The read's own skip, plus the events this side refused (#1641): one count, exact, and the
770
+ * ids capped as the read caps them. Undefined when there was neither — a clean pass reports
771
+ * nothing rather than a zero.
772
+ */
773
+ function skippedOf(read, refused) {
774
+ const count = (read?.count ?? 0) + refused.length;
775
+ if (count === 0)
776
+ return undefined;
777
+ return { count, eventIds: [...(read?.eventIds ?? []), ...refused].slice(0, UNDRAINED_SKIPPED_IDS) };
654
778
  }
655
779
  /**
656
780
  * One ship→stamp→prune cycle over the access log. Split out because the ORDER is the