@coreplane/switchboard 1.253.0 → 1.254.1

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 (130) hide show
  1. package/dist/assets/config/config.example.yaml +40 -11
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +310 -7
  3. package/dist/assets/package-lock.json +3 -3
  4. package/dist/assets/package.json +1 -1
  5. package/dist/assets/source.json +3 -3
  6. package/dist/assets/src/core/authz/grants.ts +6 -0
  7. package/dist/assets/src/core/authz/policy.ts +11 -0
  8. package/dist/assets/src/core/budgets.ts +8 -0
  9. package/dist/assets/src/core/coordinator/driver.ts +9 -0
  10. package/dist/assets/src/core/costs.ts +221 -2
  11. package/dist/assets/src/core/costsSnapshotStore.ts +22 -1
  12. package/dist/assets/src/core/pipelineStanding.ts +294 -0
  13. package/dist/assets/src/core/plane/decide.ts +203 -0
  14. package/dist/assets/src/core/provider.ts +11 -0
  15. package/dist/assets/src/core/runEvents.ts +27 -1
  16. package/dist/assets/src/core/runLedger/transcript.ts +23 -3
  17. package/dist/assets/src/core/runRecord.ts +15 -0
  18. package/dist/assets/src/core/runUsage.ts +41 -9
  19. package/dist/assets/src/core/ship/coordinator.ts +45 -9
  20. package/dist/assets/web/dist/.vite/manifest.json +450 -430
  21. package/dist/assets/web/dist/assets/{AppShell-Bmz8nzz3.js → AppShell-CSXDCV4x.js} +1 -1
  22. package/dist/assets/web/dist/assets/CostChart-DsuPS2g7.js +2 -0
  23. package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +1 -0
  24. package/dist/assets/web/dist/assets/{DeliveryPage-C7jaEJip.js → DeliveryPage-ngPsO2to.js} +1 -1
  25. package/dist/assets/web/dist/assets/{HomePage-cWbupQzy.js → HomePage-DvxTHzPx.js} +3 -3
  26. package/dist/assets/web/dist/assets/{InputMenu-C9E7YoII.js → InputMenu-BdZXnibG.js} +1 -1
  27. package/dist/assets/web/dist/assets/MetricsPage-o5fSaCTe.js +1 -0
  28. package/dist/assets/web/dist/assets/{NotFoundPage-4nlrq7DP.js → NotFoundPage-DHuWyjlq.js} +1 -1
  29. package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +1 -0
  30. package/dist/assets/web/dist/assets/{PlanePage-BNy4tSgJ.js → PlanePage-DpWfiX4C.js} +1 -1
  31. package/dist/assets/web/dist/assets/{ResidentDetailPage-Dbtiq6_J.js → ResidentDetailPage-DG86v39Y.js} +1 -1
  32. package/dist/assets/web/dist/assets/{ResidentsIndexPage-CKtz9H1o.js → ResidentsIndexPage-x6p689VH.js} +1 -1
  33. package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +1 -0
  34. package/dist/assets/web/dist/assets/{RunRoutePage-Clh68nw9.js → RunRoutePage-ysJBY8xQ.js} +4 -4
  35. package/dist/assets/web/dist/assets/RunsIndexPage-CuzFchcn.js +1 -0
  36. package/dist/assets/web/dist/assets/{RunsTabs-DotpuCSP.js → RunsTabs-kHbbxVHx.js} +1 -1
  37. package/dist/assets/web/dist/assets/{ScheduledPage-C8FYoZy5.js → ScheduledPage-BuLmfcbG.js} +1 -1
  38. package/dist/assets/web/dist/assets/{SettingSelect-CSiBQCSX.js → SettingSelect-BfID5nF4.js} +1 -1
  39. package/dist/assets/web/dist/assets/{SettingsPage-CkYIhLUl.js → SettingsPage-BujWkdU_.js} +1 -1
  40. package/dist/assets/web/dist/assets/{StatusDot-C0TUgnC1.js → StatusDot-C8Bc0pTX.js} +1 -1
  41. package/dist/assets/web/dist/assets/{Tooltip-CN5cZdl3.js → Tooltip-BWwJx27K.js} +1 -1
  42. package/dist/assets/web/dist/assets/{UnitRoutePage-D_EtAl8r.js → UnitRoutePage-B9kjA1AT.js} +1 -1
  43. package/dist/assets/web/dist/assets/{angular-html-qW40ZL9x.js → angular-html-C8b5YTHf.js} +1 -1
  44. package/dist/assets/web/dist/assets/{angular-ts-PUtZU8Cq.js → angular-ts-C3f4WYNe.js} +1 -1
  45. package/dist/assets/web/dist/assets/{apl-D3fC7Qjd.js → apl-DSHxUJc3.js} +1 -1
  46. package/dist/assets/web/dist/assets/{astro-Bi3JFHhh.js → astro-BSGCCBP6.js} +1 -1
  47. package/dist/assets/web/dist/assets/{blade-CSpsf9Z9.js → blade-DFeYrMKk.js} +1 -1
  48. package/dist/assets/web/dist/assets/{c-DE_b1oWP.js → c-L_QOnFVv.js} +1 -1
  49. package/dist/assets/web/dist/assets/{chapel-CWXd9giL.js → chapel-DLQWUwhu.js} +1 -1
  50. package/dist/assets/web/dist/assets/{cobol-CnMOmWNF.js → cobol-1DlMmrEO.js} +1 -1
  51. package/dist/assets/web/dist/assets/{coffee-B9WvURu8.js → coffee-BTB6os6N.js} +1 -1
  52. package/dist/assets/web/dist/assets/{cpp-BZAJXlWI.js → cpp-BvJI2z3u.js} +1 -1
  53. package/dist/assets/web/dist/assets/{crystal-Bm48wAlb.js → crystal-B11wGW_h.js} +1 -1
  54. package/dist/assets/web/dist/assets/{css-WtMeC3ab.js → css-BiIWx5Pw.js} +1 -1
  55. package/dist/assets/web/dist/assets/{dist-B1Mf2JbM.js → dist-CpnyQGOb.js} +2 -2
  56. package/dist/assets/web/dist/assets/{edge-BmwNa6Ss.js → edge-nSglEXaD.js} +1 -1
  57. package/dist/assets/web/dist/assets/{elixir-BotfP-Mn.js → elixir-x7o8qMl8.js} +1 -1
  58. package/dist/assets/web/dist/assets/{elm-DAFc6laN.js → elm-DGrRyqTQ.js} +1 -1
  59. package/dist/assets/web/dist/assets/{erb-QkOON3bB.js → erb--_dh58Nw.js} +1 -1
  60. package/dist/assets/web/dist/assets/{git-rebase-BPs-dd73.js → git-rebase-DbpUXwyg.js} +1 -1
  61. package/dist/assets/web/dist/assets/{glimmer-js-D8eooepk.js → glimmer-js-BG0CuZG5.js} +1 -1
  62. package/dist/assets/web/dist/assets/{glimmer-ts-B0bRiDVg.js → glimmer-ts-CQIl5qyR.js} +1 -1
  63. package/dist/assets/web/dist/assets/{glsl-Bv6bMe1n.js → glsl-9PJNQTcw.js} +1 -1
  64. package/dist/assets/web/dist/assets/{graphql-Bh9y-n9o.js → graphql-CzVTzjEI.js} +1 -1
  65. package/dist/assets/web/dist/assets/{hack-DSEbn7Ir.js → hack-BhCmAz3Q.js} +1 -1
  66. package/dist/assets/web/dist/assets/{haml-0NW1-z1f.js → haml-BmI12COR.js} +1 -1
  67. package/dist/assets/web/dist/assets/{handlebars-BfgCHFbK.js → handlebars-Cib-ISTS.js} +1 -1
  68. package/dist/assets/web/dist/assets/{html-CQHc0gQ5.js → html-D3lDapvI.js} +1 -1
  69. package/dist/assets/web/dist/assets/{html-derivative-B0Tm7wLP.js → html-derivative-DRPY8vOK.js} +1 -1
  70. package/dist/assets/web/dist/assets/{http-GAuU3aOF.js → http-B9VttK6m.js} +1 -1
  71. package/dist/assets/web/dist/assets/{hurl-CrDFbF5B.js → hurl-C0saNJYj.js} +1 -1
  72. package/dist/assets/web/dist/assets/{indexRow-BxKswdlK.js → indexRow-DABQtONT.js} +1 -1
  73. package/dist/assets/web/dist/assets/{java-DYv5KhUe.js → java-BCFbtYAj.js} +1 -1
  74. package/dist/assets/web/dist/assets/{javascript-32SiLGg0.js → javascript-nwBjnrhh.js} +1 -1
  75. package/dist/assets/web/dist/assets/{jinja--gJDUGYa.js → jinja-CMYO9byN.js} +1 -1
  76. package/dist/assets/web/dist/assets/{jison-BlKLGzZH.js → jison-Cko8HnE_.js} +1 -1
  77. package/dist/assets/web/dist/assets/{json-BICanD1N.js → json-Dzv__YsX.js} +1 -1
  78. package/dist/assets/web/dist/assets/{jsx-CgW3-uFT.js → jsx-CTFxagih.js} +1 -1
  79. package/dist/assets/web/dist/assets/{julia-BErRpwwL.js → julia-_Xvten1-.js} +1 -1
  80. package/dist/assets/web/dist/assets/{just-CordHv0h.js → just-bLps4qOu.js} +1 -1
  81. package/dist/assets/web/dist/assets/{latex-D7LVPLbi.js → latex-D6UaYsUP.js} +1 -1
  82. package/dist/assets/web/dist/assets/{liquid-3ehz2i4U.js → liquid-CoDIeULa.js} +1 -1
  83. package/dist/assets/web/dist/assets/{lua-C9ppSVrm.js → lua-D1KFRnVf.js} +1 -1
  84. package/dist/assets/web/dist/assets/main-C4QfwybO.css +1 -0
  85. package/dist/assets/web/dist/assets/main-Comxmwi4.js +28 -0
  86. package/dist/assets/web/dist/assets/{marko-xBZLUBEJ.js → marko-B5N5XcOw.js} +1 -1
  87. package/dist/assets/web/dist/assets/{mdc-BJxGoVgD.js → mdc-uxzjQO3q.js} +1 -1
  88. package/dist/assets/web/dist/assets/{nginx-B-sZmpsb.js → nginx-BMJxs2TK.js} +1 -1
  89. package/dist/assets/web/dist/assets/{nim--s9wRABK.js → nim-DqQ3-8p1.js} +1 -1
  90. package/dist/assets/web/dist/assets/{org-DoUvssoI.js → org-DsY6LK59.js} +1 -1
  91. package/dist/assets/web/dist/assets/{perl-B-vGHKOy.js → perl--pq9iJFp.js} +1 -1
  92. package/dist/assets/web/dist/assets/{php-CerWx_Xb.js → php-RHCMbsM2.js} +1 -1
  93. package/dist/assets/web/dist/assets/{pug-CzKrBMb1.js → pug-CFNq_FCp.js} +1 -1
  94. package/dist/assets/web/dist/assets/{qml-Bs8L8I6k.js → qml-C9Acx9E8.js} +1 -1
  95. package/dist/assets/web/dist/assets/{r-w6uG6mob.js → r-DxlU8FBd.js} +1 -1
  96. package/dist/assets/web/dist/assets/{razor-dz2oj2bd.js → razor-CLJ2kKih.js} +1 -1
  97. package/dist/assets/web/dist/assets/{regexp-CfQdprnY.js → regexp-CzmdGoEp.js} +1 -1
  98. package/dist/assets/web/dist/assets/{rst-DAOaX2tb.js → rst-D9yuxBET.js} +1 -1
  99. package/dist/assets/web/dist/assets/{ruby-QcCe4udB.js → ruby-CIRcEvnD.js} +1 -1
  100. package/dist/assets/web/dist/assets/{sas-BrM4lAAc.js → sas-LOl-uUPh.js} +1 -1
  101. package/dist/assets/web/dist/assets/{scss-DVbJXoUb.js → scss-BX2_sZHY.js} +1 -1
  102. package/dist/assets/web/dist/assets/{shellscript-09fP2hZf.js → shellscript-4d-QvKkY.js} +1 -1
  103. package/dist/assets/web/dist/assets/{shellsession-Qgr8xjZF.js → shellsession-DhB16UGy.js} +1 -1
  104. package/dist/assets/web/dist/assets/{soy-DIYeNTuz.js → soy-Cm1xTUBr.js} +1 -1
  105. package/dist/assets/web/dist/assets/{sql-CzLR9msV.js → sql-BsfcBJdu.js} +1 -1
  106. package/dist/assets/web/dist/assets/{sseReplay-BYo2EZKy.js → sseReplay-IzTdD4-3.js} +6 -6
  107. package/dist/assets/web/dist/assets/{stata-zCY1ZzH0.js → stata-Dwat3BSw.js} +1 -1
  108. package/dist/assets/web/dist/assets/{surrealql-CJIjTRV4.js → surrealql-P4rtxzAW.js} +1 -1
  109. package/dist/assets/web/dist/assets/{svelte-C5OTPFZg.js → svelte-DTcsi8S8.js} +1 -1
  110. package/dist/assets/web/dist/assets/{templ-DYj-UICF.js → templ-D-kP0Tg5.js} +1 -1
  111. package/dist/assets/web/dist/assets/{tex-xcie-3Gd.js → tex-D-4vuedK.js} +1 -1
  112. package/dist/assets/web/dist/assets/{ts-tags-CoN86VTt.js → ts-tags-jEEWYxsu.js} +1 -1
  113. package/dist/assets/web/dist/assets/{tsx-n3-No_Br.js → tsx-CFnlrVzT.js} +1 -1
  114. package/dist/assets/web/dist/assets/{twig-DPiJ7Vp1.js → twig-D5rGrPjs.js} +1 -1
  115. package/dist/assets/web/dist/assets/{typescript-BP6HBaOp.js → typescript-nYIQ35or.js} +1 -1
  116. package/dist/assets/web/dist/assets/{typst-Do9ZjsVP.js → typst-3-UX8dna.js} +1 -1
  117. package/dist/assets/web/dist/assets/{vue-CzAeXNRY.js → vue-CLIPIX03.js} +1 -1
  118. package/dist/assets/web/dist/assets/{vue-html-D7iYmYjd.js → vue-html-Ci9CpkHU.js} +1 -1
  119. package/dist/assets/web/dist/assets/{vue-vine-DtrMRsJx.js → vue-vine-DL5pEYYk.js} +1 -1
  120. package/dist/assets/web/dist/assets/{xml-DGJHo1c0.js → xml-bO80qikC.js} +1 -1
  121. package/dist/assets/web/dist/assets/{xsl-Q00kvNgW.js → xsl-BPze60Ej.js} +1 -1
  122. package/dist/assets/web/dist/assets/{yaml-F-UGKaiO.js → yaml-BQUSon_o.js} +1 -1
  123. package/dist/cli.js +35752 -33944
  124. package/package.json +1 -1
  125. package/dist/assets/web/dist/assets/CostsPage-CxTKdpzU.js +0 -2
  126. package/dist/assets/web/dist/assets/PendingTurnRow-C-pUgyyX.js +0 -1
  127. package/dist/assets/web/dist/assets/RunFoldRow-sYFtuK2G.js +0 -1
  128. package/dist/assets/web/dist/assets/RunsIndexPage-DPDbcOA_.js +0 -1
  129. package/dist/assets/web/dist/assets/main-Cv0q9y56.js +0 -28
  130. package/dist/assets/web/dist/assets/main-DKFCM-sy.css +0 -1
