audit-tools 0.32.68 → 0.33.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 (238) hide show
  1. package/README.md +7 -0
  2. package/dist/audit/cli/advanceAuditCommand.d.ts.map +1 -1
  3. package/dist/audit/cli/advanceAuditCommand.js +8 -3
  4. package/dist/audit/cli/advanceAuditCommand.js.map +1 -1
  5. package/dist/audit/cli/args.d.ts +9 -27
  6. package/dist/audit/cli/args.d.ts.map +1 -1
  7. package/dist/audit/cli/args.js +119 -50
  8. package/dist/audit/cli/args.js.map +1 -1
  9. package/dist/audit/cli/auditStep.d.ts +15 -1
  10. package/dist/audit/cli/auditStep.d.ts.map +1 -1
  11. package/dist/audit/cli/auditStep.js +2 -0
  12. package/dist/audit/cli/auditStep.js.map +1 -1
  13. package/dist/audit/cli/dispatch/quotaPool.d.ts +2 -1
  14. package/dist/audit/cli/dispatch/quotaPool.d.ts.map +1 -1
  15. package/dist/audit/cli/dispatch/quotaPool.js +43 -77
  16. package/dist/audit/cli/dispatch/quotaPool.js.map +1 -1
  17. package/dist/audit/cli/dispatch.d.ts.map +1 -1
  18. package/dist/audit/cli/dispatch.js +14 -6
  19. package/dist/audit/cli/dispatch.js.map +1 -1
  20. package/dist/audit/cli/hybridDispatch.d.ts +3 -1
  21. package/dist/audit/cli/hybridDispatch.d.ts.map +1 -1
  22. package/dist/audit/cli/hybridDispatch.js +6 -0
  23. package/dist/audit/cli/hybridDispatch.js.map +1 -1
  24. package/dist/audit/cli/nextStepCommand.d.ts +0 -9
  25. package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
  26. package/dist/audit/cli/nextStepCommand.js +173 -92
  27. package/dist/audit/cli/nextStepCommand.js.map +1 -1
  28. package/dist/audit/cli/nextStepHelpers.d.ts +11 -2
  29. package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
  30. package/dist/audit/cli/nextStepHelpers.js +56 -8
  31. package/dist/audit/cli/nextStepHelpers.js.map +1 -1
  32. package/dist/audit/cli/prepareDispatchCommand.d.ts.map +1 -1
  33. package/dist/audit/cli/prepareDispatchCommand.js +15 -9
  34. package/dist/audit/cli/prepareDispatchCommand.js.map +1 -1
  35. package/dist/audit/cli/prompts.d.ts +12 -32
  36. package/dist/audit/cli/prompts.d.ts.map +1 -1
  37. package/dist/audit/cli/prompts.js +19 -33
  38. package/dist/audit/cli/prompts.js.map +1 -1
  39. package/dist/audit/cli/providerConfirmationStep.d.ts +13 -5
  40. package/dist/audit/cli/providerConfirmationStep.d.ts.map +1 -1
  41. package/dist/audit/cli/providerConfirmationStep.js +47 -8
  42. package/dist/audit/cli/providerConfirmationStep.js.map +1 -1
  43. package/dist/audit/cli/quotaCommand.d.ts.map +1 -1
  44. package/dist/audit/cli/quotaCommand.js +16 -10
  45. package/dist/audit/cli/quotaCommand.js.map +1 -1
  46. package/dist/audit/cli/rollingAuditDispatch.d.ts.map +1 -1
  47. package/dist/audit/cli/rollingAuditDispatch.js +14 -1
  48. package/dist/audit/cli/rollingAuditDispatch.js.map +1 -1
  49. package/dist/audit/cli/semanticReviewStep.d.ts +11 -1
  50. package/dist/audit/cli/semanticReviewStep.d.ts.map +1 -1
  51. package/dist/audit/cli/semanticReviewStep.js +11 -15
  52. package/dist/audit/cli/semanticReviewStep.js.map +1 -1
  53. package/dist/audit/cli/validateCommand.d.ts.map +1 -1
  54. package/dist/audit/cli/validateCommand.js +3 -3
  55. package/dist/audit/cli/validateCommand.js.map +1 -1
  56. package/dist/audit/cli.js +3 -3
  57. package/dist/audit/cli.js.map +1 -1
  58. package/dist/audit/contracts/wrapperResponse.d.ts +6 -6
  59. package/dist/audit/orchestrator/advance.d.ts.map +1 -1
  60. package/dist/audit/orchestrator/advance.js +39 -7
  61. package/dist/audit/orchestrator/advance.js.map +1 -1
  62. package/dist/audit/orchestrator/advanceTypes.d.ts +52 -1
  63. package/dist/audit/orchestrator/advanceTypes.d.ts.map +1 -1
  64. package/dist/audit/orchestrator/executorRunners.d.ts.map +1 -1
  65. package/dist/audit/orchestrator/executorRunners.js +9 -1
  66. package/dist/audit/orchestrator/executorRunners.js.map +1 -1
  67. package/dist/audit/orchestrator/intakeExecutors.d.ts +27 -3
  68. package/dist/audit/orchestrator/intakeExecutors.d.ts.map +1 -1
  69. package/dist/audit/orchestrator/intakeExecutors.js +119 -19
  70. package/dist/audit/orchestrator/intakeExecutors.js.map +1 -1
  71. package/dist/audit/orchestrator/nextStep.d.ts +7 -0
  72. package/dist/audit/orchestrator/nextStep.d.ts.map +1 -1
  73. package/dist/audit/orchestrator/nextStep.js +3 -0
  74. package/dist/audit/orchestrator/nextStep.js.map +1 -1
  75. package/dist/audit/orchestrator/providerConfirmation.d.ts.map +1 -1
  76. package/dist/audit/orchestrator/providerConfirmation.js +0 -2
  77. package/dist/audit/orchestrator/providerConfirmation.js.map +1 -1
  78. package/dist/audit/orchestrator/rollingDispatch.d.ts.map +1 -1
  79. package/dist/audit/orchestrator/rollingDispatch.js +2 -0
  80. package/dist/audit/orchestrator/rollingDispatch.js.map +1 -1
  81. package/dist/audit/orchestrator/state.d.ts +17 -0
  82. package/dist/audit/orchestrator/state.d.ts.map +1 -1
  83. package/dist/audit/orchestrator/state.js +13 -1
  84. package/dist/audit/orchestrator/state.js.map +1 -1
  85. package/dist/audit/providers/claudeWorkerProvider.d.ts +10 -0
  86. package/dist/audit/providers/claudeWorkerProvider.d.ts.map +1 -0
  87. package/dist/audit/providers/claudeWorkerProvider.js +15 -0
  88. package/dist/audit/providers/claudeWorkerProvider.js.map +1 -0
  89. package/dist/audit/providers/index.d.ts.map +1 -1
  90. package/dist/audit/providers/index.js +2 -0
  91. package/dist/audit/providers/index.js.map +1 -1
  92. package/dist/audit/quota/index.d.ts +133 -128
  93. package/dist/audit/quota/index.d.ts.map +1 -1
  94. package/dist/audit/supervisor/sessionConfig.d.ts +3 -11
  95. package/dist/audit/supervisor/sessionConfig.d.ts.map +1 -1
  96. package/dist/audit/supervisor/sessionConfig.js +3 -25
  97. package/dist/audit/supervisor/sessionConfig.js.map +1 -1
  98. package/dist/audit/types/analyzerCapability.d.ts +6 -6
  99. package/dist/audit/types/externalAnalyzer.d.ts +10 -10
  100. package/dist/remediate/index.d.ts.map +1 -1
  101. package/dist/remediate/index.js +2 -2
  102. package/dist/remediate/index.js.map +1 -1
  103. package/dist/remediate/phases/plan.d.ts.map +1 -1
  104. package/dist/remediate/phases/plan.js +5 -4
  105. package/dist/remediate/phases/plan.js.map +1 -1
  106. package/dist/remediate/providers/claudeWorkerProvider.d.ts +11 -0
  107. package/dist/remediate/providers/claudeWorkerProvider.d.ts.map +1 -0
  108. package/dist/remediate/providers/claudeWorkerProvider.js +16 -0
  109. package/dist/remediate/providers/claudeWorkerProvider.js.map +1 -0
  110. package/dist/remediate/providers/index.d.ts.map +1 -1
  111. package/dist/remediate/providers/index.js +2 -0
  112. package/dist/remediate/providers/index.js.map +1 -1
  113. package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
  114. package/dist/remediate/steps/contractPipeline.js +9 -3
  115. package/dist/remediate/steps/contractPipeline.js.map +1 -1
  116. package/dist/remediate/steps/dispatch/acceptNode.d.ts +1 -1
  117. package/dist/remediate/steps/dispatch/acceptNode.d.ts.map +1 -1
  118. package/dist/remediate/steps/dispatch/acceptNode.js.map +1 -1
  119. package/dist/remediate/steps/dispatch/marshal.d.ts.map +1 -1
  120. package/dist/remediate/steps/dispatch/marshal.js +5 -4
  121. package/dist/remediate/steps/dispatch/marshal.js.map +1 -1
  122. package/dist/remediate/steps/dispatch/waveScheduling.d.ts +4 -2
  123. package/dist/remediate/steps/dispatch/waveScheduling.d.ts.map +1 -1
  124. package/dist/remediate/steps/dispatch/waveScheduling.js +33 -85
  125. package/dist/remediate/steps/dispatch/waveScheduling.js.map +1 -1
  126. package/dist/remediate/steps/nextStep.d.ts +21 -10
  127. package/dist/remediate/steps/nextStep.d.ts.map +1 -1
  128. package/dist/remediate/steps/nextStep.js +45 -25
  129. package/dist/remediate/steps/nextStep.js.map +1 -1
  130. package/dist/remediate/steps/sessionConfigLoad.d.ts +46 -0
  131. package/dist/remediate/steps/sessionConfigLoad.d.ts.map +1 -0
  132. package/dist/remediate/steps/sessionConfigLoad.js +39 -0
  133. package/dist/remediate/steps/sessionConfigLoad.js.map +1 -0
  134. package/dist/shared/config/resolveSessionConfig.d.ts +75 -0
  135. package/dist/shared/config/resolveSessionConfig.d.ts.map +1 -0
  136. package/dist/shared/config/resolveSessionConfig.js +125 -0
  137. package/dist/shared/config/resolveSessionConfig.js.map +1 -0
  138. package/dist/shared/dispatch/admissionLoop.d.ts +12 -12
  139. package/dist/shared/dispatch/coordinator.d.ts.map +1 -1
  140. package/dist/shared/dispatch/coordinator.js +52 -6
  141. package/dist/shared/dispatch/coordinator.js.map +1 -1
  142. package/dist/shared/dispatch/hybridDispatch.d.ts +6 -0
  143. package/dist/shared/dispatch/hybridDispatch.d.ts.map +1 -1
  144. package/dist/shared/dispatch/hybridDispatch.js +6 -0
  145. package/dist/shared/dispatch/hybridDispatch.js.map +1 -1
  146. package/dist/shared/dispatch/providerLaunchFinalize.d.ts +2 -1
  147. package/dist/shared/dispatch/providerLaunchFinalize.d.ts.map +1 -1
  148. package/dist/shared/dispatch/providerLaunchFinalize.js +123 -68
  149. package/dist/shared/dispatch/providerLaunchFinalize.js.map +1 -1
  150. package/dist/shared/dispatch/rollingDispatch.d.ts +69 -2
  151. package/dist/shared/dispatch/rollingDispatch.d.ts.map +1 -1
  152. package/dist/shared/dispatch/rollingDispatch.js +177 -7
  153. package/dist/shared/dispatch/rollingDispatch.js.map +1 -1
  154. package/dist/shared/dispatch/unifiedRolling.d.ts +23 -0
  155. package/dist/shared/dispatch/unifiedRolling.d.ts.map +1 -1
  156. package/dist/shared/dispatch/unifiedRolling.js +2 -0
  157. package/dist/shared/dispatch/unifiedRolling.js.map +1 -1
  158. package/dist/shared/friction/stepBoundaryCapture.d.ts +52 -1
  159. package/dist/shared/friction/stepBoundaryCapture.d.ts.map +1 -1
  160. package/dist/shared/friction/stepBoundaryCapture.js +87 -0
  161. package/dist/shared/friction/stepBoundaryCapture.js.map +1 -1
  162. package/dist/shared/index.d.ts +20 -10
  163. package/dist/shared/index.d.ts.map +1 -1
  164. package/dist/shared/index.js +20 -6
  165. package/dist/shared/index.js.map +1 -1
  166. package/dist/shared/io/stateDir.d.ts +27 -0
  167. package/dist/shared/io/stateDir.d.ts.map +1 -0
  168. package/dist/shared/io/stateDir.js +40 -0
  169. package/dist/shared/io/stateDir.js.map +1 -0
  170. package/dist/shared/providers/auditorSources.d.ts +169 -0
  171. package/dist/shared/providers/auditorSources.d.ts.map +1 -0
  172. package/dist/shared/providers/auditorSources.js +453 -0
  173. package/dist/shared/providers/auditorSources.js.map +1 -0
  174. package/dist/shared/providers/claudeWorkerProvider.d.ts +71 -0
  175. package/dist/shared/providers/claudeWorkerProvider.d.ts.map +1 -0
  176. package/dist/shared/providers/claudeWorkerProvider.js +133 -0
  177. package/dist/shared/providers/claudeWorkerProvider.js.map +1 -0
  178. package/dist/shared/providers/providerConfirmation.d.ts +3 -20
  179. package/dist/shared/providers/providerConfirmation.d.ts.map +1 -1
  180. package/dist/shared/providers/providerConfirmation.js +27 -40
  181. package/dist/shared/providers/providerConfirmation.js.map +1 -1
  182. package/dist/shared/providers/providerFactory.d.ts +13 -1
  183. package/dist/shared/providers/providerFactory.d.ts.map +1 -1
  184. package/dist/shared/providers/providerFactory.js +12 -0
  185. package/dist/shared/providers/providerFactory.js.map +1 -1
  186. package/dist/shared/providers/proxyCatalog.d.ts +95 -0
  187. package/dist/shared/providers/proxyCatalog.d.ts.map +1 -0
  188. package/dist/shared/providers/proxyCatalog.js +426 -0
  189. package/dist/shared/providers/proxyCatalog.js.map +1 -0
  190. package/dist/shared/providers/sharedProviderConfirmation.d.ts +264 -75
  191. package/dist/shared/providers/sharedProviderConfirmation.d.ts.map +1 -1
  192. package/dist/shared/providers/sharedProviderConfirmation.js +534 -113
  193. package/dist/shared/providers/sharedProviderConfirmation.js.map +1 -1
  194. package/dist/shared/quota/apiPool.d.ts +34 -11
  195. package/dist/shared/quota/apiPool.d.ts.map +1 -1
  196. package/dist/shared/quota/apiPool.js +52 -23
  197. package/dist/shared/quota/apiPool.js.map +1 -1
  198. package/dist/shared/quota/capacity.d.ts +79 -50
  199. package/dist/shared/quota/capacity.d.ts.map +1 -1
  200. package/dist/shared/quota/capacity.js +40 -5
  201. package/dist/shared/quota/capacity.js.map +1 -1
  202. package/dist/shared/quota/errorParsing.d.ts +39 -0
  203. package/dist/shared/quota/errorParsing.d.ts.map +1 -1
  204. package/dist/shared/quota/errorParsing.js +76 -0
  205. package/dist/shared/quota/errorParsing.js.map +1 -1
  206. package/dist/shared/quota/hostPool.d.ts +107 -0
  207. package/dist/shared/quota/hostPool.d.ts.map +1 -0
  208. package/dist/shared/quota/hostPool.js +103 -0
  209. package/dist/shared/quota/hostPool.js.map +1 -0
  210. package/dist/shared/quota/quotaSource.d.ts +2 -2
  211. package/dist/shared/types/auditorDescriptor.d.ts +120 -0
  212. package/dist/shared/types/auditorDescriptor.d.ts.map +1 -0
  213. package/dist/shared/types/auditorDescriptor.js +28 -0
  214. package/dist/shared/types/auditorDescriptor.js.map +1 -0
  215. package/dist/shared/types/providerConfirmation.d.ts +64 -6
  216. package/dist/shared/types/providerConfirmation.d.ts.map +1 -1
  217. package/dist/shared/types/providerConfirmation.js +1 -1
  218. package/dist/shared/types/rollingDispatch.d.ts +45 -1
  219. package/dist/shared/types/rollingDispatch.d.ts.map +1 -1
  220. package/dist/shared/types/sessionConfig.d.ts +149 -67
  221. package/dist/shared/types/sessionConfig.d.ts.map +1 -1
  222. package/dist/shared/types/sessionConfig.js +87 -2
  223. package/dist/shared/types/sessionConfig.js.map +1 -1
  224. package/dist/shared/validation/sessionConfig.d.ts +26 -14
  225. package/dist/shared/validation/sessionConfig.d.ts.map +1 -1
  226. package/dist/shared/validation/sessionConfig.js +95 -69
  227. package/dist/shared/validation/sessionConfig.js.map +1 -1
  228. package/docs/audit-pkg/contracts.md +4 -4
  229. package/docs/audit-pkg/operator-guide.md +43 -24
  230. package/package.json +1 -1
  231. package/skills/audit-code/SKILL.md +17 -14
  232. package/skills/audit-code/audit-code.prompt.md +17 -16
  233. package/wrapper/audit-code-wrapper-install-hosts.mjs +6 -10
  234. package/wrapper/remediate-code-wrapper-install-hosts.mjs +4 -8
  235. package/dist/shared/quota/repairProxyRegistry.d.ts +0 -84
  236. package/dist/shared/quota/repairProxyRegistry.d.ts.map +0 -1
  237. package/dist/shared/quota/repairProxyRegistry.js +0 -144
  238. package/dist/shared/quota/repairProxyRegistry.js.map +0 -1
