@coreplane/switchboard 1.252.0 → 1.254.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 (143) hide show
  1. package/dist/assets/config/config.example.yaml +54 -1
  2. package/dist/assets/deploy/cloudflare-memory/runMetricsSink.ts +31 -0
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +455 -32
  4. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +12 -0
  5. package/dist/assets/deploy/profile.example.json +1 -1
  6. package/dist/assets/package-lock.json +3 -3
  7. package/dist/assets/package.json +1 -1
  8. package/dist/assets/source.json +3 -3
  9. package/dist/assets/src/core/authz/grants.ts +6 -0
  10. package/dist/assets/src/core/authz/policy.ts +19 -0
  11. package/dist/assets/src/core/budgets.ts +25 -0
  12. package/dist/assets/src/core/coordinator/contract.ts +51 -1
  13. package/dist/assets/src/core/coordinator/driver.ts +111 -11
  14. package/dist/assets/src/core/costs.ts +221 -2
  15. package/dist/assets/src/core/costsSnapshotStore.ts +22 -1
  16. package/dist/assets/src/core/memory/engine.ts +25 -4
  17. package/dist/assets/src/core/memory/types.ts +6 -2
  18. package/dist/assets/src/core/pipelineStanding.ts +294 -0
  19. package/dist/assets/src/core/plane/decide.ts +203 -0
  20. package/dist/assets/src/core/provider.ts +11 -0
  21. package/dist/assets/src/core/runEvents.ts +57 -1
  22. package/dist/assets/src/core/runFriction.ts +1 -0
  23. package/dist/assets/src/core/runLedger/transcript.ts +23 -3
  24. package/dist/assets/src/core/runMetrics.ts +237 -0
  25. package/dist/assets/src/core/runRecord.ts +61 -0
  26. package/dist/assets/src/core/runUsage.ts +41 -9
  27. package/dist/assets/src/core/ship/contract.ts +8 -15
  28. package/dist/assets/src/core/ship/coordinator.ts +150 -11
  29. package/dist/assets/src/deploy/profile.ts +16 -0
  30. package/dist/assets/web/dist/.vite/manifest.json +461 -427
  31. package/dist/assets/web/dist/assets/{AppShell-DAtHiuI6.js → AppShell-CSXDCV4x.js} +1 -1
  32. package/dist/assets/web/dist/assets/CostChart-DsuPS2g7.js +2 -0
  33. package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +1 -0
  34. package/dist/assets/web/dist/assets/{DeliveryPage-3ELQWM0r.js → DeliveryPage-ngPsO2to.js} +1 -1
  35. package/dist/assets/web/dist/assets/HomePage-DvxTHzPx.js +3 -0
  36. package/dist/assets/web/dist/assets/{InputMenu-C6aPpe30.js → InputMenu-BdZXnibG.js} +1 -1
  37. package/dist/assets/web/dist/assets/MetricsPage-o5fSaCTe.js +1 -0
  38. package/dist/assets/web/dist/assets/{NotFoundPage-DtE-GgTk.js → NotFoundPage-DHuWyjlq.js} +1 -1
  39. package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +1 -0
  40. package/dist/assets/web/dist/assets/PlanePage-DpWfiX4C.js +1 -0
  41. package/dist/assets/web/dist/assets/{ResidentDetailPage-C9y3nbo8.js → ResidentDetailPage-DG86v39Y.js} +1 -1
  42. package/dist/assets/web/dist/assets/{ResidentsIndexPage-i1RG9e7g.js → ResidentsIndexPage-x6p689VH.js} +1 -1
  43. package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +1 -0
  44. package/dist/assets/web/dist/assets/RunRoutePage-ysJBY8xQ.js +9 -0
  45. package/dist/assets/web/dist/assets/RunsIndexPage-CuzFchcn.js +1 -0
  46. package/dist/assets/web/dist/assets/{RunsTabs-YSbUu5py.js → RunsTabs-kHbbxVHx.js} +1 -1
  47. package/dist/assets/web/dist/assets/{ScheduledPage-DvYwM2TE.js → ScheduledPage-BuLmfcbG.js} +1 -1
  48. package/dist/assets/web/dist/assets/{SettingSelect-BIzAsLk1.js → SettingSelect-BfID5nF4.js} +1 -1
  49. package/dist/assets/web/dist/assets/{SettingsPage-Bo6yCyXZ.js → SettingsPage-BujWkdU_.js} +1 -1
  50. package/dist/assets/web/dist/assets/{StatusDot-CAfS1AUi.js → StatusDot-C8Bc0pTX.js} +1 -1
  51. package/dist/assets/web/dist/assets/{Tooltip-tZoum_T-.js → Tooltip-BWwJx27K.js} +1 -1
  52. package/dist/assets/web/dist/assets/{UnitRoutePage-BmdOHwNn.js → UnitRoutePage-B9kjA1AT.js} +1 -1
  53. package/dist/assets/web/dist/assets/{angular-html-BjeQZdCq.js → angular-html-C8b5YTHf.js} +1 -1
  54. package/dist/assets/web/dist/assets/{angular-ts-ZGdORrL2.js → angular-ts-C3f4WYNe.js} +1 -1
  55. package/dist/assets/web/dist/assets/{apl-DhvV_X93.js → apl-DSHxUJc3.js} +1 -1
  56. package/dist/assets/web/dist/assets/{astro-DiRSE4Ug.js → astro-BSGCCBP6.js} +1 -1
  57. package/dist/assets/web/dist/assets/{blade-D8Fr5Wvq.js → blade-DFeYrMKk.js} +1 -1
  58. package/dist/assets/web/dist/assets/{c-BwbC64D4.js → c-L_QOnFVv.js} +1 -1
  59. package/dist/assets/web/dist/assets/{chapel-DgmqRK97.js → chapel-DLQWUwhu.js} +1 -1
  60. package/dist/assets/web/dist/assets/{cobol-DJod2RNK.js → cobol-1DlMmrEO.js} +1 -1
  61. package/dist/assets/web/dist/assets/{coffee-D1Y2CgBY.js → coffee-BTB6os6N.js} +1 -1
  62. package/dist/assets/web/dist/assets/{cpp-CsO97YOM.js → cpp-BvJI2z3u.js} +1 -1
  63. package/dist/assets/web/dist/assets/{crystal-CJ7m2tOc.js → crystal-B11wGW_h.js} +1 -1
  64. package/dist/assets/web/dist/assets/{css-B2M-NKoj.js → css-BiIWx5Pw.js} +1 -1
  65. package/dist/assets/web/dist/assets/{dist-DfbEpHXR.js → dist-CpnyQGOb.js} +2 -2
  66. package/dist/assets/web/dist/assets/{edge-CFVW-m9B.js → edge-nSglEXaD.js} +1 -1
  67. package/dist/assets/web/dist/assets/{elixir-B2mtRgCr.js → elixir-x7o8qMl8.js} +1 -1
  68. package/dist/assets/web/dist/assets/{elm-2teVAwCi.js → elm-DGrRyqTQ.js} +1 -1
  69. package/dist/assets/web/dist/assets/{erb-LAbeNqen.js → erb--_dh58Nw.js} +1 -1
  70. package/dist/assets/web/dist/assets/{git-rebase-DnhzH_sp.js → git-rebase-DbpUXwyg.js} +1 -1
  71. package/dist/assets/web/dist/assets/{glimmer-js-DPKjXf1-.js → glimmer-js-BG0CuZG5.js} +1 -1
  72. package/dist/assets/web/dist/assets/{glimmer-ts-CABXnt5z.js → glimmer-ts-CQIl5qyR.js} +1 -1
  73. package/dist/assets/web/dist/assets/{glsl-Dz4-3-gJ.js → glsl-9PJNQTcw.js} +1 -1
  74. package/dist/assets/web/dist/assets/{graphql-ByWUD1DL.js → graphql-CzVTzjEI.js} +1 -1
  75. package/dist/assets/web/dist/assets/{hack-CdmOK2K-.js → hack-BhCmAz3Q.js} +1 -1
  76. package/dist/assets/web/dist/assets/{haml-DzegMpd2.js → haml-BmI12COR.js} +1 -1
  77. package/dist/assets/web/dist/assets/{handlebars-B5NeiP0e.js → handlebars-Cib-ISTS.js} +1 -1
  78. package/dist/assets/web/dist/assets/{html-Zg_NHLv9.js → html-D3lDapvI.js} +1 -1
  79. package/dist/assets/web/dist/assets/{html-derivative-DZf9JNzh.js → html-derivative-DRPY8vOK.js} +1 -1
  80. package/dist/assets/web/dist/assets/{http-CVFrQZeN.js → http-B9VttK6m.js} +1 -1
  81. package/dist/assets/web/dist/assets/{hurl-L5brWAJe.js → hurl-C0saNJYj.js} +1 -1
  82. package/dist/assets/web/dist/assets/indexRow-DABQtONT.js +1 -0
  83. package/dist/assets/web/dist/assets/{java-hFphyZ4R.js → java-BCFbtYAj.js} +1 -1
  84. package/dist/assets/web/dist/assets/{javascript-CAJO1WKX.js → javascript-nwBjnrhh.js} +1 -1
  85. package/dist/assets/web/dist/assets/{jinja-N4A4srXI.js → jinja-CMYO9byN.js} +1 -1
  86. package/dist/assets/web/dist/assets/{jison-D3BZa_iU.js → jison-Cko8HnE_.js} +1 -1
  87. package/dist/assets/web/dist/assets/{json-DxD1Qh8W.js → json-Dzv__YsX.js} +1 -1
  88. package/dist/assets/web/dist/assets/{jsx-CHhag69S.js → jsx-CTFxagih.js} +1 -1
  89. package/dist/assets/web/dist/assets/{julia-CrhkZ6Tt.js → julia-_Xvten1-.js} +1 -1
  90. package/dist/assets/web/dist/assets/{just-D0LFOuhq.js → just-bLps4qOu.js} +1 -1
  91. package/dist/assets/web/dist/assets/{latex-BHAlwCXK.js → latex-D6UaYsUP.js} +1 -1
  92. package/dist/assets/web/dist/assets/{liquid-CkaLfCWh.js → liquid-CoDIeULa.js} +1 -1
  93. package/dist/assets/web/dist/assets/{lua-B1h4aAzP.js → lua-D1KFRnVf.js} +1 -1
  94. package/dist/assets/web/dist/assets/main-C4QfwybO.css +1 -0
  95. package/dist/assets/web/dist/assets/main-Comxmwi4.js +28 -0
  96. package/dist/assets/web/dist/assets/{marko-D6CG4hvX.js → marko-B5N5XcOw.js} +1 -1
  97. package/dist/assets/web/dist/assets/{mdc-CVQzimAU.js → mdc-uxzjQO3q.js} +1 -1
  98. package/dist/assets/web/dist/assets/{nginx-h-9Ir54h.js → nginx-BMJxs2TK.js} +1 -1
  99. package/dist/assets/web/dist/assets/{nim-DHVI30sp.js → nim-DqQ3-8p1.js} +1 -1
  100. package/dist/assets/web/dist/assets/{org-AasAXfMF.js → org-DsY6LK59.js} +1 -1
  101. package/dist/assets/web/dist/assets/{perl-BVi5aTEy.js → perl--pq9iJFp.js} +1 -1
  102. package/dist/assets/web/dist/assets/{php-qOkCeUKr.js → php-RHCMbsM2.js} +1 -1
  103. package/dist/assets/web/dist/assets/{pug-8TnUGvEX.js → pug-CFNq_FCp.js} +1 -1
  104. package/dist/assets/web/dist/assets/{qml-CpJbhoxn.js → qml-C9Acx9E8.js} +1 -1
  105. package/dist/assets/web/dist/assets/{r-JkUNcAJW.js → r-DxlU8FBd.js} +1 -1
  106. package/dist/assets/web/dist/assets/{razor-CtU4ws5a.js → razor-CLJ2kKih.js} +1 -1
  107. package/dist/assets/web/dist/assets/{regexp-J2zA6ceN.js → regexp-CzmdGoEp.js} +1 -1
  108. package/dist/assets/web/dist/assets/{rst-CMCvf-yZ.js → rst-D9yuxBET.js} +1 -1
  109. package/dist/assets/web/dist/assets/{ruby-ChLBY_iR.js → ruby-CIRcEvnD.js} +1 -1
  110. package/dist/assets/web/dist/assets/{sas-cwn4x2VY.js → sas-LOl-uUPh.js} +1 -1
  111. package/dist/assets/web/dist/assets/{scss-jSrJlzLI.js → scss-BX2_sZHY.js} +1 -1
  112. package/dist/assets/web/dist/assets/{shellscript-D5nAd0gs.js → shellscript-4d-QvKkY.js} +1 -1
  113. package/dist/assets/web/dist/assets/{shellsession-BZY4-Tdh.js → shellsession-DhB16UGy.js} +1 -1
  114. package/dist/assets/web/dist/assets/{soy-COppPOKr.js → soy-Cm1xTUBr.js} +1 -1
  115. package/dist/assets/web/dist/assets/{sql-Blc_6dOA.js → sql-BsfcBJdu.js} +1 -1
  116. package/dist/assets/web/dist/assets/{sseReplay-DmyMXfRC.js → sseReplay-IzTdD4-3.js} +5 -5
  117. package/dist/assets/web/dist/assets/{stata-DRv3OY-h.js → stata-Dwat3BSw.js} +1 -1
  118. package/dist/assets/web/dist/assets/{surrealql-qMv_eeJ6.js → surrealql-P4rtxzAW.js} +1 -1
  119. package/dist/assets/web/dist/assets/{svelte-d1Edb6Rz.js → svelte-DTcsi8S8.js} +1 -1
  120. package/dist/assets/web/dist/assets/{templ-CUMSIOXG.js → templ-D-kP0Tg5.js} +1 -1
  121. package/dist/assets/web/dist/assets/{tex-BGZQA-Ei.js → tex-D-4vuedK.js} +1 -1
  122. package/dist/assets/web/dist/assets/{ts-tags-Z9ulxVsq.js → ts-tags-jEEWYxsu.js} +1 -1
  123. package/dist/assets/web/dist/assets/{tsx-oI4bI3Vf.js → tsx-CFnlrVzT.js} +1 -1
  124. package/dist/assets/web/dist/assets/{twig-D_RXwBIX.js → twig-D5rGrPjs.js} +1 -1
  125. package/dist/assets/web/dist/assets/{typescript-BgqQDYoN.js → typescript-nYIQ35or.js} +1 -1
  126. package/dist/assets/web/dist/assets/{typst-5MW-KRDd.js → typst-3-UX8dna.js} +1 -1
  127. package/dist/assets/web/dist/assets/{vue-CZhHHsPm.js → vue-CLIPIX03.js} +1 -1
  128. package/dist/assets/web/dist/assets/{vue-html-DlYy2d8v.js → vue-html-Ci9CpkHU.js} +1 -1
  129. package/dist/assets/web/dist/assets/{vue-vine-CJDnx-qn.js → vue-vine-DL5pEYYk.js} +1 -1
  130. package/dist/assets/web/dist/assets/{xml-BRBDk65Q.js → xml-bO80qikC.js} +1 -1
  131. package/dist/assets/web/dist/assets/{xsl-BlCykPxo.js → xsl-BPze60Ej.js} +1 -1
  132. package/dist/assets/web/dist/assets/{yaml-BqW48szf.js → yaml-BQUSon_o.js} +1 -1
  133. package/dist/cli.js +36552 -33171
  134. package/package.json +1 -1
  135. package/dist/assets/web/dist/assets/CostsPage-BoKzYa4B.js +0 -2
  136. package/dist/assets/web/dist/assets/HomePage-BG_ok-K2.js +0 -2
  137. package/dist/assets/web/dist/assets/PendingTurnRow-ChCQOLgZ.js +0 -1
  138. package/dist/assets/web/dist/assets/RunFoldRow-D3wVpzBa.js +0 -1
  139. package/dist/assets/web/dist/assets/RunRoutePage-B3IirUVi.js +0 -9
  140. package/dist/assets/web/dist/assets/RunsIndexPage-DiFmtGaJ.js +0 -1
  141. package/dist/assets/web/dist/assets/indexRow-BT0cPVRw.js +0 -1
  142. package/dist/assets/web/dist/assets/main-5Gm_1Gv8.js +0 -28
  143. package/dist/assets/web/dist/assets/main-zbP_dTjR.css +0 -1
