audit-tools 0.34.41 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/cli/args.d.ts +6 -0
- package/dist/audit/cli/args.d.ts.map +1 -1
- package/dist/audit/cli/args.js +21 -4
- package/dist/audit/cli/args.js.map +1 -1
- package/dist/audit/cli/auditStep.d.ts +6 -15
- package/dist/audit/cli/auditStep.d.ts.map +1 -1
- package/dist/audit/cli/auditStep.js +1 -1
- package/dist/audit/cli/auditStep.js.map +1 -1
- package/dist/audit/cli/dispatch/hostFanoutGate.d.ts +21 -61
- package/dist/audit/cli/dispatch/hostFanoutGate.d.ts.map +1 -1
- package/dist/audit/cli/dispatch/hostFanoutGate.js +18 -167
- package/dist/audit/cli/dispatch/hostFanoutGate.js.map +1 -1
- package/dist/audit/cli/dispatch/packetFilter.d.ts +2 -2
- package/dist/audit/cli/dispatch/packetFilter.d.ts.map +1 -1
- package/dist/audit/cli/dispatch/packetFilter.js +3 -1
- package/dist/audit/cli/dispatch/packetFilter.js.map +1 -1
- package/dist/audit/cli/dispatch/quotaPool.d.ts +2 -14
- package/dist/audit/cli/dispatch/quotaPool.d.ts.map +1 -1
- package/dist/audit/cli/dispatch/quotaPool.js +16 -6
- package/dist/audit/cli/dispatch/quotaPool.js.map +1 -1
- package/dist/audit/cli/dispatch/tierRouting.d.ts +2 -2
- package/dist/audit/cli/dispatch/tokenUsageObservation.d.ts.map +1 -1
- package/dist/audit/cli/dispatch/tokenUsageObservation.js +10 -2
- package/dist/audit/cli/dispatch/tokenUsageObservation.js.map +1 -1
- package/dist/audit/cli/dispatch.d.ts +14 -3
- package/dist/audit/cli/dispatch.d.ts.map +1 -1
- package/dist/audit/cli/dispatch.js +87 -65
- package/dist/audit/cli/dispatch.js.map +1 -1
- package/dist/audit/cli/forceSynthesisCommand.d.ts.map +1 -1
- package/dist/audit/cli/forceSynthesisCommand.js +9 -1
- package/dist/audit/cli/forceSynthesisCommand.js.map +1 -1
- package/dist/audit/cli/hybridDispatch.d.ts +4 -9
- package/dist/audit/cli/hybridDispatch.d.ts.map +1 -1
- package/dist/audit/cli/hybridDispatch.js +1 -1
- package/dist/audit/cli/hybridDispatch.js.map +1 -1
- package/dist/audit/cli/lineIndex.d.ts +2 -0
- package/dist/audit/cli/lineIndex.d.ts.map +1 -1
- package/dist/audit/cli/lineIndex.js +4 -0
- package/dist/audit/cli/lineIndex.js.map +1 -1
- package/dist/audit/cli/nextStepCommand.d.ts +0 -26
- package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
- package/dist/audit/cli/nextStepCommand.js +14 -358
- package/dist/audit/cli/nextStepCommand.js.map +1 -1
- package/dist/audit/cli/nextStepHelpers.d.ts +5 -16
- package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
- package/dist/audit/cli/nextStepHelpers.js +27 -157
- package/dist/audit/cli/nextStepHelpers.js.map +1 -1
- package/dist/audit/cli/quotaCommand.d.ts.map +1 -1
- package/dist/audit/cli/quotaCommand.js +1 -5
- package/dist/audit/cli/quotaCommand.js.map +1 -1
- package/dist/audit/cli/reviewRun.d.ts.map +1 -1
- package/dist/audit/cli/reviewRun.js +58 -23
- package/dist/audit/cli/reviewRun.js.map +1 -1
- package/dist/audit/cli/rollingAuditDispatch.d.ts +1 -1
- package/dist/audit/cli/semanticReviewStep.d.ts.map +1 -1
- package/dist/audit/cli/semanticReviewStep.js +35 -54
- package/dist/audit/cli/semanticReviewStep.js.map +1 -1
- package/dist/audit/cli/steps.d.ts +4 -4
- package/dist/audit/cli/steps.d.ts.map +1 -1
- package/dist/audit/cli/steps.js +0 -1
- package/dist/audit/cli/steps.js.map +1 -1
- package/dist/audit/cli/workPartitionRuntime.d.ts +11 -0
- package/dist/audit/cli/workPartitionRuntime.d.ts.map +1 -0
- package/dist/audit/cli/workPartitionRuntime.js +39 -0
- package/dist/audit/cli/workPartitionRuntime.js.map +1 -0
- package/dist/audit/contracts/wrapperResponse.d.ts +6 -6
- package/dist/audit/io/artifacts.d.ts +1 -3
- package/dist/audit/io/artifacts.d.ts.map +1 -1
- package/dist/audit/io/artifacts.js +0 -3
- package/dist/audit/io/artifacts.js.map +1 -1
- package/dist/audit/orchestrator/advance.d.ts.map +1 -1
- package/dist/audit/orchestrator/advance.js +0 -35
- package/dist/audit/orchestrator/advance.js.map +1 -1
- package/dist/audit/orchestrator/advanceTypes.d.ts +4 -63
- package/dist/audit/orchestrator/advanceTypes.d.ts.map +1 -1
- package/dist/audit/orchestrator/executorRunners.d.ts.map +1 -1
- package/dist/audit/orchestrator/executorRunners.js +9 -12
- package/dist/audit/orchestrator/executorRunners.js.map +1 -1
- package/dist/audit/orchestrator/executors.d.ts.map +1 -1
- package/dist/audit/orchestrator/executors.js +0 -5
- package/dist/audit/orchestrator/executors.js.map +1 -1
- package/dist/audit/orchestrator/hostInputPause.d.ts +1 -1
- package/dist/audit/orchestrator/hostInputPause.js +1 -1
- package/dist/audit/orchestrator/ingestionExecutors.d.ts.map +1 -1
- package/dist/audit/orchestrator/ingestionExecutors.js +4 -1
- package/dist/audit/orchestrator/ingestionExecutors.js.map +1 -1
- package/dist/audit/orchestrator/intakeExecutors.d.ts +1 -65
- package/dist/audit/orchestrator/intakeExecutors.d.ts.map +1 -1
- package/dist/audit/orchestrator/intakeExecutors.js +3 -425
- package/dist/audit/orchestrator/intakeExecutors.js.map +1 -1
- package/dist/audit/orchestrator/nextStep.d.ts +0 -13
- package/dist/audit/orchestrator/nextStep.d.ts.map +1 -1
- package/dist/audit/orchestrator/nextStep.js +0 -7
- package/dist/audit/orchestrator/nextStep.js.map +1 -1
- package/dist/audit/orchestrator/planningExecutors.d.ts.map +1 -1
- package/dist/audit/orchestrator/planningExecutors.js +4 -1
- package/dist/audit/orchestrator/planningExecutors.js.map +1 -1
- package/dist/audit/orchestrator/rollingDispatch.d.ts +1 -1
- package/dist/audit/orchestrator/rollingDispatch.d.ts.map +1 -1
- package/dist/audit/orchestrator/rollingDispatch.js +5 -4
- package/dist/audit/orchestrator/rollingDispatch.js.map +1 -1
- package/dist/audit/orchestrator/state.d.ts +0 -39
- package/dist/audit/orchestrator/state.d.ts.map +1 -1
- package/dist/audit/orchestrator/state.js +3 -29
- package/dist/audit/orchestrator/state.js.map +1 -1
- package/dist/audit/orchestrator/synthesisExecutors.d.ts +8 -2
- package/dist/audit/orchestrator/synthesisExecutors.d.ts.map +1 -1
- package/dist/audit/orchestrator/synthesisExecutors.js +7 -5
- package/dist/audit/orchestrator/synthesisExecutors.js.map +1 -1
- package/dist/audit/reporting/findingIdentity.d.ts +2 -2
- package/dist/audit/reporting/findingIdentity.js +2 -2
- package/dist/audit/reporting/synthesis.d.ts +7 -1
- package/dist/audit/reporting/synthesis.d.ts.map +1 -1
- package/dist/audit/reporting/synthesis.js +27 -7
- package/dist/audit/reporting/synthesis.js.map +1 -1
- package/dist/audit/reporting/workBlocks.d.ts +14 -3
- package/dist/audit/reporting/workBlocks.d.ts.map +1 -1
- package/dist/audit/reporting/workBlocks.js +163 -73
- package/dist/audit/reporting/workBlocks.js.map +1 -1
- package/dist/audit/types/activeDispatch.d.ts +8 -7
- package/dist/audit/types/activeDispatch.d.ts.map +1 -1
- package/dist/audit/types/auditState.d.ts +6 -6
- package/dist/audit/types/externalAnalyzer.d.ts +6 -6
- package/dist/audit/validation/auditResults.d.ts.map +1 -1
- package/dist/audit/validation/auditResults.js +6 -1
- package/dist/audit/validation/auditResults.js.map +1 -1
- package/dist/remediate/contractPipeline/phaseCut.d.ts +8 -0
- package/dist/remediate/contractPipeline/phaseCut.d.ts.map +1 -1
- package/dist/remediate/contractPipeline/phaseCut.js +63 -0
- package/dist/remediate/contractPipeline/phaseCut.js.map +1 -1
- package/dist/remediate/contractPipeline/phaseCutArtifact.d.ts.map +1 -1
- package/dist/remediate/contractPipeline/phaseCutArtifact.js +5 -3
- package/dist/remediate/contractPipeline/phaseCutArtifact.js.map +1 -1
- package/dist/remediate/phases/plan.d.ts.map +1 -1
- package/dist/remediate/phases/plan.js +35 -12
- package/dist/remediate/phases/plan.js.map +1 -1
- package/dist/remediate/steps/contractPipeline.d.ts +6 -2
- package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
- package/dist/remediate/steps/contractPipeline.js +90 -6
- package/dist/remediate/steps/contractPipeline.js.map +1 -1
- package/dist/remediate/steps/contractPipelinePrompts.d.ts.map +1 -1
- package/dist/remediate/steps/contractPipelinePrompts.js +4 -2
- package/dist/remediate/steps/contractPipelinePrompts.js.map +1 -1
- package/dist/remediate/steps/dispatch/marshal.d.ts +6 -0
- package/dist/remediate/steps/dispatch/marshal.d.ts.map +1 -1
- package/dist/remediate/steps/dispatch/marshal.js +16 -17
- package/dist/remediate/steps/dispatch/marshal.js.map +1 -1
- package/dist/remediate/steps/dispatch/waveScheduling.d.ts +7 -15
- package/dist/remediate/steps/dispatch/waveScheduling.d.ts.map +1 -1
- package/dist/remediate/steps/dispatch/waveScheduling.js +26 -19
- package/dist/remediate/steps/dispatch/waveScheduling.js.map +1 -1
- package/dist/remediate/steps/nextStep.d.ts +5 -7
- package/dist/remediate/steps/nextStep.d.ts.map +1 -1
- package/dist/remediate/steps/nextStep.js +100 -82
- package/dist/remediate/steps/nextStep.js.map +1 -1
- package/dist/remediate/steps/rollingSession.d.ts +2 -1
- package/dist/remediate/steps/rollingSession.d.ts.map +1 -1
- package/dist/remediate/steps/rollingSession.js +14 -8
- package/dist/remediate/steps/rollingSession.js.map +1 -1
- package/dist/remediate/steps/sessionConfigLoad.d.ts +3 -2
- package/dist/remediate/steps/sessionConfigLoad.d.ts.map +1 -1
- package/dist/remediate/steps/sessionConfigLoad.js +4 -10
- package/dist/remediate/steps/sessionConfigLoad.js.map +1 -1
- package/dist/remediate/validation/contractPipeline.d.ts +1 -1
- package/dist/remediate/validation/contractPipeline.d.ts.map +1 -1
- package/dist/remediate/validation/contractPipeline.js +7 -1
- package/dist/remediate/validation/contractPipeline.js.map +1 -1
- package/dist/remediate/validation/contractPipelineGates.d.ts +8 -0
- package/dist/remediate/validation/contractPipelineGates.d.ts.map +1 -1
- package/dist/remediate/validation/contractPipelineGates.js +89 -0
- package/dist/remediate/validation/contractPipelineGates.js.map +1 -1
- package/dist/shared/config/resolveSessionConfig.d.ts.map +1 -1
- package/dist/shared/config/resolveSessionConfig.js +6 -5
- package/dist/shared/config/resolveSessionConfig.js.map +1 -1
- package/dist/shared/decompose/workPartition.d.ts +66 -0
- package/dist/shared/decompose/workPartition.d.ts.map +1 -0
- package/dist/shared/decompose/workPartition.js +523 -0
- package/dist/shared/decompose/workPartition.js.map +1 -0
- package/dist/shared/dispatch/admissionLoop.d.ts +5 -44
- package/dist/shared/dispatch/admissionLoop.d.ts.map +1 -1
- package/dist/shared/dispatch/admissionLoop.js +20 -82
- package/dist/shared/dispatch/admissionLoop.js.map +1 -1
- package/dist/shared/dispatch/coordinator.d.ts +5 -5
- package/dist/shared/dispatch/coordinator.d.ts.map +1 -1
- package/dist/shared/dispatch/coordinator.js +12 -11
- package/dist/shared/dispatch/coordinator.js.map +1 -1
- package/dist/shared/dispatch/costRank.d.ts +5 -155
- package/dist/shared/dispatch/costRank.d.ts.map +1 -1
- package/dist/shared/dispatch/costRank.js +2 -179
- package/dist/shared/dispatch/costRank.js.map +1 -1
- package/dist/shared/dispatch/dispatchQuotaContract.d.ts +37 -38
- package/dist/shared/dispatch/dispatchQuotaContract.d.ts.map +1 -1
- package/dist/shared/dispatch/dispatchQuotaContract.js +0 -1
- package/dist/shared/dispatch/dispatchQuotaContract.js.map +1 -1
- package/dist/shared/dispatch/hybridDispatch.d.ts +6 -2
- package/dist/shared/dispatch/hybridDispatch.d.ts.map +1 -1
- package/dist/shared/dispatch/hybridDispatch.js +1 -1
- package/dist/shared/dispatch/hybridDispatch.js.map +1 -1
- package/dist/shared/dispatch/rollingDispatch.d.ts +4 -4
- package/dist/shared/dispatch/rollingDispatch.d.ts.map +1 -1
- package/dist/shared/dispatch/rollingDispatch.js +13 -11
- package/dist/shared/dispatch/rollingDispatch.js.map +1 -1
- package/dist/shared/dispatch/unifiedRolling.d.ts +2 -2
- package/dist/shared/dispatch/unifiedRolling.d.ts.map +1 -1
- package/dist/shared/dispatch/unifiedRolling.js +1 -1
- package/dist/shared/dispatch/unifiedRolling.js.map +1 -1
- package/dist/shared/engine/obligationEngine.d.ts +2 -2
- package/dist/shared/friction/stepBoundaryCapture.d.ts +5 -59
- package/dist/shared/friction/stepBoundaryCapture.d.ts.map +1 -1
- package/dist/shared/friction/stepBoundaryCapture.js +11 -107
- package/dist/shared/friction/stepBoundaryCapture.js.map +1 -1
- package/dist/shared/index.d.ts +13 -16
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/index.js +9 -13
- package/dist/shared/index.js.map +1 -1
- package/dist/shared/io/stateDir.d.ts +2 -2
- package/dist/shared/io/stateDir.js +2 -2
- package/dist/shared/providers/auditorSources.d.ts +10 -84
- package/dist/shared/providers/auditorSources.d.ts.map +1 -1
- package/dist/shared/providers/auditorSources.js +42 -261
- package/dist/shared/providers/auditorSources.js.map +1 -1
- package/dist/shared/providers/dispatchExclusion.d.ts +22 -0
- package/dist/shared/providers/dispatchExclusion.d.ts.map +1 -0
- package/dist/shared/providers/dispatchExclusion.js +23 -0
- package/dist/shared/providers/dispatchExclusion.js.map +1 -0
- package/dist/shared/providers/identity.d.ts +2 -66
- package/dist/shared/providers/identity.d.ts.map +1 -1
- package/dist/shared/providers/identity.js +2 -72
- package/dist/shared/providers/identity.js.map +1 -1
- package/dist/shared/providers/providerFactory.d.ts +0 -3
- package/dist/shared/providers/providerFactory.d.ts.map +1 -1
- package/dist/shared/providers/providerFactory.js +5 -32
- package/dist/shared/providers/providerFactory.js.map +1 -1
- package/dist/shared/providers/providerPathGuard.d.ts +3 -3
- package/dist/shared/providers/providerPathGuard.js +7 -7
- package/dist/shared/providers/providerPathGuard.js.map +1 -1
- package/dist/shared/quota/apiPool.d.ts +19 -22
- package/dist/shared/quota/apiPool.d.ts.map +1 -1
- package/dist/shared/quota/apiPool.js +66 -47
- package/dist/shared/quota/apiPool.js.map +1 -1
- package/dist/shared/quota/capacity.d.ts +29 -24
- package/dist/shared/quota/capacity.d.ts.map +1 -1
- package/dist/shared/quota/capacity.js +30 -4
- package/dist/shared/quota/capacity.js.map +1 -1
- package/dist/shared/quota/codexHostConfig.d.ts +6 -13
- package/dist/shared/quota/codexHostConfig.d.ts.map +1 -1
- package/dist/shared/quota/codexHostConfig.js +8 -14
- package/dist/shared/quota/codexHostConfig.js.map +1 -1
- package/dist/shared/quota/fileLock.d.ts +7 -0
- package/dist/shared/quota/fileLock.d.ts.map +1 -1
- package/dist/shared/quota/fileLock.js +32 -4
- package/dist/shared/quota/fileLock.js.map +1 -1
- package/dist/shared/quota/hostLimits.d.ts +1 -1
- package/dist/shared/quota/hostLimits.d.ts.map +1 -1
- package/dist/shared/quota/hostLimits.js +8 -11
- package/dist/shared/quota/hostLimits.js.map +1 -1
- package/dist/shared/quota/hostPool.d.ts +1 -1
- package/dist/shared/quota/hostPool.js +2 -2
- package/dist/shared/quota/hostPool.js.map +1 -1
- package/dist/shared/quota/limits.d.ts.map +1 -1
- package/dist/shared/quota/limits.js +30 -26
- package/dist/shared/quota/limits.js.map +1 -1
- package/dist/shared/quota/scheduler.d.ts +2 -3
- package/dist/shared/quota/scheduler.d.ts.map +1 -1
- package/dist/shared/quota/scheduler.js +1 -1
- package/dist/shared/quota/scheduler.js.map +1 -1
- package/dist/shared/quota/types.d.ts +10 -10
- package/dist/shared/quota/types.d.ts.map +1 -1
- package/dist/shared/quota/types.js +4 -7
- package/dist/shared/quota/types.js.map +1 -1
- package/dist/shared/repair/brokeredDispatch.d.ts.map +1 -1
- package/dist/shared/repair/brokeredDispatch.js +3 -0
- package/dist/shared/repair/brokeredDispatch.js.map +1 -1
- package/dist/shared/reporting/auditDeliverable.d.ts.map +1 -1
- package/dist/shared/reporting/auditDeliverable.js +1 -0
- package/dist/shared/reporting/auditDeliverable.js.map +1 -1
- package/dist/shared/rolling/pausedState.d.ts +7 -7
- package/dist/shared/rolling/pausedState.js +3 -3
- package/dist/shared/tokens.d.ts +2 -5
- package/dist/shared/tokens.d.ts.map +1 -1
- package/dist/shared/tokens.js +15 -15
- package/dist/shared/tokens.js.map +1 -1
- package/dist/shared/types/auditorDescriptor.d.ts +3 -2
- package/dist/shared/types/auditorDescriptor.d.ts.map +1 -1
- package/dist/shared/types/auditorDescriptor.js +3 -2
- package/dist/shared/types/auditorDescriptor.js.map +1 -1
- package/dist/shared/types/finding.d.ts +80 -0
- package/dist/shared/types/finding.d.ts.map +1 -1
- package/dist/shared/types/finding.js +17 -0
- package/dist/shared/types/finding.js.map +1 -1
- package/dist/shared/types/sessionConfig.d.ts +5 -5
- package/dist/shared/types/sessionConfig.js +1 -1
- package/dist/shared/validation/findingsReport.d.ts.map +1 -1
- package/dist/shared/validation/findingsReport.js +3 -0
- package/dist/shared/validation/findingsReport.js.map +1 -1
- package/docs/audit-pkg/development.md +3 -2
- package/docs/audit-pkg/operator-guide.md +13 -106
- package/docs/audit-pkg/product.md +8 -7
- package/docs/audit-pkg/release.md +38 -29
- package/opencode.json +2 -2
- package/package.json +7 -4
- package/skills/audit-code/SKILL.md +2 -1
- package/skills/audit-code/audit-code.prompt.md +4 -3
- package/skills/remediate-code/remediate-code.prompt.md +2 -1
- package/dist/audit/cli/providerConfirmationStep.d.ts +0 -106
- package/dist/audit/cli/providerConfirmationStep.d.ts.map +0 -1
- package/dist/audit/cli/providerConfirmationStep.js +0 -396
- package/dist/audit/cli/providerConfirmationStep.js.map +0 -1
- package/dist/audit/orchestrator/providerConfirmation.d.ts +0 -25
- package/dist/audit/orchestrator/providerConfirmation.d.ts.map +0 -1
- package/dist/audit/orchestrator/providerConfirmation.js +0 -60
- package/dist/audit/orchestrator/providerConfirmation.js.map +0 -1
- package/dist/shared/providers/providerConfirmation.d.ts +0 -141
- package/dist/shared/providers/providerConfirmation.d.ts.map +0 -1
- package/dist/shared/providers/providerConfirmation.js +0 -420
- package/dist/shared/providers/providerConfirmation.js.map +0 -1
- package/dist/shared/providers/proxyCatalog.d.ts +0 -158
- package/dist/shared/providers/proxyCatalog.d.ts.map +0 -1
- package/dist/shared/providers/proxyCatalog.js +0 -658
- package/dist/shared/providers/proxyCatalog.js.map +0 -1
- package/dist/shared/providers/sharedProviderConfirmation.d.ts +0 -802
- package/dist/shared/providers/sharedProviderConfirmation.d.ts.map +0 -1
- package/dist/shared/providers/sharedProviderConfirmation.js +0 -1865
- package/dist/shared/providers/sharedProviderConfirmation.js.map +0 -1
- package/dist/shared/types/providerConfirmation.d.ts +0 -334
- package/dist/shared/types/providerConfirmation.d.ts.map +0 -1
- package/dist/shared/types/providerConfirmation.js +0 -35
- package/dist/shared/types/providerConfirmation.js.map +0 -1
|
@@ -1,1865 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DC-2 — shared, session-scoped provider confirmation (Gate-0).
|
|
3
|
-
*
|
|
4
|
-
* The design wants ONE provider confirmation spanning an audit→remediate run:
|
|
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.
|
|
32
|
-
*
|
|
33
|
-
* CE-003 (lockless read races the writer rename): writes go through the shared
|
|
34
|
-
* atomic writer (temp + atomic rename) under `withFileLock`, so a lockless
|
|
35
|
-
* reader always observes either the complete old file or the complete new file —
|
|
36
|
-
* never a torn intermediate.
|
|
37
|
-
*
|
|
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.
|
|
41
|
-
*/
|
|
42
|
-
import { mkdir, unlink } from "node:fs/promises";
|
|
43
|
-
import { join } from "node:path";
|
|
44
|
-
import { PROVIDER_NAMES } from "../types/sessionConfig.js";
|
|
45
|
-
import { isSelfSpawnBlocked } from "./providerPathGuard.js";
|
|
46
|
-
import { auditToolsDir } from "../io/auditToolsPaths.js";
|
|
47
|
-
import { readJsonFile, writeJsonFile } from "../io/json.js";
|
|
48
|
-
import { withFileLock } from "../quota/fileLock.js";
|
|
49
|
-
import { discoverProviders, annotateConfirmedPool, representativeModelId, } from "./providerConfirmation.js";
|
|
50
|
-
import { backendIdentity, exclusionPattern, serviceExclusionPattern, } from "./identity.js";
|
|
51
|
-
import { resolveConfirmedCostPositions } from "../dispatch/costRank.js";
|
|
52
|
-
import { gatherDispatchableSources } from "../quota/apiPool.js";
|
|
53
|
-
import { resolveFreshSessionProviderName } from "./providerFactory.js";
|
|
54
|
-
import { PROVIDER_CONFIRMATION_INPUT_VERSION } from "../types/providerConfirmation.js";
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
// Version + on-disk location
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
/**
|
|
59
|
-
* Schema version for the shared confirmation artifact. Bumped independently of
|
|
60
|
-
* the per-tool seam contract (PROVIDER_CONFIRMATION_RESULT_VERSION) — this is
|
|
61
|
-
* the cross-tool session artifact carrying the operator's route DECISION
|
|
62
|
-
* (exclusions, cost order, λ), a distinct shape from the seam's pool snapshot.
|
|
63
|
-
*/
|
|
64
|
-
export const SHARED_PROVIDER_CONFIRMATION_VERSION = "1.0.0";
|
|
65
|
-
/**
|
|
66
|
-
* Clamp an operator-supplied cost↔speed dispatch bias (λ) to [0, 1], or `undefined`
|
|
67
|
-
* when it is absent/non-finite. Single-sourced so parse-time and read-time agree.
|
|
68
|
-
* (spec/dispatch-quota.md).
|
|
69
|
-
*/
|
|
70
|
-
export function clampDispatchBias(value) {
|
|
71
|
-
if (typeof value !== "number" || !Number.isFinite(value))
|
|
72
|
-
return undefined;
|
|
73
|
-
return Math.min(1, Math.max(0, value));
|
|
74
|
-
}
|
|
75
|
-
/** File name of the shared session-level confirmation under `.audit-tools/`. */
|
|
76
|
-
export const SHARED_PROVIDER_CONFIRMATION_FILENAME = "provider-confirmation.json";
|
|
77
|
-
/** `<root>/.audit-tools/provider-confirmation.json` (absolute). */
|
|
78
|
-
export function sharedProviderConfirmationPath(root) {
|
|
79
|
-
return join(auditToolsDir(root), SHARED_PROVIDER_CONFIRMATION_FILENAME);
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Project the render DTO down to what actually gets PERSISTED: the operator's
|
|
83
|
-
* decision, with this auditor's reach assessment dropped (G3 B+D).
|
|
84
|
-
*
|
|
85
|
-
* The PRODUCER is split — not the write site. `writeSharedProviderConfirmation`
|
|
86
|
-
* receives an already-typed value, so projecting THERE would leave the reach fields
|
|
87
|
-
* representable on the persisted type and a future caller could put them back.
|
|
88
|
-
* Projecting here makes the persisted shape carry no reach BY CONSTRUCTION.
|
|
89
|
-
*/
|
|
90
|
-
export function buildSharedProviderConfirmation(...args) {
|
|
91
|
-
const rendered = buildProviderConfirmationRender(...args);
|
|
92
|
-
return {
|
|
93
|
-
...rendered,
|
|
94
|
-
provider_pool: rendered.provider_pool.map(toPersistedPoolEntry),
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
/** The decision half of one pool entry. Reach is dropped, never persisted. */
|
|
98
|
-
function toPersistedPoolEntry(entry) {
|
|
99
|
-
return {
|
|
100
|
-
name: entry.name,
|
|
101
|
-
...(entry.model_id !== undefined ? { model_id: entry.model_id } : {}),
|
|
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 } : {}),
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
function sortNames(names) {
|
|
110
|
-
// Deduplicate + sort so a persisted list is order-insensitive and stable.
|
|
111
|
-
return [...new Set(names)].sort();
|
|
112
|
-
}
|
|
113
|
-
function sortStrings(values) {
|
|
114
|
-
// Deduplicate + sort so a persisted list is order-insensitive and stable.
|
|
115
|
-
return [...new Set(values)].sort();
|
|
116
|
-
}
|
|
117
|
-
// ---------------------------------------------------------------------------
|
|
118
|
-
// Build
|
|
119
|
-
// ---------------------------------------------------------------------------
|
|
120
|
-
/**
|
|
121
|
-
* Build a fresh shared confirmation from auto-discovery. Guarantees the
|
|
122
|
-
* always-available `worker-command` fallback is present in the pool (it blocks
|
|
123
|
-
* auto-dispatch and so is never PATH-detected, but the pool must always be able
|
|
124
|
-
* to fall back to it) and stamps the schema version, session-level flag, and
|
|
125
|
-
* confirmation timestamp.
|
|
126
|
-
*
|
|
127
|
-
* SECURITY (self-spawn exclusion): a provider that `discoverProviders` flags as
|
|
128
|
-
* `selfSpawnBlocked` (claude-code under `CLAUDECODE`, codex under `CODEX`) is set
|
|
129
|
-
* `excluded: true` AND carries the machine-readable `self_spawn_blocked` flag, so
|
|
130
|
-
* it is OUT of the dispatchable pool by default — launching it would self-spawn a
|
|
131
|
-
* fresh agent from inside an active session of the same agent. The operator can
|
|
132
|
-
* deliberately re-include it by naming it in `include`; that overrides the
|
|
133
|
-
* exclusion (the host still always retains the worker-command fallback).
|
|
134
|
-
*
|
|
135
|
-
* @param sessionConfig - Current session config; may be an empty `{}`.
|
|
136
|
-
* @param env - Process env snapshot; defaults to `process.env`.
|
|
137
|
-
* @param exclude - {@link DispatchExclusionPattern}s to pre-exclude (from a
|
|
138
|
-
* prior gate). A `provider:model` pattern marks a pool entry excluded only when
|
|
139
|
-
* that entry's `representativeModelId` IS that model — the same key the routing
|
|
140
|
-
* filter matches on, so display and dispatch cannot disagree.
|
|
141
|
-
* @param include - Provider names the operator explicitly opts back IN,
|
|
142
|
-
* overriding the default self-spawn-blocked exclusion for those names.
|
|
143
|
-
* @param detectCommand - Injectable PATH-detection hook, forwarded to
|
|
144
|
-
* `discoverProviders` so tests can drive discovery deterministically.
|
|
145
|
-
* @param input - Operator's Gate-0 submission (interactive path): its
|
|
146
|
-
* `cost_order` overrides the suggested ordering and its `host_models` become
|
|
147
|
-
* priced, orderable host-native tiers (`host_model_cost_order`). Omit for the
|
|
148
|
-
* headless / no-operator path — the tool then emits its price-ascending
|
|
149
|
-
* suggestion with no host models, exactly as before. `exclude`/`include` are
|
|
150
|
-
* passed via the dedicated params above (the executor forwards them from the
|
|
151
|
-
* same input), so this arg governs ordering + host roster only.
|
|
152
|
-
*/
|
|
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 = []) {
|
|
169
|
-
const discovered = discoverProviders(sessionConfig, env, detectCommand);
|
|
170
|
-
const operatorExcluded = buildExclusion(migrateExclusionPatterns([...exclude, ...autoExclude]));
|
|
171
|
-
const includeSet = new Set(include);
|
|
172
|
-
// Evaluate a pool entry against the operator's rules at the SAME key the routing
|
|
173
|
-
// filter uses — `representativeModelId` is what `computeNewlyReachableBackends`
|
|
174
|
-
// and `annotateConfirmedPool` key a provider by, so a `provider:model` rule marks
|
|
175
|
-
// exactly the entry it will later filter.
|
|
176
|
-
//
|
|
177
|
-
// ⚠ The provider + model tiers ONLY. `provider_pool` is provider-granular and an
|
|
178
|
-
// entry has no endpoint, so an endpoint-host rule can never mark one — that tier
|
|
179
|
-
// addresses SOURCES, which this pool does not enumerate. The direction is safe
|
|
180
|
-
// (the rule is still honored at `buildSourcePools`; the Gate-0 table merely
|
|
181
|
-
// under-reports it as "included"), but it is a real display/routing gap and it is
|
|
182
|
-
// NOT closed here. Backlog: the Gate-0 sources table carries no status column at
|
|
183
|
-
// all, so no tier is reflected for a source today.
|
|
184
|
-
const ruledOut = (name) => operatorExcluded.excludes({
|
|
185
|
-
transport: name,
|
|
186
|
-
model: representativeModelId(name, sessionConfig),
|
|
187
|
-
});
|
|
188
|
-
const pool = [];
|
|
189
|
-
// Always include worker-command as a fallback — it's always available and is
|
|
190
|
-
// never surfaced by PATH discovery (it blocks auto-dispatch by design).
|
|
191
|
-
if (!discovered.some((p) => p.name === "worker-command")) {
|
|
192
|
-
pool.push({
|
|
193
|
-
name: "worker-command",
|
|
194
|
-
capability_tier: "unknown",
|
|
195
|
-
excluded: ruledOut("worker-command"),
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
for (const provider of discovered) {
|
|
199
|
-
// Self-spawn-blocked providers are excluded from the dispatchable pool by
|
|
200
|
-
// default; the operator can opt one back in via `include`. An operator-named
|
|
201
|
-
// `exclude` always wins.
|
|
202
|
-
const blocked = provider.selfSpawnBlocked === true;
|
|
203
|
-
const operatorIncluded = includeSet.has(provider.name);
|
|
204
|
-
const excluded = ruledOut(provider.name) || (blocked && !operatorIncluded);
|
|
205
|
-
pool.push({
|
|
206
|
-
name: provider.name,
|
|
207
|
-
capability_tier: provider.capabilityTier,
|
|
208
|
-
excluded,
|
|
209
|
-
...(blocked ? { self_spawn_blocked: true } : {}),
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
// Cost-first routing: annotate with representative model price + cost_order,
|
|
213
|
-
// read at dispatch as rung 1 of costRank (spec/dispatch-quota.md). When an
|
|
214
|
-
// operator input is present its ordering wins and its host roster is priced.
|
|
215
|
-
// `env` is threaded rather than left to `process.env`: host-provider resolution
|
|
216
|
-
// falls through to env detection when `host_provider` is unset, and an injected-env
|
|
217
|
-
// caller must not derive provider DISCOVERY and HOST IDENTITY from two different
|
|
218
|
-
// environments.
|
|
219
|
-
const annotated = annotateConfirmedPool(pool, sessionConfig, input, sources, env);
|
|
220
|
-
return {
|
|
221
|
-
schema_version: SHARED_PROVIDER_CONFIRMATION_VERSION,
|
|
222
|
-
session_level: true,
|
|
223
|
-
confirmed_at: new Date().toISOString(),
|
|
224
|
-
provider_pool: annotated.provider_pool,
|
|
225
|
-
// NOTE: the FULL entries. This is the render DTO; `buildSharedProviderConfirmation`
|
|
226
|
-
// projects them to `PersistedPoolEntry[]` on the way to disk.
|
|
227
|
-
...(annotated.host_model_cost_order.length > 0
|
|
228
|
-
? { host_model_cost_order: annotated.host_model_cost_order }
|
|
229
|
-
: {}),
|
|
230
|
-
...(annotated.source_pool_cost_order.length > 0
|
|
231
|
-
? { source_pool_cost_order: annotated.source_pool_cost_order }
|
|
232
|
-
: {}),
|
|
233
|
-
...(clampDispatchBias(input?.dispatch_bias) != null
|
|
234
|
-
? { dispatch_bias: clampDispatchBias(input?.dispatch_bias) }
|
|
235
|
-
: {}),
|
|
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) ?? {}),
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
/**
|
|
244
|
-
* Lift the operator's explicit route decision out of their Gate-0 input into the
|
|
245
|
-
* persisted policy half. Returns `undefined` when the operator named neither list
|
|
246
|
-
* (so the field stays absent rather than persisting an empty shell).
|
|
247
|
-
*/
|
|
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) {
|
|
254
|
-
return undefined;
|
|
255
|
-
}
|
|
256
|
-
return {
|
|
257
|
-
policy: {
|
|
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) } : {}),
|
|
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
|
-
: {}),
|
|
270
|
-
},
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
/**
|
|
274
|
-
* Keep every non-empty pattern verbatim — **no membership check.** Unlike
|
|
275
|
-
* {@link parseProviderNameList}, this list is an open grammar: a pattern whose head
|
|
276
|
-
* is not a provider name is a legitimate endpoint-host rule, so "unknown ⇒ drop"
|
|
277
|
-
* would silently delete the operator's endpoint tier. An unmatchable pattern is
|
|
278
|
-
* inert (it simply matches nothing), which is the safe direction for a filter.
|
|
279
|
-
*
|
|
280
|
-
* Returns `undefined` for a non-array or an all-empty array, so the field stays
|
|
281
|
-
* absent rather than persisting an empty shell.
|
|
282
|
-
*/
|
|
283
|
-
function parseExclusionPatterns(value) {
|
|
284
|
-
if (!Array.isArray(value))
|
|
285
|
-
return undefined;
|
|
286
|
-
const patterns = value.filter((v) => typeof v === "string" && v.trim().length > 0);
|
|
287
|
-
return patterns.length > 0 ? patterns.map((p) => p.trim()) : undefined;
|
|
288
|
-
}
|
|
289
|
-
/**
|
|
290
|
-
* Keep only real provider names, dropping anything unknown. Returns `undefined` for a
|
|
291
|
-
* non-array or an array with no recognizable name, so an unknown entry degrades that
|
|
292
|
-
* entry — never the whole list.
|
|
293
|
-
*
|
|
294
|
-
* Retained for `include` ONLY: that list opts a *self-spawn-blocked provider* back in,
|
|
295
|
-
* and self-spawn-blockedness is a property of a provider (`isSelfSpawnBlocked` keys on
|
|
296
|
-
* the provider name), so its keyspace is genuinely the closed name set — not the open
|
|
297
|
-
* exclusion grammar.
|
|
298
|
-
*/
|
|
299
|
-
function parseProviderNameList(value) {
|
|
300
|
-
if (!Array.isArray(value))
|
|
301
|
-
return undefined;
|
|
302
|
-
const names = value.filter((v) => typeof v === "string" && RESOLVED_PROVIDER_NAMES.includes(v));
|
|
303
|
-
return names.length > 0 ? names : undefined;
|
|
304
|
-
}
|
|
305
|
-
/** Every concrete provider name — `auto` is a resolution directive, not a backend. */
|
|
306
|
-
const RESOLVED_PROVIDER_NAMES = PROVIDER_NAMES.filter((name) => name !== "auto");
|
|
307
|
-
// ---------------------------------------------------------------------------
|
|
308
|
-
// The reconciliation gate (G3): operator DECISION vs THIS auditor's reach
|
|
309
|
-
// ---------------------------------------------------------------------------
|
|
310
|
-
/**
|
|
311
|
-
* The keys of the operator's persisted DECISION — the CONFIRMED half of the gate.
|
|
312
|
-
*
|
|
313
|
-
* All three pools contribute, and each is load-bearing: `annotateConfirmedPool`
|
|
314
|
-
* folds a source away when its model is already claimed by a provider entry, so a
|
|
315
|
-
* source can be represented ONLY by `provider_pool[].model_id`; and a host tier
|
|
316
|
-
* appears only in `host_model_cost_order`. Reading fewer than all three would
|
|
317
|
-
* manufacture a phantom delta for an already-confirmed backend.
|
|
318
|
-
*
|
|
319
|
-
* A host tier with no `provider` contributes NOTHING, and that is the deliberate
|
|
320
|
-
* fail-SAFE degradation for a confirmation written before the field existed. The
|
|
321
|
-
* alternative — falling back to the bare `model_id` — is precisely the bypass this
|
|
322
|
-
* identity exists to close: a confirmed *host* model would silently approve an
|
|
323
|
-
* identically-named model on some other provider. Contributing no key can only ever
|
|
324
|
-
* cause the gate to ASK about a backend again (loud, and the operator's answer then
|
|
325
|
-
* records the provider); it can never approve one unseen.
|
|
326
|
-
*/
|
|
327
|
-
export function confirmedBackendKeys(confirmation) {
|
|
328
|
-
const keys = new Set();
|
|
329
|
-
for (const entry of confirmation.provider_pool) {
|
|
330
|
-
keys.add(backendIdentity(entry.model_id, entry.name));
|
|
331
|
-
}
|
|
332
|
-
for (const entry of confirmation.source_pool_cost_order ?? []) {
|
|
333
|
-
keys.add(backendIdentity(entry.model_id, entry.service ?? entry.transport));
|
|
334
|
-
}
|
|
335
|
-
for (const entry of confirmation.host_model_cost_order ?? []) {
|
|
336
|
-
if (entry.provider === undefined)
|
|
337
|
-
continue;
|
|
338
|
-
keys.add(backendIdentity(entry.model_id, entry.provider));
|
|
339
|
-
}
|
|
340
|
-
return keys;
|
|
341
|
-
}
|
|
342
|
-
/**
|
|
343
|
-
* DELTA = **REACH-NOW \ CONFIRMED**: the backends this auditor can reach *right
|
|
344
|
-
* now* that the operator's persisted decision never mentions. Sorted by key, so the
|
|
345
|
-
* result is stable for prompt rendering + comparison.
|
|
346
|
-
*
|
|
347
|
-
* This is a **set difference — a FILTER over fresh reach, never additive.** The
|
|
348
|
-
* opposite direction (CONFIRMED \ REACH-NOW: a backend the operator confirmed that
|
|
349
|
-
* this auditor cannot reach) is the harmless *subset* case and is deliberately
|
|
350
|
-
* silent — it is also why the synthetic `worker-command` entry and
|
|
351
|
-
* `host_model_cost_order` need no special-casing here.
|
|
352
|
-
*
|
|
353
|
-
* @param confirmation - The persisted decision (CONFIRMED).
|
|
354
|
-
* @param sessionConfig - The EFFECTIVE config, so `representativeModelId` derives
|
|
355
|
-
* keys identically to the write side.
|
|
356
|
-
* @param sources - REACH-NOW's source half. MUST come from the
|
|
357
|
-
* `gatherDispatchableSources` chokepoint — the single async source-gather point
|
|
358
|
-
* both `buildSourcePools` and the Gate-0 surface consume, so what the operator
|
|
359
|
-
* confirms is exactly what routes. Re-deriving it from `resolveAmbientSources`
|
|
360
|
-
* would reintroduce the display/dispatch drift that invariant forbids, and is
|
|
361
|
-
* structurally blind to descriptor-supplied sources, the demoted primary, and the
|
|
362
|
-
* legacy `openai_compatible` fold.
|
|
363
|
-
* @param env - Process env, for `discoverProviders` (REACH-NOW's provider half).
|
|
364
|
-
* @param detectCommand - Injectable PATH-detection hook so tests drive discovery
|
|
365
|
-
* deterministically instead of shelling out.
|
|
366
|
-
*/
|
|
367
|
-
export function computeNewlyReachableBackends(confirmation, sessionConfig, sources = [], env = process.env, detectCommand) {
|
|
368
|
-
const confirmed = confirmedBackendKeys(confirmation);
|
|
369
|
-
const reachNow = new Map();
|
|
370
|
-
// `provider` is the TRANSPORT (what spawns / what the routing filter matches);
|
|
371
|
-
// `backendProvider` is the BACKEND ACTUALLY SERVING the model, and only a proxied
|
|
372
|
-
// source distinguishes them. The identity keys on the backend, the rule on the
|
|
373
|
-
// transport — see `backendIdentity`.
|
|
374
|
-
const record = (modelId, provider, backendProvider) => {
|
|
375
|
-
const service = backendProvider ?? provider;
|
|
376
|
-
const identity = backendIdentity(modelId, service);
|
|
377
|
-
// First writer wins: when a proxied lane and a direct lane resolve to the SAME
|
|
378
|
-
// backend identity, they are one backend reached two ways, and the rule kept is
|
|
379
|
-
// the one that rules out the lane already recorded.
|
|
380
|
-
if (reachNow.has(identity))
|
|
381
|
-
return;
|
|
382
|
-
reachNow.set(identity, {
|
|
383
|
-
key: identity,
|
|
384
|
-
provider,
|
|
385
|
-
service,
|
|
386
|
-
exclusion_pattern: exclusionPattern(modelId, provider),
|
|
387
|
-
service_exclusion_pattern: serviceExclusionPattern(modelId, service),
|
|
388
|
-
});
|
|
389
|
-
};
|
|
390
|
-
for (const provider of discoverProviders(sessionConfig, env, detectCommand)) {
|
|
391
|
-
// A discovered provider IS its own backend — no proxy indirection to unwrap.
|
|
392
|
-
record(representativeModelId(provider.name, sessionConfig), provider.name);
|
|
393
|
-
}
|
|
394
|
-
for (const source of sources) {
|
|
395
|
-
record(source.model, source.transport, source.service);
|
|
396
|
-
}
|
|
397
|
-
return [...reachNow.values()]
|
|
398
|
-
.filter((backend) => !confirmed.has(backend.key))
|
|
399
|
-
.sort((a, b) => a.key.localeCompare(b.key));
|
|
400
|
-
}
|
|
401
|
-
/**
|
|
402
|
-
* The dispatchable-pool exclusion rules for THIS process: the operator's explicit
|
|
403
|
-
* {@link DispatchExclusionPattern}s, plus every provider that is self-spawn-blocked
|
|
404
|
-
* *in this process's env* and was not explicitly opted back in.
|
|
405
|
-
*
|
|
406
|
-
* Reach is recomputed here rather than read from the artifact's derived `excluded`
|
|
407
|
-
* flag — that flag encodes the WRITING auditor's env, and an auditor for whom a
|
|
408
|
-
* provider is perfectly spawnable must not inherit another's block. The operator's
|
|
409
|
-
* decision is inherited (it is a rule); the reach assessment is not.
|
|
410
|
-
*
|
|
411
|
-
* ⚠ **These rules are only safe to apply to SOURCE pools.** Inside any agent session
|
|
412
|
-
* the self-spawn half ALWAYS names that agent (`CLAUDECODE` ⇒ `claude-code`, `CODEX`
|
|
413
|
-
* ⇒ `codex`) — i.e. the conversation host itself. Applying them to HOST pools would
|
|
414
|
-
* zero out dispatch entirely: the driver would exclude itself. It is harmless at
|
|
415
|
-
* `buildSourcePools` only because a host can never BE a source — `claude-code` is
|
|
416
|
-
* structurally absent from `DISPATCHABLE_TRANSPORTS`, so in a Claude Code
|
|
417
|
-
* session the filter is a no-op. Honoring an operator exclusion of the host/primary
|
|
418
|
-
* provider therefore is NOT a matter of passing these rules to the host-pool builder;
|
|
419
|
-
* it needs a separate decision about what excluding your own driver should even mean.
|
|
420
|
-
*/
|
|
421
|
-
export function resolveDispatchExclusion(policy, env = process.env) {
|
|
422
|
-
const included = new Set(policy?.include ?? []);
|
|
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.
|
|
426
|
-
const blocked = RESOLVED_PROVIDER_NAMES.filter((name) => !included.has(name) && isSelfSpawnBlocked(name, env));
|
|
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
|
-
]));
|
|
434
|
-
}
|
|
435
|
-
/**
|
|
436
|
-
* The pure-policy matcher: the operator's patterns and nothing else. Split out
|
|
437
|
-
* because the Gate-0 pool BUILDER must evaluate the operator's rules WITHOUT the
|
|
438
|
-
* local self-spawn fold — it derives `self_spawn_blocked` from `discoverProviders`
|
|
439
|
-
* on its own and would otherwise conflate the two into one indistinguishable
|
|
440
|
-
* `excluded` verdict.
|
|
441
|
-
*/
|
|
442
|
-
function buildExclusion(patterns) {
|
|
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 };
|
|
450
|
-
}
|
|
451
|
-
/** The three recognized axis prefixes. */
|
|
452
|
-
const VALID_EXCLUSION_AXES = new Set(["transport", "service", "host"]);
|
|
453
|
-
function parseExclusionRule(pattern) {
|
|
454
|
-
const colon = pattern.indexOf(":");
|
|
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`
|
|
508
|
-
return isResolvedProviderName(pattern)
|
|
509
|
-
? `transport:${pattern}`
|
|
510
|
-
: `host:${pattern}`;
|
|
511
|
-
}
|
|
512
|
-
const head = pattern.slice(0, colon);
|
|
513
|
-
const tail = pattern.slice(colon + 1);
|
|
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}`;
|
|
520
|
-
}
|
|
521
|
-
// `integrate.api.nvidia.com` / `localhost:8000` → `host:<pattern>`
|
|
522
|
-
return `host:${pattern}`;
|
|
523
|
-
}
|
|
524
|
-
function isResolvedProviderName(value) {
|
|
525
|
-
return RESOLVED_PROVIDER_NAMES.includes(value);
|
|
526
|
-
}
|
|
527
|
-
/** One DNS-style label: `[a-z0-9]`, internal `-` allowed, never leading/trailing. */
|
|
528
|
-
const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
529
|
-
/**
|
|
530
|
-
* Is `value` shaped like an endpoint ADDRESS — the only bare form that
|
|
531
|
-
* {@link migrateExclusionPattern} routes to the `host:` axis?
|
|
532
|
-
*
|
|
533
|
-
* **THE RULE**, stated here because the whole value of this predicate is that an
|
|
534
|
-
* operator can predict it: strip one optional trailing `:<port>` (1–5 digits),
|
|
535
|
-
* then accept what remains **iff** it is the literal `localhost` **or** a DOTTED
|
|
536
|
-
* name — two or more `.`-separated {@link HOST_LABEL}s. An IPv4 literal satisfies
|
|
537
|
-
* the dotted rule and needs no special case.
|
|
538
|
-
*
|
|
539
|
-
* Host-shaped: `integrate.api.nvidia.com`, `nim.invalid`, `nim.invalid:8443`,
|
|
540
|
-
* `localhost`, `localhost:8000`, `127.0.0.1:8791`.
|
|
541
|
-
* NOT host-shaped: `model-a`, `model:model-a`, `foo:bar`, `` (empty).
|
|
542
|
-
*
|
|
543
|
-
* **Why a dot (or the single `localhost` exception) is REQUIRED.** The bare legacy
|
|
544
|
-
* grammar had exactly two tiers — a known provider name, or an endpoint address —
|
|
545
|
-
* so a single bare label that is not a provider was never a rule anyone could have
|
|
546
|
-
* meant. That is precisely the typo class, and admitting it is what let a typo
|
|
547
|
-
* launder into a valid-but-inert `host:` rule.
|
|
548
|
-
*
|
|
549
|
-
* **Deliberately NOT accepted:** a bare launcher COMMAND PATH (`c:\tools\codex.cmd`),
|
|
550
|
-
* which the `host:` matcher does support against a non-URL endpoint. Bare, it is
|
|
551
|
-
* indistinguishable from `<typo>:<typo>`, so it must be written axis-explicitly
|
|
552
|
-
* (`host:c:\tools\codex.cmd`) — which {@link isGrammaticalExclusionPattern} accepts.
|
|
553
|
-
*
|
|
554
|
-
* **Known over-acceptance, and why it is fine:** a dotted MODEL id (`qwen2.5`)
|
|
555
|
-
* reads as host-shaped. This is grammar only — the zero-match case is caught,
|
|
556
|
-
* non-blocking, by {@link unmatchedExclusionPatterns}.
|
|
557
|
-
*/
|
|
558
|
-
function isHostShapedExclusionValue(value) {
|
|
559
|
-
const lower = value.toLowerCase();
|
|
560
|
-
const withPort = /^(.*):(\d{1,5})$/.exec(lower);
|
|
561
|
-
const address = withPort ? withPort[1] : lower;
|
|
562
|
-
if (address.length === 0)
|
|
563
|
-
return false;
|
|
564
|
-
if (address === "localhost")
|
|
565
|
-
return true;
|
|
566
|
-
const labels = address.split(".");
|
|
567
|
-
return labels.length >= 2 && labels.every((label) => HOST_LABEL.test(label));
|
|
568
|
-
}
|
|
569
|
-
/**
|
|
570
|
-
* Is `pattern` a rule an operator could actually have MEANT — i.e. does it parse
|
|
571
|
-
* to something under the grammar, either directly or via the read-time migration?
|
|
572
|
-
*
|
|
573
|
-
* ⚠ **AUTHORSHIP-ONLY.** Applied where the operator TYPES a rule
|
|
574
|
-
* ({@link parseProviderConfirmationInput}), never at read time.
|
|
575
|
-
* {@link migrateExclusionPattern} must keep absorbing ALREADY-SAVED bare forms —
|
|
576
|
-
* deleting it would silently un-exclude a backend an operator ruled out under the
|
|
577
|
-
* old grammar. So both layers accept the SAME language; this one only refuses to
|
|
578
|
-
* let a NEW member of the laundered class be written in the first place.
|
|
579
|
-
*
|
|
580
|
-
* **The defect this closes.** Migrate-on-read rewrites any unrecognized head into
|
|
581
|
-
* `host:<pattern>`, so `model:model-a` became a structurally VALID host rule that
|
|
582
|
-
* matches zero backends and never reached {@link parseExclusionRule}'s `invalid`
|
|
583
|
-
* branch. A typo persisted as policy and silently excluded nothing.
|
|
584
|
-
*
|
|
585
|
-
* **GRAMMAR ONLY — a zero-match rule is NOT an error and is never refused here.**
|
|
586
|
-
* The tool itself authors zero-match rules on every run (`auto_exclude` for a
|
|
587
|
-
* backend that then goes unreachable), and an operator may legitimately pre-declare
|
|
588
|
-
* an exclusion for a backend that is not reachable right now. So
|
|
589
|
-
* `openai-compatible:model-typo` is ACCEPTED: the head IS a real provider and the
|
|
590
|
-
* model segment is an open string. That case is reported instead, without blocking,
|
|
591
|
-
* by {@link unmatchedExclusionPatterns}.
|
|
592
|
-
*/
|
|
593
|
-
function isGrammaticalExclusionPattern(pattern) {
|
|
594
|
-
const colon = pattern.indexOf(":");
|
|
595
|
-
if (colon > 0) {
|
|
596
|
-
const head = pattern.slice(0, colon);
|
|
597
|
-
// Axis-explicit — the form the Gate-0 prompt teaches. A non-empty remainder is
|
|
598
|
-
// required: `transport:` is the one ungrammatical form the axis branch can
|
|
599
|
-
// produce, and it is what `parseExclusionRule` already calls `invalid`.
|
|
600
|
-
if (VALID_EXCLUSION_AXES.has(head))
|
|
601
|
-
return colon + 1 < pattern.length;
|
|
602
|
-
// Legacy `<provider>:<model>` → migrates to `transport:<provider>/<model>`.
|
|
603
|
-
if (isResolvedProviderName(head))
|
|
604
|
-
return true;
|
|
605
|
-
}
|
|
606
|
-
// Legacy bare forms → `transport:<provider>` / `host:<address>`.
|
|
607
|
-
return isResolvedProviderName(pattern) || isHostShapedExclusionValue(pattern);
|
|
608
|
-
}
|
|
609
|
-
/**
|
|
610
|
-
* The operator-authored patterns that no rule grammar can account for — see
|
|
611
|
-
* {@link isGrammaticalExclusionPattern}. Empty ⇒ every pattern is writable.
|
|
612
|
-
*
|
|
613
|
-
* Order is the operator's own submission order (deduplicated, first occurrence
|
|
614
|
-
* kept), so the refusal names their patterns back in the order they wrote them.
|
|
615
|
-
*/
|
|
616
|
-
export function ungrammaticalExclusionPatterns(patterns) {
|
|
617
|
-
const seen = new Set();
|
|
618
|
-
return patterns.filter((pattern) => !seen.has(pattern) &&
|
|
619
|
-
(seen.add(pattern), !isGrammaticalExclusionPattern(pattern)));
|
|
620
|
-
}
|
|
621
|
-
/**
|
|
622
|
-
* The patterns that matched NOTHING in `backends` — the ADVISORY half, and
|
|
623
|
-
* deliberately not an error.
|
|
624
|
-
*
|
|
625
|
-
* A zero-match rule is legitimate: `auto_exclude` entries go zero-match the moment
|
|
626
|
-
* their backend stops being reachable, and an operator may pre-declare an exclusion
|
|
627
|
-
* for a backend they have not configured yet. It is also the only signal available
|
|
628
|
-
* for the grammatically-valid typo `isGrammaticalExclusionPattern` cannot catch
|
|
629
|
-
* (`openai-compatible:model-typo` — real provider head, open model segment). So it
|
|
630
|
-
* is REPORTED at the confirmation summary and never refused anywhere.
|
|
631
|
-
*
|
|
632
|
-
* Patterns are migrated before matching, so an already-saved bare form is evaluated
|
|
633
|
-
* exactly as it will actually apply at dispatch — not as the string it was typed as.
|
|
634
|
-
*
|
|
635
|
-
* **Empty `backends` ⇒ empty result.** With nothing gathered there is no evidence
|
|
636
|
-
* that any rule failed to match, only that there was nothing to match against, and
|
|
637
|
-
* reporting every rule there would be noise the operator learns to read past.
|
|
638
|
-
*
|
|
639
|
-
* Order is the operator's own submission order (deduplicated, first occurrence kept).
|
|
640
|
-
*/
|
|
641
|
-
export function unmatchedExclusionPatterns(patterns, backends) {
|
|
642
|
-
if (backends.length === 0)
|
|
643
|
-
return [];
|
|
644
|
-
const seen = new Set();
|
|
645
|
-
const unmatched = [];
|
|
646
|
-
for (const pattern of patterns) {
|
|
647
|
-
if (seen.has(pattern))
|
|
648
|
-
continue;
|
|
649
|
-
seen.add(pattern);
|
|
650
|
-
const rule = parseExclusionRule(migrateExclusionPattern(pattern));
|
|
651
|
-
if (!backends.some((backend) => ruleMatches(rule, backend))) {
|
|
652
|
-
unmatched.push(pattern);
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
return unmatched;
|
|
656
|
-
}
|
|
657
|
-
function ruleMatches(rule, backend) {
|
|
658
|
-
switch (rule.kind) {
|
|
659
|
-
case "transport":
|
|
660
|
-
return backend.transport === rule.transport;
|
|
661
|
-
case "transport_model":
|
|
662
|
-
// A model-granular rule matches ONLY that model. A backend of the same
|
|
663
|
-
// transport carrying no model (a CLI whose model arrives at the dispatch
|
|
664
|
-
// handshake) is NOT matched: the operator ruled out one model, not the
|
|
665
|
-
// backend — the coarse `transport` tier is how they rule out the backend.
|
|
666
|
-
return backend.transport === rule.transport && backend.model === rule.model;
|
|
667
|
-
case "service":
|
|
668
|
-
return (backend.service ?? backend.transport) === rule.service;
|
|
669
|
-
case "service_model":
|
|
670
|
-
return ((backend.service ?? backend.transport) === rule.service &&
|
|
671
|
-
backend.model === rule.model);
|
|
672
|
-
case "host":
|
|
673
|
-
return endpointHosts(backend.endpoint).includes(rule.host);
|
|
674
|
-
case "invalid":
|
|
675
|
-
return false;
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
/**
|
|
679
|
-
* The forms of a source endpoint an operator pattern may name: `hostname` (port-
|
|
680
|
-
* agnostic — `integrate.api.nvidia.com` rules out that host on any port) and
|
|
681
|
-
* `host:port` (port-specific — `localhost:8000` rules out one of several local
|
|
682
|
-
* endpoints). Both are offered so the pattern's own specificity decides.
|
|
683
|
-
*
|
|
684
|
-
* An endpoint that is not a URL (a CLI launcher command) degrades to the raw
|
|
685
|
-
* lowercased string, which then only ever matches an identical literal pattern —
|
|
686
|
-
* never a false positive against a real host.
|
|
687
|
-
*
|
|
688
|
-
* ⚠ The authority check is load-bearing, not defensive: `new URL()` accepts ANY
|
|
689
|
-
* scheme-shaped string, so it does NOT throw on `localhost:8000` (protocol
|
|
690
|
-
* `localhost:`) or on a Windows command path like `C:\tools\codex.cmd` (protocol
|
|
691
|
-
* `c:`) — both parse to an EMPTY hostname. Relying on the `catch` alone would
|
|
692
|
-
* therefore silently yield no hosts for exactly those endpoints, making an
|
|
693
|
-
* operator's literal-identical rule match nothing.
|
|
694
|
-
*/
|
|
695
|
-
function endpointHosts(endpoint) {
|
|
696
|
-
if (!endpoint)
|
|
697
|
-
return [];
|
|
698
|
-
const raw = endpoint.toLowerCase();
|
|
699
|
-
if (endpoint.includes("//")) {
|
|
700
|
-
try {
|
|
701
|
-
const url = new URL(endpoint);
|
|
702
|
-
if (url.hostname.length > 0) {
|
|
703
|
-
return [url.hostname.toLowerCase(), url.host.toLowerCase()];
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
catch {
|
|
707
|
-
// Not a URL after all — fall through to the raw literal.
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
return [raw];
|
|
711
|
-
}
|
|
712
|
-
/**
|
|
713
|
-
* Read the operator's confirmed route policy from the shared Gate-0 confirmation.
|
|
714
|
-
*
|
|
715
|
-
* Deliberately reads the artifact DIRECTLY rather than going through
|
|
716
|
-
* {@link readSharedProviderConfirmation}, so that **a corrupt sibling field cannot
|
|
717
|
-
* discard the decision**: `parseSharedProviderConfirmation` returns `null` wholesale
|
|
718
|
-
* on any malformed required field or a `schema_version` mismatch. Routing policy
|
|
719
|
-
* through it would make an unrelated corruption (or a future version bump) silently
|
|
720
|
-
* lift the operator's exclusions — failing OPEN on the one field that must fail
|
|
721
|
-
* closed. Parsing `policy` on its own keeps that blast radius out.
|
|
722
|
-
*
|
|
723
|
-
* (Before G3 this bypass carried a second rationale — dodging the roster-freshness
|
|
724
|
-
* gate. That gate is gone: no read of this artifact is reach-gated any more, so the
|
|
725
|
-
* remaining reason is blast radius alone.)
|
|
726
|
-
*
|
|
727
|
-
* **Honest limit — this is not absolutely fail-closed.** An absent or unparseable
|
|
728
|
-
* artifact yields `null` (no operator policy). That residue is irreducible here: with
|
|
729
|
-
* no readable decision on disk there is nothing to fail closed ON. Self-spawn-blocked
|
|
730
|
-
* providers are still excluded locally by {@link resolveDispatchExclusion}, which
|
|
731
|
-
* needs no artifact.
|
|
732
|
-
*/
|
|
733
|
-
export async function readConfirmedDispatchPolicy(root) {
|
|
734
|
-
if (!root)
|
|
735
|
-
return null;
|
|
736
|
-
let raw;
|
|
737
|
-
try {
|
|
738
|
-
raw = await readJsonFile(sharedProviderConfirmationPath(root));
|
|
739
|
-
}
|
|
740
|
-
catch {
|
|
741
|
-
// Absent (ENOENT) / unreadable / invalid JSON — never-block, same contract as
|
|
742
|
-
// every other read of this artifact.
|
|
743
|
-
return null;
|
|
744
|
-
}
|
|
745
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
746
|
-
return null;
|
|
747
|
-
return parseConfirmedDispatchPolicy(raw.policy) ?? null;
|
|
748
|
-
}
|
|
749
|
-
// ---------------------------------------------------------------------------
|
|
750
|
-
// Write (audit writes it)
|
|
751
|
-
// ---------------------------------------------------------------------------
|
|
752
|
-
/**
|
|
753
|
-
* Atomically write the shared confirmation to
|
|
754
|
-
* `<root>/.audit-tools/provider-confirmation.json`. The durable write goes
|
|
755
|
-
* through the shared atomic writer (temp + atomic rename) and the whole
|
|
756
|
-
* operation is guarded by `withFileLock` so a concurrent writer can never
|
|
757
|
-
* interleave — and a lockless reader (see `readSharedProviderConfirmation`)
|
|
758
|
-
* never observes a torn file (CE-003).
|
|
759
|
-
*/
|
|
760
|
-
export async function writeSharedProviderConfirmation(root, confirmation, logger) {
|
|
761
|
-
const path = sharedProviderConfirmationPath(root);
|
|
762
|
-
const lockPath = `${path}.lock`;
|
|
763
|
-
// Ensure the `.audit-tools` dir exists BEFORE acquiring the lock — the lock is
|
|
764
|
-
// a sibling file, so its atomic `wx` create would otherwise ENOENT on a fresh
|
|
765
|
-
// root (mirrors StateStore.saveState mkdir-then-lock).
|
|
766
|
-
await mkdir(auditToolsDir(root), { recursive: true });
|
|
767
|
-
await withFileLock(lockPath, async () => {
|
|
768
|
-
await writeJsonFile(path, confirmation);
|
|
769
|
-
}, undefined, logger);
|
|
770
|
-
}
|
|
771
|
-
// ---------------------------------------------------------------------------
|
|
772
|
-
// Validation
|
|
773
|
-
// ---------------------------------------------------------------------------
|
|
774
|
-
/**
|
|
775
|
-
* The persisted pool entry's gate: `name` ONLY.
|
|
776
|
-
*
|
|
777
|
-
* B+D: this gate previously hard-required `capability_tier` AND `excluded` — the
|
|
778
|
-
* exact reach fields B removes from the persisted shape. That coupling is why B and
|
|
779
|
-
* D are ONE commit: a post-B artifact failing a pre-B gate parses to `null`, which
|
|
780
|
-
* degrades SILENTLY to empty cost positions and λ=0. Requiring only `name` also
|
|
781
|
-
* makes the gate forward-tolerant of a confirmation written before B (its extra
|
|
782
|
-
* reach fields are simply ignored on read, never re-persisted).
|
|
783
|
-
*/
|
|
784
|
-
function isPersistedPoolEntry(value) {
|
|
785
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
786
|
-
return false;
|
|
787
|
-
}
|
|
788
|
-
return typeof value.name === "string";
|
|
789
|
-
}
|
|
790
|
-
function isHostModelCostEntry(value) {
|
|
791
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
792
|
-
return false;
|
|
793
|
-
}
|
|
794
|
-
const obj = value;
|
|
795
|
-
return (typeof obj.model_id === "string" &&
|
|
796
|
-
// Optional (pre-field confirmations parse), but when present it must be a real
|
|
797
|
-
// provider name — `confirmedBackendKeys` builds a gate key from it, and the
|
|
798
|
-
// validator guards every field its callers read.
|
|
799
|
-
(obj.provider === undefined ||
|
|
800
|
-
(typeof obj.provider === "string" && isResolvedProviderName(obj.provider))) &&
|
|
801
|
-
(obj.blended_price_usd_per_mtok === null ||
|
|
802
|
-
typeof obj.blended_price_usd_per_mtok === "number") &&
|
|
803
|
-
typeof obj.cost_order === "number");
|
|
804
|
-
}
|
|
805
|
-
function isSourcePoolCostEntry(value) {
|
|
806
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
807
|
-
return false;
|
|
808
|
-
}
|
|
809
|
-
const obj = value;
|
|
810
|
-
return (typeof obj.source_id === "string" &&
|
|
811
|
-
typeof obj.transport === "string" &&
|
|
812
|
-
// Optional, but guarded: `confirmedBackendKeys` builds a gate key from it.
|
|
813
|
-
(obj.service === undefined ||
|
|
814
|
-
typeof obj.service === "string") &&
|
|
815
|
-
(obj.model_id === undefined || typeof obj.model_id === "string") &&
|
|
816
|
-
(obj.blended_price_usd_per_mtok === null ||
|
|
817
|
-
typeof obj.blended_price_usd_per_mtok === "number") &&
|
|
818
|
-
typeof obj.cost_order === "number");
|
|
819
|
-
}
|
|
820
|
-
/**
|
|
821
|
-
* Validate a parsed value as a SharedProviderConfirmation. Returns the typed
|
|
822
|
-
* value or `null` when any required field is missing or malformed — a corrupt
|
|
823
|
-
* artifact must degrade to the never-block path, never throw.
|
|
824
|
-
*/
|
|
825
|
-
function parseSharedProviderConfirmation(value) {
|
|
826
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
827
|
-
return null;
|
|
828
|
-
}
|
|
829
|
-
const obj = value;
|
|
830
|
-
if (obj.schema_version !== SHARED_PROVIDER_CONFIRMATION_VERSION)
|
|
831
|
-
return null;
|
|
832
|
-
if (obj.session_level !== true)
|
|
833
|
-
return null;
|
|
834
|
-
if (typeof obj.confirmed_at !== "string")
|
|
835
|
-
return null;
|
|
836
|
-
if (!Array.isArray(obj.provider_pool) ||
|
|
837
|
-
!obj.provider_pool.every(isPersistedPoolEntry)) {
|
|
838
|
-
return null;
|
|
839
|
-
}
|
|
840
|
-
// host_model_cost_order is optional + additive; a malformed value degrades to
|
|
841
|
-
// absent (the field never blocks parsing — INV-DC1-6 never-block spirit).
|
|
842
|
-
const hostModels = Array.isArray(obj.host_model_cost_order) &&
|
|
843
|
-
obj.host_model_cost_order.every(isHostModelCostEntry)
|
|
844
|
-
? obj.host_model_cost_order
|
|
845
|
-
: undefined;
|
|
846
|
-
// source_pool_cost_order is optional + additive; a malformed value degrades to absent.
|
|
847
|
-
const sourcePools = Array.isArray(obj.source_pool_cost_order) &&
|
|
848
|
-
obj.source_pool_cost_order.every(isSourcePoolCostEntry)
|
|
849
|
-
? obj.source_pool_cost_order
|
|
850
|
-
: undefined;
|
|
851
|
-
// dispatch_bias is optional + additive; a malformed/out-of-range value degrades to
|
|
852
|
-
// the cost-first default (clamp → undefined only when non-finite), never blocking.
|
|
853
|
-
const dispatchBias = clampDispatchBias(obj.dispatch_bias);
|
|
854
|
-
// policy is optional + additive; a malformed value degrades to absent. Note the
|
|
855
|
-
// asymmetry with the fields above: degrading policy to absent fails OPEN (the
|
|
856
|
-
// operator's exclusions stop applying), so each list is validated independently —
|
|
857
|
-
// a malformed `include` must not silently discard a well-formed `exclude`.
|
|
858
|
-
const policy = parseConfirmedDispatchPolicy(obj.policy);
|
|
859
|
-
return {
|
|
860
|
-
schema_version: SHARED_PROVIDER_CONFIRMATION_VERSION,
|
|
861
|
-
session_level: true,
|
|
862
|
-
confirmed_at: obj.confirmed_at,
|
|
863
|
-
provider_pool: obj.provider_pool,
|
|
864
|
-
...(hostModels && hostModels.length > 0
|
|
865
|
-
? { host_model_cost_order: hostModels }
|
|
866
|
-
: {}),
|
|
867
|
-
...(sourcePools && sourcePools.length > 0
|
|
868
|
-
? { source_pool_cost_order: sourcePools }
|
|
869
|
-
: {}),
|
|
870
|
-
...(dispatchBias != null ? { dispatch_bias: dispatchBias } : {}),
|
|
871
|
-
...(policy ? { policy } : {}),
|
|
872
|
-
};
|
|
873
|
-
}
|
|
874
|
-
/**
|
|
875
|
-
* Parse the optional policy half. Each list is validated on its own so one
|
|
876
|
-
* malformed list cannot discard the other — degrading a well-formed `exclude` to
|
|
877
|
-
* absent would fail OPEN and route to a backend the operator ruled out.
|
|
878
|
-
*/
|
|
879
|
-
function parseConfirmedDispatchPolicy(value) {
|
|
880
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
881
|
-
return undefined;
|
|
882
|
-
}
|
|
883
|
-
const obj = value;
|
|
884
|
-
// `exclude` is the OPEN exclusion grammar (a pattern's head need not be a provider
|
|
885
|
-
// name — the endpoint tier's never is), so it is kept verbatim; an unmatchable
|
|
886
|
-
// pattern is inert, which is the safe direction for a filter. `include` is the
|
|
887
|
-
// CLOSED provider-name set and stays membership-checked, so an unknown name cannot
|
|
888
|
-
// type-assert its way into overriding a self-spawn block.
|
|
889
|
-
const exclude = parseExclusionPatterns(obj.exclude);
|
|
890
|
-
const autoExclude = parseExclusionPatterns(obj.auto_exclude);
|
|
891
|
-
const include = parseProviderNameList(obj.include);
|
|
892
|
-
// Same open-grammar treatment as `exclude`: a capability key is a model id, not a
|
|
893
|
-
// member of any closed set, so it is kept verbatim and an unmatchable key is inert.
|
|
894
|
-
const capabilityOrder = Array.isArray(obj.capability_order)
|
|
895
|
-
? obj.capability_order.filter((v) => typeof v === "string" && v.trim().length > 0)
|
|
896
|
-
: undefined;
|
|
897
|
-
// R3-3 provenance — same open-grammar, verbatim treatment: a set of model ids, not
|
|
898
|
-
// itself a positional ordering, so order within the parsed array is immaterial (the
|
|
899
|
-
// writer sorts it anyway). Must be reconstructed here explicitly like every other
|
|
900
|
-
// field on this parser: an unlisted field is silently dropped on every round-trip,
|
|
901
|
-
// and dropping this one is not a degrade — it reverts every LLM-ranked id to
|
|
902
|
-
// "unattributed" (still a valid rank, just an authorship the next operator
|
|
903
|
-
// repositioning would then wrongly discard-and-report as a moved anchor instead of
|
|
904
|
-
// silently honoring — see `capabilityOrderNonAnchors`).
|
|
905
|
-
const capabilityOrderLlmRanked = Array.isArray(obj.capability_order_llm_ranked)
|
|
906
|
-
? obj.capability_order_llm_ranked.filter((v) => typeof v === "string" && v.trim().length > 0)
|
|
907
|
-
: undefined;
|
|
908
|
-
if (!exclude?.length &&
|
|
909
|
-
!autoExclude?.length &&
|
|
910
|
-
!include?.length &&
|
|
911
|
-
!capabilityOrder?.length &&
|
|
912
|
-
!capabilityOrderLlmRanked?.length) {
|
|
913
|
-
return undefined;
|
|
914
|
-
}
|
|
915
|
-
return {
|
|
916
|
-
...(exclude?.length ? { exclude } : {}),
|
|
917
|
-
...(autoExclude?.length ? { auto_exclude: autoExclude } : {}),
|
|
918
|
-
...(include?.length ? { include } : {}),
|
|
919
|
-
...(capabilityOrder?.length ? { capability_order: capabilityOrder } : {}),
|
|
920
|
-
...(capabilityOrderLlmRanked?.length
|
|
921
|
-
? { capability_order_llm_ranked: capabilityOrderLlmRanked }
|
|
922
|
-
: {}),
|
|
923
|
-
};
|
|
924
|
-
}
|
|
925
|
-
// ---------------------------------------------------------------------------
|
|
926
|
-
// Read (remediate gains this)
|
|
927
|
-
// ---------------------------------------------------------------------------
|
|
928
|
-
/**
|
|
929
|
-
* Read + parse the shared confirmation for `root`. TWO-valued:
|
|
930
|
-
*
|
|
931
|
-
* - returns `null` when the artifact is ABSENT or MALFORMED — the caller then
|
|
932
|
-
* resolves its provider independently, exactly as today (INV-DC1-6
|
|
933
|
-
* never-block). Absence is the standalone-remediate case and is not an error.
|
|
934
|
-
* - returns the parsed confirmation otherwise — the operator's persisted route
|
|
935
|
-
* DECISION, honored as-is.
|
|
936
|
-
*
|
|
937
|
-
* **Reach-free by construction (G3).** This read does NOT check whether the
|
|
938
|
-
* reachable backend set still matches whatever the writing auditor saw. It cannot
|
|
939
|
-
* meaningfully: a *different* auditor legitimately has different reach, so that
|
|
940
|
-
* comparison was noise cross-auditor — and answering it by discarding the
|
|
941
|
-
* operator's decision fails OPEN on a policy question. A backend becoming newly
|
|
942
|
-
* reachable is a real event, handled by the reconciliation gate
|
|
943
|
-
* ({@link computeNewlyReachableBackends}), which compares the DECISION against
|
|
944
|
-
* *this* auditor's reach and is keyed on `autonomous_mode`.
|
|
945
|
-
*
|
|
946
|
-
* Never throws: a read/parse failure is treated as absent/malformed → `null`.
|
|
947
|
-
* The read is lockless (no lock needed: the writer's atomic rename guarantees a
|
|
948
|
-
* complete file either way — CE-003) and so cannot deadlock against a writer.
|
|
949
|
-
*/
|
|
950
|
-
export async function readSharedProviderConfirmation(root) {
|
|
951
|
-
let raw;
|
|
952
|
-
try {
|
|
953
|
-
raw = await readJsonFile(sharedProviderConfirmationPath(root));
|
|
954
|
-
}
|
|
955
|
-
catch (error) {
|
|
956
|
-
// Both degrade to the never-block path, but only ABSENCE is legitimately silent.
|
|
957
|
-
//
|
|
958
|
-
// A file that EXISTS and cannot be read (truncated, invalid JSON, permissions) used
|
|
959
|
-
// to return `null` with no warning, and that composed with two other
|
|
960
|
-
// individually-justified silences into TOTAL silence: no pool gets a rank ⇒
|
|
961
|
-
// `anyBanded === false`, which by design suppresses the capability fail-open
|
|
962
|
-
// reporter; and `resolveUnevidencedCapabilityPools` returns `[]` on a null
|
|
963
|
-
// confirmation ⇒ the obligation reports SATISFIED. Net effect on a corrupt file: the
|
|
964
|
-
// capability floor is globally inert, every `deep` packet routes anywhere, and not
|
|
965
|
-
// one path says a word — the exact case the loud path was built for.
|
|
966
|
-
if (error?.code !== "ENOENT") {
|
|
967
|
-
warnConfirmationUnreadable(error, root);
|
|
968
|
-
}
|
|
969
|
-
return null;
|
|
970
|
-
}
|
|
971
|
-
// Malformed (wrong shape / version drift) → never-block, but never SILENT (D).
|
|
972
|
-
const parsed = parseSharedProviderConfirmation(raw);
|
|
973
|
-
if (parsed === null)
|
|
974
|
-
warnConfirmationRejected(raw, root);
|
|
975
|
-
return parsed;
|
|
976
|
-
}
|
|
977
|
-
/**
|
|
978
|
-
* D — loud rejection. `null` from the parser is indistinguishable at the call sites
|
|
979
|
-
* from "no confirmation exists", and every consumer treats that as "no operator
|
|
980
|
-
* decision": empty cost positions, λ=0, no exclusions. So a `schema_version` bump —
|
|
981
|
-
* or any shape drift — would SILENTLY discard the operator's whole route decision
|
|
982
|
-
* and quietly re-route the run. Absence is legitimately silent; a file that EXISTS
|
|
983
|
-
* and was rejected is not.
|
|
984
|
-
*
|
|
985
|
-
* A warning, not a throw: INV-DC1-6 (never-block) is the standing invariant here —
|
|
986
|
-
* the same loud-degrade shape as `readQuotaStateOrDegrade` and the blind-dispatch
|
|
987
|
-
* warning. Reached only on the rejection path, so it cannot become hot.
|
|
988
|
-
*/
|
|
989
|
-
/**
|
|
990
|
-
* The sibling of {@link warnConfirmationRejected} for a file that exists but cannot be
|
|
991
|
-
* READ (truncated write, invalid JSON, permissions) — the case that used to degrade
|
|
992
|
-
* silently. Same never-block contract, same loud-degrade shape.
|
|
993
|
-
*
|
|
994
|
-
* The message names the CAPABILITY consequence explicitly because that is the one a
|
|
995
|
-
* corrupt file silences most dangerously: with no ranks, nothing bands, the fail-open
|
|
996
|
-
* reporter self-suppresses (it fires only when something else banded), and the
|
|
997
|
-
* capability obligation simultaneously reports satisfied. Nothing else would tell the
|
|
998
|
-
* operator their capability floor stopped existing.
|
|
999
|
-
*/
|
|
1000
|
-
function warnConfirmationUnreadable(error, root) {
|
|
1001
|
-
const why = error instanceof Error ? error.message : String(error);
|
|
1002
|
-
process.stderr.write(`WARNING: the provider confirmation at ${sharedProviderConfirmationPath(root)} exists ` +
|
|
1003
|
-
`but could NOT be read (${why}). It is being treated as absent: the operator's ` +
|
|
1004
|
-
`confirmed cost order, dispatch bias, and CAPABILITY RANKS are all unavailable, so ` +
|
|
1005
|
-
`the admission capability floor is inert for this run and packets may route to pools ` +
|
|
1006
|
-
`above their capability. Repair or delete the file and re-confirm at Gate-0.\n`);
|
|
1007
|
-
}
|
|
1008
|
-
function warnConfirmationRejected(raw, root) {
|
|
1009
|
-
const version = raw !== null && typeof raw === "object" && !Array.isArray(raw)
|
|
1010
|
-
? raw.schema_version
|
|
1011
|
-
: undefined;
|
|
1012
|
-
const why = version !== undefined && version !== SHARED_PROVIDER_CONFIRMATION_VERSION
|
|
1013
|
-
? `schema_version "${String(version)}" != expected "${SHARED_PROVIDER_CONFIRMATION_VERSION}"`
|
|
1014
|
-
: "malformed shape";
|
|
1015
|
-
// Scoped to what the rejection ACTUALLY discards. Exclusions deliberately survive:
|
|
1016
|
-
// `readConfirmedDispatchPolicy` bypasses this parser (and its version check) and reads
|
|
1017
|
-
// `policy` straight off the raw JSON, so an exclusion keeps failing CLOSED through a
|
|
1018
|
-
// schema drift — the correct direction, and the reason this message must NOT claim the
|
|
1019
|
-
// pool is unfiltered. Saying otherwise sends the operator hunting a routing change that
|
|
1020
|
-
// did not happen.
|
|
1021
|
-
process.stderr.write(`WARNING: ignoring the provider confirmation at ${sharedProviderConfirmationPath(root)} ` +
|
|
1022
|
-
`(${why}). The operator's confirmed COST ORDER and DISPATCH BIAS are NOT being applied ` +
|
|
1023
|
-
`to this run — dispatch falls back to price-then-tier at the default bias. ` +
|
|
1024
|
-
`(Exclusions are read separately and still apply.) Re-run the provider-confirmation ` +
|
|
1025
|
-
`gate to rewrite it.
|
|
1026
|
-
`);
|
|
1027
|
-
}
|
|
1028
|
-
/**
|
|
1029
|
-
* Read the operator-confirmed cost ordering (rung 1 of costRank; see
|
|
1030
|
-
* spec/dispatch-quota.md) from the shared Gate-0 confirmation as a model-keyed
|
|
1031
|
-
* `Map<model_id, cost_order>` for the dispatch build sites. Single-sourced so audit
|
|
1032
|
-
* and remediate honor it identically. Best-effort and never throws: an absent
|
|
1033
|
-
* `root` or a missing/malformed confirmation yields an empty map — dispatch then
|
|
1034
|
-
* falls to real price then tier.
|
|
1035
|
-
*
|
|
1036
|
-
* **Not gated on reach (G3 step 1).** The cost order is the operator's POLICY —
|
|
1037
|
-
* "the operator may reorder" — so a shift in what happens to be reachable must not
|
|
1038
|
-
* silently discard it. The former roster-freshness gate did exactly that, on the
|
|
1039
|
-
* false premise that these positions are reach-derived; they are not, and it was
|
|
1040
|
-
* the live defect this fixes.
|
|
1041
|
-
*/
|
|
1042
|
-
export async function readConfirmedCostPositions(root) {
|
|
1043
|
-
if (!root)
|
|
1044
|
-
return new Map();
|
|
1045
|
-
const confirmation = await readSharedProviderConfirmation(root);
|
|
1046
|
-
if (!confirmation)
|
|
1047
|
-
return new Map();
|
|
1048
|
-
// Provider-pool positions (configured models) PLUS any host-native tiers the
|
|
1049
|
-
// operator confirmed at Gate-0 (follow-up c). Both are model-keyed; a host tier
|
|
1050
|
-
// and a configured pool thread to dispatch identically. Host entries are already
|
|
1051
|
-
// in the single unified cost order, so a plain merge preserves the total order.
|
|
1052
|
-
const positions = resolveConfirmedCostPositions(confirmation.provider_pool);
|
|
1053
|
-
for (const entry of confirmation.host_model_cost_order ?? []) {
|
|
1054
|
-
if (entry.model_id &&
|
|
1055
|
-
Number.isFinite(entry.cost_order) &&
|
|
1056
|
-
entry.cost_order >= 0) {
|
|
1057
|
-
positions.set(entry.model_id, entry.cost_order);
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
// Source pools (explicit sources[] + proxy expansion) route by their confirmed
|
|
1061
|
-
// position keyed on the source's model id — the SAME model-keyed lookup a proxy
|
|
1062
|
-
// dispatch pool resolves against (pool.model = the namespaced `provider/model`). An entry
|
|
1063
|
-
// without a model_id is display-only and contributes no dispatch position.
|
|
1064
|
-
for (const entry of confirmation.source_pool_cost_order ?? []) {
|
|
1065
|
-
if (entry.model_id &&
|
|
1066
|
-
Number.isFinite(entry.cost_order) &&
|
|
1067
|
-
entry.cost_order >= 0 &&
|
|
1068
|
-
// FIRST-WINS, and this guard is load-bearing. This map is keyed by BARE model
|
|
1069
|
-
// id (costRank looks positions up with no service in hand), so two backends on
|
|
1070
|
-
// different services sharing a model string necessarily collide here. The
|
|
1071
|
-
// source fold used to prevent that by DROPPING such a source outright — which
|
|
1072
|
-
// silently cost the confirmed set that source's identity and livelocked its
|
|
1073
|
-
// Gate-0 confirmation. The fold is now identity-keyed and keeps the source, so
|
|
1074
|
-
// the collision surfaces here instead and is resolved in favor of the
|
|
1075
|
-
// provider/host entry, preserving the original "a source must not overwrite a
|
|
1076
|
-
// configured pool's position" intent without discarding the source.
|
|
1077
|
-
!positions.has(entry.model_id)) {
|
|
1078
|
-
positions.set(entry.model_id, entry.cost_order);
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
return positions;
|
|
1082
|
-
}
|
|
1083
|
-
/**
|
|
1084
|
-
* THE enumeration of every persisted entry that can carry a `capability_rank`, across
|
|
1085
|
-
* all three confirmation arrays.
|
|
1086
|
-
*
|
|
1087
|
-
* Single-sourced deliberately, and this is the fix for the round-3 critical defect: the
|
|
1088
|
-
* rank JOIN read three arrays while the evidence OBLIGATION enumerated only two, so
|
|
1089
|
-
* `provider_pool` was a rank SOURCE but never a delta SUBJECT — the conversation-first
|
|
1090
|
-
* default (host pool, no volunteered roster) therefore banded `null` forever and the
|
|
1091
|
-
* gate never once asked about it. Two independent walks over "the same" set is exactly
|
|
1092
|
-
* the drift this project keeps paying for; with one walk, a new rank-bearing array is
|
|
1093
|
-
* added HERE and both consumers follow automatically. Do not re-inline either walk.
|
|
1094
|
-
*/
|
|
1095
|
-
function* capabilitySubjects(confirmation) {
|
|
1096
|
-
for (const entry of confirmation.provider_pool ?? []) {
|
|
1097
|
-
yield { modelId: entry.model_id, rank: entry.capability_rank, excluded: entry.excluded === true };
|
|
1098
|
-
}
|
|
1099
|
-
for (const entry of confirmation.host_model_cost_order ?? []) {
|
|
1100
|
-
yield { modelId: entry.model_id, rank: entry.capability_rank, excluded: false };
|
|
1101
|
-
}
|
|
1102
|
-
// `SourcePoolCostEntry.capability_rank` has existed and been WRITTEN since the Gate-0
|
|
1103
|
-
// source fold (providerConfirmation.ts) with no reader at all. This is that reader.
|
|
1104
|
-
for (const entry of confirmation.source_pool_cost_order ?? []) {
|
|
1105
|
-
yield { modelId: entry.model_id, rank: entry.capability_rank, excluded: false };
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
/**
|
|
1109
|
-
* Is this a usable capability rank? A rank is a position in a relative ordering: finite
|
|
1110
|
-
* and non-negative. Shared by the join and the delta so "evidenced" means the same thing
|
|
1111
|
-
* to both — a predicate they disagreed on would re-create the drift above in miniature.
|
|
1112
|
-
*/
|
|
1113
|
-
function isUsableRank(rank) {
|
|
1114
|
-
return rank !== undefined && Number.isFinite(rank) && rank >= 0;
|
|
1115
|
-
}
|
|
1116
|
-
export async function readConfirmedCapabilityRanks(root) {
|
|
1117
|
-
if (!root)
|
|
1118
|
-
return new Map();
|
|
1119
|
-
const confirmation = await readSharedProviderConfirmation(root);
|
|
1120
|
-
if (!confirmation)
|
|
1121
|
-
return new Map();
|
|
1122
|
-
const ranks = new Map();
|
|
1123
|
-
for (const subject of capabilitySubjects(confirmation)) {
|
|
1124
|
-
// An entry without a model_id is display-only and contributes no dispatch rank (it
|
|
1125
|
-
// is unjoinable by the model-keyed lookup — the infinite-re-prompt trap).
|
|
1126
|
-
if (!subject.modelId || !isUsableRank(subject.rank))
|
|
1127
|
-
continue;
|
|
1128
|
-
ranks.set(subject.modelId, subject.rank);
|
|
1129
|
-
}
|
|
1130
|
-
return ranks;
|
|
1131
|
-
}
|
|
1132
|
-
/**
|
|
1133
|
-
* The capability-evidence delta: dispatchable models with NO resolvable capability
|
|
1134
|
-
* rank. Computed once per invocation (it reads the confirmation + gathers sources)
|
|
1135
|
-
* and threaded by reference on the gate, exactly like the reach delta.
|
|
1136
|
-
*
|
|
1137
|
-
* "Evidenced" is deliberately defined as **the dispatch join resolves** — the same
|
|
1138
|
-
* lookup the pool constructors take ({@link readConfirmedCapabilityRanks} keyed on the
|
|
1139
|
-
* pool's model), never a parallel predicate. Two consequences, both load-bearing:
|
|
1140
|
-
* - a pool with NO model is skipped entirely. It is unjoinable, so pinning it could
|
|
1141
|
-
* never clear the delta and it would re-prompt forever.
|
|
1142
|
-
* - external evidence (`source.capability_rank`) counts, so a fully-ranked roster
|
|
1143
|
-
* never fires the gate at all.
|
|
1144
|
-
*
|
|
1145
|
-
* Returns [] when no confirmation exists yet — the first-time `missing` case already
|
|
1146
|
-
* pauses for the operator, and reporting a delta against a pool they have never seen
|
|
1147
|
-
* would fold a second question into a prompt that has not asked the first one yet.
|
|
1148
|
-
*
|
|
1149
|
-
* Lives HERE, beside {@link readConfirmedCapabilityRanks}, rather than in the audit CLI
|
|
1150
|
-
* command it is called from: its failure mode is a LIVELOCK (wrongly admitting an
|
|
1151
|
-
* unrankable pool re-prompts `provider_confirmation` forever), and a delta computation
|
|
1152
|
-
* with that failure mode must be reachable by a test.
|
|
1153
|
-
*/
|
|
1154
|
-
export async function resolveUnevidencedCapabilityPools(root, effectiveConfig) {
|
|
1155
|
-
const confirmation = await readSharedProviderConfirmation(root);
|
|
1156
|
-
if (!confirmation)
|
|
1157
|
-
return [];
|
|
1158
|
-
const primaryProviderName = resolveFreshSessionProviderName(undefined, effectiveConfig, { env: process.env });
|
|
1159
|
-
const { sources } = await gatherDispatchableSources(effectiveConfig, primaryProviderName);
|
|
1160
|
-
const confirmedRanks = await readConfirmedCapabilityRanks(root);
|
|
1161
|
-
const unevidenced = new Set();
|
|
1162
|
-
for (const source of sources) {
|
|
1163
|
-
// Unjoinable ⇒ unpinnable ⇒ never admitted to the delta (see above).
|
|
1164
|
-
if (!source.model)
|
|
1165
|
-
continue;
|
|
1166
|
-
if (source.capability_rank != null)
|
|
1167
|
-
continue;
|
|
1168
|
-
if (confirmedRanks.has(source.model))
|
|
1169
|
-
continue;
|
|
1170
|
-
unevidenced.add(source.model);
|
|
1171
|
-
}
|
|
1172
|
-
// Every PERSISTED rank-bearing entry, walked through the SAME enumeration the join
|
|
1173
|
-
// uses ({@link capabilitySubjects}) so the two can never disagree about what the
|
|
1174
|
-
// subjects are. Host models are ranked exactly like any other model — the host is not
|
|
1175
|
-
// a special case, it was simply never looked up — and `provider_pool` is included
|
|
1176
|
-
// here, which it previously was not (the round-3 critical defect: the default
|
|
1177
|
-
// conversation-first pool was unrankable AND unpinnable, so the fail-open it caused
|
|
1178
|
-
// had no road to a fix).
|
|
1179
|
-
for (const subject of capabilitySubjects(confirmation)) {
|
|
1180
|
-
// Unjoinable ⇒ unpinnable ⇒ never admitted to the delta, same rule as sources.
|
|
1181
|
-
if (!subject.modelId)
|
|
1182
|
-
continue;
|
|
1183
|
-
// An excluded pool never dispatches, so it needs no capability evidence — asking
|
|
1184
|
-
// about it would be a question whose answer changes nothing.
|
|
1185
|
-
if (subject.excluded)
|
|
1186
|
-
continue;
|
|
1187
|
-
if (isUsableRank(subject.rank))
|
|
1188
|
-
continue;
|
|
1189
|
-
if (confirmedRanks.has(subject.modelId))
|
|
1190
|
-
continue;
|
|
1191
|
-
unevidenced.add(subject.modelId);
|
|
1192
|
-
}
|
|
1193
|
-
// Stable, content-derived order (never gather/iteration order) — this string list
|
|
1194
|
-
// reaches the obligation's reason text and the prompt, and an incidentally-ordered
|
|
1195
|
-
// array churns downstream content hashes.
|
|
1196
|
-
return [...unevidenced].sort();
|
|
1197
|
-
}
|
|
1198
|
-
/**
|
|
1199
|
-
* Read the operator-confirmed cost↔speed dispatch bias (λ ∈ [0,1]) from the shared
|
|
1200
|
-
* Gate-0 confirmation for the dispatch build sites (spec/dispatch-quota.md).
|
|
1201
|
-
* Single-sourced so audit and remediate apply the identical operating point.
|
|
1202
|
-
* Best-effort and never throws: an absent `root`, a missing/malformed confirmation,
|
|
1203
|
-
* or an absent field all yield the cost-first default `0`.
|
|
1204
|
-
*
|
|
1205
|
-
* **Not gated on reach (G3 step 1)** — λ is the operator's durable operating point
|
|
1206
|
-
* on the cost-vs-throughput frontier, i.e. POLICY. See
|
|
1207
|
-
* {@link readConfirmedCostPositions}.
|
|
1208
|
-
*/
|
|
1209
|
-
export async function readConfirmedDispatchBias(root) {
|
|
1210
|
-
if (!root)
|
|
1211
|
-
return 0;
|
|
1212
|
-
const confirmation = await readSharedProviderConfirmation(root);
|
|
1213
|
-
if (!confirmation)
|
|
1214
|
-
return 0;
|
|
1215
|
-
return clampDispatchBias(confirmation.dispatch_bias) ?? 0;
|
|
1216
|
-
}
|
|
1217
|
-
// ---------------------------------------------------------------------------
|
|
1218
|
-
// Interactive Gate-0 operator input (spec/dispatch-quota.md — Gate-0)
|
|
1219
|
-
// ---------------------------------------------------------------------------
|
|
1220
|
-
/** File name of the host-written Gate-0 input under the audit artifacts dir. */
|
|
1221
|
-
export const PROVIDER_CONFIRMATION_INPUT_FILENAME = "provider-confirmation.input.json";
|
|
1222
|
-
/**
|
|
1223
|
-
* Validate a parsed value as a ProviderConfirmationInput. Degrade-safe: returns
|
|
1224
|
-
* `null` for absent/malformed so a missing or corrupt input is never an error
|
|
1225
|
-
* (the executor then falls back to the tool's suggested ordering). Only the
|
|
1226
|
-
* version is required; every other field is optional and validated to its
|
|
1227
|
-
* expected shape (a malformed field is dropped, not fatal).
|
|
1228
|
-
*
|
|
1229
|
-
* ⚠ **One deliberate exception: an ungrammatical `exclude` rule THROWS.** Every
|
|
1230
|
-
* other field degrades because dropping it costs the operator only that field's
|
|
1231
|
-
* effect; dropping an exclusion costs them a backend they ruled out, dispatchable
|
|
1232
|
-
* and silent. See the `exclude` branch below for the full argument.
|
|
1233
|
-
*/
|
|
1234
|
-
export function parseProviderConfirmationInput(value) {
|
|
1235
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1236
|
-
return null;
|
|
1237
|
-
}
|
|
1238
|
-
const obj = value;
|
|
1239
|
-
if (obj.schema_version !== PROVIDER_CONFIRMATION_INPUT_VERSION)
|
|
1240
|
-
return null;
|
|
1241
|
-
const stringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string")
|
|
1242
|
-
? v
|
|
1243
|
-
: undefined;
|
|
1244
|
-
const costOrder = stringArray(obj.cost_order);
|
|
1245
|
-
// The capability-evidence answer. MUST be reconstructed here explicitly: this parser
|
|
1246
|
-
// is field-by-field, so an unlisted field is silently dropped — and dropping THIS one
|
|
1247
|
-
// is not a degrade, it is a livelock. The operator answers the prompt, the answer
|
|
1248
|
-
// never reaches `annotateConfirmedPool`, no `capability_rank` is written, the delta
|
|
1249
|
-
// recomputes identical, and `provider_confirmation` (PRIORITY[0]) re-prompts the same
|
|
1250
|
-
// question forever. Any future field on ProviderConfirmationInput needs a line here.
|
|
1251
|
-
const capabilityOrder = stringArray(obj.capability_order);
|
|
1252
|
-
// No cast: `exclude` is the OPEN exclusion grammar, so asserting the operator's
|
|
1253
|
-
// raw strings into the closed provider-name union would be a lie — and the exact
|
|
1254
|
-
// type-assert-your-way-in move the policy parser refuses for `include`.
|
|
1255
|
-
//
|
|
1256
|
-
// Open is not the same as unchecked. THIS is the authorship seam — the one place
|
|
1257
|
-
// an operator TYPES a rule — and it is where an ungrammatical one has to be
|
|
1258
|
-
// refused, because nothing downstream can: `migrateExclusionPattern` rewrites any
|
|
1259
|
-
// unrecognized head into `host:<pattern>`, so `model:model-a` becomes a
|
|
1260
|
-
// structurally VALID host rule that matches zero backends and never reaches
|
|
1261
|
-
// `parseExclusionRule`'s `invalid` branch. Read-time migration STAYS (it is what
|
|
1262
|
-
// keeps an operator's already-saved bare rules excluded); validation is added
|
|
1263
|
-
// here, where a new one is written.
|
|
1264
|
-
const exclude = stringArray(obj.exclude);
|
|
1265
|
-
if (exclude) {
|
|
1266
|
-
// THROWS — the one field in this otherwise degrade-safe parser that refuses
|
|
1267
|
-
// rather than drops, and the asymmetry is the whole point. Dropping a bad rule
|
|
1268
|
-
// fails OPEN: the backend the operator meant to rule out stays dispatchable,
|
|
1269
|
-
// silently. Returning `null` for the whole file is worse still — indistinguishable
|
|
1270
|
-
// from "no submission", so the same prompt re-emits forever. Throwing names the
|
|
1271
|
-
// exact pattern and the fix, and the submission file is NOT consumed (it is
|
|
1272
|
-
// unlinked only after a successful promotion), so the operator edits the typo and
|
|
1273
|
-
// re-runs. Loud and resumable, never silent and lossy.
|
|
1274
|
-
//
|
|
1275
|
-
// Authorship-blind on purpose: an autonomous run's LLM-authored submission is held
|
|
1276
|
-
// to the identical grammar. Its `exclude` would be stripped a step later anyway
|
|
1277
|
-
// (it may only answer `capability_order`), so a rule that cannot even parse is a
|
|
1278
|
-
// malformed submission on either path — one grammar, one seam, no branch to drift.
|
|
1279
|
-
const ungrammatical = ungrammaticalExclusionPatterns(exclude);
|
|
1280
|
-
if (ungrammatical.length > 0) {
|
|
1281
|
-
throw new Error(`Refused the Gate-0 submission: ${ungrammatical.length} \`exclude\` rule(s) ` +
|
|
1282
|
-
`are not valid exclusion patterns — ${ungrammatical
|
|
1283
|
-
.map((p) => JSON.stringify(p))
|
|
1284
|
-
.join(", ")}. ` +
|
|
1285
|
-
`A rule names its axis: \`transport:<provider>\` or ` +
|
|
1286
|
-
`\`transport:<provider>/<model>\` (one adapter), \`service:<vendor>\` or ` +
|
|
1287
|
-
`\`service:<vendor>/<model>\` (one vendor, however reached), or ` +
|
|
1288
|
-
`\`host:<endpoint-address>\` (e.g. \`host:integrate.api.nvidia.com\`, ` +
|
|
1289
|
-
`\`host:localhost:8000\`). There is no \`model:\` axis. ` +
|
|
1290
|
-
`Fix the rule(s) in ${PROVIDER_CONFIRMATION_INPUT_FILENAME} and re-run — ` +
|
|
1291
|
-
`nothing was promoted, and no other field of this submission was lost.`);
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
const include = stringArray(obj.include);
|
|
1295
|
-
const dispatchBias = clampDispatchBias(obj.dispatch_bias);
|
|
1296
|
-
// An EXPLICIT empty array is preserved, not dropped to absent. `[]` is the
|
|
1297
|
-
// operator deliberately emptying their host roster; omission is them saying
|
|
1298
|
-
// nothing about it. `carryForwardConfirmationInput` reseeds only the second case,
|
|
1299
|
-
// so collapsing the two here would resurrect a roster the operator deleted — and
|
|
1300
|
-
// there would be no way left to express the deletion at all.
|
|
1301
|
-
const hostModels = Array.isArray(obj.host_models)
|
|
1302
|
-
? obj.host_models
|
|
1303
|
-
.filter((m) => m !== null &&
|
|
1304
|
-
typeof m === "object" &&
|
|
1305
|
-
typeof m.model_id === "string")
|
|
1306
|
-
.map((m) => ({ model_id: m.model_id }))
|
|
1307
|
-
: undefined;
|
|
1308
|
-
return {
|
|
1309
|
-
schema_version: PROVIDER_CONFIRMATION_INPUT_VERSION,
|
|
1310
|
-
...(costOrder ? { cost_order: costOrder } : {}),
|
|
1311
|
-
...(capabilityOrder ? { capability_order: capabilityOrder } : {}),
|
|
1312
|
-
...(exclude ? { exclude } : {}),
|
|
1313
|
-
...(include ? { include } : {}),
|
|
1314
|
-
...(hostModels ? { host_models: hostModels } : {}),
|
|
1315
|
-
...(dispatchBias != null ? { dispatch_bias: dispatchBias } : {}),
|
|
1316
|
-
};
|
|
1317
|
-
}
|
|
1318
|
-
/**
|
|
1319
|
-
* Seed an incoming Gate-0 submission from the PRIOR confirmation, field by field.
|
|
1320
|
-
*
|
|
1321
|
-
* **The defect class this closes.** `buildProviderConfirmationRender` rebuilds the
|
|
1322
|
-
* whole confirmation from the submission ALONE — every operator decision it persists
|
|
1323
|
-
* (`cost_order`, `capability_rank`, the host roster, λ, and the `policy` exclusions) is
|
|
1324
|
-
* reconstructed from `input` and from nothing else. So *any* field a submission omits
|
|
1325
|
-
* is not "left alone", it is DESTROYED. That is one defect with six faces, and fixing
|
|
1326
|
-
* it per-field is what let three of them survive a review round: the prompt's capability
|
|
1327
|
-
* example is `{ "capability_order": [...] }`, so an operator answering exactly what was
|
|
1328
|
-
* asked omits all five other fields and silently wipes them.
|
|
1329
|
-
*
|
|
1330
|
-
* Two rules, both load-bearing:
|
|
1331
|
-
*
|
|
1332
|
-
* 1. **`undefined` means "said nothing"; an explicit empty array means "delete".** A
|
|
1333
|
-
* submission that never mentions host models is not a decision to remove them; an
|
|
1334
|
-
* explicit `"host_models": []` is. `parseProviderConfirmationInput` therefore
|
|
1335
|
-
* PRESERVES an empty array rather than dropping it to absent — without that the two
|
|
1336
|
-
* cases are indistinguishable here and the carry-forward resurrects a roster the
|
|
1337
|
-
* operator deleted.
|
|
1338
|
-
* 2. **It applies with `input === null` too.** The autonomous/headless path promotes
|
|
1339
|
-
* with no submission at all, and the capability delta is a brand-new trigger for
|
|
1340
|
-
* that path — so short-circuiting on `input &&` would let an unattended re-promotion
|
|
1341
|
-
* wipe the operator's entire persisted decision and then report convergence.
|
|
1342
|
-
*
|
|
1343
|
-
* Returns `null` only when there is nothing on either side. A prior confirmation with
|
|
1344
|
-
* no incoming submission still yields a synthesized input carrying it forward.
|
|
1345
|
-
*/
|
|
1346
|
-
/**
|
|
1347
|
-
* The gate-authored exclusions that SURVIVE this promotion.
|
|
1348
|
-
*
|
|
1349
|
-
* Round-3 defect (high, fail-OPEN): `auto_exclude` was rebuilt on every promotion from
|
|
1350
|
-
* `gate.newlyReachable` alone. But `confirmedBackendKeys` counts an excluded entry as
|
|
1351
|
-
* CONFIRMED, so once the gate fail-closed-excludes backend X and folds it into the pool,
|
|
1352
|
-
* the reach delta is empty forever — and the very next promotion rebuilt `auto_exclude`
|
|
1353
|
-
* from that empty delta and dropped X, making a backend the operator never confirmed
|
|
1354
|
-
* dispatchable. The docstring's "a submission supersedes it" was true; the code
|
|
1355
|
-
* superseded it on EVERY promotion, including the no-submission one.
|
|
1356
|
-
*
|
|
1357
|
-
* The rule is therefore narrower than "any submission clears it". A submission
|
|
1358
|
-
* supersedes an auto-exclusion only when it actually ADDRESSES that backend:
|
|
1359
|
-
* - the operator re-stated the pattern in `exclude` — it is now operator-authored and
|
|
1360
|
-
* lives there, so retaining an `auto_exclude` copy would double-record it; or
|
|
1361
|
-
* - the operator named that provider in `include` — an explicit opt-back-IN.
|
|
1362
|
-
* Anything else is SILENCE, and silence is not confirmation ("the operator confirms
|
|
1363
|
-
* model choices"). A capability-only answer must not lift an exclusion the operator was
|
|
1364
|
-
* never even shown — the reach section does not render once the backend is a confirmed
|
|
1365
|
-
* key, so they cannot see what they would be lifting.
|
|
1366
|
-
*
|
|
1367
|
-
* Fail-CLOSED by construction: the uncertain case retains the exclusion.
|
|
1368
|
-
*/
|
|
1369
|
-
export function retainAutoExclusions(priorAuto, input) {
|
|
1370
|
-
if (priorAuto.length === 0)
|
|
1371
|
-
return [];
|
|
1372
|
-
const restated = new Set(input?.exclude ?? []);
|
|
1373
|
-
const optedIn = input?.include ?? [];
|
|
1374
|
-
const addressed = (pattern) => {
|
|
1375
|
-
if (restated.has(pattern))
|
|
1376
|
-
return true;
|
|
1377
|
-
// Match the opt-in against the pattern's TRANSPORT, parsed — not against its raw
|
|
1378
|
-
// text. Auto-authored patterns are axis-prefixed (`transport:codex`,
|
|
1379
|
-
// `transport:codex/model`), so the old raw `pattern.startsWith(provider + ":")`
|
|
1380
|
-
// test stopped matching the moment the grammar became axis-explicit: an operator
|
|
1381
|
-
// opting `codex` back in never cleared `transport:codex`, their explicit include
|
|
1382
|
-
// was silently ignored, and no submission could ever supersede an auto-exclusion.
|
|
1383
|
-
const rule = parseExclusionRule(migrateExclusionPattern(pattern));
|
|
1384
|
-
const transport = rule.kind === "transport"
|
|
1385
|
-
? rule.transport
|
|
1386
|
-
: rule.kind === "transport_model"
|
|
1387
|
-
? rule.transport
|
|
1388
|
-
: null;
|
|
1389
|
-
if (transport === null)
|
|
1390
|
-
return false;
|
|
1391
|
-
// Both tiers (`transport:x` and `transport:x/model`) belong to the named provider.
|
|
1392
|
-
return optedIn.some((provider) => provider === transport);
|
|
1393
|
-
};
|
|
1394
|
-
return priorAuto.filter((pattern) => !addressed(pattern));
|
|
1395
|
-
}
|
|
1396
|
-
export function carryForwardConfirmationInput(input,
|
|
1397
|
-
// `undefined` is a first-class absent-prior: the `if (!prior)` guard below is
|
|
1398
|
-
// a tested tolerance, and the type states it rather than leaving `undefined`
|
|
1399
|
-
// reachable only from non-TS callers.
|
|
1400
|
-
prior,
|
|
1401
|
-
/**
|
|
1402
|
-
* R3-3: true when THIS submission is LLM-authored (tool-derived — see
|
|
1403
|
-
* `runProviderConfirmationAutoComplete`'s `authoredByLlm`). Governs which
|
|
1404
|
-
* previously-ranked ids the capability merge below treats as fixed anchors:
|
|
1405
|
-
* an LLM submission anchors every previously-ranked id (operator's and prior
|
|
1406
|
-
* LLM's alike — rule 1); an operator submission does NOT anchor ids already in
|
|
1407
|
-
* `capability_order_llm_ranked` (rule 2 — they interpolate like new models).
|
|
1408
|
-
* Also gates the total-replacement escape in `mergeCapabilityOrder` (operator
|
|
1409
|
-
* only — see its docstring). Defaults `false` (today's operator behavior) so
|
|
1410
|
-
* every pre-existing call site is unaffected.
|
|
1411
|
-
*/
|
|
1412
|
-
authoredByLlm = false) {
|
|
1413
|
-
if (!prior)
|
|
1414
|
-
return input;
|
|
1415
|
-
const base = input ?? {
|
|
1416
|
-
schema_version: PROVIDER_CONFIRMATION_INPUT_VERSION,
|
|
1417
|
-
};
|
|
1418
|
-
const priorHostModels = (prior.host_model_cost_order ?? []).map((entry) => ({
|
|
1419
|
-
model_id: entry.model_id,
|
|
1420
|
-
}));
|
|
1421
|
-
const priorCostOrder = priorConfirmedCostOrder(prior);
|
|
1422
|
-
// The operator's RAW answer, read back verbatim — never reconstructed from the
|
|
1423
|
-
// resulting `capability_rank`s. Reconstruction could not tell an operator-authored
|
|
1424
|
-
// rank from EXTERNAL evidence, so it laundered external numbers into the operator's
|
|
1425
|
-
// ordering and made the laundered model read as evidenced forever.
|
|
1426
|
-
const priorCapabilityOrder = prior.policy?.capability_order ?? [];
|
|
1427
|
-
// `exclude` ONLY — `auto_exclude` is deliberately NOT carried. It is the gate's
|
|
1428
|
-
// placeholder for an answer the operator never gave, and a submission supersedes it.
|
|
1429
|
-
const priorExclude = prior.policy?.exclude ?? [];
|
|
1430
|
-
const priorInclude = prior.policy?.include ?? [];
|
|
1431
|
-
return {
|
|
1432
|
-
...base,
|
|
1433
|
-
// `=== undefined` at every field, never a truthiness/length test: that is the
|
|
1434
|
-
// said-nothing-vs-delete distinction, and a `!length` test collapses them.
|
|
1435
|
-
...(base.cost_order === undefined && priorCostOrder.length > 0
|
|
1436
|
-
? { cost_order: priorCostOrder }
|
|
1437
|
-
: {}),
|
|
1438
|
-
// The capability answer is the ONE field that does not follow the plain
|
|
1439
|
-
// said-nothing/carry rule, because its PROMPT is delta-scoped: it renders only the
|
|
1440
|
-
// unevidenced models, so a submission is a partial answer BY CONSTRUCTION and
|
|
1441
|
-
// taking it as the whole ordering erases every rank the operator gave before —
|
|
1442
|
-
// the `PRIORITY[0]` livelock. It is therefore MERGED by anchored insertion rather
|
|
1443
|
-
// than replacing or being replaced (see {@link mergeCapabilityOrder}).
|
|
1444
|
-
//
|
|
1445
|
-
// The MERGED order is what gets persisted, not the raw submission:
|
|
1446
|
-
// `buildConfirmedDispatchPolicy` stores whatever `input.capability_order` holds, and
|
|
1447
|
-
// `policy.capability_order` is the only thing the NEXT promotion reads back. Keeping
|
|
1448
|
-
// the raw answer there instead would mean the prior answers exist nowhere on disk and
|
|
1449
|
-
// the merge would have nothing to merge against on the third promotion — the livelock
|
|
1450
|
-
// one round-trip later. This does NOT undo "store the operator's answer verbatim":
|
|
1451
|
-
// that rule exists to keep EXTERNAL evidence out of the operator's ordering, and both
|
|
1452
|
-
// operands here are operator answers. Nothing derived from a `capability_rank` (which
|
|
1453
|
-
// cannot distinguish operator from external provenance) enters this list.
|
|
1454
|
-
...(base.capability_order === undefined
|
|
1455
|
-
? priorCapabilityOrder.length > 0
|
|
1456
|
-
? { capability_order: priorCapabilityOrder }
|
|
1457
|
-
: {}
|
|
1458
|
-
: {
|
|
1459
|
-
capability_order: mergeCapabilityOrder(priorCapabilityOrder, base.capability_order, capabilityOrderNonAnchors(prior.policy?.capability_order_llm_ranked ?? [], authoredByLlm), authoredByLlm),
|
|
1460
|
-
}),
|
|
1461
|
-
...(base.host_models === undefined && priorHostModels.length > 0
|
|
1462
|
-
? { host_models: priorHostModels }
|
|
1463
|
-
: {}),
|
|
1464
|
-
...(base.exclude === undefined && priorExclude.length > 0
|
|
1465
|
-
? { exclude: [...priorExclude] }
|
|
1466
|
-
: {}),
|
|
1467
|
-
...(base.include === undefined && priorInclude.length > 0
|
|
1468
|
-
? { include: [...priorInclude] }
|
|
1469
|
-
: {}),
|
|
1470
|
-
...(base.dispatch_bias === undefined && prior.dispatch_bias !== undefined
|
|
1471
|
-
? { dispatch_bias: prior.dispatch_bias }
|
|
1472
|
-
: {}),
|
|
1473
|
-
};
|
|
1474
|
-
}
|
|
1475
|
-
/** How many already-ranked models the capability prompt shows as fixed reference points. */
|
|
1476
|
-
export const DEFAULT_CAPABILITY_ANCHOR_COUNT = 5;
|
|
1477
|
-
/**
|
|
1478
|
-
* Pick a BOUNDED, spread sample of an already-confirmed capability ordering to show
|
|
1479
|
-
* beside the unevidenced models as fixed reference points.
|
|
1480
|
-
*
|
|
1481
|
-
* The roster may be HUNDREDS of models, so the prompt must be O(new + constant) — it
|
|
1482
|
-
* can never render the whole ordering. First, last, and evenly-spaced interior picks
|
|
1483
|
-
* give the operator a usable coordinate space (top / middle / bottom of the confirmed
|
|
1484
|
-
* ranking) at constant cost, which is exactly what {@link mergeCapabilityOrder}
|
|
1485
|
-
* interpolates against.
|
|
1486
|
-
*
|
|
1487
|
-
* @param priorOrder - The confirmed ordering, most-capable-first.
|
|
1488
|
-
* @param exclude - Models already being asked about (the unevidenced delta); an
|
|
1489
|
-
* anchor must be a model whose rank is settled, never one under question.
|
|
1490
|
-
* @param max - Ceiling on the sample size.
|
|
1491
|
-
*/
|
|
1492
|
-
export function selectCapabilityAnchors(priorOrder, exclude = [], max = DEFAULT_CAPABILITY_ANCHOR_COUNT) {
|
|
1493
|
-
const excluded = new Set(exclude);
|
|
1494
|
-
const unique = [...new Set(priorOrder)].filter((id) => !excluded.has(id));
|
|
1495
|
-
if (max <= 0)
|
|
1496
|
-
return [];
|
|
1497
|
-
if (unique.length <= max || max === 1)
|
|
1498
|
-
return unique.slice(0, max);
|
|
1499
|
-
// Evenly spaced, endpoints included. `Set` absorbs a repeated index when the
|
|
1500
|
-
// ordering is barely longer than `max`, so the result is never padded with dupes.
|
|
1501
|
-
const picks = new Set();
|
|
1502
|
-
for (let i = 0; i < max; i++) {
|
|
1503
|
-
picks.add(Math.round((i * (unique.length - 1)) / (max - 1)));
|
|
1504
|
-
}
|
|
1505
|
-
return [...picks].sort((a, b) => a - b).map((i) => unique[i]);
|
|
1506
|
-
}
|
|
1507
|
-
/**
|
|
1508
|
-
* Whether `submitted` restates EVERY id in `priorOrder` — the one case
|
|
1509
|
-
* {@link mergeCapabilityOrder} treats as a total, verbatim replacement rather than an
|
|
1510
|
-
* anchored partial answer (OPERATOR path only — see that function's docstring for why
|
|
1511
|
-
* the escape is authorship-gated). Single-sourced so it and
|
|
1512
|
-
* {@link detectDiscardedCapabilityReorder} can never disagree about which case they
|
|
1513
|
-
* are in (a mismatch there reports a phantom discard, or misses a real one).
|
|
1514
|
-
*/
|
|
1515
|
-
function isTotalCapabilitySubmission(priorOrder, submitted) {
|
|
1516
|
-
const prior = [...new Set(priorOrder)];
|
|
1517
|
-
if (prior.length === 0)
|
|
1518
|
-
return false;
|
|
1519
|
-
const answerSet = new Set(submitted);
|
|
1520
|
-
return prior.every((id) => answerSet.has(id));
|
|
1521
|
-
}
|
|
1522
|
-
/**
|
|
1523
|
-
* R3-3 — the submission's ANCHOR EXCLUSION set, single-sourced so
|
|
1524
|
-
* {@link mergeCapabilityOrder} and {@link detectDiscardedCapabilityReorder} can never
|
|
1525
|
-
* disagree about what counts as a fixed reference point for a given submission:
|
|
1526
|
-
*
|
|
1527
|
-
* - LLM-authored (`authoredByLlm`): every previously-ranked id — the operator's and
|
|
1528
|
-
* any prior LLM's alike — stays a fixed anchor (rule 1). Nothing is excluded.
|
|
1529
|
-
* - Operator-authored: ids already in `capability_order_llm_ranked` are NOT anchors
|
|
1530
|
-
* (rule 2) — the operator may reposition them exactly like new models, and doing
|
|
1531
|
-
* so removes them from that set (see {@link advanceCapabilityOrderLlmRanked}).
|
|
1532
|
-
*/
|
|
1533
|
-
export function capabilityOrderNonAnchors(priorLlmRanked, authoredByLlm) {
|
|
1534
|
-
return authoredByLlm ? new Set() : new Set(priorLlmRanked);
|
|
1535
|
-
}
|
|
1536
|
-
/**
|
|
1537
|
-
* Advance the R3-3 `capability_order_llm_ranked` authorship SET across one
|
|
1538
|
-
* promotion. Always a subset of the resulting `capability_order` — see
|
|
1539
|
-
* {@link ConfirmedDispatchPolicy.capability_order_llm_ranked}.
|
|
1540
|
-
*
|
|
1541
|
-
* - LLM-authored submission: every id this submission newly ranked — i.e. not
|
|
1542
|
-
* already in `priorOrder` — is ADDED. `mergeCapabilityOrder` never lets an
|
|
1543
|
-
* LLM-authored submission move a previously-ranked id (its total-replacement
|
|
1544
|
-
* escape is operator-only), so a prior id is never repositioned on this path and
|
|
1545
|
-
* this function does not need to special-case "total" either: it is simply
|
|
1546
|
-
* `answer \ priorOrder`.
|
|
1547
|
-
* - Operator-authored submission: every id the submission NAMES is REMOVED — naming
|
|
1548
|
-
* a previously LLM-ranked id (and, via {@link capabilityOrderNonAnchors}'s
|
|
1549
|
-
* non-anchor treatment, freely repositioning it) makes it operator-authored from
|
|
1550
|
-
* here on, exactly like a first-time rank.
|
|
1551
|
-
* - No submission at all (`submitted` absent/empty): the running set is unchanged.
|
|
1552
|
-
*/
|
|
1553
|
-
export function advanceCapabilityOrderLlmRanked(priorLlmRanked, priorOrder, submitted, authoredByLlm) {
|
|
1554
|
-
const running = new Set(priorLlmRanked);
|
|
1555
|
-
const answer = submitted ? [...new Set(submitted)] : [];
|
|
1556
|
-
if (answer.length === 0)
|
|
1557
|
-
return [...running].sort();
|
|
1558
|
-
if (!authoredByLlm) {
|
|
1559
|
-
for (const id of answer)
|
|
1560
|
-
running.delete(id);
|
|
1561
|
-
return [...running].sort();
|
|
1562
|
-
}
|
|
1563
|
-
const priorSet = new Set(priorOrder);
|
|
1564
|
-
for (const id of answer.filter((id) => !priorSet.has(id)))
|
|
1565
|
-
running.add(id);
|
|
1566
|
-
return [...running].sort();
|
|
1567
|
-
}
|
|
1568
|
-
/**
|
|
1569
|
-
* The anchor ids whose relative order the submission changed but the merge will NOT
|
|
1570
|
-
* honor — i.e. an operator reorder that is about to be silently discarded.
|
|
1571
|
-
*
|
|
1572
|
-
* {@link mergeCapabilityOrder} treats every submitted id already present in
|
|
1573
|
-
* `priorOrder` as a FIXED reference point, so a submission that swaps two of them
|
|
1574
|
-
* returns the prior order unchanged. Without this, that is invisible: the promotion
|
|
1575
|
-
* succeeds, the artifact is byte-identical, and nothing anywhere says the operator's
|
|
1576
|
-
* decision was dropped. `unrankedOnPromotion` cannot catch it either — a reordered id
|
|
1577
|
-
* IS present in `capability_order`, so it reports nothing.
|
|
1578
|
-
*
|
|
1579
|
-
* An accepted-then-discarded operator decision is the same defect class as laundering a
|
|
1580
|
-
* tool guess into operator policy: not corruption, but SILENCE. The standing rule is
|
|
1581
|
-
* that the operator must never have to notice — so the caller reports this loudly.
|
|
1582
|
-
*
|
|
1583
|
-
* Returns `[]` when the reorder will actually be honored: an OPERATOR-authored TOTAL
|
|
1584
|
-
* submission (every prior id restated) is applied verbatim, and a submission with
|
|
1585
|
-
* fewer than two anchors cannot express a reorder at all.
|
|
1586
|
-
*
|
|
1587
|
-
* NOTE this reports the LIMITATION, it does not lift it. Making a repositioning
|
|
1588
|
-
* expressible without restating the whole roster needs the anchor-provenance split
|
|
1589
|
-
* tracked in `docs/backlog.md`; this only ensures the drop is never silent.
|
|
1590
|
-
*/
|
|
1591
|
-
export function detectDiscardedCapabilityReorder(priorOrder, submitted,
|
|
1592
|
-
/**
|
|
1593
|
-
* R3-3: ids to exclude from anchor treatment — must be the SAME set passed to
|
|
1594
|
-
* {@link mergeCapabilityOrder} for this submission ({@link capabilityOrderNonAnchors}),
|
|
1595
|
-
* or the two disagree about what an "anchor" is and this reports a discard the
|
|
1596
|
-
* merge actually honored (or misses one it silently dropped).
|
|
1597
|
-
*/
|
|
1598
|
-
nonAnchorIds = new Set(),
|
|
1599
|
-
/**
|
|
1600
|
-
* R3-3: mirrors the authorship-gated condition in {@link mergeCapabilityOrder} — the
|
|
1601
|
-
* total-submission escape is OPERATOR-only, so an LLM-authored submission that
|
|
1602
|
-
* happens to restate every prior id (a small roster's anchor sample can cover the
|
|
1603
|
-
* whole ordering) still has its anchor reorder detected and reported, never
|
|
1604
|
-
* silently honored via the escape.
|
|
1605
|
-
*/
|
|
1606
|
-
authoredByLlm = false) {
|
|
1607
|
-
const prior = [...new Set(priorOrder)];
|
|
1608
|
-
const answer = [...new Set(submitted)];
|
|
1609
|
-
if (prior.length === 0 || answer.length === 0)
|
|
1610
|
-
return [];
|
|
1611
|
-
const priorPos = new Map(prior.map((id, index) => [id, index]));
|
|
1612
|
-
// A total submission is honored verbatim — nothing is discarded. Mirrors the same
|
|
1613
|
-
// authorship-gated condition in `mergeCapabilityOrder`; the two must agree or this
|
|
1614
|
-
// reports phantoms.
|
|
1615
|
-
if (!authoredByLlm && isTotalCapabilitySubmission(prior, answer))
|
|
1616
|
-
return [];
|
|
1617
|
-
const anchors = answer.filter((id) => priorPos.has(id) && !nonAnchorIds.has(id));
|
|
1618
|
-
if (anchors.length < 2)
|
|
1619
|
-
return [];
|
|
1620
|
-
// Discarded iff the anchors' order in the SUBMISSION differs from their order in the
|
|
1621
|
-
// PRIOR ordering — compare against the anchors sorted by prior position.
|
|
1622
|
-
const asConfirmed = [...anchors].sort((a, b) => priorPos.get(a) - priorPos.get(b));
|
|
1623
|
-
// Report only the anchors that actually MOVED, not every anchor in the submission.
|
|
1624
|
-
// `["a","c","b"]` against `["a","b","c","d"]` moves `c` and `b`; naming `a` too would
|
|
1625
|
-
// tell the operator their unchanged entry was dropped, which is false — and a warning
|
|
1626
|
-
// that over-reports is one the operator learns to discount.
|
|
1627
|
-
return anchors.filter((id, i) => id !== asConfirmed[i]);
|
|
1628
|
-
}
|
|
1629
|
-
/**
|
|
1630
|
-
* Merge an operator's capability answer into the previously confirmed ordering by
|
|
1631
|
-
* **ANCHORED INSERTION**.
|
|
1632
|
-
*
|
|
1633
|
-
* **The livelock this closes.** The capability prompt is DELTA-SCOPED — it renders only
|
|
1634
|
-
* the models with no evidence — while `annotateConfirmedPool` built its positions from
|
|
1635
|
-
* the submission ALONE, i.e. total replacement. So each answer erased the last: rank A,
|
|
1636
|
-
* the delta asks C, rank C, A loses its rank, the delta asks A, forever. `PRIORITY[0]`
|
|
1637
|
-
* never converges. Reproduced across three promotions.
|
|
1638
|
-
*
|
|
1639
|
-
* The fix cannot be "render the whole ordering" (the roster may be hundreds of models —
|
|
1640
|
-
* the prompt must stay O(new + constant)) and it cannot be an absolute score or tier
|
|
1641
|
-
* (only a RELATIVE ordering is representable, by standing decision). Anchored insertion
|
|
1642
|
-
* is what remains: show a bounded, spread sample of the confirmed ordering
|
|
1643
|
-
* ({@link selectCapabilityAnchors}) as fixed reference points, and interpolate the new
|
|
1644
|
-
* models into the coordinate space those points define.
|
|
1645
|
-
*
|
|
1646
|
-
* Semantics, exactly:
|
|
1647
|
-
*
|
|
1648
|
-
* - **Anchors** = submitted entries that already appear in `priorOrder`. They are
|
|
1649
|
-
* REFERENCE POINTS: their prior positions define the coordinate space, and **a
|
|
1650
|
-
* reordering of anchors relative to each other is deliberately NOT honored** on a
|
|
1651
|
-
* partial submission. The operator saw at most a handful of them out of a possibly
|
|
1652
|
-
* enormous ordering, so a swap between two anchors carries no information about the
|
|
1653
|
-
* models BETWEEN them — honoring it would silently reshuffle models the operator
|
|
1654
|
-
* never saw.
|
|
1655
|
-
* - **Exception — an OPERATOR-authored TOTAL submission is a total replacement.**
|
|
1656
|
-
* When the submission mentions every model in `priorOrder` there are no unmentioned
|
|
1657
|
-
* models, so the coordinate space is fully respecified and the answer is honored
|
|
1658
|
-
* verbatim. This is the only case where "reorder what you already confirmed" is a
|
|
1659
|
-
* well-defined request, and it is the pre-existing behavior for a complete
|
|
1660
|
-
* re-ranking. **R3-3: this escape is OPERATOR-only** (`authoredByLlm: false`,
|
|
1661
|
-
* the default) — on a SMALL roster the bounded anchor sample
|
|
1662
|
-
* ({@link selectCapabilityAnchors}) can cover the entire prior ordering, so an
|
|
1663
|
-
* LLM's answer over "new models + all the anchors it was shown" becomes total BY
|
|
1664
|
-
* ACCIDENT, not by the LLM's intent to reorder. Honoring it verbatim would let an
|
|
1665
|
-
* LLM silently reorder ranks an operator (or a prior LLM) set; an LLM-authored
|
|
1666
|
-
* submission therefore NEVER takes this exception — every previously-ranked id
|
|
1667
|
-
* stays a fixed anchor regardless of coverage, and an attempted reorder is
|
|
1668
|
-
* discarded and reported exactly like a partial submission's would be (see
|
|
1669
|
-
* {@link detectDiscardedCapabilityReorder}).
|
|
1670
|
-
* - **New models** (not in `priorOrder`) interpolate to a fractional position between
|
|
1671
|
-
* the prior positions of the nearest preceding and following anchors IN THE SUBMITTED
|
|
1672
|
-
* LIST. Before the first anchor ⇒ just below it (more capable); after the last ⇒ just
|
|
1673
|
-
* above it. Consecutive new models keep their submitted relative order.
|
|
1674
|
-
* - **Every model in `priorOrder` the submission does not mention keeps its prior
|
|
1675
|
-
* position.** THIS IS THE LIVELOCK FIX.
|
|
1676
|
-
* - **No anchors at all** (a partial submission naming only unknown models): there is no
|
|
1677
|
-
* coordinate to interpolate against, so the new models are appended AFTER the whole
|
|
1678
|
-
* prior ordering — the conservative direction, since a higher rank is less capable and
|
|
1679
|
-
* therefore trusted with less.
|
|
1680
|
-
* - **Duplicates**: first occurrence wins, matching `annotateConfirmedPool`'s rule that a
|
|
1681
|
-
* positional list is the operator's ordering and a later repeat must not re-rank it.
|
|
1682
|
-
* - **Result** is every model sorted by resolved position, ties broken by model id.
|
|
1683
|
-
* Deterministic by construction: an incidentally-ordered array here would churn the
|
|
1684
|
-
* confirmation's content hash on every promotion and cascade phantom staleness.
|
|
1685
|
-
*
|
|
1686
|
-
* Degenerate cases: an empty `priorOrder` (the first-ever answer) returns the submission;
|
|
1687
|
-
* an empty submission returns the prior ordering unchanged (an omitted answer is
|
|
1688
|
-
* "said nothing" — there is no way to express "delete the whole ranking", and the
|
|
1689
|
-
* un-delete direction is the one that cannot livelock).
|
|
1690
|
-
*
|
|
1691
|
-
* Pure — no I/O, no clock, no config. Exported so the merge that decides whether the
|
|
1692
|
-
* gate converges is directly testable.
|
|
1693
|
-
*/
|
|
1694
|
-
export function mergeCapabilityOrder(priorOrder, submitted,
|
|
1695
|
-
/**
|
|
1696
|
-
* R3-3: ids to treat as NOT anchored even though they already appear in
|
|
1697
|
-
* `priorOrder` — the operator-authored path passes the current
|
|
1698
|
-
* `capability_order_llm_ranked` set here ({@link capabilityOrderNonAnchors}) so an
|
|
1699
|
-
* operator may freely reposition a previously LLM-ranked id, exactly like a new
|
|
1700
|
-
* model. Empty by default (today's behavior: every previously-ranked id anchors).
|
|
1701
|
-
*/
|
|
1702
|
-
nonAnchorIds = new Set(),
|
|
1703
|
-
/**
|
|
1704
|
-
* R3-3: true when THIS submission is LLM-authored. Gates the total-replacement
|
|
1705
|
-
* escape below (OPERATOR-only — see the docstring's exception): an LLM-authored
|
|
1706
|
-
* submission never takes it, so a previously-ranked id is NEVER repositioned on
|
|
1707
|
-
* this path, regardless of how much of `priorOrder` the submission happens to
|
|
1708
|
-
* cover.
|
|
1709
|
-
*/
|
|
1710
|
-
authoredByLlm = false) {
|
|
1711
|
-
const prior = [...new Set(priorOrder)];
|
|
1712
|
-
const answer = [...new Set(submitted)];
|
|
1713
|
-
if (prior.length === 0)
|
|
1714
|
-
return answer;
|
|
1715
|
-
if (answer.length === 0)
|
|
1716
|
-
return prior;
|
|
1717
|
-
const priorPos = new Map(prior.map((id, index) => [id, index]));
|
|
1718
|
-
// TOTAL submission ⇒ total replacement (see the docstring's exception) — OPERATOR
|
|
1719
|
-
// path only. An LLM-authored submission never takes this escape, even when it
|
|
1720
|
-
// happens to cover every prior id (a small roster's anchor sample can do that by
|
|
1721
|
-
// accident): it would otherwise silently reorder ranks the LLM was never asked to
|
|
1722
|
-
// move.
|
|
1723
|
-
if (!authoredByLlm && isTotalCapabilitySubmission(prior, answer))
|
|
1724
|
-
return answer;
|
|
1725
|
-
// A non-anchor id still HOLDS its prior position (seeded below, so an unmentioned
|
|
1726
|
-
// one keeps it) but cannot anchor a run boundary — it is swept into the
|
|
1727
|
-
// surrounding run of new models and repositioned exactly like one (R3-3 rule 2).
|
|
1728
|
-
const isAnchor = (id) => priorPos.has(id) && !nonAnchorIds.has(id);
|
|
1729
|
-
/** Resolved position per model — seeded with every prior model, so an unmentioned one keeps its rank. */
|
|
1730
|
-
const positions = new Map(priorPos);
|
|
1731
|
-
const anchorCount = answer.filter(isAnchor).length;
|
|
1732
|
-
if (anchorCount === 0) {
|
|
1733
|
-
answer.forEach((id, index) => positions.set(id, prior.length + index));
|
|
1734
|
-
}
|
|
1735
|
-
else {
|
|
1736
|
-
let i = 0;
|
|
1737
|
-
while (i < answer.length) {
|
|
1738
|
-
if (isAnchor(answer[i])) {
|
|
1739
|
-
i++;
|
|
1740
|
-
continue;
|
|
1741
|
-
}
|
|
1742
|
-
// A maximal run of NEW/non-anchor models, [i, j). `answer[i - 1]` is
|
|
1743
|
-
// necessarily an anchor when `i > 0` — otherwise the run would have started
|
|
1744
|
-
// earlier.
|
|
1745
|
-
let j = i;
|
|
1746
|
-
while (j < answer.length && !isAnchor(answer[j]))
|
|
1747
|
-
j++;
|
|
1748
|
-
const before = i > 0 ? priorPos.get(answer[i - 1]) : undefined;
|
|
1749
|
-
const after = j < answer.length ? priorPos.get(answer[j]) : undefined;
|
|
1750
|
-
let lo;
|
|
1751
|
-
let hi;
|
|
1752
|
-
if (before === undefined) {
|
|
1753
|
-
hi = after;
|
|
1754
|
-
lo = hi - 1;
|
|
1755
|
-
}
|
|
1756
|
-
else if (after === undefined) {
|
|
1757
|
-
lo = before;
|
|
1758
|
-
hi = lo + 1;
|
|
1759
|
-
}
|
|
1760
|
-
else {
|
|
1761
|
-
lo = before;
|
|
1762
|
-
// A reordered anchor pair yields an inverted or empty span. Anchor reordering
|
|
1763
|
-
// is not honored, so degrade to "insert just after the preceding anchor"
|
|
1764
|
-
// rather than emitting descending positions.
|
|
1765
|
-
hi = after > lo ? after : lo + 1;
|
|
1766
|
-
}
|
|
1767
|
-
const run = j - i;
|
|
1768
|
-
for (let t = 0; t < run; t++) {
|
|
1769
|
-
positions.set(answer[i + t], lo + ((hi - lo) * (t + 1)) / (run + 1));
|
|
1770
|
-
}
|
|
1771
|
-
i = j;
|
|
1772
|
-
}
|
|
1773
|
-
}
|
|
1774
|
-
return [...positions.entries()]
|
|
1775
|
-
.sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]))
|
|
1776
|
-
.map(([id]) => id);
|
|
1777
|
-
}
|
|
1778
|
-
/**
|
|
1779
|
-
* Reconstruct the operator's confirmed cost ordering as the `cost_order` KEY list a
|
|
1780
|
-
* fresh submission would carry — i.e. the inverse of `resolveFinalCostOrder`'s
|
|
1781
|
-
* index⇒position mapping.
|
|
1782
|
-
*
|
|
1783
|
-
* Keyspace is the CANDIDATE key (`annotateConfirmedPool`'s `CostCandidate.key`):
|
|
1784
|
-
* provider NAME for a provider pool, `model_id` for a host tier, `source_id` for a
|
|
1785
|
-
* source pool. Deliberately NOT the MODEL keyspace the capability ordering
|
|
1786
|
-
* ({@link mergeCapabilityOrder}) uses — the two genuinely key differently on the write
|
|
1787
|
-
* side (a provider entry is capability-ranked by its `model_id`, never by its provider
|
|
1788
|
-
* name), and unifying
|
|
1789
|
-
* them here would silently drop every provider pool's position.
|
|
1790
|
-
*/
|
|
1791
|
-
function priorConfirmedCostOrder(prior) {
|
|
1792
|
-
const ranked = [];
|
|
1793
|
-
for (const entry of prior.provider_pool ?? []) {
|
|
1794
|
-
if (typeof entry.cost_order === "number") {
|
|
1795
|
-
ranked.push({ key: entry.name, order: entry.cost_order });
|
|
1796
|
-
}
|
|
1797
|
-
}
|
|
1798
|
-
for (const entry of prior.host_model_cost_order ?? []) {
|
|
1799
|
-
ranked.push({ key: entry.model_id, order: entry.cost_order });
|
|
1800
|
-
}
|
|
1801
|
-
for (const entry of prior.source_pool_cost_order ?? []) {
|
|
1802
|
-
ranked.push({ key: entry.source_id, order: entry.cost_order });
|
|
1803
|
-
}
|
|
1804
|
-
return sortRankedKeys(ranked);
|
|
1805
|
-
}
|
|
1806
|
-
/**
|
|
1807
|
-
* Rank-ascending key list, de-duplicated first-occurrence-wins. The key tiebreak keeps
|
|
1808
|
-
* the result deterministic when two pools share a position (a host tier defaulting to
|
|
1809
|
-
* `cost_order: 0`, say) — an incidentally-ordered list here would churn the artifact's
|
|
1810
|
-
* content hash on every promotion and cascade phantom staleness downstream.
|
|
1811
|
-
*/
|
|
1812
|
-
function sortRankedKeys(ranked) {
|
|
1813
|
-
const seen = new Set();
|
|
1814
|
-
return ranked
|
|
1815
|
-
.sort((a, b) => a.order - b.order || a.key.localeCompare(b.key))
|
|
1816
|
-
.filter((entry) => (seen.has(entry.key) ? false : (seen.add(entry.key), true)))
|
|
1817
|
-
.map((entry) => entry.key);
|
|
1818
|
-
}
|
|
1819
|
-
/**
|
|
1820
|
-
* Read the operator's Gate-0 input from `<artifactsDir>/provider-confirmation.input.json`.
|
|
1821
|
-
* Returns `null` when the file is absent, unreadable, or malformed — the "operator
|
|
1822
|
-
* has not acted yet" signal the gate uses to decide emit-vs-consume.
|
|
1823
|
-
*
|
|
1824
|
-
* ⚠ Throws on exactly one condition, inherited from
|
|
1825
|
-
* {@link parseProviderConfirmationInput}: an ungrammatical `exclude` rule. That is a
|
|
1826
|
-
* REFUSAL, not a degrade — swallowing it here would turn a typo'd exclusion into
|
|
1827
|
-
* "operator has not acted yet" and re-emit the same prompt forever, which is the
|
|
1828
|
-
* silent failure the check exists to prevent. Every other malformed input still
|
|
1829
|
-
* yields `null`.
|
|
1830
|
-
*/
|
|
1831
|
-
export async function readProviderConfirmationInput(artifactsDir) {
|
|
1832
|
-
const path = join(artifactsDir, PROVIDER_CONFIRMATION_INPUT_FILENAME);
|
|
1833
|
-
let raw;
|
|
1834
|
-
try {
|
|
1835
|
-
raw = await readJsonFile(path);
|
|
1836
|
-
}
|
|
1837
|
-
catch {
|
|
1838
|
-
return null;
|
|
1839
|
-
}
|
|
1840
|
-
return parseProviderConfirmationInput(raw);
|
|
1841
|
-
}
|
|
1842
|
-
/**
|
|
1843
|
-
* Invalidate a CONSUMED Gate-0 input by deleting it — the second half of
|
|
1844
|
-
* consume-and-invalidate, paired here with {@link readProviderConfirmationInput} so
|
|
1845
|
-
* the two cannot drift apart.
|
|
1846
|
-
*
|
|
1847
|
-
* The input's presence is the "operator has acted" signal the gate reads to decide
|
|
1848
|
-
* emit-vs-consume. Once promoted into the canonical artifacts it is SPENT: leaving it
|
|
1849
|
-
* on disk means a later reconciliation delta silently re-consumes a submission that
|
|
1850
|
-
* answered an older question, auto-satisfying the gate instead of asking the
|
|
1851
|
-
* operator. Deleting it is what makes the gate able to fire a second time at all.
|
|
1852
|
-
*
|
|
1853
|
-
* Best-effort and never throws: an already-absent file is the desired end state, and
|
|
1854
|
-
* a failed unlink must not break the in-flight obligation (the promotion itself
|
|
1855
|
-
* already succeeded).
|
|
1856
|
-
*/
|
|
1857
|
-
export async function unlinkProviderConfirmationInput(artifactsDir) {
|
|
1858
|
-
try {
|
|
1859
|
-
await unlink(join(artifactsDir, PROVIDER_CONFIRMATION_INPUT_FILENAME));
|
|
1860
|
-
}
|
|
1861
|
-
catch {
|
|
1862
|
-
// Absent / locked / read-only — nothing to invalidate, or nothing we can do.
|
|
1863
|
-
}
|
|
1864
|
-
}
|
|
1865
|
-
//# sourceMappingURL=sharedProviderConfirmation.js.map
|