@@ -2,41 +2,51 @@
2
2
  * DC-2 — shared, session-scoped provider confirmation (Gate-0).
3
3
  *
4
4
  * The design wants ONE provider confirmation spanning an audit→remediate run:
5
- * the first tool to run writes the confirmed provider pool to a SHARED artifact
6
- * at `<root>/.audit-tools/provider-confirmation.json` (NOT the per-tool audit
7
- * artifacts dir); the second tool reads and honors it unless the roster has
8
- * since changed. "Session" = the shared `.audit-tools` dir for that repo+run, so
9
- * no new identity scheme is needed.
10
- *
11
- * Two invariants are in tension and are reconciled here by the CE-012 third
12
- * state:
13
- * - INV-DC1-6 (never-block): remediate run standalone with no prior audit must
14
- * resolve its provider independently, exactly as today — absence of the
15
- * artifact is NOT an error.
16
- * - INV-DC2-3 (roster-stale-re-confirm): a confirmation whose discovered roster
17
- * no longer matches the current one must NOT be silently honored (it could
18
- * pin a provider that has since disappeared) — it must re-confirm.
19
- * A single `null` return cannot carry both meanings (CE-012). So the accessor
20
- * returns a THREE-valued result: `null` for absent/malformed (never-block),
21
- * `{ status: 'confirmed' }` for a fresh honor, and the DISTINCT
22
- * `{ status: 'reconfirm' }` for roster-stale — so honoring INV-DC2-3 no longer
23
- * contradicts INV-DC1-6.
5
+ * the first tool to run writes the operator's confirmed route DECISION to a
6
+ * SHARED artifact at `<root>/.audit-tools/provider-confirmation.json` (NOT the
7
+ * per-tool audit artifacts dir); the second tool reads and honors it. "Session" =
8
+ * the shared `.audit-tools` dir for that repo+run, so no new identity scheme is
9
+ * needed.
10
+ *
11
+ * **What this artifact carries is POLICY, not reach (G3).** The operator's
12
+ * decision — exclusions, cost order, λ — is a set of *rules*, valid for any
13
+ * auditor. What is *reachable* is per-auditor capability and is re-resolved from
14
+ * live env/PATH at the moment of use, never inherited from whoever wrote this
15
+ * file. So every read here is reach-free: it returns the persisted decision and
16
+ * nothing else.
17
+ *
18
+ * INV-DC1-6 (never-block) is the only invariant left in tension, and it now
19
+ * resolves to a plain two-valued read: a remediate run standalone with no prior
20
+ * audit resolves its provider independently — absence or corruption of the
21
+ * artifact is NOT an error, it is `null`.
22
+ *
23
+ * The former roster-staleness check (and its CE-012 three-valued `reconfirm`
24
+ * state) is GONE. It compared the *writing* auditor's roster against the reader's
25
+ * — meaningless cross-auditor by construction — and answered a real event (a
26
+ * backend the operator never confirmed became reachable) by silently discarding
27
+ * the operator's cost order and λ, while reaching no obligation at all. The
28
+ * `autonomous_mode`-keyed reconciliation gate
29
+ * ({@link computeNewlyReachableBackends}) replaces it: it compares the operator's
30
+ * DECISION against *this* auditor's freshly-resolved reach, which is well-defined
31
+ * across auditors, and it actually fires.
24
32
  *