@@ -11,6 +11,13 @@ providers:
11
11
  anthropic:
12
12
  wire: anthropic-messages
13
13
  apiKeyEnv: ANTHROPIC_API_KEY
14
+ # The biller's invoice for the daily tie-out on the costs page (costs.md item
15
+ # 4d): each block may name its own invoice API and the env var holding that
16
+ # API's key — an admin or management key, never the inference key. This is
17
+ # where `costs.anthropicAdminKeyEnv` moved; the old key under `costs:` is
18
+ # still accepted for one release.
19
+ # invoiceApi: anthropic-cost-report # GET /v1/organizations/cost_report
20
+ # invoiceKeyEnv: ANTHROPIC_ADMIN_KEY # sk-ant-admin…
14
21
  # OpenAI itself runs on its Responses API (record 0052): every first-party
15
22
  # OpenAI card in pi's registry is `openai-responses`, OpenAI recommends the
16
23
  # wire for new projects, and its chat route loses tool calling with any
@@ -21,6 +28,8 @@ providers:
21
28
  wire: openai-responses
22
29
  baseUrl: https://api.openai.com/v1
23
30
  apiKeyEnv: OPENAI_API_KEY
31
+ # invoiceApi: openai-costs # GET /v1/organization/costs, an admin key
32
+ # invoiceKeyEnv: OPENAI_ADMIN_KEY
24
33
  # groq:
