audit-tools 0.34.3 → 0.34.6

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 (93) hide show
  1. package/LICENSE +1 -1
  2. package/dist/audit/cli/dispatch/hostFanoutGate.d.ts.map +1 -1
  3. package/dist/audit/cli/dispatch/hostFanoutGate.js +6 -0
  4. package/dist/audit/cli/dispatch/hostFanoutGate.js.map +1 -1
  5. package/dist/audit/cli/dispatch/quotaPool.d.ts +6 -0
  6. package/dist/audit/cli/dispatch/quotaPool.d.ts.map +1 -1
  7. package/dist/audit/cli/dispatch/quotaPool.js +7 -8
  8. package/dist/audit/cli/dispatch/quotaPool.js.map +1 -1
  9. package/dist/audit/cli/dispatch.d.ts.map +1 -1
  10. package/dist/audit/cli/dispatch.js +6 -1
  11. package/dist/audit/cli/dispatch.js.map +1 -1
  12. package/dist/audit/cli/hybridDispatch.d.ts +13 -3
  13. package/dist/audit/cli/hybridDispatch.d.ts.map +1 -1
  14. package/dist/audit/cli/hybridDispatch.js +12 -6
  15. package/dist/audit/cli/hybridDispatch.js.map +1 -1
  16. package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
  17. package/dist/audit/cli/nextStepCommand.js +40 -2
  18. package/dist/audit/cli/nextStepCommand.js.map +1 -1
  19. package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
  20. package/dist/audit/cli/nextStepHelpers.js +61 -6
  21. package/dist/audit/cli/nextStepHelpers.js.map +1 -1
  22. package/dist/audit/cli/providerConfirmationStep.d.ts +52 -0
  23. package/dist/audit/cli/providerConfirmationStep.d.ts.map +1 -1
  24. package/dist/audit/cli/providerConfirmationStep.js +224 -55
  25. package/dist/audit/cli/providerConfirmationStep.js.map +1 -1
  26. package/dist/audit/cli/quotaCommand.d.ts.map +1 -1
  27. package/dist/audit/cli/quotaCommand.js +5 -2
  28. package/dist/audit/cli/quotaCommand.js.map +1 -1
  29. package/dist/audit/orchestrator/advance.d.ts.map +1 -1
  30. package/dist/audit/orchestrator/advance.js +13 -0
  31. package/dist/audit/orchestrator/advance.js.map +1 -1
  32. package/dist/audit/orchestrator/advanceTypes.d.ts +12 -0
  33. package/dist/audit/orchestrator/advanceTypes.d.ts.map +1 -1
  34. package/dist/audit/orchestrator/intakeExecutors.d.ts +20 -8
  35. package/dist/audit/orchestrator/intakeExecutors.d.ts.map +1 -1
  36. package/dist/audit/orchestrator/intakeExecutors.js +215 -26
  37. package/dist/audit/orchestrator/intakeExecutors.js.map +1 -1
  38. package/dist/audit/orchestrator/nextStep.d.ts +6 -0
  39. package/dist/audit/orchestrator/nextStep.d.ts.map +1 -1
  40. package/dist/audit/orchestrator/nextStep.js +3 -0
  41. package/dist/audit/orchestrator/nextStep.js.map +1 -1
  42. package/dist/audit/orchestrator/state.d.ts +22 -0
  43. package/dist/audit/orchestrator/state.d.ts.map +1 -1
  44. package/dist/audit/orchestrator/state.js +16 -4
  45. package/dist/audit/orchestrator/state.js.map +1 -1
  46. package/dist/audit/types/externalAnalyzer.d.ts +6 -6
  47. package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
  48. package/dist/remediate/steps/contractPipeline.js +6 -0
  49. package/dist/remediate/steps/contractPipeline.js.map +1 -1
  50. package/dist/remediate/steps/dispatch/marshal.d.ts.map +1 -1
  51. package/dist/remediate/steps/dispatch/marshal.js +6 -1
  52. package/dist/remediate/steps/dispatch/marshal.js.map +1 -1
  53. package/dist/remediate/steps/dispatch/waveScheduling.d.ts +34 -2
  54. package/dist/remediate/steps/dispatch/waveScheduling.d.ts.map +1 -1
  55. package/dist/remediate/steps/dispatch/waveScheduling.js +18 -5
  56. package/dist/remediate/steps/dispatch/waveScheduling.js.map +1 -1
  57. package/dist/remediate/steps/nextStep.d.ts.map +1 -1
  58. package/dist/remediate/steps/nextStep.js +20 -5
  59. package/dist/remediate/steps/nextStep.js.map +1 -1
  60. package/dist/shared/dispatch/admissionLoop.d.ts +15 -2
  61. package/dist/shared/dispatch/admissionLoop.d.ts.map +1 -1
  62. package/dist/shared/dispatch/admissionLoop.js +21 -17
  63. package/dist/shared/dispatch/admissionLoop.js.map +1 -1
  64. package/dist/shared/friction/stepBoundaryCapture.d.ts +57 -1
  65. package/dist/shared/friction/stepBoundaryCapture.d.ts.map +1 -1
  66. package/dist/shared/friction/stepBoundaryCapture.js +89 -0
  67. package/dist/shared/friction/stepBoundaryCapture.js.map +1 -1
  68. package/dist/shared/index.d.ts +5 -4
  69. package/dist/shared/index.d.ts.map +1 -1
  70. package/dist/shared/index.js +4 -7
  71. package/dist/shared/index.js.map +1 -1
  72. package/dist/shared/providers/identity.d.ts +18 -7
  73. package/dist/shared/providers/identity.d.ts.map +1 -1
  74. package/dist/shared/providers/identity.js +23 -8
  75. package/dist/shared/providers/identity.js.map +1 -1
  76. package/dist/shared/providers/providerConfirmation.d.ts.map +1 -1
  77. package/dist/shared/providers/providerConfirmation.js +36 -1
  78. package/dist/shared/providers/providerConfirmation.js.map +1 -1
  79. package/dist/shared/providers/sharedProviderConfirmation.d.ts +359 -20
  80. package/dist/shared/providers/sharedProviderConfirmation.d.ts.map +1 -1
  81. package/dist/shared/providers/sharedProviderConfirmation.js +820 -43
  82. package/dist/shared/providers/sharedProviderConfirmation.js.map +1 -1
  83. package/dist/shared/quota/apiPool.d.ts +45 -2
  84. package/dist/shared/quota/apiPool.d.ts.map +1 -1
  85. package/dist/shared/quota/apiPool.js +74 -7
  86. package/dist/shared/quota/apiPool.js.map +1 -1
  87. package/dist/shared/quota/hostPool.d.ts +8 -0
  88. package/dist/shared/quota/hostPool.d.ts.map +1 -1
  89. package/dist/shared/quota/hostPool.js +1 -0
  90. package/dist/shared/quota/hostPool.js.map +1 -1
  91. package/dist/shared/types/providerConfirmation.d.ts +55 -2
  92. package/dist/shared/types/providerConfirmation.d.ts.map +1 -1
  93. package/package.json +1 -1
@@ -47,8 +47,10 @@ import { auditToolsDir } from "../io/auditToolsPaths.js";
47
47
  import { readJsonFile, writeJsonFile } from "../io/json.js";
48
48
  import { withFileLock } from "../quota/fileLock.js";
49
49
  import { discoverProviders, annotateConfirmedPool, representativeModelId, } from "./providerConfirmation.js";
50
- import { backendIdentity, transportRoute } from "./identity.js";
50
+ import { backendIdentity, exclusionPattern, serviceExclusionPattern, } from "./identity.js";
51
51
  import { resolveConfirmedCostPositions } from "../dispatch/costRank.js";
52
+ import { gatherDispatchableSources } from "../quota/apiPool.js";
53
+ import { resolveFreshSessionProviderName } from "./providerFactory.js";
52
54
  import { PROVIDER_CONFIRMATION_INPUT_VERSION } from "../types/providerConfirmation.js";
53
55
  // ---------------------------------------------------------------------------
54
56
  // Version + on-disk location
@@ -98,6 +100,10 @@ function toPersistedPoolEntry(entry) {
98
100
  name: entry.name,
99
101
  ...(entry.model_id !== undefined ? { model_id: entry.model_id } : {}),
100
102
  ...(entry.cost_order !== undefined ? { cost_order: entry.cost_order } : {}),
103
+ // The capability-evidence decision persists (it is a DECISION, not reach) — and it
104
+ // must be carried explicitly: this builder reconstructs field-by-field, so a field
105
+ // absent here is silently dropped on every round-trip.
106
+ ...(entry.capability_rank !== undefined ? { capability_rank: entry.capability_rank } : {}),
101
107
  };