25
33
  * CE-003 (lockless read races the writer rename): writes go through the shared
26
34
  * atomic writer (temp + atomic rename) under `withFileLock`, so a lockless
27
35
  * reader always observes either the complete old file or the complete new file —
28
36
  * never a torn intermediate.
29
37
  *
30
- * PB-1 (opencode opt-in): the roster is derived from `discoverProviders`, which
31
- * already withholds a bare-PATH opencode unless it is explicitly configured, so
32
- * the shared confirmation inherits that opt-in for free.
38
+ * PB-1 (opencode opt-in): the confirmed pool is derived from `discoverProviders`,
39
+ * which already withholds a bare-PATH opencode unless it is explicitly
40
+ * configured, so the shared confirmation inherits that opt-in for free.
33
41
  */
34
- import { mkdir } from "node:fs/promises";
42
+ import { mkdir, unlink } from "node:fs/promises";
35
43
  import { join } from "node:path";
44
+ import { PROVIDER_NAMES } from "../types/sessionConfig.js";
45
+ import { isSelfSpawnBlocked } from "./providerPathGuard.js";
36
46
  import { auditToolsDir } from "../io/auditToolsPaths.js";
37
47
  import { readJsonFile, writeJsonFile } from "../io/json.js";
38
48
  import { withFileLock } from "../quota/fileLock.js";
39
- import { discoverProviders, annotateConfirmedPool, } from "./providerConfirmation.js";
49
+ import { discoverProviders, annotateConfirmedPool, representativeModelId, } from "./providerConfirmation.js";
40
50
  import { resolveConfirmedCostPositions } from "../dispatch/costRank.js";
41
51
  import { PROVIDER_CONFIRMATION_INPUT_VERSION } from "../types/providerConfirmation.js";
42
52
  // ---------------------------------------------------------------------------
@@ -45,8 +55,8 @@ import { PROVIDER_CONFIRMATION_INPUT_VERSION } from "../types/providerConfirmati
45
55
  /**
46
56
  * Schema version for the shared confirmation artifact. Bumped independently of
47
57
  * the per-tool seam contract (PROVIDER_CONFIRMATION_RESULT_VERSION) — this is
48
- * the cross-tool session artifact, a distinct shape that also carries the
49
- * roster snapshot used for staleness.
58
+ * the cross-tool session artifact carrying the operator's route DECISION
59
+ * (exclusions, cost order, λ), a distinct shape from the seam's pool snapshot.
50
60
  */
51
61
  export const SHARED_PROVIDER_CONFIRMATION_VERSION = "1.0.0";
52
62
  /**
@@ -65,33 +75,37 @@ export const SHARED_PROVIDER_CONFIRMATION_FILENAME = "provider-confirmation.json
65
75
  export function sharedProviderConfirmationPath(root) {
66
76
  return join(auditToolsDir(root), SHARED_PROVIDER_CONFIRMATION_FILENAME);
67
77
  }
68
- // ---------------------------------------------------------------------------
69
- // Roster derivation
70
- // ---------------------------------------------------------------------------
71
78
  /**
72
- * The current discovered provider roster: the sorted set of provider names
73
- * `discoverProviders` surfaces for this session config + environment. PB-1's
74
- * opencode opt-in is inherited from `discoverProviders` (a bare-PATH opencode is
75
- * not surfaced unless explicitly configured), so it never spuriously perturbs
76
- * the roster.
79
+ * Project the render DTO down to what actually gets PERSISTED: the operator's
80
+ * decision, with this auditor's reach assessment dropped (G3 B+D).
81
+ *
82
+ * The PRODUCER is split — not the write site. `writeSharedProviderConfirmation`
83
+ * receives an already-typed value, so projecting THERE would leave the reach fields
84
+ * representable on the persisted type and a future caller could put them back.
85
+ * Projecting here makes the persisted shape carry no reach BY CONSTRUCTION.
77
86
  */