25
34
  # wire: openai-chat
26
35
  # baseUrl: https://api.groq.com/openai/v1
@@ -42,6 +51,10 @@ providers:
42
51
  catalog: openrouter
43
52
  baseUrl: https://openrouter.ai/api/v1
44
53
  apiKeyEnv: OPENROUTER_API_KEY
54
+ # invoiceApi: openrouter-activity # GET /api/v1/activity, a management key;
55
+ # # its `usage` ties against the summed BYOK
56
+ # # fees, `byok_usage_inference` the remainder
57
+ # invoiceKeyEnv: OPENROUTER_MANAGEMENT_KEY
45
58
  # The operator's layer over the registry card: what a run needs that no
46
59
  # layer names goes out unvouched (and a note says so on the record) until
47
60
  # an override like this pins it. `providers check` (a later unit) reads the
@@ -462,16 +475,17 @@ workspaceDir: ./workspaces
462
475
  # `tool` (default) forces a call to the router's `route` tool, whose schema is
463
476
  # the answer, so prose cannot occur; `text` is the one-JSON-object text contract
464
477
  # alone — set it for a provider or model that cannot take a forced tool call.
465
- # `operator` is the one door's flag (routing-and-config item 29): `off`
466
- # (default) — the operator never runs; `shadow` — one operator turn per
467
- # admitted chat event, ahead of stage A, its decision written beside the
468
- # routed request in the run store and nothing run from it; `on` — the
469
- # operator's decision is what runs. `shadow` runs even under `auto: false`.
478
+ # `operator` is the one door's flag (routing-and-config item 29): `on`
479
+ # (default) — the operator's decision is what runs; `shadow` — one operator
480
+ # turn per admitted chat event, ahead of stage A, its decision written beside
481
+ # the routed request in the run store and nothing run from it; `off` — the
482
+ # rollback lever: it turns the operator back off after a bad day, not a choice
483
+ # to make up front. `shadow` and `on` run even under `auto: false`.
470
484
  # routing:
471
485
  # auto: false
472
486
  # model: anthropic/claude-haiku-4-5
473
487
  # answer: text
474
- # operator: shadow
488
+ # operator: off
475
489
 
476
490
  # The thread-reply intake gate (docs/decisions/0058-a-thread-reply-is-read-before-it-is-answered-intake-decides-whether-the-bot-was-addressed.md;
477
491
  # docs/reference/specs/routing-and-config.md item 27). How an unmentioned reply
@@ -493,6 +507,19 @@ workspaceDir: ./workspaces
493
507
  # threadReplies: classify
494
508
  # model: anthropic/claude-haiku-4-5
495
509
 
510
+ # The orchestration plane (docs/decisions/0064-the-plane-owns-every-runs-state-a-refusal-becomes-a-queue-position-an-ending-is-judged-by-the-ledger-that-saw-it-and-a-release-is-a-quiet-window-a-person-closes.md;
511
+ # docs/reference/specs/routing-and-config.md item 31; orchestration-plane.md).
512
+ # `admission` gates the ledger object's queue decider: `off` (default) — the
513
+ # plane hears of no dispatch and nothing is posted; `shadow` — the bot posts
514
+ # its own outcome per dispatch (`proceeded`, `refused:<code>`) to the ledger
515
+ # object, which logs its decider's decision beside it — nothing runs from the
516
+ # decider; `on` — a later release's flip (the decider's answer is what runs).
517
+ # `reaskMinutes` (default 2) is the plane's one re-ask cadence, used only while
518
+ # something waits on a reporter that fell silent; nothing reads it yet.
519
+ # plane:
520
+ # admission: shadow
521
+ # reaskMinutes: 2
522
+
496
523
  # Linked threads (docs/decisions/0037-a-linked-thread-is-quoted-not-joined.md;