102
108
  }
103
109
  function sortNames(names) {
@@ -144,9 +150,24 @@ function sortStrings(values) {
144
150
  * passed via the dedicated params above (the executor forwards them from the
145
151
  * same input), so this arg governs ordering + host roster only.
146
152
  */
147
- export function buildProviderConfirmationRender(sessionConfig = {}, env = process.env, exclude = [], include = [], detectCommand, input, sources = []) {
153
+ export function buildProviderConfirmationRender(sessionConfig = {}, env = process.env, exclude = [], include = [], detectCommand, input, sources = [],
154
+ /**
155
+ * Gate-authored fail-closed patterns, kept SEPARATE from the operator's `exclude`
156
+ * so provenance survives to disk (see {@link ConfirmedDispatchPolicy.auto_exclude}).
157
+ * Both kinds mark a pool entry excluded in the render — the split governs lifetime,
158
+ * not enforcement — so the two are unioned for the display/routing decision below
159
+ * and only split again when the policy is persisted.
160
+ */
161
+ autoExclude = [],
162
+ /**
163
+ * R3-3: the FINAL `capability_order_llm_ranked` set for this promotion (already
164
+ * advanced across rule 1/2 by the caller — {@link advanceCapabilityOrderLlmRanked}).
165
+ * Persisted verbatim into the policy; empty for the display-only "suggested"
166
+ * render nextStepCommand builds (that call never persists, so provenance is moot).
167
+ */
168
+ capabilityOrderLlmRanked = []) {
148
169
  const discovered = discoverProviders(sessionConfig, env, detectCommand);
149
- const operatorExcluded = buildExclusion(exclude);
170
+ const operatorExcluded = buildExclusion(migrateExclusionPatterns([...exclude, ...autoExclude]));
150
171
  const includeSet = new Set(include);
151
172
  // Evaluate a pool entry against the operator's rules at the SAME key the routing
152
173
  // filter uses — `representativeModelId` is what `computeNewlyReachableBackends`
@@ -212,7 +233,11 @@ export function buildProviderConfirmationRender(sessionConfig = {}, env = proces
212
233
  ...(clampDispatchBias(input?.dispatch_bias) != null
213
234
  ? { dispatch_bias: clampDispatchBias(input?.dispatch_bias) }
214
235
  : {}),
215
- ...(buildConfirmedDispatchPolicy(exclude, include) ?? {}),
236
+ ...(buildConfirmedDispatchPolicy(exclude, include, autoExclude,
237
+ // The operator's RAW capability answer, persisted verbatim so the next
238
+ // carry-forward reads back what they SAID rather than re-deriving it from the
239
+ // ranks it produced (which cannot distinguish it from external evidence).
240
+ input?.capability_order ?? [], capabilityOrderLlmRanked) ?? {}),
216
241
  };
217
242
  }
218
243
  /**
@@ -220,13 +245,28 @@ export function buildProviderConfirmationRender(sessionConfig = {}, env = proces
220
245
  * persisted policy half. Returns `undefined` when the operator named neither list
221
246
  * (so the field stays absent rather than persisting an empty shell).
222
247
  */
223
- function buildConfirmedDispatchPolicy(exclude, include) {
224
- if (exclude.length === 0 && include.length === 0)
248
+ function buildConfirmedDispatchPolicy(exclude, include, autoExclude = [], capabilityOrder = [], capabilityOrderLlmRanked = []) {
249
+ if (exclude.length === 0 &&
250
+ include.length === 0 &&
251
+ autoExclude.length === 0 &&
252
+ capabilityOrder.length === 0 &&
253
+ capabilityOrderLlmRanked.length === 0) {
225
254
  return undefined;
255
+ }
226
256
  return {
227
257
  policy: {
228
258
  ...(exclude.length > 0 ? { exclude: sortStrings(exclude) } : {}),
259
+ // Sorted like its sibling — an auto-exclusion is a set membership, order-free.
260
+ ...(autoExclude.length > 0 ? { auto_exclude: sortStrings(autoExclude) } : {}),
229
261
  ...(include.length > 0 ? { include: sortNames(include) } : {}),
262
+ // NOT sorted, NOT deduped by sortStrings: this is a positional ORDERING, and
263
+ // sorting it would destroy the operator's answer outright.
264
+ ...(capabilityOrder.length > 0 ? { capability_order: [...capabilityOrder] } : {}),
265
+ // An AUTHORSHIP SET, not an ordering — sorted like `exclude`/`auto_exclude` so
266
+ // an incidentally-ordered array never churns the artifact's content hash.
267
+ ...(capabilityOrderLlmRanked.length > 0
268
+ ? { capability_order_llm_ranked: sortStrings(capabilityOrderLlmRanked) }
269
+ : {}),
230
270
  },
231
271
  };
232
272
  }
@@ -332,7 +372,8 @@ export function computeNewlyReachableBackends(confirmation, sessionConfig, sourc
332
372
  // source distinguishes them. The identity keys on the backend, the rule on the
333
373
  // transport — see `backendIdentity`.
334
374
  const record = (modelId, provider, backendProvider) => {
335
- const identity = backendIdentity(modelId, backendProvider ?? provider);
375
+ const service = backendProvider ?? provider;
376
+ const identity = backendIdentity(modelId, service);
336
377
  // First writer wins: when a proxied lane and a direct lane resolve to the SAME
337
378
  // backend identity, they are one backend reached two ways, and the rule kept is
338
379
  // the one that rules out the lane already recorded.
@@ -341,7 +382,9 @@ export function computeNewlyReachableBackends(confirmation, sessionConfig, sourc
341
382
  reachNow.set(identity, {
342
383
  key: identity,
343
384
  provider,
344
- exclusion_pattern: transportRoute(modelId, provider),
385
+ service,
386
+ exclusion_pattern: exclusionPattern(modelId, provider),
387
+ service_exclusion_pattern: serviceExclusionPattern(modelId, service),
345
388
  });
346
389
  };
347
390
  for (const provider of discoverProviders(sessionConfig, env, detectCommand)) {
@@ -377,11 +420,17 @@ export function computeNewlyReachableBackends(confirmation, sessionConfig, sourc
377
420
  */
378
421
  export function resolveDispatchExclusion(policy, env = process.env) {
379
422
  const included = new Set(policy?.include ?? []);
380
- // The local reach half: a self-spawn-blocked provider is ruled out at PROVIDER
381
- // granularity (blockedness is a property of the provider, not of one of its
382
- // models), recomputed against THIS process's env rather than inherited.
423
+ // The local reach half: a self-spawn-blocked provider is ruled out at the
424
+ // TRANSPORT axis (blockedness is a property of the process, not the vendor),
425
+ // recomputed against THIS process's env rather than inherited.
383
426
  const blocked = RESOLVED_PROVIDER_NAMES.filter((name) => !included.has(name) && isSelfSpawnBlocked(name, env));
384
- return buildExclusion([...(policy?.exclude ?? []), ...blocked]);
427
+ // Both provenances are enforced identically — the split is about LIFETIME (which
428
+ // survives the next submission), never about which patterns bite at dispatch.
429
+ return buildExclusion(migrateExclusionPatterns([
430
+ ...(policy?.exclude ?? []),
431
+ ...(policy?.auto_exclude ?? []),
432
+ ...blocked.map((name) => `transport:${name}`),
433
+ ]));
385
434
  }
386
435
  /**
387
436
  * The pure-policy matcher: the operator's patterns and nothing else. Split out
@@ -391,44 +440,109 @@ export function resolveDispatchExclusion(policy, env = process.env) {
391
440
  * `excluded` verdict.
392
441
  */
393
442
  function buildExclusion(patterns) {
394
- const rules = patterns.map(parseExclusionRule);
395
- return { excludes: (backend) => rules.some((rule) => ruleMatches(rule, backend)) };
443
+ // Keep each rule beside the pattern it came from: the capacity guard reports the
444
+ // operator's ORIGINAL text, not a re-rendering of the parsed rule (a round-trip
445
+ // through `ExclusionRule` would show them a string they never wrote).
446
+ const rules = patterns.map((pattern) => ({ pattern, rule: parseExclusionRule(pattern) }));
447
+ const excludedBy = (backend) => rules.find(({ rule }) => ruleMatches(rule, backend))?.pattern ?? null;
448
+ // `excludes` is DERIVED, never a parallel implementation — the two verdicts cannot drift.
449
+ return { excludes: (backend) => excludedBy(backend) !== null, excludedBy };
396
450
  }
451
+ /** The three recognized axis prefixes. */
452
+ const VALID_EXCLUSION_AXES = new Set(["transport", "service", "host"]);
397
453
  function parseExclusionRule(pattern) {
398
454
  const colon = pattern.indexOf(":");
399
455
  if (colon === -1) {
456
+ // No axis prefix — invalid under the axis-explicit grammar.
457
+ return { kind: "invalid", raw: pattern };
458
+ }
459
+ const axis = pattern.slice(0, colon);
460
+ const rest = pattern.slice(colon + 1);
461
+ if (!VALID_EXCLUSION_AXES.has(axis) || rest.length === 0) {
462
+ return { kind: "invalid", raw: pattern };
463
+ }
464
+ switch (axis) {
465
+ case "transport": {
466
+ const slash = rest.indexOf("/");
467
+ if (slash === -1)
468
+ return { kind: "transport", transport: rest };
469
+ if (slash === rest.length - 1)
470
+ return { kind: "transport", transport: rest.slice(0, slash) };
471
+ return { kind: "transport_model", transport: rest.slice(0, slash), model: rest.slice(slash + 1) };
472
+ }
473
+ case "service": {
474
+ const slash = rest.indexOf("/");
475
+ if (slash === -1)
476
+ return { kind: "service", service: rest };
477
+ if (slash === rest.length - 1)
478
+ return { kind: "service", service: rest.slice(0, slash) };
479
+ return { kind: "service_model", service: rest.slice(0, slash), model: rest.slice(slash + 1) };
480
+ }
481
+ case "host":
482
+ return { kind: "host", host: rest.toLowerCase() };
483
+ default:
484
+ return { kind: "invalid", raw: pattern };
485
+ }
486
+ }
487
+ /**
488
+ * Migrate persisted bare-form exclusion patterns (pre-stage-4) to the
489
+ * axis-explicit grammar. The old grammar was unambiguous within its own rules
490
+ * (head token against the closed provider set), so the migration reproduces
491
+ * exactly what the old parser would have inferred, then emits the explicit form.
492
+ *
493
+ * Applied at read time in {@link resolveDispatchExclusion}. A re-confirmation
494
+ * (any Gate-0 delta) persists new-form patterns naturally because the pattern
495
+ * generator now emits prefixed strings.
496
+ */
497
+ function migrateExclusionPatterns(patterns) {
498
+ return patterns.map(migrateExclusionPattern);
499
+ }
500
+ function migrateExclusionPattern(pattern) {
501
+ // Already axis-explicit — no migration needed.
502
+ if (VALID_EXCLUSION_AXES.has(pattern.slice(0, pattern.indexOf(":"))) && pattern.indexOf(":") > 0) {
503
+ return pattern;
504
+ }
505
+ const colon = pattern.indexOf(":");
506
+ if (colon === -1) {
507
+ // Bare token: `codex` → `transport:codex`, `localhost` → `host:localhost`
400
508
  return isResolvedProviderName(pattern)
401
- ? { kind: "provider", provider: pattern }
402
- : { kind: "endpoint", host: pattern.toLowerCase() };
509
+ ? `transport:${pattern}`
510
+ : `host:${pattern}`;
403
511
  }
404
512
  const head = pattern.slice(0, colon);
405
513
  const tail = pattern.slice(colon + 1);
406
- if (!isResolvedProviderName(head)) {
407
- return { kind: "endpoint", host: pattern.toLowerCase() };
514
+ if (isResolvedProviderName(head)) {
515
+ // `openai-compatible:model-a` `transport:openai-compatible/model-a`
516
+ // `codex:` (empty tail) → `transport:codex`
517
+ return tail.length > 0
518
+ ? `transport:${head}/${tail}`
519
+ : `transport:${head}`;
408
520
  }
409
- // The head decides the tier — an empty tail does NOT demote a provider-name head
410
- // to the endpoint tier. `codex:` reads as "codex, every model"; classifying it as
411
- // an (unmatchable) endpoint rule would silently drop the operator's intent, and
412
- // the head-decides rule this type documents would not actually hold.
413
- return tail.length > 0
414
- ? { kind: "provider_model", provider: head, model: tail }
415
- : { kind: "provider", provider: head };
521
+ // `integrate.api.nvidia.com` / `localhost:8000` `host:<pattern>`
522
+ return `host:${pattern}`;
416
523
  }
417
524
  function isResolvedProviderName(value) {
418
525
  return RESOLVED_PROVIDER_NAMES.includes(value);
419
526
  }
420
527
  function ruleMatches(rule, backend) {
421
528
  switch (rule.kind) {
422
- case "provider":
423
- return backend.transport === rule.provider;
424
- case "provider_model":
529
+ case "transport":
530
+ return backend.transport === rule.transport;
531
+ case "transport_model":
425
532
  // A model-granular rule matches ONLY that model. A backend of the same
426
- // provider carrying no model (a CLI whose model arrives at the dispatch
533
+ // transport carrying no model (a CLI whose model arrives at the dispatch
427
534
  // handshake) is NOT matched: the operator ruled out one model, not the
428
- // backend — the coarse `provider` tier is how they rule out the backend.
429
- return backend.transport === rule.provider && backend.model === rule.model;
430
- case "endpoint":
535
+ // backend — the coarse `transport` tier is how they rule out the backend.
536
+ return backend.transport === rule.transport && backend.model === rule.model;
537
+ case "service":
538
+ return (backend.service ?? backend.transport) === rule.service;
539
+ case "service_model":
540
+ return ((backend.service ?? backend.transport) === rule.service &&
541
+ backend.model === rule.model);
542
+ case "host":
431
543
  return endpointHosts(backend.endpoint).includes(rule.host);
544
+ case "invalid":
545
+ return false;
432
546
  }
433
547
  }
434
548
  /**
@@ -643,12 +757,39 @@ function parseConfirmedDispatchPolicy(value) {
643
757
  // CLOSED provider-name set and stays membership-checked, so an unknown name cannot
644
758
  // type-assert its way into overriding a self-spawn block.
645
759
  const exclude = parseExclusionPatterns(obj.exclude);
760
+ const autoExclude = parseExclusionPatterns(obj.auto_exclude);
646
761
  const include = parseProviderNameList(obj.include);
647
- if (!exclude?.length && !include?.length)
762
+ // Same open-grammar treatment as `exclude`: a capability key is a model id, not a
763
+ // member of any closed set, so it is kept verbatim and an unmatchable key is inert.
764
+ const capabilityOrder = Array.isArray(obj.capability_order)
765
+ ? obj.capability_order.filter((v) => typeof v === "string" && v.trim().length > 0)
766
+ : undefined;
767
+ // R3-3 provenance — same open-grammar, verbatim treatment: a set of model ids, not
768
+ // itself a positional ordering, so order within the parsed array is immaterial (the
769
+ // writer sorts it anyway). Must be reconstructed here explicitly like every other
770
+ // field on this parser: an unlisted field is silently dropped on every round-trip,
771
+ // and dropping this one is not a degrade — it reverts every LLM-ranked id to
772
+ // "unattributed" (still a valid rank, just an authorship the next operator
773
+ // repositioning would then wrongly discard-and-report as a moved anchor instead of
774
+ // silently honoring — see `capabilityOrderNonAnchors`).
775
+ const capabilityOrderLlmRanked = Array.isArray(obj.capability_order_llm_ranked)
776
+ ? obj.capability_order_llm_ranked.filter((v) => typeof v === "string" && v.trim().length > 0)
777
+ : undefined;
778
+ if (!exclude?.length &&
779
+ !autoExclude?.length &&
780
+ !include?.length &&
781
+ !capabilityOrder?.length &&
782
+ !capabilityOrderLlmRanked?.length) {
648
783
  return undefined;
784
+ }
649
785
  return {
650
786
  ...(exclude?.length ? { exclude } : {}),
787
+ ...(autoExclude?.length ? { auto_exclude: autoExclude } : {}),
651
788
  ...(include?.length ? { include } : {}),
789
+ ...(capabilityOrder?.length ? { capability_order: capabilityOrder } : {}),
790
+ ...(capabilityOrderLlmRanked?.length
791
+ ? { capability_order_llm_ranked: capabilityOrderLlmRanked }
792
+ : {}),
652
793
  };
653
794
  }
654
795
  // ---------------------------------------------------------------------------
@@ -681,9 +822,20 @@ export async function readSharedProviderConfirmation(root) {
681
822
  try {
682
823
  raw = await readJsonFile(sharedProviderConfirmationPath(root));
683
824
  }
684
- catch {
685
- // Absent (ENOENT) OR unreadable / invalid-JSON both degrade to the
686
- // never-block path — a missing or corrupt artifact is never an error here.
825
+ catch (error) {
826
+ // Both degrade to the never-block path, but only ABSENCE is legitimately silent.
827
+ //
828
+ // A file that EXISTS and cannot be read (truncated, invalid JSON, permissions) used
829
+ // to return `null` with no warning, and that composed with two other
830
+ // individually-justified silences into TOTAL silence: no pool gets a rank ⇒
831
+ // `anyBanded === false`, which by design suppresses the capability fail-open
832
+ // reporter; and `resolveUnevidencedCapabilityPools` returns `[]` on a null
833
+ // confirmation ⇒ the obligation reports SATISFIED. Net effect on a corrupt file: the
834
+ // capability floor is globally inert, every `deep` packet routes anywhere, and not
835
+ // one path says a word — the exact case the loud path was built for.
836
+ if (error?.code !== "ENOENT") {
837
+ warnConfirmationUnreadable(error, root);
838
+ }
687
839
  return null;
688
840
  }
689
841
  // Malformed (wrong shape / version drift) → never-block, but never SILENT (D).
@@ -704,6 +856,25 @@ export async function readSharedProviderConfirmation(root) {
704
856
  * the same loud-degrade shape as `readQuotaStateOrDegrade` and the blind-dispatch
705
857
  * warning. Reached only on the rejection path, so it cannot become hot.
706
858
  */
859
+ /**
860
+ * The sibling of {@link warnConfirmationRejected} for a file that exists but cannot be
861
+ * READ (truncated write, invalid JSON, permissions) — the case that used to degrade
862
+ * silently. Same never-block contract, same loud-degrade shape.
863
+ *
864
+ * The message names the CAPABILITY consequence explicitly because that is the one a
865
+ * corrupt file silences most dangerously: with no ranks, nothing bands, the fail-open
866
+ * reporter self-suppresses (it fires only when something else banded), and the
867
+ * capability obligation simultaneously reports satisfied. Nothing else would tell the
868
+ * operator their capability floor stopped existing.
869
+ */
870
+ function warnConfirmationUnreadable(error, root) {
871
+ const why = error instanceof Error ? error.message : String(error);
872
+ process.stderr.write(`WARNING: the provider confirmation at ${sharedProviderConfirmationPath(root)} exists ` +
873
+ `but could NOT be read (${why}). It is being treated as absent: the operator's ` +
874
+ `confirmed cost order, dispatch bias, and CAPABILITY RANKS are all unavailable, so ` +
875
+ `the admission capability floor is inert for this run and packets may route to pools ` +
876
+ `above their capability. Repair or delete the file and re-confirm at Gate-0.\n`);
877
+ }
707
878
  function warnConfirmationRejected(raw, root) {
708
879
  const version = raw !== null && typeof raw === "object" && !Array.isArray(raw)
709
880
  ? raw.schema_version
@@ -779,6 +950,121 @@ export async function readConfirmedCostPositions(root) {
779
950
  }
780
951
  return positions;
781
952
  }
953
+ /**
954
+ * THE enumeration of every persisted entry that can carry a `capability_rank`, across
955
+ * all three confirmation arrays.
956
+ *
957
+ * Single-sourced deliberately, and this is the fix for the round-3 critical defect: the
958
+ * rank JOIN read three arrays while the evidence OBLIGATION enumerated only two, so
959
+ * `provider_pool` was a rank SOURCE but never a delta SUBJECT — the conversation-first
960
+ * default (host pool, no volunteered roster) therefore banded `null` forever and the
961
+ * gate never once asked about it. Two independent walks over "the same" set is exactly
962
+ * the drift this project keeps paying for; with one walk, a new rank-bearing array is
963
+ * added HERE and both consumers follow automatically. Do not re-inline either walk.
964
+ */
965
+ function* capabilitySubjects(confirmation) {
966
+ for (const entry of confirmation.provider_pool ?? []) {
967
+ yield { modelId: entry.model_id, rank: entry.capability_rank, excluded: entry.excluded === true };
968
+ }
969
+ for (const entry of confirmation.host_model_cost_order ?? []) {
970
+ yield { modelId: entry.model_id, rank: entry.capability_rank, excluded: false };
971
+ }
972
+ // `SourcePoolCostEntry.capability_rank` has existed and been WRITTEN since the Gate-0
973
+ // source fold (providerConfirmation.ts) with no reader at all. This is that reader.
974
+ for (const entry of confirmation.source_pool_cost_order ?? []) {
975
+ yield { modelId: entry.model_id, rank: entry.capability_rank, excluded: false };
976
+ }
977
+ }
978
+ /**
979
+ * Is this a usable capability rank? A rank is a position in a relative ordering: finite
980
+ * and non-negative. Shared by the join and the delta so "evidenced" means the same thing
981
+ * to both — a predicate they disagreed on would re-create the drift above in miniature.
982
+ */
983
+ function isUsableRank(rank) {
984
+ return rank !== undefined && Number.isFinite(rank) && rank >= 0;
985
+ }
986
+ export async function readConfirmedCapabilityRanks(root) {
987
+ if (!root)
988
+ return new Map();
989
+ const confirmation = await readSharedProviderConfirmation(root);
990
+ if (!confirmation)
991
+ return new Map();
992
+ const ranks = new Map();
993
+ for (const subject of capabilitySubjects(confirmation)) {
994
+ // An entry without a model_id is display-only and contributes no dispatch rank (it
995
+ // is unjoinable by the model-keyed lookup — the infinite-re-prompt trap).
996
+ if (!subject.modelId || !isUsableRank(subject.rank))
997
+ continue;
998
+ ranks.set(subject.modelId, subject.rank);
999
+ }
1000
+ return ranks;
1001
+ }
1002
+ /**
1003
+ * The capability-evidence delta: dispatchable models with NO resolvable capability
1004
+ * rank. Computed once per invocation (it reads the confirmation + gathers sources)
1005
+ * and threaded by reference on the gate, exactly like the reach delta.
1006
+ *
1007
+ * "Evidenced" is deliberately defined as **the dispatch join resolves** — the same
1008
+ * lookup the pool constructors take ({@link readConfirmedCapabilityRanks} keyed on the
1009
+ * pool's model), never a parallel predicate. Two consequences, both load-bearing:
1010
+ * - a pool with NO model is skipped entirely. It is unjoinable, so pinning it could
1011
+ * never clear the delta and it would re-prompt forever.
1012
+ * - external evidence (`source.capability_rank`) counts, so a fully-ranked roster
1013
+ * never fires the gate at all.
1014
+ *
1015
+ * Returns [] when no confirmation exists yet — the first-time `missing` case already
1016
+ * pauses for the operator, and reporting a delta against a pool they have never seen
1017
+ * would fold a second question into a prompt that has not asked the first one yet.
1018
+ *
1019
+ * Lives HERE, beside {@link readConfirmedCapabilityRanks}, rather than in the audit CLI
1020
+ * command it is called from: its failure mode is a LIVELOCK (wrongly admitting an
1021
+ * unrankable pool re-prompts `provider_confirmation` forever), and a delta computation
1022
+ * with that failure mode must be reachable by a test.
1023
+ */
1024
+ export async function resolveUnevidencedCapabilityPools(root, effectiveConfig) {
1025
+ const confirmation = await readSharedProviderConfirmation(root);
1026
+ if (!confirmation)
1027
+ return [];
1028
+ const primaryProviderName = resolveFreshSessionProviderName(undefined, effectiveConfig, { env: process.env });
1029
+ const sources = await gatherDispatchableSources(effectiveConfig, primaryProviderName);
1030
+ const confirmedRanks = await readConfirmedCapabilityRanks(root);
1031
+ const unevidenced = new Set();
1032
+ for (const source of sources) {
1033
+ // Unjoinable ⇒ unpinnable ⇒ never admitted to the delta (see above).
1034
+ if (!source.model)
1035
+ continue;
1036
+ if (source.capability_rank != null)
1037
+ continue;
1038
+ if (confirmedRanks.has(source.model))
1039
+ continue;
1040
+ unevidenced.add(source.model);
1041
+ }
1042
+ // Every PERSISTED rank-bearing entry, walked through the SAME enumeration the join
1043
+ // uses ({@link capabilitySubjects}) so the two can never disagree about what the
1044
+ // subjects are. Host models are ranked exactly like any other model — the host is not
1045
+ // a special case, it was simply never looked up — and `provider_pool` is included
1046
+ // here, which it previously was not (the round-3 critical defect: the default
1047
+ // conversation-first pool was unrankable AND unpinnable, so the fail-open it caused
1048
+ // had no road to a fix).
1049
+ for (const subject of capabilitySubjects(confirmation)) {
1050
+ // Unjoinable ⇒ unpinnable ⇒ never admitted to the delta, same rule as sources.
1051
+ if (!subject.modelId)
1052
+ continue;
1053
+ // An excluded pool never dispatches, so it needs no capability evidence — asking
1054
+ // about it would be a question whose answer changes nothing.
1055
+ if (subject.excluded)
1056
+ continue;
1057
+ if (isUsableRank(subject.rank))
1058
+ continue;
1059
+ if (confirmedRanks.has(subject.modelId))
1060
+ continue;
1061
+ unevidenced.add(subject.modelId);
1062
+ }
1063
+ // Stable, content-derived order (never gather/iteration order) — this string list
1064
+ // reaches the obligation's reason text and the prompt, and an incidentally-ordered
1065
+ // array churns downstream content hashes.
1066
+ return [...unevidenced].sort();
1067
+ }
782
1068
  /**
783
1069
  * Read the operator-confirmed cost↔speed dispatch bias (λ ∈ [0,1]) from the shared
784
1070
  * Gate-0 confirmation for the dispatch build sites (spec/dispatch-cost-speed-dial.md).
@@ -821,33 +1107,524 @@ export function parseProviderConfirmationInput(value) {
821
1107
  ? v
822
1108
  : undefined;
823
1109
  const costOrder = stringArray(obj.cost_order);
1110
+ // The capability-evidence answer. MUST be reconstructed here explicitly: this parser
1111
+ // is field-by-field, so an unlisted field is silently dropped — and dropping THIS one
1112
+ // is not a degrade, it is a livelock. The operator answers the prompt, the answer
1113
+ // never reaches `annotateConfirmedPool`, no `capability_rank` is written, the delta
1114
+ // recomputes identical, and `provider_confirmation` (PRIORITY[0]) re-prompts the same
1115
+ // question forever. Any future field on ProviderConfirmationInput needs a line here.
1116
+ const capabilityOrder = stringArray(obj.capability_order);
824
1117
  // No cast: `exclude` is the OPEN exclusion grammar, so asserting the operator's
825
1118
  // raw strings into the closed provider-name union would be a lie — and the exact
826
1119
  // type-assert-your-way-in move the policy parser refuses for `include`.
827
1120
  const exclude = stringArray(obj.exclude);
828
1121
  const include = stringArray(obj.include);
829
1122
  const dispatchBias = clampDispatchBias(obj.dispatch_bias);
1123
+ // An EXPLICIT empty array is preserved, not dropped to absent. `[]` is the
1124
+ // operator deliberately emptying their host roster; omission is them saying
1125
+ // nothing about it. `carryForwardConfirmationInput` reseeds only the second case,
1126
+ // so collapsing the two here would resurrect a roster the operator deleted — and
1127
+ // there would be no way left to express the deletion at all.
830
1128
  const hostModels = Array.isArray(obj.host_models)
831
1129
  ? obj.host_models
832
1130
  .filter((m) => m !== null &&
833
1131
  typeof m === "object" &&
834
1132
  typeof m.model_id === "string")
835
- .map((m) => ({
836
- model_id: m.model_id,
837
- ...(typeof m.tier === "string"
838
- ? { tier: m.tier }
839
- : {}),
840
- }))
1133
+ .map((m) => ({ model_id: m.model_id }))
841
1134
  : undefined;
842
1135
  return {
843
1136
  schema_version: PROVIDER_CONFIRMATION_INPUT_VERSION,
844
1137
  ...(costOrder ? { cost_order: costOrder } : {}),
1138
+ ...(capabilityOrder ? { capability_order: capabilityOrder } : {}),
845
1139
  ...(exclude ? { exclude } : {}),
846
1140
  ...(include ? { include } : {}),
847
- ...(hostModels && hostModels.length > 0 ? { host_models: hostModels } : {}),
1141
+ ...(hostModels ? { host_models: hostModels } : {}),
848
1142
  ...(dispatchBias != null ? { dispatch_bias: dispatchBias } : {}),
849
1143
  };
850
1144
  }
1145
+ /**
1146
+ * Seed an incoming Gate-0 submission from the PRIOR confirmation, field by field.
1147
+ *
1148
+ * **The defect class this closes.** `buildProviderConfirmationRender` rebuilds the
1149
+ * whole confirmation from the submission ALONE — every operator decision it persists
1150
+ * (`cost_order`, `capability_rank`, the host roster, λ, and the `policy` exclusions) is
1151
+ * reconstructed from `input` and from nothing else. So *any* field a submission omits
1152
+ * is not "left alone", it is DESTROYED. That is one defect with six faces, and fixing
1153
+ * it per-field is what let three of them survive a review round: the prompt's capability
1154
+ * example is `{ "capability_order": [...] }`, so an operator answering exactly what was
1155
+ * asked omits all five other fields and silently wipes them.
1156
+ *
1157
+ * Two rules, both load-bearing:
1158
+ *
1159
+ * 1. **`undefined` means "said nothing"; an explicit empty array means "delete".** A
1160
+ * submission that never mentions host models is not a decision to remove them; an
1161
+ * explicit `"host_models": []` is. `parseProviderConfirmationInput` therefore
1162
+ * PRESERVES an empty array rather than dropping it to absent — without that the two
1163
+ * cases are indistinguishable here and the carry-forward resurrects a roster the
1164
+ * operator deleted.
1165
+ * 2. **It applies with `input === null` too.** The autonomous/headless path promotes
1166
+ * with no submission at all, and the capability delta is a brand-new trigger for
1167
+ * that path — so short-circuiting on `input &&` would let an unattended re-promotion
1168
+ * wipe the operator's entire persisted decision and then report convergence.
1169
+ *
1170
+ * Returns `null` only when there is nothing on either side. A prior confirmation with
1171
+ * no incoming submission still yields a synthesized input carrying it forward.
1172
+ */
1173
+ /**
1174
+ * The gate-authored exclusions that SURVIVE this promotion.
1175
+ *
1176
+ * Round-3 defect (high, fail-OPEN): `auto_exclude` was rebuilt on every promotion from
1177
+ * `gate.newlyReachable` alone. But `confirmedBackendKeys` counts an excluded entry as
1178
+ * CONFIRMED, so once the gate fail-closed-excludes backend X and folds it into the pool,
1179
+ * the reach delta is empty forever — and the very next promotion rebuilt `auto_exclude`
1180
+ * from that empty delta and dropped X, making a backend the operator never confirmed
1181
+ * dispatchable. The docstring's "a submission supersedes it" was true; the code
1182
+ * superseded it on EVERY promotion, including the no-submission one.
1183
+ *
1184
+ * The rule is therefore narrower than "any submission clears it". A submission
1185
+ * supersedes an auto-exclusion only when it actually ADDRESSES that backend:
1186
+ * - the operator re-stated the pattern in `exclude` — it is now operator-authored and
1187
+ * lives there, so retaining an `auto_exclude` copy would double-record it; or
1188
+ * - the operator named that provider in `include` — an explicit opt-back-IN.
1189
+ * Anything else is SILENCE, and silence is not confirmation ("the operator confirms
1190
+ * model choices"). A capability-only answer must not lift an exclusion the operator was
1191
+ * never even shown — the reach section does not render once the backend is a confirmed
1192
+ * key, so they cannot see what they would be lifting.
1193
+ *
1194
+ * Fail-CLOSED by construction: the uncertain case retains the exclusion.
1195
+ */
1196
+ export function retainAutoExclusions(priorAuto, input) {
1197
+ if (priorAuto.length === 0)
1198
+ return [];
1199
+ const restated = new Set(input?.exclude ?? []);
1200
+ const optedIn = input?.include ?? [];
1201
+ const addressed = (pattern) => {
1202
+ if (restated.has(pattern))
1203
+ return true;
1204
+ // `provider` and `provider:model` tiers both belong to the named provider.
1205
+ return optedIn.some((provider) => pattern === provider || pattern.startsWith(`${provider}:`));
1206
+ };
1207
+ return priorAuto.filter((pattern) => !addressed(pattern));
1208
+ }
1209
+ export function carryForwardConfirmationInput(input, prior,
1210
+ /**
1211
+ * R3-3: true when THIS submission is LLM-authored (tool-derived — see
1212
+ * `runProviderConfirmationAutoComplete`'s `authoredByLlm`). Governs which
1213
+ * previously-ranked ids the capability merge below treats as fixed anchors:
1214
+ * an LLM submission anchors every previously-ranked id (operator's and prior
1215
+ * LLM's alike — rule 1); an operator submission does NOT anchor ids already in
1216
+ * `capability_order_llm_ranked` (rule 2 — they interpolate like new models).
1217
+ * Also gates the total-replacement escape in `mergeCapabilityOrder` (operator
1218
+ * only — see its docstring). Defaults `false` (today's operator behavior) so
1219
+ * every pre-existing call site is unaffected.
1220
+ */
1221
+ authoredByLlm = false) {
1222
+ if (!prior)
1223
+ return input;
1224
+ const base = input ?? {
1225
+ schema_version: PROVIDER_CONFIRMATION_INPUT_VERSION,
1226
+ };
1227
+ const priorHostModels = (prior.host_model_cost_order ?? []).map((entry) => ({
1228
+ model_id: entry.model_id,
1229
+ }));
1230
+ const priorCostOrder = priorConfirmedCostOrder(prior);
1231
+ // The operator's RAW answer, read back verbatim — never reconstructed from the
1232
+ // resulting `capability_rank`s. Reconstruction could not tell an operator-authored
1233
+ // rank from EXTERNAL evidence, so it laundered external numbers into the operator's
1234
+ // ordering and made the laundered model read as evidenced forever.
1235
+ const priorCapabilityOrder = prior.policy?.capability_order ?? [];
1236
+ // `exclude` ONLY — `auto_exclude` is deliberately NOT carried. It is the gate's
1237
+ // placeholder for an answer the operator never gave, and a submission supersedes it.
1238
+ const priorExclude = prior.policy?.exclude ?? [];
1239
+ const priorInclude = prior.policy?.include ?? [];
1240
+ return {
1241
+ ...base,
1242
+ // `=== undefined` at every field, never a truthiness/length test: that is the
1243
+ // said-nothing-vs-delete distinction, and a `!length` test collapses them.
1244
+ ...(base.cost_order === undefined && priorCostOrder.length > 0
1245
+ ? { cost_order: priorCostOrder }
1246
+ : {}),
1247
+ // The capability answer is the ONE field that does not follow the plain
1248
+ // said-nothing/carry rule, because its PROMPT is delta-scoped: it renders only the
1249
+ // unevidenced models, so a submission is a partial answer BY CONSTRUCTION and
1250
+ // taking it as the whole ordering erases every rank the operator gave before —
1251
+ // the `PRIORITY[0]` livelock. It is therefore MERGED by anchored insertion rather
1252
+ // than replacing or being replaced (see {@link mergeCapabilityOrder}).
1253
+ //
1254
+ // The MERGED order is what gets persisted, not the raw submission:
1255
+ // `buildConfirmedDispatchPolicy` stores whatever `input.capability_order` holds, and
1256
+ // `policy.capability_order` is the only thing the NEXT promotion reads back. Keeping
1257
+ // the raw answer there instead would mean the prior answers exist nowhere on disk and
1258
+ // the merge would have nothing to merge against on the third promotion — the livelock
1259
+ // one round-trip later. This does NOT undo "store the operator's answer verbatim":
1260
+ // that rule exists to keep EXTERNAL evidence out of the operator's ordering, and both
1261
+ // operands here are operator answers. Nothing derived from a `capability_rank` (which
1262
+ // cannot distinguish operator from external provenance) enters this list.
1263
+ ...(base.capability_order === undefined
1264
+ ? priorCapabilityOrder.length > 0
1265
+ ? { capability_order: priorCapabilityOrder }
1266
+ : {}
1267
+ : {
1268
+ capability_order: mergeCapabilityOrder(priorCapabilityOrder, base.capability_order, capabilityOrderNonAnchors(prior.policy?.capability_order_llm_ranked ?? [], authoredByLlm), authoredByLlm),
1269
+ }),
1270
+ ...(base.host_models === undefined && priorHostModels.length > 0
1271
+ ? { host_models: priorHostModels }
1272
+ : {}),
1273
+ ...(base.exclude === undefined && priorExclude.length > 0
1274
+ ? { exclude: [...priorExclude] }
1275
+ : {}),
1276
+ ...(base.include === undefined && priorInclude.length > 0
1277
+ ? { include: [...priorInclude] }
1278
+ : {}),
1279
+ ...(base.dispatch_bias === undefined && prior.dispatch_bias !== undefined
1280
+ ? { dispatch_bias: prior.dispatch_bias }
1281
+ : {}),
1282
+ };
1283
+ }
1284
+ /** How many already-ranked models the capability prompt shows as fixed reference points. */
1285
+ export const DEFAULT_CAPABILITY_ANCHOR_COUNT = 5;
1286
+ /**
1287
+ * Pick a BOUNDED, spread sample of an already-confirmed capability ordering to show
1288
+ * beside the unevidenced models as fixed reference points.
1289
+ *
1290
+ * The roster may be HUNDREDS of models, so the prompt must be O(new + constant) — it
1291
+ * can never render the whole ordering. First, last, and evenly-spaced interior picks
1292
+ * give the operator a usable coordinate space (top / middle / bottom of the confirmed
1293
+ * ranking) at constant cost, which is exactly what {@link mergeCapabilityOrder}
1294
+ * interpolates against.
1295
+ *
1296
+ * @param priorOrder - The confirmed ordering, most-capable-first.
1297
+ * @param exclude - Models already being asked about (the unevidenced delta); an
1298
+ * anchor must be a model whose rank is settled, never one under question.
1299
+ * @param max - Ceiling on the sample size.
1300
+ */
1301
+ export function selectCapabilityAnchors(priorOrder, exclude = [], max = DEFAULT_CAPABILITY_ANCHOR_COUNT) {
1302
+ const excluded = new Set(exclude);
1303
+ const unique = [...new Set(priorOrder)].filter((id) => !excluded.has(id));
1304
+ if (max <= 0)
1305
+ return [];
1306
+ if (unique.length <= max || max === 1)
1307
+ return unique.slice(0, max);
1308
+ // Evenly spaced, endpoints included. `Set` absorbs a repeated index when the
1309
+ // ordering is barely longer than `max`, so the result is never padded with dupes.
1310
+ const picks = new Set();
1311
+ for (let i = 0; i < max; i++) {
1312
+ picks.add(Math.round((i * (unique.length - 1)) / (max - 1)));
1313
+ }
1314
+ return [...picks].sort((a, b) => a - b).map((i) => unique[i]);
1315
+ }
1316
+ /**
1317
+ * Whether `submitted` restates EVERY id in `priorOrder` — the one case
1318
+ * {@link mergeCapabilityOrder} treats as a total, verbatim replacement rather than an
1319
+ * anchored partial answer (OPERATOR path only — see that function's docstring for why
1320
+ * the escape is authorship-gated). Single-sourced so it and
1321
+ * {@link detectDiscardedCapabilityReorder} can never disagree about which case they
1322
+ * are in (a mismatch there reports a phantom discard, or misses a real one).
1323
+ */
1324
+ function isTotalCapabilitySubmission(priorOrder, submitted) {
1325
+ const prior = [...new Set(priorOrder)];
1326
+ if (prior.length === 0)
1327
+ return false;
1328
+ const answerSet = new Set(submitted);
1329
+ return prior.every((id) => answerSet.has(id));
1330
+ }
1331
+ /**
1332
+ * R3-3 — the submission's ANCHOR EXCLUSION set, single-sourced so
1333
+ * {@link mergeCapabilityOrder} and {@link detectDiscardedCapabilityReorder} can never
1334
+ * disagree about what counts as a fixed reference point for a given submission:
1335
+ *
1336
+ * - LLM-authored (`authoredByLlm`): every previously-ranked id — the operator's and
1337
+ * any prior LLM's alike — stays a fixed anchor (rule 1). Nothing is excluded.
1338
+ * - Operator-authored: ids already in `capability_order_llm_ranked` are NOT anchors
1339
+ * (rule 2) — the operator may reposition them exactly like new models, and doing
1340
+ * so removes them from that set (see {@link advanceCapabilityOrderLlmRanked}).
1341
+ */
1342
+ export function capabilityOrderNonAnchors(priorLlmRanked, authoredByLlm) {
1343
+ return authoredByLlm ? new Set() : new Set(priorLlmRanked);
1344
+ }
1345
+ /**
1346
+ * Advance the R3-3 `capability_order_llm_ranked` authorship SET across one
1347
+ * promotion. Always a subset of the resulting `capability_order` — see
1348
+ * {@link ConfirmedDispatchPolicy.capability_order_llm_ranked}.
1349
+ *
1350
+ * - LLM-authored submission: every id this submission newly ranked — i.e. not
1351
+ * already in `priorOrder` — is ADDED. `mergeCapabilityOrder` never lets an
1352
+ * LLM-authored submission move a previously-ranked id (its total-replacement
1353
+ * escape is operator-only), so a prior id is never repositioned on this path and
1354
+ * this function does not need to special-case "total" either: it is simply
1355
+ * `answer \ priorOrder`.
1356
+ * - Operator-authored submission: every id the submission NAMES is REMOVED — naming
1357
+ * a previously LLM-ranked id (and, via {@link capabilityOrderNonAnchors}'s
1358
+ * non-anchor treatment, freely repositioning it) makes it operator-authored from
1359
+ * here on, exactly like a first-time rank.
1360
+ * - No submission at all (`submitted` absent/empty): the running set is unchanged.
1361
+ */
1362
+ export function advanceCapabilityOrderLlmRanked(priorLlmRanked, priorOrder, submitted, authoredByLlm) {
1363
+ const running = new Set(priorLlmRanked);
1364
+ const answer = submitted ? [...new Set(submitted)] : [];
1365
+ if (answer.length === 0)
1366
+ return [...running].sort();
1367
+ if (!authoredByLlm) {
1368
+ for (const id of answer)
1369
+ running.delete(id);
1370
+ return [...running].sort();
1371
+ }
1372
+ const priorSet = new Set(priorOrder);
1373
+ for (const id of answer.filter((id) => !priorSet.has(id)))
1374
+ running.add(id);
1375
+ return [...running].sort();
1376
+ }
1377
+ /**
1378
+ * The anchor ids whose relative order the submission changed but the merge will NOT
1379
+ * honor — i.e. an operator reorder that is about to be silently discarded.
1380
+ *
1381
+ * {@link mergeCapabilityOrder} treats every submitted id already present in
1382
+ * `priorOrder` as a FIXED reference point, so a submission that swaps two of them
1383
+ * returns the prior order unchanged. Without this, that is invisible: the promotion
1384
+ * succeeds, the artifact is byte-identical, and nothing anywhere says the operator's
1385
+ * decision was dropped. `unrankedOnPromotion` cannot catch it either — a reordered id
1386
+ * IS present in `capability_order`, so it reports nothing.
1387
+ *
1388
+ * An accepted-then-discarded operator decision is the same defect class as laundering a
1389
+ * tool guess into operator policy: not corruption, but SILENCE. The standing rule is
1390
+ * that the operator must never have to notice — so the caller reports this loudly.
1391
+ *
1392
+ * Returns `[]` when the reorder will actually be honored: an OPERATOR-authored TOTAL
1393
+ * submission (every prior id restated) is applied verbatim, and a submission with
1394
+ * fewer than two anchors cannot express a reorder at all.
1395
+ *
1396
+ * NOTE this reports the LIMITATION, it does not lift it. Making a repositioning
1397
+ * expressible without restating the whole roster needs the anchor-provenance split
1398
+ * tracked in `docs/backlog.md`; this only ensures the drop is never silent.
1399
+ */
1400
+ export function detectDiscardedCapabilityReorder(priorOrder, submitted,
1401
+ /**
1402
+ * R3-3: ids to exclude from anchor treatment — must be the SAME set passed to
1403
+ * {@link mergeCapabilityOrder} for this submission ({@link capabilityOrderNonAnchors}),
1404
+ * or the two disagree about what an "anchor" is and this reports a discard the
1405
+ * merge actually honored (or misses one it silently dropped).
1406
+ */
1407
+ nonAnchorIds = new Set(),
1408
+ /**
1409
+ * R3-3: mirrors the authorship-gated condition in {@link mergeCapabilityOrder} — the
1410
+ * total-submission escape is OPERATOR-only, so an LLM-authored submission that
1411
+ * happens to restate every prior id (a small roster's anchor sample can cover the
1412
+ * whole ordering) still has its anchor reorder detected and reported, never
1413
+ * silently honored via the escape.
1414
+ */
1415
+ authoredByLlm = false) {
1416
+ const prior = [...new Set(priorOrder)];
1417
+ const answer = [...new Set(submitted)];
1418
+ if (prior.length === 0 || answer.length === 0)
1419
+ return [];
1420
+ const priorPos = new Map(prior.map((id, index) => [id, index]));
1421
+ // A total submission is honored verbatim — nothing is discarded. Mirrors the same
1422
+ // authorship-gated condition in `mergeCapabilityOrder`; the two must agree or this
1423
+ // reports phantoms.
1424
+ if (!authoredByLlm && isTotalCapabilitySubmission(prior, answer))
1425
+ return [];
1426
+ const anchors = answer.filter((id) => priorPos.has(id) && !nonAnchorIds.has(id));
1427
+ if (anchors.length < 2)
1428
+ return [];
1429
+ // Discarded iff the anchors' order in the SUBMISSION differs from their order in the
1430
+ // PRIOR ordering — compare against the anchors sorted by prior position.
1431
+ const asConfirmed = [...anchors].sort((a, b) => priorPos.get(a) - priorPos.get(b));
1432
+ // Report only the anchors that actually MOVED, not every anchor in the submission.
1433
+ // `["a","c","b"]` against `["a","b","c","d"]` moves `c` and `b`; naming `a` too would
1434
+ // tell the operator their unchanged entry was dropped, which is false — and a warning
1435
+ // that over-reports is one the operator learns to discount.
1436
+ return anchors.filter((id, i) => id !== asConfirmed[i]);
1437
+ }
1438
+ /**
1439
+ * Merge an operator's capability answer into the previously confirmed ordering by
1440
+ * **ANCHORED INSERTION**.
1441
+ *
1442
+ * **The livelock this closes.** The capability prompt is DELTA-SCOPED — it renders only
1443
+ * the models with no evidence — while `annotateConfirmedPool` built its positions from
1444
+ * the submission ALONE, i.e. total replacement. So each answer erased the last: rank A,
1445
+ * the delta asks C, rank C, A loses its rank, the delta asks A, forever. `PRIORITY[0]`
1446
+ * never converges. Reproduced across three promotions.
1447
+ *
1448
+ * The fix cannot be "render the whole ordering" (the roster may be hundreds of models —
1449
+ * the prompt must stay O(new + constant)) and it cannot be an absolute score or tier
1450
+ * (only a RELATIVE ordering is representable, by standing decision). Anchored insertion
1451
+ * is what remains: show a bounded, spread sample of the confirmed ordering
1452
+ * ({@link selectCapabilityAnchors}) as fixed reference points, and interpolate the new
1453
+ * models into the coordinate space those points define.
1454
+ *
1455
+ * Semantics, exactly:
1456
+ *
1457
+ * - **Anchors** = submitted entries that already appear in `priorOrder`. They are
1458
+ * REFERENCE POINTS: their prior positions define the coordinate space, and **a
1459
+ * reordering of anchors relative to each other is deliberately NOT honored** on a
1460
+ * partial submission. The operator saw at most a handful of them out of a possibly
1461
+ * enormous ordering, so a swap between two anchors carries no information about the
1462
+ * models BETWEEN them — honoring it would silently reshuffle models the operator
1463
+ * never saw.
1464
+ * - **Exception — an OPERATOR-authored TOTAL submission is a total replacement.**
1465
+ * When the submission mentions every model in `priorOrder` there are no unmentioned
1466
+ * models, so the coordinate space is fully respecified and the answer is honored
1467
+ * verbatim. This is the only case where "reorder what you already confirmed" is a
1468
+ * well-defined request, and it is the pre-existing behavior for a complete
1469
+ * re-ranking. **R3-3: this escape is OPERATOR-only** (`authoredByLlm: false`,
1470
+ * the default) — on a SMALL roster the bounded anchor sample
1471
+ * ({@link selectCapabilityAnchors}) can cover the entire prior ordering, so an
1472
+ * LLM's answer over "new models + all the anchors it was shown" becomes total BY
1473
+ * ACCIDENT, not by the LLM's intent to reorder. Honoring it verbatim would let an
1474
+ * LLM silently reorder ranks an operator (or a prior LLM) set; an LLM-authored
1475
+ * submission therefore NEVER takes this exception — every previously-ranked id
1476
+ * stays a fixed anchor regardless of coverage, and an attempted reorder is
1477
+ * discarded and reported exactly like a partial submission's would be (see
1478
+ * {@link detectDiscardedCapabilityReorder}).
1479
+ * - **New models** (not in `priorOrder`) interpolate to a fractional position between
1480
+ * the prior positions of the nearest preceding and following anchors IN THE SUBMITTED
1481
+ * LIST. Before the first anchor ⇒ just below it (more capable); after the last ⇒ just
1482
+ * above it. Consecutive new models keep their submitted relative order.
1483
+ * - **Every model in `priorOrder` the submission does not mention keeps its prior
1484
+ * position.** THIS IS THE LIVELOCK FIX.
1485
+ * - **No anchors at all** (a partial submission naming only unknown models): there is no
1486
+ * coordinate to interpolate against, so the new models are appended AFTER the whole
1487
+ * prior ordering — the conservative direction, since a higher rank is less capable and
1488
+ * therefore trusted with less.
1489
+ * - **Duplicates**: first occurrence wins, matching `annotateConfirmedPool`'s rule that a
1490
+ * positional list is the operator's ordering and a later repeat must not re-rank it.
1491
+ * - **Result** is every model sorted by resolved position, ties broken by model id.
1492
+ * Deterministic by construction: an incidentally-ordered array here would churn the
1493
+ * confirmation's content hash on every promotion and cascade phantom staleness.
1494
+ *
1495
+ * Degenerate cases: an empty `priorOrder` (the first-ever answer) returns the submission;
1496
+ * an empty submission returns the prior ordering unchanged (an omitted answer is
1497
+ * "said nothing" — there is no way to express "delete the whole ranking", and the
1498
+ * un-delete direction is the one that cannot livelock).
1499
+ *
1500
+ * Pure — no I/O, no clock, no config. Exported so the merge that decides whether the
1501
+ * gate converges is directly testable.
1502
+ */
1503
+ export function mergeCapabilityOrder(priorOrder, submitted,
1504
+ /**
1505
+ * R3-3: ids to treat as NOT anchored even though they already appear in
1506
+ * `priorOrder` — the operator-authored path passes the current
1507
+ * `capability_order_llm_ranked` set here ({@link capabilityOrderNonAnchors}) so an
1508
+ * operator may freely reposition a previously LLM-ranked id, exactly like a new
1509
+ * model. Empty by default (today's behavior: every previously-ranked id anchors).
1510
+ */
1511
+ nonAnchorIds = new Set(),
1512
+ /**
1513
+ * R3-3: true when THIS submission is LLM-authored. Gates the total-replacement
1514
+ * escape below (OPERATOR-only — see the docstring's exception): an LLM-authored
1515
+ * submission never takes it, so a previously-ranked id is NEVER repositioned on
1516
+ * this path, regardless of how much of `priorOrder` the submission happens to
1517
+ * cover.
1518
+ */
1519
+ authoredByLlm = false) {
1520
+ const prior = [...new Set(priorOrder)];
1521
+ const answer = [...new Set(submitted)];
1522
+ if (prior.length === 0)
1523
+ return answer;
1524
+ if (answer.length === 0)
1525
+ return prior;
1526
+ const priorPos = new Map(prior.map((id, index) => [id, index]));
1527
+ // TOTAL submission ⇒ total replacement (see the docstring's exception) — OPERATOR
1528
+ // path only. An LLM-authored submission never takes this escape, even when it
1529
+ // happens to cover every prior id (a small roster's anchor sample can do that by
1530
+ // accident): it would otherwise silently reorder ranks the LLM was never asked to
1531
+ // move.
1532
+ if (!authoredByLlm && isTotalCapabilitySubmission(prior, answer))
1533
+ return answer;
1534
+ // A non-anchor id still HOLDS its prior position (seeded below, so an unmentioned
1535
+ // one keeps it) but cannot anchor a run boundary — it is swept into the
1536
+ // surrounding run of new models and repositioned exactly like one (R3-3 rule 2).
1537
+ const isAnchor = (id) => priorPos.has(id) && !nonAnchorIds.has(id);
1538
+ /** Resolved position per model — seeded with every prior model, so an unmentioned one keeps its rank. */
1539
+ const positions = new Map(priorPos);
1540
+ const anchorCount = answer.filter(isAnchor).length;
1541
+ if (anchorCount === 0) {
1542
+ answer.forEach((id, index) => positions.set(id, prior.length + index));
1543
+ }
1544
+ else {
1545
+ let i = 0;
1546
+ while (i < answer.length) {
1547
+ if (isAnchor(answer[i])) {
1548
+ i++;
1549
+ continue;
1550
+ }
1551
+ // A maximal run of NEW/non-anchor models, [i, j). `answer[i - 1]` is
1552
+ // necessarily an anchor when `i > 0` — otherwise the run would have started
1553
+ // earlier.
1554
+ let j = i;
1555
+ while (j < answer.length && !isAnchor(answer[j]))
1556
+ j++;
1557
+ const before = i > 0 ? priorPos.get(answer[i - 1]) : undefined;
1558
+ const after = j < answer.length ? priorPos.get(answer[j]) : undefined;
1559
+ let lo;
1560
+ let hi;
1561
+ if (before === undefined) {
1562
+ hi = after;
1563
+ lo = hi - 1;
1564
+ }
1565
+ else if (after === undefined) {
1566
+ lo = before;
1567
+ hi = lo + 1;
1568
+ }
1569
+ else {
1570
+ lo = before;
1571
+ // A reordered anchor pair yields an inverted or empty span. Anchor reordering
1572
+ // is not honored, so degrade to "insert just after the preceding anchor"
1573
+ // rather than emitting descending positions.
1574
+ hi = after > lo ? after : lo + 1;
1575
+ }
1576
+ const run = j - i;
1577
+ for (let t = 0; t < run; t++) {
1578
+ positions.set(answer[i + t], lo + ((hi - lo) * (t + 1)) / (run + 1));
1579
+ }
1580
+ i = j;
1581
+ }
1582
+ }
1583
+ return [...positions.entries()]
1584
+ .sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]))
1585
+ .map(([id]) => id);
1586
+ }
1587
+ /**
1588
+ * Reconstruct the operator's confirmed cost ordering as the `cost_order` KEY list a
1589
+ * fresh submission would carry — i.e. the inverse of `resolveFinalCostOrder`'s
1590
+ * index⇒position mapping.
1591
+ *
1592
+ * Keyspace is the CANDIDATE key (`annotateConfirmedPool`'s `CostCandidate.key`):
1593
+ * provider NAME for a provider pool, `model_id` for a host tier, `source_id` for a
1594
+ * source pool. Deliberately NOT the MODEL keyspace the capability ordering
1595
+ * ({@link mergeCapabilityOrder}) uses — the two genuinely key differently on the write
1596
+ * side (a provider entry is capability-ranked by its `model_id`, never by its provider
1597
+ * name), and unifying
1598
+ * them here would silently drop every provider pool's position.
1599
+ */
1600
+ function priorConfirmedCostOrder(prior) {
1601
+ const ranked = [];
1602
+ for (const entry of prior.provider_pool ?? []) {
1603
+ if (typeof entry.cost_order === "number") {
1604
+ ranked.push({ key: entry.name, order: entry.cost_order });
1605
+ }
1606
+ }
1607
+ for (const entry of prior.host_model_cost_order ?? []) {
1608
+ ranked.push({ key: entry.model_id, order: entry.cost_order });
1609
+ }
1610
+ for (const entry of prior.source_pool_cost_order ?? []) {
1611
+ ranked.push({ key: entry.source_id, order: entry.cost_order });
1612
+ }
1613
+ return sortRankedKeys(ranked);
1614
+ }
1615
+ /**
1616
+ * Rank-ascending key list, de-duplicated first-occurrence-wins. The key tiebreak keeps
1617
+ * the result deterministic when two pools share a position (a host tier defaulting to
1618
+ * `cost_order: 0`, say) — an incidentally-ordered list here would churn the artifact's
1619
+ * content hash on every promotion and cascade phantom staleness downstream.
1620
+ */
1621
+ function sortRankedKeys(ranked) {
1622
+ const seen = new Set();
1623
+ return ranked
1624
+ .sort((a, b) => a.order - b.order || a.key.localeCompare(b.key))
1625
+ .filter((entry) => (seen.has(entry.key) ? false : (seen.add(entry.key), true)))
1626
+ .map((entry) => entry.key);
1627
+ }
851
1628
  /**
852
1629
  * Read the operator's Gate-0 input from `<artifactsDir>/provider-confirmation.input.json`.
853
1630
  * Returns `null` when the file is absent, unreadable, or malformed — the "operator