@@ -78,6 +78,18 @@ import {
78
78
  selectReclaim,
79
79
  } from "../../src/core/runLedger/decisions.ts";
80
80
  import { intakeReceiptRetentionMs } from "../../src/core/budgets.ts";
81
+ import {
82
+ decide,
83
+ planeAskWordOf,
84
+ type PlaneAckOutcome,
85
+ type PlaneEffect,
86
+ type PlaneEvent,
87
+ type PlaneOutcomePost,
88
+ type PlaneQueueRow,
89
+ type PlaneStage,
90
+ type PlaneState,
91
+ type PlaneWrite,
92
+ } from "../../src/core/plane/decide.ts";
81
93
  import {
82
94
  IDEMPOTENCY_KEY_PATTERN,
83
95
  capThreadEvent,
@@ -119,6 +131,8 @@ import {
119
131
  type McpTicketState,
120
132
  type SealedCredential,
121
133
  } from "../../src/mcp/registry.ts";
134
+ import { isRunMetricsPoint, pointTurnsFinal, type RunMetricsPoint } from "../../src/core/runMetrics.ts";
135
+ import { AnalyticsEngineSink, NullSink, type RunMetricsSink } from "./runMetricsSink.ts";
122
136
  import { injectedBuildStamp } from "../../src/deploy/buildStamp.ts";
123
137
  import { systemClock } from "../../src/core/trace/clock.ts";
124
138
  import { createTracer } from "../../src/core/trace/tracer.ts";
@@ -148,7 +162,7 @@ const traceSinks = [workerLogSink((line) => console.log(line))];
148
162
  //
149
163
  // Route surface (JSON in/out; bearer MEMORY_TOKEN on everything but /healthz):
150
164
  // POST /retrieve {scopeKey, query, limit} → {records: MemoryRecord[]}
151
- // POST /write {scopeKey, records: MemoryCandidate[]} → {ok, inserted, deduped, superseded}
165
+ // POST /write {scopeKey, records: MemoryCandidate[]} → {ok, inserted, deduped, restated, superseded, evicted}
152
166
  // POST /sweep {scopeKey, dryRun?} → {ok, swept} (+ ids under dryRun — the marked rows, flipped to `swept`)
153
167
  // GET /healthz → {ok:true} (deploy wake ping; touches no DO)
154
168
  // Scheduled-firing routes (the record behind the /runs Scheduled panel;
@@ -161,7 +175,7 @@ const traceSinks = [workerLogSink((line) => console.log(line))];
161
175
  // store key, owning the retention policy. Same bearer; /runs/put has its own 2 MiB
162
176
  // body fence (a record is budgeted to 1.5 MiB upstream), every other route
163
177
  // keeps the 512 KB one.
164
- // POST /runs/put {storeKey, record, policy?, policyUpdatedAt?} → {ok, retained, stored, rewritten}
178
+ // POST /runs/put {storeKey, record, policy?, policyUpdatedAt?, point?} → {ok, retained, stored, rewritten}
165
179
  // POST /runs/get {storeKey, id} → {record: RunRecord | null} (unknown/expired: null, 200)
166
180
  // POST /runs/list {storeKey, limit?, before?, beforeId?, sinceMs?, agent?, channel?, threadKey?, parentRunId?}
167
181
  // → {items: RunListItem[], nextBefore?: {finishedAt, id}} (cursor = the last row's list key)
@@ -198,6 +212,14 @@ export interface Env {
198
212
  * binding is a cross-script one, and the class must exist on the bot before
199
213
  * the state Worker may name it), and a finish then commits with no event. */
200
214
  SHIP_COORDINATOR?: Workflow;
215
+ /** The run-metrics dataset (docs/reference/specs/run-metrics.md): where the
216
+ * RunHistoryDO writes one point per run whose row turned final. Optional
217
+ * like SHIP_COORDINATOR: this Worker deploys without it and every answer is
218
+ * byte-identical — the NullSink swallows the points. */
219
+ RUN_METRICS?: AnalyticsEngineDataset;
220
+ /** The dataset's name, rendered beside the binding, so `/healthz` can answer
221
+ * `runMetrics:<dataset>` and the bot's boot probe can compare names. */
222
+ RUN_METRICS_DATASET?: string;
201
223
  MEMORY_TOKEN?: string;
202
224
  }