497
524
  # docs/reference/specs/routing-and-config.md item 22). Off by default. On, a
498
525
  # permalink in a request to a thread in another PUBLIC channel the bot is a
@@ -508,7 +535,8 @@ workspaceDir: ./workspaces
508
535
  # cloudflareAccountId: <32-hex account id>
509
536
  # cloudflareAccountName: acme-infra # optional: how the page names the account beside its share (else the id's first hex)
510
537
  # cloudflareTokenEnv: CF_ANALYTICS_TOKEN # token with Account Analytics:Read
511
- # anthropicAdminKeyEnv: ANTHROPIC_ADMIN_KEY # optional: sk-ant-admin… for LLM spend
538
+ # anthropicAdminKeyEnv: ANTHROPIC_ADMIN_KEY # legacy (one release): moved to providers.anthropic.invoiceKeyEnv
539
+ # # (invoiceApi: anthropic-cost-report); still feeds the LLM line here
512
540
  # snapshot: # the page serves a snapshot of both billing sources, never a live read;
513
541
  # everyHours: 24 # how often the bot reads them (1–168, default 24). `costs snapshot`
514
542
  # # takes one now. Kept on the state Worker any `*.worker` block names
@@ -536,12 +564,13 @@ workspaceDir: ./workspaces
536
564
  # Run metrics (docs/reference/specs/run-metrics.md). Optional. The Analytics Engine