78
- export function currentProviderRoster(sessionConfig, env = process.env, detectCommand) {
79
- const names = discoverProviders(sessionConfig, env, detectCommand).map((p) => p.name);
80
- return sortRoster(names);
87
+ export function buildSharedProviderConfirmation(...args) {
88
+ const rendered = buildProviderConfirmationRender(...args);
89
+ return {
90
+ ...rendered,
91
+ provider_pool: rendered.provider_pool.map(toPersistedPoolEntry),
92
+ };
81
93
  }
82
- function sortRoster(names) {
83
- // Deduplicate + sort so the snapshot is order-insensitive and comparison is a
84
- // plain stringified equality.
94
+ /** The decision half of one pool entry. Reach is dropped, never persisted. */
95
+ function toPersistedPoolEntry(entry) {
96
+ return {
97
+ name: entry.name,
98
+ ...(entry.model_id !== undefined ? { model_id: entry.model_id } : {}),
99
+ ...(entry.cost_order !== undefined ? { cost_order: entry.cost_order } : {}),
100
+ };
101
+ }
102
+ function sortNames(names) {
103
+ // Deduplicate + sort so a persisted list is order-insensitive and stable.
85
104
  return [...new Set(names)].sort();
86
105
  }
87
- function rostersEqual(a, b) {
88
- if (a.length !== b.length)
89
- return false;
90
- for (let i = 0; i < a.length; i++) {
91
- if (a[i] !== b[i])
92
- return false;
93
- }
94
- return true;
106
+ function sortStrings(values) {
107
+ // Deduplicate + sort so a persisted list is order-insensitive and stable.
108
+ return [...new Set(values)].sort();
95
109
  }
96
110
  // ---------------------------------------------------------------------------
97
111
  // Build
@@ -113,7 +127,10 @@ function rostersEqual(a, b) {
113
127
  *
114
128
  * @param sessionConfig - Current session config; may be an empty `{}`.
115
129
  * @param env - Process env snapshot; defaults to `process.env`.
116
- * @param exclude - Provider names to pre-exclude (from a prior gate).
130
+ * @param exclude - {@link DispatchExclusionPattern}s to pre-exclude (from a
131
+ * prior gate). A `provider:model` pattern marks a pool entry excluded only when
132
+ * that entry's `representativeModelId` IS that model — the same key the routing
133
+ * filter matches on, so display and dispatch cannot disagree.
117
134
  * @param include - Provider names the operator explicitly opts back IN,
118
135
  * overriding the default self-spawn-blocked exclusion for those names.
119
136
  * @param detectCommand - Injectable PATH-detection hook, forwarded to
@@ -126,10 +143,26 @@ function rostersEqual(a, b) {
126
143
  * passed via the dedicated params above (the executor forwards them from the
127
144
  * same input), so this arg governs ordering + host roster only.
128
145
  */
129
- export function buildSharedProviderConfirmation(sessionConfig = {}, env = process.env, exclude = [], include = [], detectCommand, input, sources = []) {
146
+ export function buildProviderConfirmationRender(sessionConfig = {}, env = process.env, exclude = [], include = [], detectCommand, input, sources = []) {
130
147
  const discovered = discoverProviders(sessionConfig, env, detectCommand);
131
- const excludeSet = new Set(exclude);
148
+ const operatorExcluded = buildExclusion(exclude);
132
149
  const includeSet = new Set(include);
150
+ // Evaluate a pool entry against the operator's rules at the SAME key the routing
151
+ // filter uses — `representativeModelId` is what `computeNewlyReachableBackends`
152
+ // and `annotateConfirmedPool` key a provider by, so a `provider:model` rule marks
153
+ // exactly the entry it will later filter.
154
+ //
155
+ // ⚠ The provider + model tiers ONLY. `provider_pool` is provider-granular and an
156
+ // entry has no endpoint, so an endpoint-host rule can never mark one — that tier
157
+ // addresses SOURCES, which this pool does not enumerate. The direction is safe
158
+ // (the rule is still honored at `buildSourcePools`; the Gate-0 table merely
159
+ // under-reports it as "included"), but it is a real display/routing gap and it is
160
+ // NOT closed here. Backlog: the Gate-0 sources table carries no status column at
161
+ // all, so no tier is reflected for a source today.
162
+ const ruledOut = (name) => operatorExcluded.excludes({
163
+ provider: name,
164
+ model: representativeModelId(name, sessionConfig),
165
+ });
133
166
  const pool = [];
134
167
  // Always include worker-command as a fallback — it's always available and is
135
168
  // never surfaced by PATH discovery (it blocks auto-dispatch by design).
@@ -137,24 +170,21 @@ export function buildSharedProviderConfirmation(sessionConfig = {}, env = proces
137
170
  pool.push({
138
171
  name: "worker-command",
139
172
  capability_tier: "unknown",
140
- excluded: excludeSet.has("worker-command"),
141
- reason: "always-available fallback; no PATH detection required",
173
+ excluded: ruledOut("worker-command"),
142
174
  });
143
175
  }
144
176
  for (const provider of discovered) {
145
177
  // Self-spawn-blocked providers are excluded from the dispatchable pool by
146
178
  // default; the operator can opt one back in via `include`. An operator-named
147
- // `exclude` always wins. The machine-readable flag rides along so downstream
148
- // consumers never have to parse `reason`.
179
+ // `exclude` always wins.
149
180
  const blocked = provider.selfSpawnBlocked === true;
150
181
  const operatorIncluded = includeSet.has(provider.name);
151
- const excluded = excludeSet.has(provider.name) || (blocked && !operatorIncluded);
182
+ const excluded = ruledOut(provider.name) || (blocked && !operatorIncluded);
152
183
  pool.push({
153
184
  name: provider.name,
154
185
  capability_tier: provider.capabilityTier,
155
186
  excluded,
156
187
  ...(blocked ? { self_spawn_blocked: true } : {}),
157
- reason: provider.reason,
158
188
  });
159
189
  }
160
190
  // Cost-first routing: annotate with representative model price + cost_order,
@@ -166,6 +196,8 @@ export function buildSharedProviderConfirmation(sessionConfig = {}, env = proces
166
196
  session_level: true,
167
197
  confirmed_at: new Date().toISOString(),
168
198
  provider_pool: annotated.provider_pool,
199
+ // NOTE: the FULL entries. This is the render DTO; `buildSharedProviderConfirmation`
200
+ // projects them to `PersistedPoolEntry[]` on the way to disk.
169
201
  ...(annotated.host_model_cost_order.length > 0
170
202
  ? { host_model_cost_order: annotated.host_model_cost_order }
171
203
  : {}),
@@ -175,9 +207,309 @@ export function buildSharedProviderConfirmation(sessionConfig = {}, env = proces
175
207
  ...(clampDispatchBias(input?.dispatch_bias) != null
176
208
  ? { dispatch_bias: clampDispatchBias(input?.dispatch_bias) }
177
209
  : {}),
178
- roster: sortRoster(discovered.map((p) => p.name)),
210
+ ...(buildConfirmedDispatchPolicy(exclude, include) ?? {}),
179
211
  };
180
212
  }
213
+ /**
214
+ * Lift the operator's explicit route decision out of their Gate-0 input into the
215
+ * persisted policy half. Returns `undefined` when the operator named neither list
216
+ * (so the field stays absent rather than persisting an empty shell).
217
+ */
218
+ function buildConfirmedDispatchPolicy(exclude, include) {
219
+ if (exclude.length === 0 && include.length === 0)
220
+ return undefined;
221
+ return {
222
+ policy: {
223
+ ...(exclude.length > 0 ? { exclude: sortStrings(exclude) } : {}),
224
+ ...(include.length > 0 ? { include: sortNames(include) } : {}),
225
+ },
226
+ };
227
+ }
228
+ /**
229
+ * Keep every non-empty pattern verbatim — **no membership check.** Unlike
230
+ * {@link parseProviderNameList}, this list is an open grammar: a pattern whose head
231
+ * is not a provider name is a legitimate endpoint-host rule, so "unknown ⇒ drop"
232
+ * would silently delete the operator's endpoint tier. An unmatchable pattern is
233
+ * inert (it simply matches nothing), which is the safe direction for a filter.
234
+ *
235
+ * Returns `undefined` for a non-array or an all-empty array, so the field stays
236
+ * absent rather than persisting an empty shell.
237
+ */
238
+ function parseExclusionPatterns(value) {
239
+ if (!Array.isArray(value))
240
+ return undefined;
241
+ const patterns = value.filter((v) => typeof v === "string" && v.trim().length > 0);
242
+ return patterns.length > 0 ? patterns.map((p) => p.trim()) : undefined;
243
+ }
244
+ /**
245
+ * Keep only real provider names, dropping anything unknown. Returns `undefined` for a
246
+ * non-array or an array with no recognizable name, so an unknown entry degrades that
247
+ * entry — never the whole list.
248
+ *
249
+ * Retained for `include` ONLY: that list opts a *self-spawn-blocked provider* back in,
250
+ * and self-spawn-blockedness is a property of a provider (`isSelfSpawnBlocked` keys on
251
+ * the provider name), so its keyspace is genuinely the closed name set — not the open
252
+ * exclusion grammar.
253
+ */
254
+ function parseProviderNameList(value) {
255
+ if (!Array.isArray(value))
256
+ return undefined;
257
+ const names = value.filter((v) => typeof v === "string" && RESOLVED_PROVIDER_NAMES.includes(v));
258
+ return names.length > 0 ? names : undefined;
259
+ }
260
+ /** Every concrete provider name — `auto` is a resolution directive, not a backend. */
261
+ const RESOLVED_PROVIDER_NAMES = PROVIDER_NAMES.filter((name) => name !== "auto");
262
+ // ---------------------------------------------------------------------------
263
+ // The reconciliation gate (G3): operator DECISION vs THIS auditor's reach
264
+ // ---------------------------------------------------------------------------
265
+ /**
266
+ * The gate's comparison key for one backend: **the model where it is knowable,
267
+ * else the coarse provider name.**
268
+ *
269
+ * This is the operator-facing exclusion grammar's own provision (`provider:model`,
270
+ * with `provider` as the coarser pattern — spec/unified-dispatch-worker-model.md).
271
+ * Model granularity is the POINT, not a refinement: the operator confirms *model*
272
+ * choices, so a second model under an already-confirmed `openai-compatible` is a
273
+ * new choice even though it introduces no new provider.
274
+ *
275
+ * But a bare-`model_id` key would be worse than useless: `representativeModelId`
276
+ * knows a model only for `openai-compatible` and `codex` — for claude-code / agy /
277
+ * opencode / worker-command a CLI backend's model arrives only at the dispatch
278
+ * handshake. Such a backend would contribute NO key, so installing `agy` on PATH
279
+ * would leave the delta empty and the gate would silently dispatch it — re-opening
280
+ * the exact PATH-appearance case the gate exists to catch, blind rather than loud.
281
+ *
282
+ * ⚠ This is a THIRD keyspace, deliberately distinct from the quota-ledger pool
283
+ * identity (`provider[#account]/model`, `buildProviderModelKey`) — an account is
284
+ * irrelevant to a rule about a backend. Do not unify them.
285
+ */
286
+ function backendGateKey(modelId, providerName) {
287
+ return modelId ?? providerName;
288
+ }
289
+ /**
290
+ * The keys of the operator's persisted DECISION — the CONFIRMED half of the gate.
291
+ *
292
+ * All three pools contribute, and each is load-bearing: `annotateConfirmedPool`
293
+ * folds a source away when its model is already claimed by a provider entry, so a
294
+ * source can be represented ONLY by `provider_pool[].model_id`; and a host tier
295
+ * appears only in `host_model_cost_order`. Reading fewer than all three would
296
+ * manufacture a phantom delta for an already-confirmed backend.
297
+ */
298
+ export function confirmedBackendKeys(confirmation) {
299
+ const keys = new Set();
300
+ for (const entry of confirmation.provider_pool) {
301
+ keys.add(backendGateKey(entry.model_id, entry.name));
302
+ }
303
+ for (const entry of confirmation.source_pool_cost_order ?? []) {
304
+ keys.add(backendGateKey(entry.model_id, entry.provider));
305
+ }
306
+ for (const entry of confirmation.host_model_cost_order ?? []) {
307
+ keys.add(entry.model_id);
308
+ }
309
+ return keys;
310
+ }
311
+ /**
312
+ * The pattern that rules out one backend at the finest granularity its model is
313
+ * knowable at. The inverse of {@link backendGateKey}, and deliberately adjacent to
314
+ * it: a `provider:model` rule matches only when the routing filter sees that exact
315
+ * model, so a backend whose model arrives at the dispatch handshake (a CLI) must be
316
+ * ruled out at the coarse `provider` tier or the rule would never match.
317
+ */
318
+ function backendExclusionPattern(modelId, providerName) {
319
+ return modelId ? `${providerName}:${modelId}` : providerName;
320
+ }
321
+ /**
322
+ * DELTA = **REACH-NOW \ CONFIRMED**: the backends this auditor can reach *right
323
+ * now* that the operator's persisted decision never mentions. Sorted by key, so the
324
+ * result is stable for prompt rendering + comparison.
325
+ *
326
+ * This is a **set difference — a FILTER over fresh reach, never additive.** The
327
+ * opposite direction (CONFIRMED \ REACH-NOW: a backend the operator confirmed that
328
+ * this auditor cannot reach) is the harmless *subset* case and is deliberately
329
+ * silent — it is also why the synthetic `worker-command` entry and
330
+ * `host_model_cost_order` need no special-casing here.
331
+ *
332
+ * @param confirmation - The persisted decision (CONFIRMED).
333
+ * @param sessionConfig - The EFFECTIVE config, so `representativeModelId` derives
334
+ * keys identically to the write side.
335
+ * @param sources - REACH-NOW's source half. MUST come from the
336
+ * `gatherDispatchableSources` chokepoint — the single async source-gather point
337
+ * both `buildSourcePools` and the Gate-0 surface consume, so what the operator
338
+ * confirms is exactly what routes. Re-deriving it from `resolveAmbientSources`
339
+ * would reintroduce the display/dispatch drift that invariant forbids, and is
340
+ * structurally blind to descriptor-supplied sources, the demoted primary, and the
341
+ * legacy `openai_compatible` fold.
342
+ * @param env - Process env, for `discoverProviders` (REACH-NOW's provider half).
343
+ * @param detectCommand - Injectable PATH-detection hook so tests drive discovery
344
+ * deterministically instead of shelling out.
345
+ */
346
+ export function computeNewlyReachableBackends(confirmation, sessionConfig, sources = [], env = process.env, detectCommand) {
347
+ const confirmed = confirmedBackendKeys(confirmation);
348
+ const reachNow = new Map();
349
+ const record = (modelId, provider) => {
350
+ reachNow.set(backendGateKey(modelId, provider), {
351
+ key: backendGateKey(modelId, provider),
352
+ provider,
353
+ exclusion_pattern: backendExclusionPattern(modelId, provider),
354
+ });
355
+ };
356
+ for (const provider of discoverProviders(sessionConfig, env, detectCommand)) {
357
+ record(representativeModelId(provider.name, sessionConfig), provider.name);
358
+ }
359
+ for (const source of sources) {
360
+ record(source.model, source.provider);
361
+ }
362
+ return [...reachNow.values()]
363
+ .filter((backend) => !confirmed.has(backend.key))
364
+ .sort((a, b) => a.key.localeCompare(b.key));
365
+ }
366
+ /**
367
+ * The dispatchable-pool exclusion rules for THIS process: the operator's explicit
368
+ * {@link DispatchExclusionPattern}s, plus every provider that is self-spawn-blocked
369
+ * *in this process's env* and was not explicitly opted back in.
370
+ *
371
+ * Reach is recomputed here rather than read from the artifact's derived `excluded`
372
+ * flag — that flag encodes the WRITING auditor's env, and an auditor for whom a
373
+ * provider is perfectly spawnable must not inherit another's block. The operator's
374
+ * decision is inherited (it is a rule); the reach assessment is not.
375
+ *
376
+ * ⚠ **These rules are only safe to apply to SOURCE pools.** Inside any agent session
377
+ * the self-spawn half ALWAYS names that agent (`CLAUDECODE` ⇒ `claude-code`, `CODEX`
378
+ * ⇒ `codex`) — i.e. the conversation host itself. Applying them to HOST pools would
379
+ * zero out dispatch entirely: the driver would exclude itself. It is harmless at
380
+ * `buildSourcePools` only because a host can never BE a source — `claude-code` is
381
+ * structurally absent from `DISPATCHABLE_SOURCE_PROVIDERS`, so in a Claude Code
382
+ * session the filter is a no-op. Honoring an operator exclusion of the host/primary
383
+ * provider therefore is NOT a matter of passing these rules to the host-pool builder;
384
+ * it needs a separate decision about what excluding your own driver should even mean.
385
+ */
386
+ export function resolveDispatchExclusion(policy, env = process.env) {
387
+ const included = new Set(policy?.include ?? []);
388
+ // The local reach half: a self-spawn-blocked provider is ruled out at PROVIDER
389
+ // granularity (blockedness is a property of the provider, not of one of its
390
+ // models), recomputed against THIS process's env rather than inherited.
391
+ const blocked = RESOLVED_PROVIDER_NAMES.filter((name) => !included.has(name) && isSelfSpawnBlocked(name, env));
392
+ return buildExclusion([...(policy?.exclude ?? []), ...blocked]);
393
+ }
394
+ /**
395
+ * The pure-policy matcher: the operator's patterns and nothing else. Split out
396
+ * because the Gate-0 pool BUILDER must evaluate the operator's rules WITHOUT the
397
+ * local self-spawn fold — it derives `self_spawn_blocked` from `discoverProviders`
398
+ * on its own and would otherwise conflate the two into one indistinguishable
399
+ * `excluded` verdict.
400
+ */
401
+ function buildExclusion(patterns) {
402
+ const rules = patterns.map(parseExclusionRule);
403
+ return { excludes: (backend) => rules.some((rule) => ruleMatches(rule, backend)) };
404
+ }
405
+ function parseExclusionRule(pattern) {
406
+ const colon = pattern.indexOf(":");
407
+ if (colon === -1) {
408
+ return isResolvedProviderName(pattern)
409
+ ? { kind: "provider", provider: pattern }
410
+ : { kind: "endpoint", host: pattern.toLowerCase() };
411
+ }
412
+ const head = pattern.slice(0, colon);
413
+ const tail = pattern.slice(colon + 1);
414
+ if (!isResolvedProviderName(head)) {
415
+ return { kind: "endpoint", host: pattern.toLowerCase() };
416
+ }
417
+ // The head decides the tier — an empty tail does NOT demote a provider-name head
418
+ // to the endpoint tier. `codex:` reads as "codex, every model"; classifying it as
419
+ // an (unmatchable) endpoint rule would silently drop the operator's intent, and
420
+ // the head-decides rule this type documents would not actually hold.
421
+ return tail.length > 0
422
+ ? { kind: "provider_model", provider: head, model: tail }
423
+ : { kind: "provider", provider: head };
424
+ }
425
+ function isResolvedProviderName(value) {
426
+ return RESOLVED_PROVIDER_NAMES.includes(value);
427
+ }
428
+ function ruleMatches(rule, backend) {
429
+ switch (rule.kind) {
430
+ case "provider":
431
+ return backend.provider === rule.provider;
432
+ case "provider_model":
433
+ // A model-granular rule matches ONLY that model. A backend of the same
434
+ // provider carrying no model (a CLI whose model arrives at the dispatch
435
+ // handshake) is NOT matched: the operator ruled out one model, not the
436
+ // backend — the coarse `provider` tier is how they rule out the backend.
437
+ return backend.provider === rule.provider && backend.model === rule.model;
438
+ case "endpoint":
439
+ return endpointHosts(backend.endpoint).includes(rule.host);
440
+ }
441
+ }
442
+ /**
443
+ * The forms of a source endpoint an operator pattern may name: `hostname` (port-
444
+ * agnostic — `integrate.api.nvidia.com` rules out that host on any port) and
445
+ * `host:port` (port-specific — `localhost:8000` rules out one of several local
446
+ * endpoints). Both are offered so the pattern's own specificity decides.
447
+ *
448
+ * An endpoint that is not a URL (a CLI launcher command) degrades to the raw
449
+ * lowercased string, which then only ever matches an identical literal pattern —
450
+ * never a false positive against a real host.
451
+ *
452
+ * ⚠ The authority check is load-bearing, not defensive: `new URL()` accepts ANY
453
+ * scheme-shaped string, so it does NOT throw on `localhost:8000` (protocol
454
+ * `localhost:`) or on a Windows command path like `C:\tools\codex.cmd` (protocol
455
+ * `c:`) — both parse to an EMPTY hostname. Relying on the `catch` alone would
456
+ * therefore silently yield no hosts for exactly those endpoints, making an
457
+ * operator's literal-identical rule match nothing.
458
+ */
459
+ function endpointHosts(endpoint) {
460
+ if (!endpoint)
461
+ return [];
462
+ const raw = endpoint.toLowerCase();
463
+ if (endpoint.includes("//")) {
464
+ try {
465
+ const url = new URL(endpoint);
466
+ if (url.hostname.length > 0) {
467
+ return [url.hostname.toLowerCase(), url.host.toLowerCase()];
468
+ }
469
+ }
470
+ catch {
471
+ // Not a URL after all — fall through to the raw literal.
472
+ }
473
+ }
474
+ return [raw];
475
+ }
476
+ /**
477
+ * Read the operator's confirmed route policy from the shared Gate-0 confirmation.
478
+ *
479
+ * Deliberately reads the artifact DIRECTLY rather than going through
480
+ * {@link readSharedProviderConfirmation}, so that **a corrupt sibling field cannot
481
+ * discard the decision**: `parseSharedProviderConfirmation` returns `null` wholesale
482
+ * on any malformed required field or a `schema_version` mismatch. Routing policy
483
+ * through it would make an unrelated corruption (or a future version bump) silently
484
+ * lift the operator's exclusions — failing OPEN on the one field that must fail
485
+ * closed. Parsing `policy` on its own keeps that blast radius out.
486
+ *
487
+ * (Before G3 this bypass carried a second rationale — dodging the roster-freshness
488
+ * gate. That gate is gone: no read of this artifact is reach-gated any more, so the
489
+ * remaining reason is blast radius alone.)
490
+ *
491
+ * **Honest limit — this is not absolutely fail-closed.** An absent or unparseable
492
+ * artifact yields `null` (no operator policy). That residue is irreducible here: with
493
+ * no readable decision on disk there is nothing to fail closed ON. Self-spawn-blocked
494
+ * providers are still excluded locally by {@link resolveDispatchExclusion}, which
495
+ * needs no artifact.
496
+ */
497
+ export async function readConfirmedDispatchPolicy(root) {
498
+ if (!root)
499
+ return null;
500
+ let raw;
501
+ try {
502
+ raw = await readJsonFile(sharedProviderConfirmationPath(root));
503
+ }
504
+ catch {
505
+ // Absent (ENOENT) / unreadable / invalid JSON — never-block, same contract as
506
+ // every other read of this artifact.
507
+ return null;
508
+ }
509
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
510
+ return null;
511
+ return parseConfirmedDispatchPolicy(raw.policy) ?? null;
512
+ }
181
513
  // ---------------------------------------------------------------------------
182
514
  // Write (audit writes it)
183
515
  // ---------------------------------------------------------------------------
@@ -203,17 +535,21 @@ export async function writeSharedProviderConfirmation(root, confirmation, logger
203
535
  // ---------------------------------------------------------------------------
204
536
  // Validation
205
537
  // ---------------------------------------------------------------------------
206
- function isResolvedProviderNameArray(value) {
207
- return Array.isArray(value) && value.every((v) => typeof v === "string");
208
- }
209
- function isConfirmedPoolEntry(value) {
538
+ /**
539
+ * The persisted pool entry's gate: `name` ONLY.
540
+ *
541
+ * B+D: this gate previously hard-required `capability_tier` AND `excluded` — the
542
+ * exact reach fields B removes from the persisted shape. That coupling is why B and
543
+ * D are ONE commit: a post-B artifact failing a pre-B gate parses to `null`, which
544
+ * degrades SILENTLY to empty cost positions and λ=0. Requiring only `name` also
545
+ * makes the gate forward-tolerant of a confirmation written before B (its extra
546
+ * reach fields are simply ignored on read, never re-persisted).
547
+ */
548
+ function isPersistedPoolEntry(value) {
210
549
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
211
550
  return false;
212
551
  }
213
- const obj = value;
214
- return (typeof obj.name === "string" &&
215
- typeof obj.capability_tier === "string" &&
216
- typeof obj.excluded === "boolean");
552
+ return typeof value.name === "string";
217
553
  }
218
554
  function isHostModelCostEntry(value) {
219
555
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
@@ -254,11 +590,9 @@ function parseSharedProviderConfirmation(value) {
254
590
  if (typeof obj.confirmed_at !== "string")
255
591
  return null;
256
592
  if (!Array.isArray(obj.provider_pool) ||
257
- !obj.provider_pool.every(isConfirmedPoolEntry)) {
593
+ !obj.provider_pool.every(isPersistedPoolEntry)) {
258
594
  return null;
259
595
  }
260
- if (!isResolvedProviderNameArray(obj.roster))
261
- return null;
262
596
  // host_model_cost_order is optional + additive; a malformed value degrades to
263
597
  // absent (the field never blocks parsing — INV-DC1-6 never-block spirit).
264
598
  const hostModels = Array.isArray(obj.host_model_cost_order) &&
@@ -273,6 +607,11 @@ function parseSharedProviderConfirmation(value) {
273
607
  // dispatch_bias is optional + additive; a malformed/out-of-range value degrades to
274
608
  // the cost-first default (clamp → undefined only when non-finite), never blocking.
275
609
  const dispatchBias = clampDispatchBias(obj.dispatch_bias);
610
+ // policy is optional + additive; a malformed value degrades to absent. Note the
611
+ // asymmetry with the fields above: degrading policy to absent fails OPEN (the
612
+ // operator's exclusions stop applying), so each list is validated independently —
613
+ // a malformed `include` must not silently discard a well-formed `exclude`.
614
+ const policy = parseConfirmedDispatchPolicy(obj.policy);
276
615
  return {
277
616
  schema_version: SHARED_PROVIDER_CONFIRMATION_VERSION,
278
617
  session_level: true,
@@ -285,79 +624,132 @@ function parseSharedProviderConfirmation(value) {
285
624
  ? { source_pool_cost_order: sourcePools }
286
625
  : {}),
287
626
  ...(dispatchBias != null ? { dispatch_bias: dispatchBias } : {}),
288
- roster: obj.roster,
627
+ ...(policy ? { policy } : {}),
628
+ };
629
+ }
630
+ /**
631
+ * Parse the optional policy half. Each list is validated on its own so one
632
+ * malformed list cannot discard the other — degrading a well-formed `exclude` to
633
+ * absent would fail OPEN and route to a backend the operator ruled out.
634
+ */
635
+ function parseConfirmedDispatchPolicy(value) {
636
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
637
+ return undefined;
638
+ }
639
+ const obj = value;
640
+ // `exclude` is the OPEN exclusion grammar (a pattern's head need not be a provider
641
+ // name — the endpoint tier's never is), so it is kept verbatim; an unmatchable
642
+ // pattern is inert, which is the safe direction for a filter. `include` is the
643
+ // CLOSED provider-name set and stays membership-checked, so an unknown name cannot
644
+ // type-assert its way into overriding a self-spawn block.
645
+ const exclude = parseExclusionPatterns(obj.exclude);
646
+ const include = parseProviderNameList(obj.include);
647
+ if (!exclude?.length && !include?.length)
648
+ return undefined;
649
+ return {
650
+ ...(exclude?.length ? { exclude } : {}),
651
+ ...(include?.length ? { include } : {}),
289
652
  };
290
653
  }
291
654
  // ---------------------------------------------------------------------------
292
655
  // Read (remediate gains this)
293
656
  // ---------------------------------------------------------------------------
294
657
  /**
295
- * Read + interpret the shared confirmation for `root`. THREE-valued (CE-012):
658
+ * Read + parse the shared confirmation for `root`. TWO-valued:
296
659
  *
297
660
  * - returns `null` when the artifact is ABSENT or MALFORMED — the caller then
298
661
  * resolves its provider independently, exactly as today (INV-DC1-6
299
662
  * never-block). Absence is the standalone-remediate case and is not an error.
300
- * - returns `{ status: 'confirmed', confirmation }` when the artifact is valid
301
- * AND the stamped roster still matches the currently-discovered roster — the
302
- * caller honors the recorded pool.
303
- * - returns `{ status: 'reconfirm', confirmation, reason }` when the artifact
304
- * is valid but the roster has CHANGED since it was written (a provider
305
- * appeared or disappeared) — a DISTINCT signal so the caller re-confirms
306
- * rather than pinning a stale pool (INV-DC2-3). This is the CE-012 third
307
- * state that keeps INV-DC2-3 from collapsing into the never-block `null`.
663
+ * - returns the parsed confirmation otherwise — the operator's persisted route
664
+ * DECISION, honored as-is.
665
+ *
666
+ * **Reach-free by construction (G3).** This read does NOT check whether the
667
+ * reachable backend set still matches whatever the writing auditor saw. It cannot
668
+ * meaningfully: a *different* auditor legitimately has different reach, so that
669
+ * comparison was noise cross-auditor — and answering it by discarding the
670
+ * operator's decision fails OPEN on a policy question. A backend becoming newly
671
+ * reachable is a real event, handled by the reconciliation gate
672
+ * ({@link computeNewlyReachableBackends}), which compares the DECISION against
673
+ * *this* auditor's reach and is keyed on `autonomous_mode`.
308
674
  *
309
675
  * Never throws: a read/parse failure is treated as absent/malformed → `null`.
310
676
  * The read is lockless (no lock needed: the writer's atomic rename guarantees a
311
677
  * complete file either way — CE-003) and so cannot deadlock against a writer.
312
678
  */
313
- export async function readSharedProviderConfirmation(root, sessionConfig = {}, env = process.env) {
314
- const path = sharedProviderConfirmationPath(root);
679
+ export async function readSharedProviderConfirmation(root) {
315
680
  let raw;
316
681
  try {
317
- raw = await readJsonFile(path);
682
+ raw = await readJsonFile(sharedProviderConfirmationPath(root));
318
683
  }
319
684
  catch {
320
685
  // Absent (ENOENT) OR unreadable / invalid-JSON both degrade to the
321
686
  // never-block path — a missing or corrupt artifact is never an error here.
322
687
  return null;
323
688
  }
324
- const confirmation = parseSharedProviderConfirmation(raw);
325
- if (confirmation === null) {
326
- // Malformed (wrong shape / version drift) → never-block.
327
- return null;
328
- }
329
- const current = currentProviderRoster(sessionConfig, env);
330
- if (!rostersEqual(confirmation.roster, current)) {
331
- return {
332
- status: "reconfirm",
333
- confirmation,
334
- reason: `discovered provider roster changed since confirmation ` +
335
- `(was [${confirmation.roster.join(", ")}], now [${current.join(", ")}])`,
336
- };
337
- }
338
- return { status: "confirmed", confirmation };
689
+ // Malformed (wrong shape / version drift) → never-block, but never SILENT (D).
690
+ const parsed = parseSharedProviderConfirmation(raw);
691
+ if (parsed === null)
692
+ warnConfirmationRejected(raw, root);
693
+ return parsed;
694
+ }
695
+ /**
696
+ * D — loud rejection. `null` from the parser is indistinguishable at the call sites
697
+ * from "no confirmation exists", and every consumer treats that as "no operator
698
+ * decision": empty cost positions, λ=0, no exclusions. So a `schema_version` bump —
699
+ * or any shape drift — would SILENTLY discard the operator's whole route decision
700
+ * and quietly re-route the run. Absence is legitimately silent; a file that EXISTS
701
+ * and was rejected is not.
702
+ *
703
+ * A warning, not a throw: INV-DC1-6 (never-block) is the standing invariant here —
704
+ * the same loud-degrade shape as `readQuotaStateOrDegrade` and the blind-dispatch
705
+ * warning. Reached only on the rejection path, so it cannot become hot.
706
+ */
707
+ function warnConfirmationRejected(raw, root) {
708
+ const version = raw !== null && typeof raw === "object" && !Array.isArray(raw)
709
+ ? raw.schema_version
710
+ : undefined;
711
+ const why = version !== undefined && version !== SHARED_PROVIDER_CONFIRMATION_VERSION
712
+ ? `schema_version "${String(version)}" != expected "${SHARED_PROVIDER_CONFIRMATION_VERSION}"`
713
+ : "malformed shape";
714
+ // Scoped to what the rejection ACTUALLY discards. Exclusions deliberately survive:
715
+ // `readConfirmedDispatchPolicy` bypasses this parser (and its version check) and reads
716
+ // `policy` straight off the raw JSON, so an exclusion keeps failing CLOSED through a
717
+ // schema drift — the correct direction, and the reason this message must NOT claim the
718
+ // pool is unfiltered. Saying otherwise sends the operator hunting a routing change that
719
+ // did not happen.
720
+ process.stderr.write(`WARNING: ignoring the provider confirmation at ${sharedProviderConfirmationPath(root)} ` +
721
+ `(${why}). The operator's confirmed COST ORDER and DISPATCH BIAS are NOT being applied ` +
722
+ `to this run — dispatch falls back to price-then-tier at the default bias. ` +
723
+ `(Exclusions are read separately and still apply.) Re-run the provider-confirmation ` +
724
+ `gate to rewrite it.
725
+ `);
339
726
  }
340
727
  /**
341
728
  * Read the operator-confirmed cost ordering (rung 1 of costRank; see
342
729
  * spec/cost-first-routing.md) from the shared Gate-0 confirmation as a model-keyed
343
730
  * `Map<model_id, cost_order>` for the dispatch build sites. Single-sourced so audit
344
731
  * and remediate honor it identically. Best-effort and never throws: an absent
345
- * `root`, a missing/malformed confirmation, or a roster that has since changed
346
- * (`reconfirm`) all yield an empty map — dispatch then falls to real price then
347
- * tier. Only a `confirmed` (roster-fresh) confirmation contributes positions.
732
+ * `root` or a missing/malformed confirmation yields an empty map — dispatch then
733
+ * falls to real price then tier.
734
+ *
735
+ * **Not gated on reach (G3 step 1).** The cost order is the operator's POLICY —
736
+ * "the operator may reorder" — so a shift in what happens to be reachable must not
737
+ * silently discard it. The former roster-freshness gate did exactly that, on the
738
+ * false premise that these positions are reach-derived; they are not, and it was
739
+ * the live defect this fixes.
348
740
  */
349
- export async function readConfirmedCostPositions(root, sessionConfig = {}, env = process.env) {
741
+ export async function readConfirmedCostPositions(root) {
350
742
  if (!root)
351
743
  return new Map();
352
- const read = await readSharedProviderConfirmation(root, sessionConfig, env);
353
- if (!read || read.status !== "confirmed")
744
+ const confirmation = await readSharedProviderConfirmation(root);
745
+ if (!confirmation)
354
746
  return new Map();
355
747
  // Provider-pool positions (configured models) PLUS any host-native tiers the
356
748
  // operator confirmed at Gate-0 (follow-up c). Both are model-keyed; a host tier
357
749
  // and a configured pool thread to dispatch identically. Host entries are already
358
750
  // in the single unified cost order, so a plain merge preserves the total order.
359
- const positions = resolveConfirmedCostPositions(read.confirmation.provider_pool);
360
- for (const entry of read.confirmation.host_model_cost_order ?? []) {
751
+ const positions = resolveConfirmedCostPositions(confirmation.provider_pool);
752
+ for (const entry of confirmation.host_model_cost_order ?? []) {
361
753
  if (entry.model_id &&
362
754
  Number.isFinite(entry.cost_order) &&
363
755
  entry.cost_order >= 0) {
@@ -368,7 +760,7 @@ export async function readConfirmedCostPositions(root, sessionConfig = {}, env =
368
760
  // position keyed on the source's model id — the SAME model-keyed lookup a repair-proxy
369
761
  // dispatch pool resolves against (pool.model = the namespaced `provider/model`). An entry
370
762
  // without a model_id is display-only and contributes no dispatch position.
371
- for (const entry of read.confirmation.source_pool_cost_order ?? []) {
763
+ for (const entry of confirmation.source_pool_cost_order ?? []) {
372
764
  if (entry.model_id &&
373
765
  Number.isFinite(entry.cost_order) &&
374
766
  entry.cost_order >= 0) {
@@ -381,17 +773,20 @@ export async function readConfirmedCostPositions(root, sessionConfig = {}, env =
381
773
  * Read the operator-confirmed cost↔speed dispatch bias (λ ∈ [0,1]) from the shared
382
774
  * Gate-0 confirmation for the dispatch build sites (spec/dispatch-cost-speed-dial.md).
383
775
  * Single-sourced so audit and remediate apply the identical operating point.
384
- * Best-effort and never throws: an absent `root`, a missing/malformed confirmation, a
385
- * roster that has since changed (`reconfirm`), or an absent field all yield the
386
- * cost-first default `0`. Only a `confirmed` (roster-fresh) confirmation contributes.
776
+ * Best-effort and never throws: an absent `root`, a missing/malformed confirmation,
777
+ * or an absent field all yield the cost-first default `0`.
778
+ *
779
+ * **Not gated on reach (G3 step 1)** — λ is the operator's durable operating point
780
+ * on the cost-vs-throughput frontier, i.e. POLICY. See
781
+ * {@link readConfirmedCostPositions}.
387
782
  */
388
- export async function readConfirmedDispatchBias(root, sessionConfig = {}, env = process.env) {
783
+ export async function readConfirmedDispatchBias(root) {
389
784
  if (!root)
390
785
  return 0;
391
- const read = await readSharedProviderConfirmation(root, sessionConfig, env);
392
- if (!read || read.status !== "confirmed")
786
+ const confirmation = await readSharedProviderConfirmation(root);
787
+ if (!confirmation)
393
788
  return 0;
394
- return clampDispatchBias(read.confirmation.dispatch_bias) ?? 0;
789
+ return clampDispatchBias(confirmation.dispatch_bias) ?? 0;
395
790
  }
396
791
  // ---------------------------------------------------------------------------
397
792
  // Interactive Gate-0 operator input (spec/cost-first-routing.md — Gate-0)
@@ -416,6 +811,9 @@ export function parseProviderConfirmationInput(value) {
416
811
  ? v
417
812
  : undefined;
418
813
  const costOrder = stringArray(obj.cost_order);
814
+ // No cast: `exclude` is the OPEN exclusion grammar, so asserting the operator's
815
+ // raw strings into the closed provider-name union would be a lie — and the exact
816
+ // type-assert-your-way-in move the policy parser refuses for `include`.
419
817
  const exclude = stringArray(obj.exclude);
420
818
  const include = stringArray(obj.include);
421
819
  const dispatchBias = clampDispatchBias(obj.dispatch_bias);
@@ -456,4 +854,27 @@ export async function readProviderConfirmationInput(artifactsDir) {
456
854
  }
457
855
  return parseProviderConfirmationInput(raw);
458
856
  }
857
+ /**
858
+ * Invalidate a CONSUMED Gate-0 input by deleting it — the second half of
859
+ * consume-and-invalidate, paired here with {@link readProviderConfirmationInput} so
860
+ * the two cannot drift apart.
861
+ *
862
+ * The input's presence is the "operator has acted" signal the gate reads to decide
863
+ * emit-vs-consume. Once promoted into the canonical artifacts it is SPENT: leaving it
864
+ * on disk means a later reconciliation delta silently re-consumes a submission that
865
+ * answered an older question, auto-satisfying the gate instead of asking the
866
+ * operator. Deleting it is what makes the gate able to fire a second time at all.
867
+ *
868
+ * Best-effort and never throws: an already-absent file is the desired end state, and
869
+ * a failed unlink must not break the in-flight obligation (the promotion itself
870
+ * already succeeded).
871
+ */
872
+ export async function unlinkProviderConfirmationInput(artifactsDir) {
873
+ try {
874
+ await unlink(join(artifactsDir, PROVIDER_CONFIRMATION_INPUT_FILENAME));
875
+ }
876
+ catch {
877
+ // Absent / locked / read-only — nothing to invalidate, or nothing we can do.
878
+ }
879
+ }
459
880
  //# sourceMappingURL=sharedProviderConfirmation.js.map