203
225
 
@@ -381,8 +403,8 @@ export class MemoryDO extends DurableObject<Env> {
381
403
  scopeKey: string,
382
404
  candidates: MemoryCandidate[],
383
405
  cap: number = DEFAULT_SCOPE_CAP,
384
- ): Promise<{ inserted: number; deduped: number; superseded: number; evicted: number }> {
385
- const counts = { inserted: 0, deduped: 0, superseded: 0, evicted: 0 };
406
+ ): Promise<{ inserted: number; deduped: number; restated: number; superseded: number; evicted: number }> {
407
+ const counts = { inserted: 0, deduped: 0, restated: 0, superseded: 0, evicted: 0 };
386
408
  if (candidates.length === 0) return counts;
387
409
  this.ctx.storage.transactionSync(() => {
388
410
  let seq = this.sql.exec<{ next: number }>(`SELECT COALESCE(MAX(seq), -1) + 1 AS next FROM records`).one().next;
@@ -400,18 +422,41 @@ export class MemoryDO extends DurableObject<Env> {
400
422
  // own TRUTHINESS test: `supersedes: ""` passes validation but means NO
401
423
  // supersede to the engine, so it must dedup against the norm pool —
402
424
  // an `!== undefined` branch here would hand it an empty pool and
403
- // insert a duplicate active row.
404
- const relevant = (
405
- cand.supersedes
406
- ? this.sql.exec<Row>(`SELECT * FROM records WHERE id = ? AND status = 'active'`, cand.supersedes)
407
- : this.sql.exec<Row>(
408
- `SELECT * FROM records WHERE status = 'active' AND norm = ? ORDER BY seq`,
409
- normalizeText(cand.text),
425
+ // insert a duplicate active row. A `restates` id is looked up first
426
+ // (the restate target); when it misses — not active, or another
427
+ // scope's id, which this DO simply doesn't hold — the candidate takes
428
+ // today's pool, so planWrite falls through to dedup-or-insert.
429
+ const restatePool = cand.restates
430
+ ? this.sql
431
+ .exec<Row>(`SELECT * FROM records WHERE id = ? AND status = 'active'`, cand.restates)
432
+ .toArray()
433
+ .map(toRecord)
434
+ : [];
435
+ const relevant =
436
+ restatePool.length > 0
437
+ ? restatePool
438
+ : (cand.supersedes
439
+ ? this.sql.exec<Row>(`SELECT * FROM records WHERE id = ? AND status = 'active'`, cand.supersedes)
440
+ : this.sql.exec<Row>(
441
+ `SELECT * FROM records WHERE status = 'active' AND norm = ? ORDER BY seq`,
442
+ normalizeText(cand.text),
443
+ )
410
444
  )
411
- )
412
- .toArray()
413
- .map(toRecord);
445
+ .toArray()
446
+ .map(toRecord);
414
447
  const plan = planWrite(relevant, cand, (c) => mintRecord(scopeKey, seq++, now, c));
448
+ if (plan.action === "restate") {
449
+ // The shown record is refreshed in place; COALESCE keeps the stored
450
+ // confidence when the plan carries none (neither side had a value).
451
+ this.sql.exec(
452
+ `UPDATE records SET use_count = use_count + 1, last_used_at = ?, confidence = COALESCE(?, confidence) WHERE id = ?`,
453
+ now,
454
+ plan.confidence ?? null,
455
+ plan.target.id,
456
+ );
457
+ counts.restated++;
458
+ continue;
459
+ }
415
460
  if (plan.action === "dedup") {
416
461
  this.sql.exec(`UPDATE records SET use_count = use_count + 1 WHERE id = ?`, plan.target.id);
417
462
  counts.deduped++;
@@ -1173,13 +1218,16 @@ export class CostsSnapshotDO extends DurableObject<Env> {
1173
1218
  `);
1174
1219
  }
1175
1220
 
1176
- /** Replace the snapshot whole: every part rewritten in one transaction. */
1221
+ /** Replace the snapshot whole: every part rewritten in one transaction. Each
1222
+ * logical part is its own named row — `invoices` included, never a rest-spread
1223
+ * into the meta row that would silently absorb future fields. */
1177
1224
  async put(snapshot: CostsSnapshot): Promise<void> {
1178
- const { usage, llm, runUsage, ...meta } = snapshot;
1225
+ const { usage, llm, invoices, runUsage, ...meta } = snapshot;
1179
1226
  const rows: Array<[string, unknown]> = [
1180
1227
  ["meta", meta],
1181
1228
  ...USAGE_PARTS.map((name): [string, unknown] => [`usage.${name}`, usage[name]]),
1182
1229
  ["llm", llm],
1230
+ ...(invoices !== undefined ? [["invoices", invoices] as [string, unknown]] : []),
1183
1231
  ["runUsage", runUsage],
1184
1232
  ];
1185
1233
  this.ctx.storage.transactionSync(() => {
@@ -1204,10 +1252,12 @@ export class CostsSnapshotDO extends DurableObject<Env> {
1204
1252
  return body === undefined ? undefined : (JSON.parse(body) as unknown);
1205
1253
  };
1206
1254
  const usage = Object.fromEntries(USAGE_PARTS.map((name) => [name, read(`usage.${name}`)]));
1255
+ const invoices = read("invoices");
1207
1256
  const snapshot = {
1208
1257
  ...(JSON.parse(meta) as Record<string, unknown>),
1209
1258
  usage,
1210
1259
  llm: read("llm"),
1260
+ ...(invoices !== undefined ? { invoices } : {}),
1211
1261
  runUsage: read("runUsage"),
1212
1262
  };
1213
1263
  return isCostsSnapshot(snapshot) ? snapshot : null;
@@ -1481,14 +1531,32 @@ function rowToLive(r: LiveRow): LiveRunRow {
1481
1531
  };
1482
1532
  }
1483
1533
 
1484
- type HeartbeatAnswer = FenceResult & { stop?: StopMode | null; phase?: LivePhase };
1534
+ type HeartbeatAnswer = FenceResult & { stop?: StopMode | null; phase?: LivePhase; effects?: PlaneEffect[] };
1535
+
1536
+ /** Whether the bot's outcome and the decider's word agree (orchestration-plane item 8): `proceeded`
1537
+ * beside `proceed`, and a thread-live refusal beside `queued` — the refusal
1538
+ * IS the queue position the plane would hold. `null` for a pair the decider
1539
+ * does not model yet: logged, never counted. */
1540
+ function planeAgreementOf(outcome: string, decider: "proceed" | "queued"): boolean | null {
1541
+ if (outcome === "proceeded") return decider === "proceed";
1542
+ if (outcome === "refused:thread-live") return decider === "queued";
1543
+ return null;
1544
+ }
1545
+
1546
+ /** The most effects one answer carries (record 0064; orchestration-plane item 7): the rest ride the next heartbeat. */
1547
+ const PLANE_EFFECTS_PER_ANSWER = 32;
1485
1548
 
1486
1549
  export class RunHistoryDO extends DurableObject<Env> {
1487
1550
  private readonly sql: SqlStorage;
1551
+ /** Where a turned-final run's point goes (run-metrics.md): the Analytics
1552
+ * Engine dataset when the deploy bound one, the NullSink otherwise —
1553
+ * selected once at construction, the `SHIP_COORDINATOR?` shape. */
1554
+ private readonly metrics: RunMetricsSink;
1488
1555
 
1489
1556
  constructor(ctx: DurableObjectState, env: Env) {
1490
1557
  super(ctx, env);
1491
1558
  this.sql = ctx.storage.sql;
1559
+ this.metrics = env.RUN_METRICS !== undefined ? new AnalyticsEngineSink(env.RUN_METRICS) : new NullSink();
1492
1560
  // Idempotent schema. `runs` carries the listing columns plus the record
1493
1561
  // minus its events as JSON (`summary_json`, what `list` returns); events
1494
1562
  // live one per row keyed (run_id, seq) so a 5000-event run is paged, never
@@ -1643,6 +1711,215 @@ export class RunHistoryDO extends DurableObject<Env> {
1643
1711
  PRIMARY KEY (instance_id, unit, seq)
1644
1712
  );
1645
1713
  `);
1714
+ // The orchestration plane's tables (record 0064; orchestration-plane.md item 7):
1715
+ // the queue with its conditions, the reservations, the quiet and pressure
1716
+ // windows, the watches' findings, the offered effects and the residents'
1717
+ // levels. This unit opens them all so a later Worker and an earlier one agree on
1718
+ // the schema; only the queue and the effects are written yet.
1719
+ this.sql.exec(`
1720
+ CREATE TABLE IF NOT EXISTS plane_queue (
1721
+ run_id TEXT PRIMARY KEY,
1722
+ requester TEXT NOT NULL,
1723
+ thread_key TEXT NOT NULL,
1724
+ stage TEXT NOT NULL,
1725
+ request_json TEXT NOT NULL,
1726
+ conditions_json TEXT NOT NULL,
1727
+ position_at INTEGER NOT NULL,
1728
+ queued_at INTEGER NOT NULL,
1729
+ state TEXT NOT NULL
1730
+ );
1731
+ CREATE INDEX IF NOT EXISTS plane_queue_waiting ON plane_queue(state, queued_at);
1732
+ CREATE TABLE IF NOT EXISTS plane_reservations (
1733
+ kind TEXT NOT NULL,
1734
+ key TEXT NOT NULL,
1735
+ run_id TEXT NOT NULL,
1736
+ at INTEGER NOT NULL,
1737
+ PRIMARY KEY (kind, key)
1738
+ );
1739
+ CREATE TABLE IF NOT EXISTS plane_windows (
1740
+ kind TEXT NOT NULL,
1741
+ key TEXT NOT NULL,
1742
+ phase TEXT NOT NULL,
1743
+ opened_at INTEGER NOT NULL,
1744
+ reason_json TEXT NOT NULL,
1745
+ PRIMARY KEY (kind, key)
1746
+ );
1747
+ CREATE TABLE IF NOT EXISTS plane_findings (
1748
+ id TEXT PRIMARY KEY,
1749
+ watch TEXT NOT NULL,
1750
+ subject TEXT NOT NULL,
1751
+ timeline_json TEXT NOT NULL,
1752
+ filed_at INTEGER NOT NULL
1753
+ );
1754
+ CREATE TABLE IF NOT EXISTS plane_effects (
1755
+ id TEXT PRIMARY KEY,
1756
+ body_json TEXT NOT NULL,
1757
+ offered_at INTEGER NOT NULL,
1758
+ acked_at INTEGER
1759
+ );
1760
+ CREATE INDEX IF NOT EXISTS plane_effects_open ON plane_effects(acked_at, offered_at);
1761
+ CREATE TABLE IF NOT EXISTS plane_levels (
1762
+ resident TEXT NOT NULL,
1763
+ name TEXT NOT NULL,
1764
+ side TEXT NOT NULL,
1765
+ reported_at INTEGER NOT NULL,
1766
+ generation TEXT NOT NULL,
1767
+ PRIMARY KEY (resident, name)
1768
+ );
1769
+ `);
1770
+ }
1771
+
1772
+ // ---- the orchestration plane (record 0064; orchestration-plane.md) ----------
1773
+
1774
+ /** The decider's state, read inside the caller's `transactionSync`: the
1775
+ * queue oldest first and the threads a live row holds. `excludeRunId` drops
1776
+ * that run's own live row from the view — a shadow post judged after the
1777
+ * dispatch it describes claimed the thread must not read its own claim as
1778
+ * "thread live" (orchestration-plane item 8). */
1779
+ private planeState(excludeRunId?: string): PlaneState {
1780
+ const queue = this.sql
1781
+ .exec<{
1782
+ run_id: string;
1783
+ requester: string;
1784
+ thread_key: string;
1785
+ stage: string;
1786
+ request_json: string;
1787
+ conditions_json: string;
1788
+ position_at: number;
1789
+ queued_at: number;
1790
+ state: string;
1791
+ }>(`SELECT * FROM plane_queue ORDER BY queued_at ASC, run_id ASC`)
1792
+ .toArray()
1793
+ .map((r): PlaneQueueRow => ({
1794
+ runId: r.run_id,
1795
+ requester: r.requester,
1796
+ threadKey: r.thread_key,
1797
+ stage: r.stage as PlaneStage,
1798
+ request: JSON.parse(r.request_json) as Record<string, unknown>,
1799
+ conditions: JSON.parse(r.conditions_json) as PlaneQueueRow["conditions"],
1800
+ position: r.position_at,
1801
+ queuedAt: r.queued_at,
1802
+ state: r.state as PlaneQueueRow["state"],
1803
+ }));
1804
+ const liveThreads = this.sql
1805
+ .exec<{ thread_key: string }>(`SELECT thread_key FROM live_runs WHERE run_id IS NOT ?`, excludeRunId ?? null)
1806
+ .toArray()
1807
+ .map((r) => r.thread_key);
1808
+ return { queue, liveThreads };
1809
+ }
1810
+
1811
+ /** The decider's writes, applied inside the same `transactionSync` that read
1812
+ * the state — the decision and its consequences land together (orchestration-plane item 6). */
1813
+ private applyPlaneWrites(writes: PlaneWrite[]): void {
1814
+ for (const w of writes) {
1815
+ if (w.table === "plane_queue" && w.op === "put") {
1816
+ this.sql.exec(
1817
+ `INSERT OR REPLACE INTO plane_queue
1818
+ (run_id, requester, thread_key, stage, request_json, conditions_json, position_at, queued_at, state)
1819
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1820
+ w.row.runId,
1821
+ w.row.requester,
1822
+ w.row.threadKey,
1823
+ w.row.stage,
1824
+ JSON.stringify(w.row.request),
1825
+ JSON.stringify(w.row.conditions),
1826
+ w.row.position,
1827
+ w.row.queuedAt,
1828
+ w.row.state,
1829
+ );
1830
+ } else if (w.table === "plane_queue" && w.op === "state") {
1831
+ this.sql.exec(`UPDATE plane_queue SET state = ? WHERE run_id = ?`, w.state, w.runId);
1832
+ } else {
1833
+ // An offer keeps its first `offered_at`: a re-decided admit after a
1834
+ // roll is the same effect, not a younger one.
1835
+ this.sql.exec(
1836
+ `INSERT OR IGNORE INTO plane_effects (id, body_json, offered_at, acked_at) VALUES (?, ?, ?, NULL)`,
1837
+ w.effect.id,
1838
+ JSON.stringify(w.effect),
1839
+ w.at,
1840
+ );
1841
+ }
1842
+ }
1843
+ }
1844
+
1845
+ /** Apply one plane event: state read, decider, writes and effects in ONE
1846
+ * `transactionSync` (orchestration-plane item 6). The transport unit's routes feed it; the tests pin the atomicity. */
1847
+ planeApply(event: PlaneEvent): { effects: PlaneEffect[] } {
1848
+ let effects: PlaneEffect[] = [];
1849
+ this.ctx.storage.transactionSync(() => {
1850
+ const decision = decide(this.planeState(), event);
1851
+ this.applyPlaneWrites(decision.writes);
1852
+ effects = decision.effects;
1853
+ });
1854
+ return { effects };
1855
+ }
1856
+
1857
+ /** The unacknowledged effects, oldest first, at most `PLANE_EFFECTS_PER_ANSWER`
1858
+ * (orchestration-plane item 7) — what every heartbeat and reclaim answer carries. Public: the
1859
+ * reclaim route composes it beside the runs it took. */
1860
+ openPlaneEffects(): PlaneEffect[] {
1861
+ return this.sql
1862
+ .exec<{ body_json: string }>(
1863
+ `SELECT body_json FROM plane_effects WHERE acked_at IS NULL ORDER BY offered_at ASC, id ASC LIMIT ?`,
1864
+ PLANE_EFFECTS_PER_ANSWER,
1865
+ )
1866
+ .toArray()
1867
+ .map((r) => JSON.parse(r.body_json) as PlaneEffect);
1868
+ }
1869
+
1870
+ /** Shadow (orchestration-plane item 8): the bot's own outcome for one dispatch, judged beside the
1871
+ * decider's word for the same ask. Nothing runs and nothing queues from
1872
+ * the decider here — the comparison is logged, and a disagreement bumps a
1873
+ * per-condition counter in `meta` for the `plane disagreements` line. An
1874
+ * outcome the decider does not model yet (an allowlist refusal, a cold
1875
+ * fall) is logged uncounted. The post is fired without an await, so it can
1876
+ * arrive AFTER the dispatch it describes claimed this thread's `live_runs`
1877
+ * row; a post carrying the run's own id excludes that row from the live
1878
+ * view so the run's own claim never reads as a false disagreement. */
1879
+ planeOutcome(
1880
+ post: PlaneOutcomePost,
1881
+ now: number,
1882
+ ): { ok: true; decider: "proceed" | "queued"; agreed: boolean | null } {
1883
+ let decider: "proceed" | "queued" = "proceed";
1884
+ let agreed: boolean | null = null;
1885
+ this.ctx.storage.transactionSync(() => {
1886
+ const ask: PlaneEvent = {
1887
+ kind: "ask",
1888
+ at: now,
1889
+ runId: post.runId ?? `ask:${post.threadKey}:${now}`,
1890
+ requester: post.requester,
1891
+ threadKey: post.threadKey,
1892
+ stage: post.stage,
1893
+ request: {},
1894
+ };
1895
+ decider = planeAskWordOf(decide(this.planeState(post.runId), ask), ask.runId);
1896
+ agreed = planeAgreementOf(post.outcome, decider);
1897
+ if (agreed === false) this.bumpPlaneDisagreement("thread_free");
1898
+ });
1899
+ console.log(
1900
+ `[plane/outcome] ${post.threadKey} ${post.stage} bot=${post.outcome} decider=${decider} agreed=${agreed ?? "uncompared"}`,
1901
+ );
1902
+ return { ok: true, decider, agreed };
1903
+ }
1904
+
1905
+ /** The per-condition disagreement counts (orchestration-plane item 8), kept in `meta` so the table's
1906
+ * later `plane disagreements` line can read them. */
1907
+ private bumpPlaneDisagreement(condition: string): void {
1908
+ const row = this.sql
1909
+ .exec<{ value: string }>(`SELECT value FROM meta WHERE key = 'plane_disagreements'`)
1910
+ .toArray()[0];
1911
+ const counts = row ? (JSON.parse(row.value) as Record<string, number>) : {};
1912
+ counts[condition] = (counts[condition] ?? 0) + 1;
1913
+ this.sql.exec(`INSERT OR REPLACE INTO meta (key, value) VALUES ('plane_disagreements', ?)`, JSON.stringify(counts));
1914
+ }
1915
+
1916
+ /** An effect's acknowledgement by id (orchestration-plane item 7): `done` and `skipped` close it,
1917
+ * `deferred` leaves it offered for the next answer. An unknown id is a
1918
+ * no-op — the bot may ack an effect an older table never held. */
1919
+ planeAck(id: string, outcome: PlaneAckOutcome, now: number): { ok: true } {
1920
+ if (outcome !== "deferred")
1921
+ this.sql.exec(`UPDATE plane_effects SET acked_at = ? WHERE id = ? AND acked_at IS NULL`, now, id);
1922
+ return { ok: true };
1646
1923
  }
1647
1924
 
1648
1925
  // ---- the coordinator's parent records (run-history item 49) -----------------
@@ -1931,7 +2208,10 @@ export class RunHistoryDO extends DurableObject<Env> {
1931
2208
  return;
1932
2209
  }
1933
2210
  this.sql.exec(`UPDATE live_runs SET lease_until = ? WHERE run_id = ?`, now + leaseMs, runId);
1934
- out = { ok: true, stop: row.stop, phase: row.phase };
2211
+ // The plane's open effects ride every owner's heartbeat answer (record
2212
+ // 0064; orchestration-plane item 7) — empty until a unit writes them, but always present, so the
2213
+ // client's ack loop needs no version probe.
2214
+ out = { ok: true, stop: row.stop, phase: row.phase, effects: this.openPlaneEffects() };
1935
2215
  });
1936
2216
  return out;
1937
2217
  }
@@ -2063,8 +2343,10 @@ export class RunHistoryDO extends DurableObject<Env> {
2063
2343
  gen: string,
2064
2344
  record: RunRecord,
2065
2345
  proposal?: RunPolicyProposal,
2346
+ point?: RunMetricsPoint,
2066
2347
  ): Promise<FenceResult & { stored?: boolean; event?: RunFinishedSend["kind"] }> {
2067
2348
  let out: FenceResult & { stored?: boolean } = { ok: true };
2349
+ let turnedFinal = false;
2068
2350
  this.ctx.storage.transactionSync(() => {
2069
2351
  const fence = checkFence(this.liveRow(runId), gen);
2070
2352
  if (!fence.ok) {
@@ -2073,9 +2355,14 @@ export class RunHistoryDO extends DurableObject<Env> {
2073
2355
  }
2074
2356
  const put = this.upsertInTransaction(record, proposal);
2075
2357
  this.deleteLiveRows([runId]);
2358
+ turnedFinal = put.turnedFinal;
2076
2359
  out = { ok: true, stored: put.stored };
2077
2360
  });
2078
2361
  if (!out.ok) return out;
2362
+ // The point after the commit, never inside it (`sendRunFinished`'s placement):
2363
+ // the finish usually replaces the start tombstone, so this is where most
2364
+ // runs are counted (run-metrics.md item 2).
2365
+ this.writeMetricsPoint(runId, point, turnedFinal && out.stored === true);
2079
2366
  if ((await this.ctx.storage.getAlarm()) === null)
2080
2367
  await this.ctx.storage.setAlarm(systemClock() + RUN_SWEEP_INTERVAL_MS);
2081
2368
  await this.refreshSessionBytes(record.session?.key);
@@ -2338,15 +2625,21 @@ export class RunHistoryDO extends DurableObject<Env> {
2338
2625
  * when the stored version changed (`event_count`, `finished_at`, `bytes`) —
2339
2626
  * an identical retry is a no-op on `run_events`. `stored: false` when the
2340
2627
  * record itself fell outside the (possibly just-updated) policy: it was
2341
- * written and deleted in the same transaction, so nothing of it remains. */
2628
+ * written and deleted in the same transaction, so nothing of it remains.
2629
+ * `point` is the record's metrics point, computed by the client
2630
+ * (run-metrics.md): written to the sink AFTER the commit, only when the row
2631
+ * turned final and the record was stored — `turnedFinal` stays internal (the
2632
+ * route strips it), so the wire answer is exactly the shape it always was. */
2342
2633
  async put(
2343
2634
  record: RunRecord,
2344
2635
  proposal?: RunPolicyProposal,
2345
- ): Promise<{ ok: true; retained: number; stored: boolean; rewritten: boolean }> {
2346
- let result = { ok: true as const, retained: 0, stored: false, rewritten: false };
2636
+ point?: RunMetricsPoint,
2637
+ ): Promise<{ ok: true; retained: number; stored: boolean; rewritten: boolean; turnedFinal: boolean }> {
2638
+ let result = { ok: true as const, retained: 0, stored: false, rewritten: false, turnedFinal: false };
2347
2639
  this.ctx.storage.transactionSync(() => {
2348
2640
  result = this.upsertInTransaction(record, proposal);
2349
2641
  });
2642
+ this.writeMetricsPoint(record.id, point, result.turnedFinal && result.stored);
2350
2643
  // A coordinator child closed OUTSIDE the ledger's finish — the run loop or
2351
2644
  // a reclaim writing an `interrupted` record, the pi harness's typed restart,
2352
2645
  // a resume abandoning a lost workspace — still wakes its parent's wait at
@@ -2370,7 +2663,7 @@ export class RunHistoryDO extends DurableObject<Env> {
2370
2663
  private upsertInTransaction(
2371
2664
  record: RunRecord,
2372
2665
  proposal?: RunPolicyProposal,
2373
- ): { ok: true; retained: number; stored: boolean; rewritten: boolean } {
2666
+ ): { ok: true; retained: number; stored: boolean; rewritten: boolean; turnedFinal: boolean } {
2374
2667
  {
2375
2668
  const now = systemClock();
2376
2669
  const policy = proposal ? this.applyProposal(proposal, now).policy : this.policyState().policy;
@@ -2387,11 +2680,29 @@ export class RunHistoryDO extends DurableObject<Env> {
2387
2680
  const { events, ...summary } = stored;
2388
2681
  const bytes = utf8ByteLength(JSON.stringify(stored));
2389
2682
  const existing = this.sql
2390
- .exec<{ event_count: number; finished_at: number; bytes: number }>(
2391
- `SELECT event_count, finished_at, bytes FROM runs WHERE run_id = ?`,
2683
+ .exec<{ event_count: number; finished_at: number; bytes: number; summary_json: string }>(
2684
+ `SELECT event_count, finished_at, bytes, summary_json FROM runs WHERE run_id = ?`,
2392
2685
  record.id,
2393
2686
  )
2394
2687
  .toArray()[0];
2688
+ // The one field of the stored summary the emission rule reads (run-metrics.md
2689
+ // item 2): the row's `provisional`, parsed alone — never deserialized whole.
2690
+ const existingProvisional = existing !== undefined && summaryIsProvisional(existing.summary_json);
2691
+ const turnedFinal = pointTurnsFinal(
2692
+ existing !== undefined ? { provisional: existingProvisional } : undefined,
2693
+ stored,
2694
+ );
2695
+ // A provisional record never overwrites a final row (run-history.md item 27;
2696
+ // run-metrics.md item 3): a start tombstone sitting in retry backoff or a
2697
+ // drain upgrade racing a fast finish would otherwise land after the finish
2698
+ // record, overwrite it with `interrupted` — and let the run's point be
2699
+ // written twice when a later final write turned the row "final" again.
2700
+ // Answered as stored, with nothing written: the row, its events and the
2701
+ // sessions table stay exactly as the final write left them.
2702
+ if (stored.provisional === true && existing !== undefined && !existingProvisional) {
2703
+ const retained = this.sql.exec<{ n: number }>(`SELECT COUNT(*) AS n FROM runs`).one().n;
2704
+ return { ok: true as const, retained, stored: true, rewritten: false, turnedFinal: false };
2705
+ }
2395
2706
  const unchanged =
2396
2707
  existing !== undefined &&
2397
2708
  sameStoredVersion(
@@ -2471,10 +2782,25 @@ export class RunHistoryDO extends DurableObject<Env> {
2471
2782
  retained: kept.size,
2472
2783
  stored: kept.has(record.id),
2473
2784
  rewritten: existing !== undefined && !unchanged,
2785
+ turnedFinal,
2474
2786
  };
2475
2787
  }
2476
2788
  }
2477
2789
 
2790
+ /** One point per run whose row turned final, AFTER the commit (run-metrics.md
2791
+ * item 2) — advisory: a throwing sink leaves the answer exactly as a
2792
+ * recording one would, and says so in one warn line with the run id and the
2793
+ * error's constructor name, never the point's contents. */
2794
+ private writeMetricsPoint(runId: string, point: RunMetricsPoint | undefined, turnedFinal: boolean): void {
2795
+ if (point === undefined || !turnedFinal) return;
2796
+ try {
2797
+ this.metrics.write(point);
2798
+ } catch (err) {
2799
+ const kind = err instanceof Error ? err.constructor.name : "Error";
2800
+ console.warn(`[runs/metrics] ${runId} point not written: ${kind}`);
2801
+ }
2802
+ }
2803
+
2478
2804
  /** Remove a run and its events. Returns whether a run row existed. */
2479
2805
  async delete(id: string): Promise<boolean> {
2480
2806
  let deleted = false;
@@ -2924,6 +3250,17 @@ function parseSummary(row: RunRow): Omit<RunRecord, "events"> | null {
2924
3250
  }
2925
3251
  }
2926
3252
 
3253
+ /** The one field of a stored row's summary the emission rule reads: whether the
3254
+ * row is a provisional tombstone. Deliberately not a full `RunRecord` parse
3255
+ * (run-metrics.md item 2) — this runs inside every upsert's transaction. */
3256
+ function summaryIsProvisional(summaryJson: string): boolean {
3257
+ try {
3258
+ return (JSON.parse(summaryJson) as { provisional?: unknown }).provisional === true;
3259
+ } catch {
3260
+ return false;
3261
+ }
3262
+ }
3263
+
2927
3264
  function parseRunId(v: unknown): Validated<string> {
2928
3265
  if (typeof v !== "string" || !RUN_ID_PATTERN.test(v)) return invalid("id must match ^[A-Za-z0-9_-]{1,64}$");
2929
3266
  return { ok: true, value: v };
@@ -2939,16 +3276,24 @@ function parsePositiveInt(v: unknown, name: string, max: number): Validated<numb
2939
3276
  return { ok: true, value: v };
2940
3277
  }
2941
3278
 
2942
- function parseRunPut(body: unknown): Validated<{ storeKey: string; record: RunRecord; proposal?: RunPolicyProposal }> {
3279
+ function parseRunPut(
3280
+ body: unknown,
3281
+ ): Validated<{ storeKey: string; record: RunRecord; proposal?: RunPolicyProposal; point?: RunMetricsPoint }> {
2943
3282
  if (typeof body !== "object" || body === null) return invalid("body must be a JSON object");
2944
3283
  const b = body as Record<string, unknown>;
2945
3284
  const key = parseStoreKey(b);
2946
3285
  if (!key.ok) return key;
2947
3286
  if (!isRunRecord(b.record)) return invalid("record must be a RunRecord");
2948
- const out: { storeKey: string; record: RunRecord; proposal?: RunPolicyProposal } = {
3287
+ const out: { storeKey: string; record: RunRecord; proposal?: RunPolicyProposal; point?: RunMetricsPoint } = {
2949
3288
  storeKey: key.value,
2950
3289
  record: b.record,
2951
3290
  };
3291
+ // The record's metrics point (run-metrics.md item 1), validated at the door
3292
+ // like everything else that reaches storage.
3293
+ if (b.point !== undefined) {
3294
+ if (!isRunMetricsPoint(b.point)) return invalid("point must be a RunMetricsPoint");
3295
+ out.point = b.point;
3296
+ }
2952
3297
  if (b.policy !== undefined) {
2953
3298
  if (typeof b.policy !== "object" || b.policy === null) return invalid("policy must be an object");
2954
3299
  const p = b.policy as Record<string, unknown>;
@@ -3259,6 +3604,11 @@ function parseCandidate(v: unknown, i: number): Validated<MemoryCandidate> {
3259
3604
  return invalid(`${at}.supersedes must be a string`);
3260
3605
  out.supersedes = c.supersedes;
3261
3606
  }
3607
+ if (c.restates !== undefined) {
3608
+ if (typeof c.restates !== "string" || c.restates.length > MAX_KEY_CHARS)
3609
+ return invalid(`${at}.restates must be a string`);
3610
+ out.restates = c.restates;
3611
+ }
3262
3612
  return { ok: true, value: out };
3263
3613
  }
3264
3614
 
@@ -3823,6 +4173,60 @@ const LEDGER_ROUTES = new Set([
3823
4173
  "/runs/session/notepad/write",
3824
4174
  ]);
3825
4175
 
4176
+ /** The plane's routes (record 0064; orchestration-plane items 7 and 8): the shadow outcome post and the
4177
+ * effect acknowledgement. Both land on the ledger object of the given store
4178
+ * key, like every `/runs/*` route. */
4179
+ const PLANE_ROUTES = new Set(["/plane/outcome", "/plane/ack"]);
4180
+
4181
+ const PLANE_STAGES = new Set(["admission", "runner", "resident"]);
4182
+ const PLANE_OUTCOME = /^(proceeded|refused:[a-z0-9-]+|fell_cold:[a-z0-9_-]+)$/;
4183
+ const PLANE_ACK_OUTCOMES = new Set(["done", "skipped", "deferred"]);
4184
+
4185
+ /** The `/plane/*` routes: validated before any object call, ids and words
4186
+ * only on the log lines. */
4187
+ async function handlePlane(pathname: string, body: unknown, env: Env): Promise<Response> {
4188
+ if (typeof body !== "object" || body === null) return json({ error: "body must be a JSON object" }, 400);
4189
+ const b = body as Record<string, unknown>;
4190
+ const key = parseStoreKey(b);
4191
+ if (!key.ok) return json({ error: key.error }, 400);
4192
+ const stub = env.RUNS.get(env.RUNS.idFromName(key.value));
4193
+ const now = systemClock();
4194
+ if (pathname === "/plane/outcome") {
4195
+ if (typeof b.threadKey !== "string" || b.threadKey.length === 0)
4196
+ return json({ error: "threadKey must be a non-empty string" }, 400);
4197
+ if (typeof b.requester !== "string" || b.requester.length === 0)
4198
+ return json({ error: "requester must be a non-empty string" }, 400);
4199
+ if (typeof b.stage !== "string" || !PLANE_STAGES.has(b.stage))
4200
+ return json({ error: "stage must be admission, runner or resident" }, 400);
4201
+ if (typeof b.outcome !== "string" || !PLANE_OUTCOME.test(b.outcome))
4202
+ return json({ error: "outcome must be proceeded, refused:<code> or fell_cold:<token>" }, 400);
4203
+ let runId: string | undefined;
4204
+ if (b.runId !== undefined) {
4205
+ const parsed = parseRunId(b.runId);
4206
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
4207
+ runId = parsed.value;
4208
+ }
4209
+ const r = await stub.planeOutcome(
4210
+ {
4211
+ ...(runId !== undefined ? { runId } : {}),
4212
+ requester: b.requester,
4213
+ threadKey: b.threadKey,
4214
+ stage: b.stage as PlaneStage,
4215
+ outcome: b.outcome,
4216
+ },
4217
+ now,
4218
+ );
4219
+ return json(r);
4220
+ }
4221
+ if (pathname === "/plane/ack") {
4222
+ if (typeof b.id !== "string" || b.id.length === 0) return json({ error: "id must be a non-empty string" }, 400);
4223
+ if (typeof b.outcome !== "string" || !PLANE_ACK_OUTCOMES.has(b.outcome))
4224
+ return json({ error: "outcome must be done, skipped or deferred" }, 400);
4225
+ return json(await stub.planeAck(b.id, b.outcome as PlaneAckOutcome, now));
4226
+ }
4227
+ return json({ error: "not found" }, 404);
4228
+ }
4229
+
3826
4230
  /** Routes whose bodies may carry a record, a transcript chunk, or an event batch. */
3827
4231
  const WIDE_BODY_ROUTES = new Set([
3828
4232
  "/runs/put",
@@ -4129,7 +4533,9 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
4129
4533
  // declared shape.
4130
4534
  const runs = (await stub.reclaim(g.value, at, lease.value)) as unknown as ReclaimedRun[];
4131
4535
  console.log(`[runs/reclaim] ${key.value} ${g.value} took ${runs.length} run(s)`);
4132
- return json({ runs });
4536
+ // The reclaim sweep's answer carries the plane's open effects like every
4537
+ // heartbeat answer does (record 0064; orchestration-plane item 7) — empty until a unit writes them.
4538
+ return json({ runs, effects: await stub.openPlaneEffects() });
4133
4539
  }
4134
4540
  if (pathname === "/runs/handoff") {
4135
4541
  const g = gen(b.gen);
@@ -4264,7 +4670,10 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
4264
4670
  if (pathname === "/runs/heartbeat") {
4265
4671
  const lease = parseLeaseMs(b.leaseMs);
4266
4672
  if (!lease.ok) return json({ error: lease.error }, 400);
4267
- const r = await stub.heartbeat(runId.value, g.value, lease.value, now);
4673
+ // The RPC type mapping reads the effects' open-ended `request` JSON as
4674
+ // unserializable; the values are plain JSON, so the cast only restores the
4675
+ // declared shape (as the reclaim route's does).
4676
+ const r = (await stub.heartbeat(runId.value, g.value, lease.value, now)) as unknown as HeartbeatAnswer;
4268
4677
  return r.ok ? json(r) : json(r, 409);
4269
4678
  }
4270
4679
  if (pathname === "/runs/append") {
@@ -4302,7 +4711,7 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
4302
4711
  const parsed = parseRunPut({ ...b, storeKey: key.value });
4303
4712
  if (!parsed.ok) return json({ error: parsed.error }, 400);
4304
4713
  if (parsed.value.record.id !== runId.value) return json({ error: "record.id must equal runId" }, 400);
4305
- const r = await stub.finish(runId.value, g.value, parsed.value.record, parsed.value.proposal);
4714
+ const r = await stub.finish(runId.value, g.value, parsed.value.record, parsed.value.proposal, parsed.value.point);
4306
4715
  console.log(
4307
4716
  `[runs/finish] ${key.value} ${runId.value} ok=${r.ok}${r.ok ? ` stored=${r.stored} event=${r.event}` : ` ${r.reason}`}`,
4308
4717
  );
@@ -4319,8 +4728,10 @@ async function handleRuns(pathname: string, body: unknown, env: Env): Promise<Re
4319
4728
  if (pathname === "/runs/put") {
4320
4729
  const parsed = parseRunPut(body);
4321
4730
  if (!parsed.ok) return json({ error: parsed.error }, 400);
4322
- const { storeKey, record, proposal } = parsed.value;
4323
- const result = await stub(storeKey).put(record, proposal);
4731
+ const { storeKey, record, proposal, point } = parsed.value;
4732
+ // `turnedFinal` stays internal: the wire answer is exactly the shape it
4733
+ // always was, binding or no binding (run-metrics.md item 4).
4734
+ const { turnedFinal: _turnedFinal, ...result } = await stub(storeKey).put(record, proposal, point);
4324
4735
  console.log(
4325
4736
  `[runs/put] ${storeKey} <- ${record.id} (${record.storedEventCount} events, stored=${result.stored}, ${result.retained} retained)`,
4326
4737
  );
@@ -4394,6 +4805,7 @@ const ROUTES = new Set([
4394
4805
  "/runs/usage",
4395
4806
  "/runs/delete",
4396
4807
  ...LEDGER_ROUTES,
4808
+ ...PLANE_ROUTES,
4397
4809
  ]);
4398
4810
 
4399
4811
  /** The two decisions `fetch` makes once and hands down: is the path one of
@@ -4404,11 +4816,21 @@ interface Admission {
4404
4816
  authorized: boolean;
4405
4817
  }
4406
4818
 
4819
+ /** What this deploy carries, for the bot's boot probe: the fixed route set,
4820
+ * plus `runMetrics:<dataset>` when the deploy bound the Analytics Engine
4821
+ * dataset (run-metrics.md item 5) — the name from the `RUN_METRICS_DATASET`
4822
+ * var rendered beside the binding, so the probe can compare it to the bot's. */
4823
+ export function featuresOf(env: Pick<Env, "RUN_METRICS" | "RUN_METRICS_DATASET">): string[] {
4824
+ const features = ["memory", "schedules", "runs", "config", "delivery", "costs", "plane"];
4825
+ if (env.RUN_METRICS !== undefined) features.push(`runMetrics:${env.RUN_METRICS_DATASET ?? "unknown"}`);
4826
+ return features;
4827
+ }
4828
+
4407
4829
  /** Every request, once `fetch` has decided whether it gets a root. */
4408
4830
  async function handleRequest(request: Request, env: Env, admission: Admission): Promise<Response> {
4409
4831
  const url = new URL(request.url);
4410
4832
  if (url.pathname === "/healthz" && request.method === "GET")
4411
- return json({ ok: true, build: BUILD, features: ["memory", "schedules", "runs", "config", "delivery", "costs"] });
4833
+ return json({ ok: true, build: BUILD, features: featuresOf(env) });
4412
4834
  if (!admission.known) return json({ error: "not found" }, 404);
4413
4835
  if (request.method !== "POST") return json({ error: "method not allowed" }, 405);
4414
4836
  if (!admission.authorized) return json({ error: "unauthorized" }, 401);
@@ -4458,6 +4880,7 @@ async function handleRequest(request: Request, env: Env, admission: Admission):
4458
4880
  return json({ firings });
4459
4881
  }
4460
4882
  if (url.pathname.startsWith("/runs/")) return handleRuns(url.pathname, body, env);
4883
+ if (url.pathname.startsWith("/plane/")) return handlePlane(url.pathname, body, env);
4461
4884
  if (url.pathname.startsWith("/config/")) return handleConfig(url.pathname, body, env);
4462
4885
  if (url.pathname.startsWith("/delivery/")) return handleDelivery(url.pathname, body, env);
4463
4886
  if (url.pathname.startsWith("/costs/")) return handleCosts(url.pathname, body, env);