537
565
  # dataset every finished run's point lands in — the SAME name the deployment
538
566
  # profile's `metrics.dataset` binds on the state Worker as RUN_METRICS (the bot
539
- # warns at boot when the two differ; neither side refuses to start). The reader
540
- # over the dataset — `metrics trend` and the /metrics page, behind the `metrics`
541
- # capability — is later work of the same decision; until it lands, only `dataset`
542
- # is read, for that boot comparison.
567
+ # warns at boot when the two differ; neither side refuses to start). With the
568
+ # `costs` block's Cloudflare account and analytics token this turns the `metrics`
569
+ # capability on: `metrics trend` reads the trend report from the dataset over the
570
+ # Analytics Engine SQL API, weighted for sampling.
543
571
  # metrics:
544
572
  # dataset: switchboard_runs
573
+ # days: 30 # optional: the default report range in whole UTC days, 1..90
545
574
 
546
575
  # Delivery indicators: GET /delivery and `delivery report` (docs/reference/specs/delivery.md).
547
576
  # Optional. Needs a GitHub credential (the App triple or GH_TOKEN); read-only on GitHub.
@@ -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,
@@ -1206,13 +1218,16 @@ export class CostsSnapshotDO extends DurableObject<Env> {
1206
1218
  `);
1207
1219
  }
1208
1220
 
1209
- /** 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. */
1210
1224
  async put(snapshot: CostsSnapshot): Promise<void> {
1211
- const { usage, llm, runUsage, ...meta } = snapshot;
1225
+ const { usage, llm, invoices, runUsage, ...meta } = snapshot;
1212
1226
  const rows: Array<[string, unknown]> = [
1213
1227
  ["meta", meta],
1214
1228
  ...USAGE_PARTS.map((name): [string, unknown] => [`usage.${name}`, usage[name]]),
1215
1229
  ["llm", llm],
1230
+ ...(invoices !== undefined ? [["invoices", invoices] as [string, unknown]] : []),
1216
1231
  ["runUsage", runUsage],
1217
1232
  ];
1218
1233
  this.ctx.storage.transactionSync(() => {
@@ -1237,10 +1252,12 @@ export class CostsSnapshotDO extends DurableObject<Env> {
1237
1252
  return body === undefined ? undefined : (JSON.parse(body) as unknown);
1238
1253
  };
1239
1254
  const usage = Object.fromEntries(USAGE_PARTS.map((name) => [name, read(`usage.${name}`)]));
1255
+ const invoices = read("invoices");
1240
1256
  const snapshot = {
1241
1257
  ...(JSON.parse(meta) as Record<string, unknown>),
1242
1258
  usage,
1243
1259
  llm: read("llm"),
1260
+ ...(invoices !== undefined ? { invoices } : {}),
1244
1261
  runUsage: read("runUsage"),
1245
1262
  };
1246
1263
  return isCostsSnapshot(snapshot) ? snapshot : null;
@@ -1514,7 +1531,20 @@ function rowToLive(r: LiveRow): LiveRunRow {
1514
1531
  };
1515
1532
  }
1516
1533
 
1517
- 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;
1518
1548
 
1519
1549
  export class RunHistoryDO extends DurableObject<Env> {
1520
1550
  private readonly sql: SqlStorage;
@@ -1681,6 +1711,215 @@ export class RunHistoryDO extends DurableObject<Env> {
1681
1711
  PRIMARY KEY (instance_id, unit, seq)
1682
1712
  );
1683
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 };
1684
1923
  }
1685
1924
 
1686
1925
  // ---- the coordinator's parent records (run-history item 49) -----------------
@@ -1969,7 +2208,10 @@ export class RunHistoryDO extends DurableObject<Env> {
1969
2208
  return;
1970
2209
  }
1971
2210
  this.sql.exec(`UPDATE live_runs SET lease_until = ? WHERE run_id = ?`, now + leaseMs, runId);
1972
- 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() };
1973
2215
  });
1974
2216
  return out;
1975
2217
  }
@@ -3931,6 +4173,60 @@ const LEDGER_ROUTES = new Set([
3931
4173
  "/runs/session/notepad/write",
3932
4174
  ]);
3933
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
+
3934
4230
  /** Routes whose bodies may carry a record, a transcript chunk, or an event batch. */
3935
4231
  const WIDE_BODY_ROUTES = new Set([
3936
4232
  "/runs/put",
@@ -4237,7 +4533,9 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
4237
4533
  // declared shape.
4238
4534
  const runs = (await stub.reclaim(g.value, at, lease.value)) as unknown as ReclaimedRun[];
4239
4535
  console.log(`[runs/reclaim] ${key.value} ${g.value} took ${runs.length} run(s)`);
4240
- 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() });
4241
4539
  }
4242
4540
  if (pathname === "/runs/handoff") {
4243
4541
  const g = gen(b.gen);
@@ -4372,7 +4670,10 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
4372
4670
  if (pathname === "/runs/heartbeat") {
4373
4671
  const lease = parseLeaseMs(b.leaseMs);
4374
4672
  if (!lease.ok) return json({ error: lease.error }, 400);
4375
- 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;
4376
4677
  return r.ok ? json(r) : json(r, 409);
4377
4678
  }
4378
4679
  if (pathname === "/runs/append") {
@@ -4504,6 +4805,7 @@ const ROUTES = new Set([
4504
4805
  "/runs/usage",
4505
4806
  "/runs/delete",
4506
4807
  ...LEDGER_ROUTES,
4808
+ ...PLANE_ROUTES,
4507
4809
  ]);
4508
4810
 
4509
4811
  /** The two decisions `fetch` makes once and hands down: is the path one of
@@ -4519,7 +4821,7 @@ interface Admission {
4519
4821
  * dataset (run-metrics.md item 5) — the name from the `RUN_METRICS_DATASET`
4520
4822
  * var rendered beside the binding, so the probe can compare it to the bot's. */
4521
4823
  export function featuresOf(env: Pick<Env, "RUN_METRICS" | "RUN_METRICS_DATASET">): string[] {
4522
- const features = ["memory", "schedules", "runs", "config", "delivery", "costs"];
4824
+ const features = ["memory", "schedules", "runs", "config", "delivery", "costs", "plane"];
4523
4825
  if (env.RUN_METRICS !== undefined) features.push(`runMetrics:${env.RUN_METRICS_DATASET ?? "unknown"}`);
4524
4826
  return features;
4525
4827
  }
@@ -4578,6 +4880,7 @@ async function handleRequest(request: Request, env: Env, admission: Admission):
4578
4880
  return json({ firings });
4579
4881
  }
4580
4882
  if (url.pathname.startsWith("/runs/")) return handleRuns(url.pathname, body, env);
4883
+ if (url.pathname.startsWith("/plane/")) return handlePlane(url.pathname, body, env);
4581
4884
  if (url.pathname.startsWith("/config/")) return handleConfig(url.pathname, body, env);
4582
4885
  if (url.pathname.startsWith("/delivery/")) return handleDelivery(url.pathname, body, env);
4583
4886
  if (url.pathname.startsWith("/costs/")) return handleCosts(url.pathname, body, env);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.253.0",
3
+ "version": "1.254.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.253.0",
9
+ "version": "1.254.1",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20445,7 +20445,7 @@
20445
20445
  },
20446
20446
  "packages/switchboard": {
20447
20447
  "name": "@coreplane/switchboard",
20448
- "version": "1.253.0",
20448
+ "version": "1.254.1",
20449
20449
  "license": "Apache-2.0",
20450
20450
  "dependencies": {
20451
20451
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.253.0",
3
+ "version": "1.254.1",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.253.0",
3
- "commit": "c96eda5a0bced3e6e6347180846163cc19b28c49",
4
- "builtAt": "2026-09-19T15:36:27.120Z"
2
+ "version": "1.254.1",
3
+ "commit": "48a9a480354e503b8fea59dc926f94132cf79756",
4
+ "builtAt": "2026-09-19T21:28:07.753Z"
5
5
  }
@@ -60,6 +60,12 @@ export const CHAT_OPEN_ACTIONS: readonly string[] = [
60
60
  "schedule:read",
61
61
  "memory:write",
62
62
  "mcp:write",
63
+ // `steer.run` (chat-only): the door admits every chat user because the real
64
+ // fence is the steer OWNER rule at the wired sender — the named run's
65
+ // requester, an id their identity record links, or a `runs:write` grant
66
+ // (authorization item 16a) — which the command door cannot see. Never held
67
+ // by a token: the machine surfaces have `send_to_run` and the ingress.
68
+ "steer:write",
63
69
  ];
64
70
 
65
71
  /** The two writes a browser session holds beside its reads (record 0043): the
@@ -58,6 +58,11 @@ export const POLICY: readonly Rule[] = [
58
58
  // List-shaped `runs.*`: the grant admits the command; the store predicate narrows the rows.
59
59
  { action: "runs:read", resource: "command", when: [grant("runs:read")] },
60
60
  { action: "runs:write", resource: "command", when: [grant("runs:write")] },
61
+ // `steer.run`: every chat user holds the grant (CHAT_OPEN_ACTIONS) because
62
+ // the fence is the steer owner rule at the wired sender, decided against the
63
+ // NAMED run (`authorizeSteerOwner`, authorization item 16a) — the requester,
64
+ // a linked id, or `runs:write` — which no command-door row can see.
65
+ { action: "steer:write", resource: "command", when: [grant("steer:write")] },
61
66
 
62
67
  // ── conversations ────────────────────────────────────────────────────────
63
68
  // Who may point the bot at another thread (record 0037). Asked for a
@@ -91,6 +96,12 @@ export const POLICY: readonly Rule[] = [
91
96
  // `all` and a named `grants` entry hold it).
92
97
  { action: "costs:write", resource: "command", when: [grant("costs:write")] },
93
98
 
99
+ // ── metrics ──────────────────────────────────────────────────────────────
100
+ // `metrics trend` reads the run-metrics dataset's report: the `costs:read`
101
+ // shape — a browser session's baseline, a grant for a Slack user or a token,
102
+ // never a chat baseline.
103
+ { action: "metrics:read", resource: "command", when: [grant("metrics:read")] },
104
+
94
105
  // ── providers ────────────────────────────────────────────────────────────
95
106
  // `providers check` reads the provider's own endpoints on request: a browser
96
107
  // session's read baseline, a grant everywhere else — an on-request provider
@@ -38,6 +38,14 @@ export const WEEK_MS = 7 * DAY_MS;
38
38
  * which stamps the expiry on its own clock. */
39
39
  export const CONFIRMATION_TTL_MS = 10 * MINUTE_MS;
40
40
 
41
+ /** How long a question's Yes stays pending (docs/decisions/0054, as amended;
42
+ * docs/reference/specs/routing-and-config.md item 25): one day. A Yes only
43
+ * re-dispatches the corrected message, which is then routed, authorized and,
44
+ * for a write, offered its own graded button — the write's ten-minute
45
+ * freshness is enforced downstream, so a short window here protects nothing
46
+ * and only loses answers. People answer a question in minutes to hours. */
47
+ export const QUESTION_TTL_MS = DAY_MS;
48
+
41
49
  /** How long a dispatch's FIRST attach to a resident — a fresh run's, or a
42
50
  * resumed run's re-attach to its recorded worktree — waits for the resident
43
51
  * to wake when the Worker typed its refusal as the platform's transient
@@ -390,6 +390,11 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
390
390
  prNumber,
391
391
  url,
392
392
  ...(typeof headSha === "string" ? { headSha } : {}),
393
+ // The entry facts (issue 1689): the branch's own tip and whether the
394
+ // bot's approval stands at the head, read at the unit-start's pre-check
395
+ // so a re-issued plan resumes at review or at the merge decision.
396
+ ...(typeof a.body.branchHead === "string" ? { branchHead: a.body.branchHead } : {}),
397
+ ...(typeof a.body.approved === "boolean" ? { approved: a.body.approved } : {}),
393
398
  ...(typeof a.body.autoMergeEnabled === "boolean" ? { autoMergeEnabled: a.body.autoMergeEnabled } : {}),
394
399
  ...(isCommitChecks(a.body.checks) ? { checks: a.body.checks } : {}),
395
400
  // The ready-state facts beside the checks (agent-ship item 9): the
@@ -584,6 +589,10 @@ async function perform(
584
589
  await step.do(action.step, STEP_CONFIG, () =>
585
590
  call(bot, "pr-check", {
586
591
  ...tag,
592
+ // `entry` is the unit-start's pre-check (issue 1689): the bot
593
+ // reads the branch's tip, the approval and the checks beside the
594
+ // listing, so a re-issued plan's unit resumes instead of recoding.
595
+ ...(action.entry === true ? { entry: true } : {}),
587
596
  ...(action.recover !== undefined ? { recover: action.recover } : {}),
588
597
  ...(action.pr !== undefined ? { pr: action.pr } : {}),
589
598
  }),