@aiwg/cli 2026.9.24 → 2026.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agentic/code/providers/capability-matrix.yaml +2 -1
- package/dist/src/artifacts/discovery-shadow-evaluator.js +177 -0
- package/dist/src/artifacts/discovery-shadow.js +195 -0
- package/dist/src/artifacts/index-builder.js +47 -9
- package/dist/src/artifacts/query-engine.js +94 -1
- package/dist/src/artifacts/types.js +1 -0
- package/dist/src/cli/handlers/decision.js +177 -0
- package/dist/src/cli/handlers/gates.js +154 -0
- package/dist/src/cli/handlers/index.js +5 -1
- package/dist/src/cli/handlers/sessions.js +10 -7
- package/dist/src/cli/handlers/utilities.js +20 -0
- package/dist/src/config/aiwg-config.js +15 -2
- package/dist/src/decision/acceptance.js +25 -0
- package/dist/src/decision/adapters/jev.js +29 -3
- package/dist/src/decision/ask.js +352 -0
- package/dist/src/decision/calibration/registry.js +9 -0
- package/dist/src/decision/compose.js +74 -15
- package/dist/src/decision/context-live-promotion.js +344 -0
- package/dist/src/decision/context-live-qualification.js +591 -0
- package/dist/src/decision/context-pruning.js +847 -0
- package/dist/src/decision/driver.js +432 -0
- package/dist/src/decision/egress-live-qualification.js +874 -0
- package/dist/src/decision/ensemble/contract.js +64 -1
- package/dist/src/decision/ensemble/index.js +1 -0
- package/dist/src/decision/ensemble/report.js +8 -42
- package/dist/src/decision/ensemble/runtime.js +571 -0
- package/dist/src/decision/ensemble-study/artifacts.js +85 -0
- package/dist/src/decision/ensemble-study/calibration.js +443 -0
- package/dist/src/decision/ensemble-study/corpus.js +229 -0
- package/dist/src/decision/ensemble-study/multifact-artifacts.js +23 -0
- package/dist/src/decision/ensemble-study/multifact-study.js +596 -0
- package/dist/src/decision/ensemble-study/multifact.js +241 -0
- package/dist/src/decision/ensemble-study/protocol.js +77 -0
- package/dist/src/decision/ensemble-study/score.js +462 -0
- package/dist/src/decision/ensemble-study/staged.js +87 -0
- package/dist/src/decision/ensemble-study/statistics.js +84 -0
- package/dist/src/decision/evaluate.js +227 -14
- package/dist/src/decision/export.js +4 -0
- package/dist/src/decision/feature-export.js +1074 -0
- package/dist/src/decision/graph-index.js +16 -0
- package/dist/src/decision/graph-live-qualification.js +1041 -0
- package/dist/src/decision/graph-live-workload.js +245 -0
- package/dist/src/decision/graph-skill-bridge.js +21 -3
- package/dist/src/decision/heldout/calibration.js +140 -0
- package/dist/src/decision/heldout/collector.js +430 -0
- package/dist/src/decision/heldout/contract.js +309 -0
- package/dist/src/decision/heldout/d29-generator-ids.js +12 -0
- package/dist/src/decision/heldout/d29-passage-baseline-v2.js +93 -0
- package/dist/src/decision/heldout/d29-passage-baseline-v3.js +100 -0
- package/dist/src/decision/heldout/d29-passage-baseline.js +66 -0
- package/dist/src/decision/heldout/d29-pools-v8.js +575 -0
- package/dist/src/decision/heldout/d29-pools.js +288 -0
- package/dist/src/decision/heldout/d29-v6.js +169 -0
- package/dist/src/decision/heldout/d29-v7.js +202 -0
- package/dist/src/decision/heldout/d29-v8.js +586 -0
- package/dist/src/decision/heldout/generator-registry.js +67 -0
- package/dist/src/decision/heldout/generators.js +696 -0
- package/dist/src/decision/heldout/index.js +8 -0
- package/dist/src/decision/heldout/journal.js +346 -0
- package/dist/src/decision/heldout/study-generators.js +64 -0
- package/dist/src/decision/heldout/types.js +2 -0
- package/dist/src/decision/host-policy.js +61 -0
- package/dist/src/decision/index.js +13 -0
- package/dist/src/decision/interop-profile.v1.json +112 -0
- package/dist/src/decision/interop.js +653 -0
- package/dist/src/decision/issue-triage/index.js +3 -0
- package/dist/src/decision/issue-triage/runtime.js +1069 -0
- package/dist/src/decision/issue-triage/types.js +2 -0
- package/dist/src/decision/jev-credentials.js +121 -0
- package/dist/src/decision/patterns/catalog.js +28 -8
- package/dist/src/decision/patterns/playground.js +28 -11
- package/dist/src/decision/patterns/specs.js +19 -10
- package/dist/src/decision/preprocessed-evidence.js +724 -0
- package/dist/src/decision/qualification/capture.js +8 -2
- package/dist/src/decision/qualification/gate-evidence.js +19 -4
- package/dist/src/decision/qualification/quality.js +57 -49
- package/dist/src/decision/qualification/release.js +76 -3
- package/dist/src/decision/qualification/runner.js +5 -2
- package/dist/src/decision/routing/contract.js +175 -0
- package/dist/src/decision/routing/index.js +5 -0
- package/dist/src/decision/routing/report.js +216 -0
- package/dist/src/decision/routing/runtime.js +685 -0
- package/dist/src/decision/routing/types.js +2 -0
- package/dist/src/decision/sdlc-screening.js +1157 -0
- package/dist/src/decision/sensitivity/comparative.js +154 -0
- package/dist/src/decision/sensitivity/contract.js +218 -0
- package/dist/src/decision/sensitivity/index.js +5 -0
- package/dist/src/decision/sensitivity/runtime.js +912 -0
- package/dist/src/decision/sensitivity/types.js +2 -0
- package/dist/src/decision/validate.js +26 -0
- package/dist/src/extensions/commands/definitions.js +38 -0
- package/dist/src/extensions/manifest.js +44 -0
- package/dist/src/gates/discovery.js +315 -0
- package/dist/src/gates/driver.js +388 -0
- package/dist/src/gates/evaluate.js +719 -0
- package/dist/src/gates/floors.js +255 -0
- package/dist/src/gates/index.js +17 -0
- package/dist/src/gates/providers/evidence.js +38 -0
- package/dist/src/gates/providers/index.js +21 -0
- package/dist/src/gates/providers/isolated-child-source.js +446 -0
- package/dist/src/gates/providers/loader.js +1069 -0
- package/dist/src/gates/providers/paired.js +52 -0
- package/dist/src/gates/providers/proportion.js +60 -0
- package/dist/src/gates/providers/registry.js +40 -0
- package/dist/src/gates/providers/scalar.js +41 -0
- package/dist/src/gates/providers/screening.js +53 -0
- package/dist/src/gates/providers/types.js +25 -0
- package/dist/src/gates/registry.js +1003 -0
- package/dist/src/gates/report.js +102 -0
- package/dist/src/gates/schema.js +56 -0
- package/dist/src/gates/stats/binomial.js +26 -0
- package/dist/src/gates/stats/bootstrap.js +62 -0
- package/dist/src/gates/stats/clopper-pearson.js +95 -0
- package/dist/src/gates/stats/digest.js +26 -0
- package/dist/src/gates/stats/error.js +5 -0
- package/dist/src/gates/stats/index.js +9 -0
- package/dist/src/gates/stats/normal.js +35 -0
- package/dist/src/gates/stats/paired.js +120 -0
- package/dist/src/gates/stats/splits.js +30 -0
- package/dist/src/gates/types.js +8 -0
- package/dist/src/governance/redaction.js +14 -4
- package/dist/src/mcp/muse-native-config.mjs +123 -0
- package/dist/src/mcp/tools/decision.mjs +228 -0
- package/dist/src/mcp/tools/subsystems.mjs +2 -0
- package/dist/src/providers/capability-matrix.yaml +2 -1
- package/dist/src/providers/provider-definitions.js +2 -1
- package/dist/src/sessions/adapters/muse.js +336 -39
- package/dist/src/sessions/batch-import.js +7 -0
- package/dist/src/sessions/workspace-discovery.js +13 -2
- package/package.json +1 -1
- package/schemas/decision/ConformalFrozenOpenData.v2.schema.json +371 -0
- package/schemas/decision/ConformalOpenDataReport.v2.schema.json +421 -0
- package/schemas/decision/ConformalPreregistration.v2.schema.json +529 -0
- package/schemas/decision/ContextPruningCandidate.v1.schema.json +47 -0
- package/schemas/decision/ContextPruningEvaluationReport.v1.schema.json +758 -0
- package/schemas/decision/ContextPruningPreregistration.v1.schema.json +175 -0
- package/schemas/decision/ContextPruningReceipt.v1.schema.json +72 -0
- package/schemas/decision/D17Multifact.v1.schema.json +1851 -0
- package/schemas/decision/D17StudyAnalysis.v1.schema.json +42 -0
- package/schemas/decision/D17StudyAnalysis.v2.schema.json +51 -0
- package/schemas/decision/D17StudyCalibration.v1.schema.json +819 -0
- package/schemas/decision/D17StudyDryRun.v1.schema.json +169 -0
- package/schemas/decision/D17StudyDryRun.v2.schema.json +239 -0
- package/schemas/decision/D17StudyGold.v1.schema.json +108 -0
- package/schemas/decision/D17StudyGuide.v1.schema.json +63 -0
- package/schemas/decision/D17StudyNativeTemplates.v1.schema.json +619 -0
- package/schemas/decision/D17StudyProtocol.v1.schema.json +241 -0
- package/schemas/decision/D17StudyReport.v1.schema.json +902 -0
- package/schemas/decision/D17StudyReport.v2.schema.json +1319 -0
- package/schemas/decision/D17StudyReview.v1.schema.json +175 -0
- package/schemas/decision/D17StudySplits.v1.schema.json +217 -0
- package/schemas/decision/D29Study.v1.schema.json +1903 -0
- package/schemas/decision/D29Study.v2.schema.json +1344 -0
- package/schemas/decision/D29Study.v3.schema.json +609 -0
- package/schemas/decision/D29Study.v4.schema.json +1133 -0
- package/schemas/decision/D29Study.v6.schema.json +1205 -0
- package/schemas/decision/D29Study.v7.schema.json +1471 -0
- package/schemas/decision/D29Study.v8.schema.json +671 -0
- package/schemas/decision/D29Study.v9.schema.json +778 -0
- package/schemas/decision/DecisionComparativeReplayAuditSample.v1.schema.json +137 -0
- package/schemas/decision/DecisionComparativeReplayCorpus.v1.schema.json +258 -0
- package/schemas/decision/DecisionComparativeReplayFreeze.v1.schema.json +74 -0
- package/schemas/decision/DecisionComparativeReplayGold.v1.schema.json +50 -0
- package/schemas/decision/DecisionComparativeReplayGoldAsset.v1.schema.json +37 -0
- package/schemas/decision/DecisionComparativeReplayIntegrity.v1.schema.json +81 -0
- package/schemas/decision/DecisionComparativeReplayMembers.v1.schema.json +52 -0
- package/schemas/decision/DecisionComparativeReplayPreregistration.v1.schema.json +113 -0
- package/schemas/decision/DecisionComparativeReplayReport.v1.schema.json +407 -0
- package/schemas/decision/DecisionComparativeReplayReviewPackets.v1.schema.json +260 -0
- package/schemas/decision/DecisionComparativeReplaySplit.v1.schema.json +84 -0
- package/schemas/decision/DecisionFeatureObservation.v1.schema.json +564 -0
- package/schemas/decision/DecisionFeatureSet.v1.schema.json +155 -0
- package/schemas/decision/DecisionInteropMapping.v1.schema.json +85 -0
- package/schemas/decision/DecisionIssueTriageEvaluationManifest.v1.schema.json +174 -0
- package/schemas/decision/DecisionIssueTriageEvaluationReport.v1.schema.json +280 -0
- package/schemas/decision/DecisionIssueTriagePilotPack.v1.schema.json +114 -0
- package/schemas/decision/DecisionRouteReceipt.v1.schema.json +155 -0
- package/schemas/decision/DecisionRoutingPolicy.v1.schema.json +132 -0
- package/schemas/decision/DecisionRoutingShadowReport.v1.schema.json +170 -0
- package/schemas/decision/DecisionRoutingTask.v1.schema.json +49 -0
- package/schemas/decision/DecisionRuleset.schema.json +8 -2
- package/schemas/decision/DecisionRuleset.v1alpha2.schema.json +8 -2
- package/schemas/decision/DecisionSensitivityPlan.v1.schema.json +191 -0
- package/schemas/decision/DecisionSensitivityReport.v1.schema.json +163 -0
- package/schemas/decision/HeldoutApproval.v1.schema.json +312 -0
- package/schemas/decision/HeldoutAttempt.v1.schema.json +211 -0
- package/schemas/decision/HeldoutBaseline.v1.schema.json +90 -0
- package/schemas/decision/HeldoutCalibrationPhase.v1.schema.json +87 -0
- package/schemas/decision/HeldoutCorpus.v1.schema.json +167 -0
- package/schemas/decision/HeldoutEvent.v1.schema.json +41 -0
- package/schemas/decision/HeldoutFrozen.v1.schema.json +142 -0
- package/schemas/decision/HeldoutPreregistration.v1.schema.json +222 -0
- package/schemas/decision/HeldoutSpendEvent.v1.schema.json +122 -0
- package/schemas/decision/HeldoutSpendHead.v1.schema.json +25 -0
- package/schemas/decision/HeldoutSpendHead.v2.schema.json +46 -0
- package/schemas/decision/HeldoutSummary.v1.schema.json +101 -0
- package/schemas/decision/OpaDecisionLogExport.v1.schema.json +65 -0
- package/schemas/decision/PreprocessedEvidence.v1.schema.json +306 -0
- package/schemas/decision/RulesetResult.v1alpha2.schema.json +175 -0
- package/schemas/decision/SdlcEvidenceScreening.v1.schema.json +499 -0
- package/schemas/decision/SdlcScreeningPreregistration.v1.schema.json +132 -0
- package/schemas/decision/SdlcScreeningPreregistration.v2.schema.json +138 -0
- package/schemas/decision/SdlcScreeningRelease.v1.schema.json +654 -0
- package/schemas/decision/SdlcScreeningRelease.v2.schema.json +654 -0
- package/tools/agents/deploy-agents.mjs +1 -1
- package/tools/agents/providers/base.mjs +40 -18
- package/tools/agents/providers/factory.mjs +10 -2
- package/tools/agents/providers/muse-hooks.mjs +1 -29
- package/tools/agents/providers/muse.mjs +6 -3
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
import { canonicalJson } from '../security/artifact-trust.js';
|
|
2
|
+
import { artifactDigest } from '../decision/validate.js';
|
|
3
|
+
import { validateGateDocument } from './schema.js';
|
|
4
|
+
import { qualifyGateParameter } from './types.js';
|
|
5
|
+
import { INTEGRITY_CEILING_FLOOR_PACK_ID, assertCeilingDefaults, projectCeilingSatisfied } from './floors.js';
|
|
6
|
+
export class GateRegistryError extends Error {
|
|
7
|
+
constructor(message) { super(message); this.name = 'GateRegistryError'; }
|
|
8
|
+
}
|
|
9
|
+
export const GATE_NAMESPACES = ['aiwg', 'framework', 'addon', 'extension', 'project'];
|
|
10
|
+
export function splitPackId(id) {
|
|
11
|
+
if (typeof id !== 'string')
|
|
12
|
+
throw new GateRegistryError('pack id must be a string');
|
|
13
|
+
const separator = id.indexOf(':');
|
|
14
|
+
const namespace = separator < 0 ? '' : id.slice(0, separator);
|
|
15
|
+
const rest = separator < 0 ? '' : id.slice(separator + 1);
|
|
16
|
+
if (!GATE_NAMESPACES.includes(namespace) || !rest || /\s/.test(rest)) {
|
|
17
|
+
throw new GateRegistryError(`pack id must be <namespace>:<path> with namespace ${GATE_NAMESPACES.join('/')}: '${id}'`);
|
|
18
|
+
}
|
|
19
|
+
if (namespace === 'project') {
|
|
20
|
+
if (rest.includes(':') || rest.includes('/'))
|
|
21
|
+
throw new GateRegistryError(`project pack ids are project:<name>: '${id}'`);
|
|
22
|
+
}
|
|
23
|
+
else if (!/^[^/\s:]+\/[^/\s:]+$/.test(rest)) {
|
|
24
|
+
throw new GateRegistryError(`pack id must be <namespace>:<bundle>/<name>: '${id}'`);
|
|
25
|
+
}
|
|
26
|
+
return { namespace: namespace, rest };
|
|
27
|
+
}
|
|
28
|
+
const fail = (message) => { throw new GateRegistryError(message); };
|
|
29
|
+
const STATISTICAL_KINDS = ['interval-bound', 'paired-difference', 'bootstrap-bound'];
|
|
30
|
+
function checkThresholdBlock(gate, params) {
|
|
31
|
+
const needsThreshold = gate.kind === 'interval-bound' || gate.kind === 'bootstrap-bound'
|
|
32
|
+
|| gate.kind === 'count-max' || gate.kind === 'count-min' || gate.kind === 'value-threshold';
|
|
33
|
+
const threshold = gate.threshold;
|
|
34
|
+
if (threshold === undefined) {
|
|
35
|
+
if (needsThreshold)
|
|
36
|
+
fail(`gate ${gate.id} of kind ${gate.kind} requires a threshold`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (!needsThreshold)
|
|
40
|
+
fail(`gate ${gate.id} of kind ${gate.kind} must not carry a threshold`);
|
|
41
|
+
const param = threshold.param === undefined ? undefined : params[threshold.param];
|
|
42
|
+
if (threshold.param !== undefined && param === undefined)
|
|
43
|
+
fail(`gate ${gate.id} references unknown parameter '${threshold.param}'`);
|
|
44
|
+
const expectedParamType = gate.kind === 'interval-bound' || gate.kind === 'bootstrap-bound' ? 'bps'
|
|
45
|
+
: gate.kind === 'count-max' || gate.kind === 'count-min' ? 'count' : 'value';
|
|
46
|
+
if (param !== undefined && param.type !== expectedParamType) {
|
|
47
|
+
fail(`gate ${gate.id} parameter '${threshold.param}' must have type ${expectedParamType}`);
|
|
48
|
+
}
|
|
49
|
+
if (threshold.value !== undefined) {
|
|
50
|
+
if (!Number.isFinite(threshold.value))
|
|
51
|
+
fail(`gate ${gate.id} threshold value must be finite`);
|
|
52
|
+
if (expectedParamType === 'bps' && (threshold.value < 0 || threshold.value > 10000)) {
|
|
53
|
+
fail(`gate ${gate.id} threshold must be within 0..10000 bps`);
|
|
54
|
+
}
|
|
55
|
+
if (expectedParamType === 'count' && (!Number.isSafeInteger(threshold.value) || threshold.value < 0)) {
|
|
56
|
+
fail(`gate ${gate.id} threshold must be a non-negative integer count`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const op = gate.kind === 'count-max' ? 'lte' : gate.kind === 'count-min' ? 'gte' : threshold.op;
|
|
60
|
+
if (gate.kind === 'count-max' && threshold.op !== 'lte')
|
|
61
|
+
fail(`gate ${gate.id} count-max requires op lte`);
|
|
62
|
+
if (gate.kind === 'count-min' && threshold.op !== 'gte')
|
|
63
|
+
fail(`gate ${gate.id} count-min requires op gte`);
|
|
64
|
+
if (gate.kind === 'interval-bound' && gate.statistic?.bound === 'upper' && threshold.op !== 'lte') {
|
|
65
|
+
fail(`gate ${gate.id} upper-bound gates require op lte`);
|
|
66
|
+
}
|
|
67
|
+
if (gate.kind === 'interval-bound' && gate.statistic?.bound === 'lower' && threshold.op !== 'gte') {
|
|
68
|
+
fail(`gate ${gate.id} lower-bound gates require op gte`);
|
|
69
|
+
}
|
|
70
|
+
if (gate.kind === 'value-threshold') {
|
|
71
|
+
if (threshold.op === 'lte' && gate.direction !== 'lower-is-stricter')
|
|
72
|
+
fail(`gate ${gate.id} lte requires direction lower-is-stricter`);
|
|
73
|
+
if (threshold.op === 'gte' && gate.direction !== 'higher-is-stricter')
|
|
74
|
+
fail(`gate ${gate.id} gte requires direction higher-is-stricter`);
|
|
75
|
+
}
|
|
76
|
+
if (gate.kind === 'interval-bound' || gate.kind === 'bootstrap-bound') {
|
|
77
|
+
if (op === 'lte' && gate.direction !== 'lower-is-stricter')
|
|
78
|
+
fail(`gate ${gate.id} upper-bound gates require direction lower-is-stricter`);
|
|
79
|
+
if (op === 'gte' && gate.direction !== 'higher-is-stricter')
|
|
80
|
+
fail(`gate ${gate.id} lower-bound gates require direction higher-is-stricter`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const isParamRef = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
84
|
+
&& typeof value.param === 'string';
|
|
85
|
+
/**
|
|
86
|
+
* Resolve a literal-or-parameter number against pack parameter defaults.
|
|
87
|
+
* Returns the default for a `{param}` reference, or null when the reference
|
|
88
|
+
* names an unknown parameter, the type mismatches, or no default pins it.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveNumberDefault(value, params, expectedType) {
|
|
91
|
+
if (value === undefined)
|
|
92
|
+
return null;
|
|
93
|
+
if (typeof value === 'number')
|
|
94
|
+
return value;
|
|
95
|
+
if (!isParamRef(value))
|
|
96
|
+
return null;
|
|
97
|
+
const declared = params[value.param];
|
|
98
|
+
if (declared === undefined || declared.type !== expectedType)
|
|
99
|
+
return null;
|
|
100
|
+
return declared.default ?? null;
|
|
101
|
+
}
|
|
102
|
+
/** A literal-or-parameter reference names a known parameter of the expected type. */
|
|
103
|
+
function checkParamRef(gateId, value, params, expectedType, what) {
|
|
104
|
+
if (value === undefined || typeof value === 'number')
|
|
105
|
+
return;
|
|
106
|
+
if (!isParamRef(value) || params[value.param] === undefined) {
|
|
107
|
+
fail(`gate ${gateId} ${what} references unknown parameter '${value?.param}'`);
|
|
108
|
+
}
|
|
109
|
+
if (params[value.param].type !== expectedType) {
|
|
110
|
+
fail(`gate ${gateId} ${what} parameter '${value.param}' must have type ${expectedType}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Statistic coherence per kind. Irrelevant statistic fields are rejected so confused authoring fails at load. */
|
|
114
|
+
function checkStatisticBlock(gate, params) {
|
|
115
|
+
const statistic = gate.statistic;
|
|
116
|
+
if (statistic === undefined) {
|
|
117
|
+
if (STATISTICAL_KINDS.includes(gate.kind)) {
|
|
118
|
+
fail(`gate ${gate.id} of kind ${gate.kind} requires a statistic`);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!STATISTICAL_KINDS.includes(gate.kind)) {
|
|
123
|
+
fail(`gate ${gate.id} of kind ${gate.kind} must not carry a statistic`);
|
|
124
|
+
}
|
|
125
|
+
if (statistic.kind !== gate.kind)
|
|
126
|
+
fail(`gate ${gate.id} statistic kind must match the gate kind`);
|
|
127
|
+
const { method, bound, levelBps, mode, marginBps, seed, resamples, bounds } = statistic;
|
|
128
|
+
if (gate.kind === 'interval-bound') {
|
|
129
|
+
if (method !== undefined && method !== 'wilson' && method !== 'clopper-pearson') {
|
|
130
|
+
fail(`gate ${gate.id} interval-bound method must be wilson or clopper-pearson`);
|
|
131
|
+
}
|
|
132
|
+
if (bound !== 'upper' && bound !== 'lower')
|
|
133
|
+
fail(`gate ${gate.id} interval-bound requires bound upper|lower`);
|
|
134
|
+
if (levelBps === undefined)
|
|
135
|
+
fail(`gate ${gate.id} interval-bound requires levelBps`);
|
|
136
|
+
checkParamRef(gate.id, levelBps, params, 'level', 'levelBps');
|
|
137
|
+
if (mode !== undefined || marginBps !== undefined || seed !== undefined || resamples !== undefined || bounds !== undefined) {
|
|
138
|
+
fail(`gate ${gate.id} interval-bound must not carry mode, margin, seed, resamples or bounds`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else if (gate.kind === 'paired-difference') {
|
|
142
|
+
if (method !== undefined && method !== 'newcombe' && method !== 'tango') {
|
|
143
|
+
fail(`gate ${gate.id} paired-difference method must be newcombe or tango`);
|
|
144
|
+
}
|
|
145
|
+
if (bound !== undefined)
|
|
146
|
+
fail(`gate ${gate.id} paired-difference must not carry bound`);
|
|
147
|
+
if (levelBps === undefined)
|
|
148
|
+
fail(`gate ${gate.id} paired-difference requires levelBps`);
|
|
149
|
+
checkParamRef(gate.id, levelBps, params, 'level', 'levelBps');
|
|
150
|
+
const resolvedMode = mode ?? 'non-inferiority';
|
|
151
|
+
if (resolvedMode !== 'non-inferiority' && resolvedMode !== 'superiority')
|
|
152
|
+
fail(`gate ${gate.id} unknown paired mode`);
|
|
153
|
+
if (resolvedMode === 'non-inferiority' && marginBps === undefined)
|
|
154
|
+
fail(`gate ${gate.id} non-inferiority requires marginBps`);
|
|
155
|
+
if (resolvedMode === 'non-inferiority' && marginBps !== undefined && marginBps > 0) {
|
|
156
|
+
fail(`gate ${gate.id} non-inferiority marginBps must be non-positive`);
|
|
157
|
+
}
|
|
158
|
+
if (resolvedMode === 'superiority' && marginBps !== undefined && marginBps < 0) {
|
|
159
|
+
fail(`gate ${gate.id} superiority marginBps must be non-negative`);
|
|
160
|
+
}
|
|
161
|
+
if (seed !== undefined || resamples !== undefined || bounds !== undefined) {
|
|
162
|
+
fail(`gate ${gate.id} paired-difference must not carry seed, resamples or bounds`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
if (method !== undefined && method !== 'bootstrap')
|
|
167
|
+
fail(`gate ${gate.id} bootstrap-bound method must be bootstrap`);
|
|
168
|
+
if (bound !== 'upper' && bound !== 'lower')
|
|
169
|
+
fail(`gate ${gate.id} bootstrap-bound requires bound upper|lower`);
|
|
170
|
+
if (levelBps === undefined || seed === undefined || resamples === undefined || bounds === undefined) {
|
|
171
|
+
fail(`gate ${gate.id} bootstrap-bound requires levelBps, seed, resamples and bounds`);
|
|
172
|
+
}
|
|
173
|
+
checkParamRef(gate.id, levelBps, params, 'level', 'levelBps');
|
|
174
|
+
if (mode !== undefined || marginBps !== undefined)
|
|
175
|
+
fail(`gate ${gate.id} bootstrap-bound must not carry mode or margin`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function checkParameter(name, parameter) {
|
|
179
|
+
if (parameter.default !== undefined) {
|
|
180
|
+
if (!Number.isFinite(parameter.default))
|
|
181
|
+
fail(`parameter ${name} default must be finite`);
|
|
182
|
+
if (parameter.type === 'bps' && (parameter.default < 0 || parameter.default > 10000)) {
|
|
183
|
+
fail(`parameter ${name} bps default must be within 0..10000`);
|
|
184
|
+
}
|
|
185
|
+
if (parameter.type === 'level' && (!Number.isSafeInteger(parameter.default)
|
|
186
|
+
|| parameter.default <= 5000 || parameter.default >= 9999)) {
|
|
187
|
+
fail(`parameter ${name} level default must be an integer strictly between 5000 and 9999`);
|
|
188
|
+
}
|
|
189
|
+
if (parameter.type === 'count' && (!Number.isSafeInteger(parameter.default) || parameter.default < 0)) {
|
|
190
|
+
fail(`parameter ${name} count default must be a non-negative integer`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** Semantic validation of one fully resolved pack against the provider registry. */
|
|
195
|
+
export function validateResolvedPack(pack, providers) {
|
|
196
|
+
const ids = pack.spec.gates.map(gate => gate.id);
|
|
197
|
+
if (new Set(ids).size !== ids.length)
|
|
198
|
+
fail(`pack ${pack.metadata.id} has duplicate gate ids`);
|
|
199
|
+
const params = pack.spec.parameters ?? {};
|
|
200
|
+
for (const [name, parameter] of Object.entries(params))
|
|
201
|
+
checkParameter(name, parameter);
|
|
202
|
+
for (const [name, metric] of Object.entries(pack.spec.metrics)) {
|
|
203
|
+
if (!providers.has(metric.provider))
|
|
204
|
+
fail(`pack ${pack.metadata.id} metric ${name} names unknown provider ${metric.provider}`);
|
|
205
|
+
const provider = providers.get(metric.provider);
|
|
206
|
+
const declared = provider.metrics[name];
|
|
207
|
+
if (declared === undefined)
|
|
208
|
+
fail(`pack ${pack.metadata.id} metric ${name} is not computed by ${metric.provider}`);
|
|
209
|
+
if (declared.kind !== metric.kind) {
|
|
210
|
+
fail(`pack ${pack.metadata.id} metric ${name} kind ${metric.kind} does not match provider kind ${declared.kind}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const gate of pack.spec.gates) {
|
|
214
|
+
if (gate.onFail !== 'HOLD' && gate.onFail !== 'ROLLBACK') {
|
|
215
|
+
fail(`gate ${gate.id} onFail must be HOLD or ROLLBACK, never PROMOTE`);
|
|
216
|
+
}
|
|
217
|
+
// Insufficient evidence always holds: ROLLBACK needs an observed blocking
|
|
218
|
+
// failure, never a shortfall. Rejected here at load, not just in schema.
|
|
219
|
+
if (gate.onInsufficient !== undefined && gate.onInsufficient !== 'HOLD') {
|
|
220
|
+
fail(`gate ${gate.id} onInsufficient must be HOLD: insufficient evidence never rolls back`);
|
|
221
|
+
}
|
|
222
|
+
if (gate.metric !== undefined) {
|
|
223
|
+
const declared = pack.spec.metrics[gate.metric.name];
|
|
224
|
+
if (declared === undefined || declared.provider !== gate.metric.provider) {
|
|
225
|
+
fail(`gate ${gate.id} references undeclared metric ${gate.metric.provider}:${gate.metric.name}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const metricKind = gate.metric === undefined ? undefined : pack.spec.metrics[gate.metric.name]?.kind;
|
|
229
|
+
const expectedKind = gate.kind === 'interval-bound' || gate.kind === 'count-max' || gate.kind === 'count-min' ? 'proportion'
|
|
230
|
+
: gate.kind === 'paired-difference' ? 'paired' : gate.kind === 'bootstrap-bound' ? 'differences'
|
|
231
|
+
: gate.kind === 'value-threshold' ? 'scalar' : gate.kind === 'evidence' ? 'evidence' : undefined;
|
|
232
|
+
if (expectedKind !== undefined && metricKind !== expectedKind) {
|
|
233
|
+
fail(`gate ${gate.id} of kind ${gate.kind} requires a ${expectedKind} metric`);
|
|
234
|
+
}
|
|
235
|
+
checkThresholdBlock(gate, params);
|
|
236
|
+
checkStatisticBlock(gate, params);
|
|
237
|
+
checkParamRef(gate.id, gate.minimumN, params, 'count', 'minimumN');
|
|
238
|
+
if (gate.kind === 'minimum-n') {
|
|
239
|
+
if (gate.minimumN === undefined)
|
|
240
|
+
fail(`gate ${gate.id} minimum-n requires minimumN >= 1`);
|
|
241
|
+
// A default is the enforced minimum, so a defaulted reference must pin
|
|
242
|
+
// at least 1; a defaultless reference is supplied by the binding and an
|
|
243
|
+
// unresolvable value holds at evaluation, never promotes.
|
|
244
|
+
const resolved = resolveNumberDefault(gate.minimumN, params, 'count');
|
|
245
|
+
if (typeof gate.minimumN === 'number' && gate.minimumN < 1) {
|
|
246
|
+
fail(`gate ${gate.id} minimum-n requires minimumN >= 1`);
|
|
247
|
+
}
|
|
248
|
+
if (isParamRef(gate.minimumN) && params[gate.minimumN.param]?.default !== undefined && (resolved ?? 0) < 1) {
|
|
249
|
+
fail(`gate ${gate.id} minimum-n requires minimumN >= 1`);
|
|
250
|
+
}
|
|
251
|
+
if (metricKind === 'evidence')
|
|
252
|
+
fail(`gate ${gate.id} minimum-n cannot observe an evidence metric`);
|
|
253
|
+
}
|
|
254
|
+
else if (gate.minimumN !== undefined && typeof gate.minimumN === 'number'
|
|
255
|
+
&& (!Number.isSafeInteger(gate.minimumN) || gate.minimumN < 0)) {
|
|
256
|
+
fail(`gate ${gate.id} minimumN must be a non-negative integer`);
|
|
257
|
+
}
|
|
258
|
+
if (gate.kind === 'evidence' && (gate.evidence === undefined || !gate.evidence.required.length)) {
|
|
259
|
+
fail(`gate ${gate.id} evidence requires a nonempty required list`);
|
|
260
|
+
}
|
|
261
|
+
if (gate.kind !== 'evidence' && gate.evidence !== undefined)
|
|
262
|
+
fail(`gate ${gate.id} of kind ${gate.kind} must not carry evidence`);
|
|
263
|
+
if (gate.kind !== 'predicate' && gate.predicate !== undefined)
|
|
264
|
+
fail(`gate ${gate.id} of kind ${gate.kind} must not carry a predicate`);
|
|
265
|
+
if ((gate.scope.mode === 'listed' || gate.scope.mode === 'pooled')
|
|
266
|
+
&& (gate.scope.slices === undefined || !gate.scope.slices.length)) {
|
|
267
|
+
fail(`gate ${gate.id} scope ${gate.scope.mode} requires a nonempty slices list`);
|
|
268
|
+
}
|
|
269
|
+
// `except` is honoured only by `each` (per-slice minus exclusions). Any other
|
|
270
|
+
// mode silently ignoring it would be a fail-open scoping bug, so it is a load error.
|
|
271
|
+
if (gate.scope.except !== undefined && gate.scope.mode !== 'each') {
|
|
272
|
+
fail(`gate ${gate.id} scope except is only allowed with mode each`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** Resolve a `$param` threshold against pack parameter defaults. Null when no default pins it. */
|
|
277
|
+
export function resolveThresholdDefault(gate, params) {
|
|
278
|
+
if (gate.threshold === undefined)
|
|
279
|
+
return null;
|
|
280
|
+
if (gate.threshold.value !== undefined)
|
|
281
|
+
return gate.threshold.value;
|
|
282
|
+
if (gate.threshold.param === undefined)
|
|
283
|
+
return null;
|
|
284
|
+
return params[gate.threshold.param]?.default ?? null;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Scope-tightening rules per gate kind. A scope change is a tightening only when
|
|
288
|
+
* the child observes every slice the parent observes under every possible
|
|
289
|
+
* binding inventory (so a breach the parent sees can never go unobserved)
|
|
290
|
+
* and every world that fails the child also fails the parent:
|
|
291
|
+
* - `listed` may only widen (more slices fail in a superset of worlds);
|
|
292
|
+
* - `pooled` sets are fixed;
|
|
293
|
+
* - `listed -> each` widens coverage to the whole binding inventory, so the
|
|
294
|
+
* child's `except` must exclude none of the parent's listed slices;
|
|
295
|
+
* - `all -> each` covers the whole inventory only with no exceptions, and is a
|
|
296
|
+
* tightening only when per-slice checks are strictly harder than the pooled
|
|
297
|
+
* check (`count-min`, `minimum-n`); for `count-max` the pooled bound is
|
|
298
|
+
* strictly harder (pooled<=2 implies per-slice<=2, not vice versa), and for
|
|
299
|
+
* interval/paired/bootstrap/value gates pooled and per-slice bounds are
|
|
300
|
+
* incomparable — so the change is rejected for every other kind.
|
|
301
|
+
* Every other cross-mode change crosses aggregation or drops coverage and is
|
|
302
|
+
* rejected for every kind.
|
|
303
|
+
*/
|
|
304
|
+
function assertScopeTightens(parent, child) {
|
|
305
|
+
const from = parent.scope;
|
|
306
|
+
const to = child.scope;
|
|
307
|
+
if (from.mode === to.mode) {
|
|
308
|
+
if (to.mode === 'listed' || to.mode === 'pooled') {
|
|
309
|
+
const before = new Set(from.slices ?? []);
|
|
310
|
+
const after = new Set(to.slices ?? []);
|
|
311
|
+
if (to.mode === 'listed') {
|
|
312
|
+
// A wider listed set fails in a superset of worlds, so it tightens.
|
|
313
|
+
for (const slice of before) {
|
|
314
|
+
if (!after.has(slice))
|
|
315
|
+
fail(`gate ${child.id} scope listed may not drop inherited slices`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
else if (after.size !== before.size || [...after].some(slice => !before.has(slice))) {
|
|
319
|
+
fail(`gate ${child.id} scope pooled may not change its slice set`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const beforeExcept = new Set(from.except ?? []);
|
|
323
|
+
for (const slice of to.except ?? []) {
|
|
324
|
+
if (!beforeExcept.has(slice))
|
|
325
|
+
fail(`gate ${child.id} scope may only narrow inherited exceptions`);
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (from.mode === 'listed' && to.mode === 'each') {
|
|
330
|
+
const excluded = new Set(to.except ?? []);
|
|
331
|
+
for (const slice of from.slices ?? []) {
|
|
332
|
+
if (excluded.has(slice))
|
|
333
|
+
fail(`gate ${child.id} scope listed -> each may not except inherited slice '${slice}'`);
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (from.mode === 'all' && to.mode === 'each'
|
|
338
|
+
&& (child.kind === 'count-min' || child.kind === 'minimum-n')) {
|
|
339
|
+
// `all` watches the whole inventory: any exception drops a watched slice.
|
|
340
|
+
if ((to.except ?? []).length)
|
|
341
|
+
fail(`gate ${child.id} scope all -> each may not except slices`);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
fail(`gate ${child.id} scope change ${from.mode} -> ${to.mode} is not a monotone tightening`);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Proves monotone tightening of a child gate over its parent. Any loosening —
|
|
348
|
+
* a relaxed threshold, a literal rewritten as a bindable parameter, a renamed
|
|
349
|
+
* parameter, a lowered minimumN, a demoted outcome, a widened scope
|
|
350
|
+
* or a changed statistic — is a load error.
|
|
351
|
+
*/
|
|
352
|
+
export function assertGateTightens(parent, child, params) {
|
|
353
|
+
if (parent.kind !== child.kind)
|
|
354
|
+
fail(`gate ${child.id} may not change kind ${parent.kind} -> ${child.kind}`);
|
|
355
|
+
if (canonicalJson(parent.metric ?? null) !== canonicalJson(child.metric ?? null)) {
|
|
356
|
+
fail(`gate ${child.id} may not change its metric reference`);
|
|
357
|
+
}
|
|
358
|
+
if (parent.direction !== child.direction)
|
|
359
|
+
fail(`gate ${child.id} may not change its tightening direction`);
|
|
360
|
+
const parentStatistic = parent.statistic;
|
|
361
|
+
const childStatistic = child.statistic;
|
|
362
|
+
if (canonicalJson({ ...parentStatistic, levelBps: 0 }) !== canonicalJson({ ...childStatistic, levelBps: 0 })) {
|
|
363
|
+
fail(`gate ${child.id} may not change its statistic (levelBps aside)`);
|
|
364
|
+
}
|
|
365
|
+
// A literal level is a pinned bound: rewriting it as a parameter hands the
|
|
366
|
+
// bound to the binding, whose allowed range is wider than the literal.
|
|
367
|
+
const parentLevel = parentStatistic?.levelBps;
|
|
368
|
+
const childLevel = childStatistic?.levelBps;
|
|
369
|
+
if (typeof parentLevel === 'number' && isParamRef(childLevel)) {
|
|
370
|
+
fail(`gate ${child.id} may not convert a literal levelBps into a parameter`);
|
|
371
|
+
}
|
|
372
|
+
if (isParamRef(parentLevel) && isParamRef(childLevel) && parentLevel.param !== childLevel.param) {
|
|
373
|
+
fail(`gate ${child.id} may not rename its levelBps parameter`);
|
|
374
|
+
}
|
|
375
|
+
const levelBefore = parentLevel === undefined ? undefined : resolveNumberDefault(parentLevel, params, 'level');
|
|
376
|
+
const levelAfter = childLevel === undefined ? undefined : resolveNumberDefault(childLevel, params, 'level');
|
|
377
|
+
if (levelBefore === null || levelAfter === null) {
|
|
378
|
+
if (canonicalJson(parentLevel ?? null) !== canonicalJson(childLevel ?? null)) {
|
|
379
|
+
fail(`gate ${child.id} levelBps change is unresolvable without parameter defaults`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
else if ((levelAfter ?? levelBefore) !== undefined && (levelAfter ?? 0) < (levelBefore ?? 0)) {
|
|
383
|
+
fail(`gate ${child.id} may only raise levelBps`);
|
|
384
|
+
}
|
|
385
|
+
if ((parentStatistic?.mode ?? 'non-inferiority') !== (childStatistic?.mode ?? 'non-inferiority')) {
|
|
386
|
+
fail(`gate ${child.id} may not change its paired mode`);
|
|
387
|
+
}
|
|
388
|
+
const marginBefore = parentStatistic?.marginBps;
|
|
389
|
+
const marginAfter = childStatistic?.marginBps;
|
|
390
|
+
if (marginBefore !== undefined && marginAfter !== undefined && marginAfter < marginBefore) {
|
|
391
|
+
fail(`gate ${child.id} may only raise marginBps`);
|
|
392
|
+
}
|
|
393
|
+
// A literal threshold is a pinned bound. Rewriting it as a parameter hands the
|
|
394
|
+
// bound to the binding, whose allowed range (e.g. 0..10000 bps) is wider than
|
|
395
|
+
// the literal — a loosening no default-equality check can see. Forbid it.
|
|
396
|
+
if (parent.threshold?.value !== undefined && child.threshold?.param !== undefined) {
|
|
397
|
+
fail(`gate ${child.id} may not convert a literal threshold into a parameter`);
|
|
398
|
+
}
|
|
399
|
+
// Renaming a parameter rebinds the gate to a fresh default and range. Params
|
|
400
|
+
// may only keep their name with a tighter-or-equal default.
|
|
401
|
+
if (parent.threshold?.param !== undefined && child.threshold?.param !== undefined
|
|
402
|
+
&& parent.threshold.param !== child.threshold.param) {
|
|
403
|
+
fail(`gate ${child.id} may not rename its threshold parameter`);
|
|
404
|
+
}
|
|
405
|
+
const before = resolveThresholdDefault(parent, params);
|
|
406
|
+
const after = resolveThresholdDefault(child, params);
|
|
407
|
+
if (before === null || after === null) {
|
|
408
|
+
if (canonicalJson(parent.threshold ?? null) !== canonicalJson(child.threshold ?? null)) {
|
|
409
|
+
fail(`gate ${child.id} threshold change is unresolvable without parameter defaults`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
else if (child.direction === 'lower-is-stricter' ? after > before : after < before) {
|
|
413
|
+
fail(`gate ${child.id} threshold ${before} -> ${after} loosens direction ${child.direction}`);
|
|
414
|
+
}
|
|
415
|
+
if (typeof parent.minimumN === 'number' && isParamRef(child.minimumN)) {
|
|
416
|
+
fail(`gate ${child.id} may not convert a literal minimumN into a parameter`);
|
|
417
|
+
}
|
|
418
|
+
if (isParamRef(parent.minimumN) && isParamRef(child.minimumN) && parent.minimumN.param !== child.minimumN.param) {
|
|
419
|
+
fail(`gate ${child.id} may not rename its minimumN parameter`);
|
|
420
|
+
}
|
|
421
|
+
const minimumBefore = parent.minimumN === undefined ? 0 : resolveNumberDefault(parent.minimumN, params, 'count');
|
|
422
|
+
const minimumAfter = child.minimumN === undefined ? 0 : resolveNumberDefault(child.minimumN, params, 'count');
|
|
423
|
+
if (minimumBefore === null || minimumAfter === null) {
|
|
424
|
+
if (canonicalJson(parent.minimumN ?? null) !== canonicalJson(child.minimumN ?? null)) {
|
|
425
|
+
fail(`gate ${child.id} minimumN change is unresolvable without parameter defaults`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
else if (minimumAfter < minimumBefore) {
|
|
429
|
+
fail(`gate ${child.id} may only raise minimumN`);
|
|
430
|
+
}
|
|
431
|
+
if (parent.onFail === 'ROLLBACK' && child.onFail !== 'ROLLBACK') {
|
|
432
|
+
fail(`gate ${child.id} may not demote onFail ROLLBACK -> HOLD`);
|
|
433
|
+
}
|
|
434
|
+
// Insufficient evidence always holds: ROLLBACK needs an observed blocking
|
|
435
|
+
// failure, so neither side may name it. Checked here as well as in schema
|
|
436
|
+
// and pack validation because this pure function is the tightening gate.
|
|
437
|
+
if ((parent.onInsufficient ?? 'HOLD') !== 'HOLD' || (child.onInsufficient ?? 'HOLD') !== 'HOLD') {
|
|
438
|
+
fail(`gate ${child.id} onInsufficient must be HOLD: insufficient evidence never rolls back`);
|
|
439
|
+
}
|
|
440
|
+
if (parent.floor === true && child.floor !== true)
|
|
441
|
+
fail(`gate ${child.id} is a floor gate and cannot be un-floored`);
|
|
442
|
+
assertScopeTightens(parent, child);
|
|
443
|
+
const afterEvidence = new Set(child.evidence?.required ?? []);
|
|
444
|
+
for (const id of parent.evidence?.required ?? []) {
|
|
445
|
+
if (!afterEvidence.has(id))
|
|
446
|
+
fail(`gate ${child.id} may not drop required evidence ${id}`);
|
|
447
|
+
}
|
|
448
|
+
if (canonicalJson(parent.predicate ?? null) !== canonicalJson(child.predicate ?? null)) {
|
|
449
|
+
fail(`gate ${child.id} may not change its predicate`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const pinLabel = (pin) => `${pin.id}@${pin.version}`;
|
|
453
|
+
/**
|
|
454
|
+
* Pure `extends` composition: merges a child's declarations over its already
|
|
455
|
+
* resolved parent. No registry state is read, so a `GateRegistry` subclass
|
|
456
|
+
* cannot change tightening semantics by overriding an instance method. The
|
|
457
|
+
* evaluator calls this (via `composeGatePack`/`resolveGateBinding`) instead of
|
|
458
|
+
* any overridable `GateRegistry` method for security-relevant resolution.
|
|
459
|
+
*/
|
|
460
|
+
export function applyGateExtends(parentResolved, child) {
|
|
461
|
+
const mergedParams = { ...(parentResolved.spec.parameters ?? {}) };
|
|
462
|
+
for (const [name, parameter] of Object.entries(child.spec.parameters ?? {})) {
|
|
463
|
+
const before = mergedParams[name];
|
|
464
|
+
if (before === undefined) {
|
|
465
|
+
mergedParams[name] = parameter;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (before.type !== parameter.type || before.direction !== parameter.direction) {
|
|
469
|
+
throw new GateRegistryError(`pack ${child.metadata.id} may not change parameter ${name} type or direction`);
|
|
470
|
+
}
|
|
471
|
+
if (before.default === undefined && parameter.default !== undefined) {
|
|
472
|
+
throw new GateRegistryError(`pack ${child.metadata.id} may not add a default to required parameter ${name}`);
|
|
473
|
+
}
|
|
474
|
+
if (before.default !== undefined && parameter.default === undefined) {
|
|
475
|
+
mergedParams[name] = parameter;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (before.default !== undefined && parameter.default !== undefined) {
|
|
479
|
+
const tightens = parameter.direction === 'lower-is-stricter'
|
|
480
|
+
? parameter.default <= before.default : parameter.default >= before.default;
|
|
481
|
+
if (!tightens)
|
|
482
|
+
throw new GateRegistryError(`pack ${child.metadata.id} parameter ${name} default loosens`);
|
|
483
|
+
mergedParams[name] = parameter;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const parentGates = new Map(parentResolved.spec.gates.map(gate => [gate.id, gate]));
|
|
487
|
+
const merged = [];
|
|
488
|
+
const seen = new Set();
|
|
489
|
+
for (const gate of parentResolved.spec.gates) {
|
|
490
|
+
const override = (child.spec.gates ?? []).find(candidate => candidate.id === gate.id);
|
|
491
|
+
if (override === undefined) {
|
|
492
|
+
// Removing a non-floor gate is allowed; floor gates pin the minimum.
|
|
493
|
+
if (gate.floor === true)
|
|
494
|
+
throw new GateRegistryError(`pack ${child.metadata.id} may not remove floor gate ${gate.id}`);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
seen.add(gate.id);
|
|
498
|
+
assertGateTightens(gate, override, mergedParams);
|
|
499
|
+
merged.push(override);
|
|
500
|
+
}
|
|
501
|
+
for (const gate of child.spec.gates ?? []) {
|
|
502
|
+
if (!seen.has(gate.id) && !parentGates.has(gate.id))
|
|
503
|
+
merged.push(gate);
|
|
504
|
+
else if (!seen.has(gate.id))
|
|
505
|
+
fail(`pack ${child.metadata.id} duplicates gate ${gate.id}`);
|
|
506
|
+
}
|
|
507
|
+
const mergedMetrics = { ...parentResolved.spec.metrics };
|
|
508
|
+
for (const [name, metric] of Object.entries(child.spec.metrics ?? {})) {
|
|
509
|
+
const before = mergedMetrics[name];
|
|
510
|
+
if (before !== undefined && (before.provider !== metric.provider || before.kind !== metric.kind)) {
|
|
511
|
+
throw new GateRegistryError(`pack ${child.metadata.id} may not change inherited metric ${name}`);
|
|
512
|
+
}
|
|
513
|
+
mergedMetrics[name] = metric;
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
apiVersion: child.apiVersion, kind: child.kind, metadata: child.metadata,
|
|
517
|
+
spec: {
|
|
518
|
+
extends: child.spec.extends, parameters: mergedParams, metrics: mergedMetrics, gates: merged,
|
|
519
|
+
// Omit `combine` when the child leaves it unset: a present-but-undefined
|
|
520
|
+
// key is not JSON and would poison the composed-pack digest.
|
|
521
|
+
...(child.spec.combine === undefined ? {} : { combine: child.spec.combine }),
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Pure extends-chain composition. The lookup returns authored packs by full id;
|
|
527
|
+
* parent pins (version + authored digest) are enforced here, so a loosened
|
|
528
|
+
* parent always moves the composed digest.
|
|
529
|
+
*/
|
|
530
|
+
export function composeGatePack(pack, lookupAuthored) {
|
|
531
|
+
if (pack.spec.extends === undefined)
|
|
532
|
+
return pack;
|
|
533
|
+
const parentAuthored = lookupAuthored(pack.spec.extends.id);
|
|
534
|
+
const parentPin = pack.spec.extends;
|
|
535
|
+
if (parentAuthored.metadata.version !== parentPin.version
|
|
536
|
+
|| artifactDigest(parentAuthored) !== parentPin.digest) {
|
|
537
|
+
throw new GateRegistryError(`pack ${pack.metadata.id} parent pin mismatch for ${pinLabel(parentPin)}`);
|
|
538
|
+
}
|
|
539
|
+
return applyGateExtends(composeGatePack(parentAuthored, lookupAuthored), pack);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Whether one expanded floor's `integrity-ceiling` gate genuinely governs the
|
|
543
|
+
* default ceiling: it must be an all-scoped `upstream-ceiling` gate that
|
|
544
|
+
* tightens the shipped default gate (floor flag included). A same-id gate of
|
|
545
|
+
* any other kind or scope never suppresses the default.
|
|
546
|
+
*/
|
|
547
|
+
function governsDefaultCeiling(floor, defaultGate) {
|
|
548
|
+
return floor.gates.some(gate => {
|
|
549
|
+
if (gate.id !== 'integrity-ceiling' || gate.kind !== 'upstream-ceiling' || gate.scope.mode !== 'all') {
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
assertGateTightens(defaultGate, gate, floor.params);
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Expands project floors over an explicit authored-pack map: inline packs are
|
|
563
|
+
* schema-checked again (direct registry callers bypass config validation) and
|
|
564
|
+
* semantically validated, pack references are verified by version and authored
|
|
565
|
+
* digest and composed through the pure `composeGatePack` (so `extends` floors
|
|
566
|
+
* enforce their composed minima). Every floor gate that binds a threshold
|
|
567
|
+
* parameter must declare a default for it: the default is the enforced
|
|
568
|
+
* minimum, and a required (default-less) floor parameter is a load error.
|
|
569
|
+
*
|
|
570
|
+
* The operator default floor — the composed gates of the shipped
|
|
571
|
+
* `aiwg:decision-engine/integrity-ceiling` pack, the single source of truth —
|
|
572
|
+
* is appended unless a configured floor governs it via `governsDefaultCeiling`
|
|
573
|
+
* above. An inline gate that merely reuses the id (any other kind or scope)
|
|
574
|
+
* never suppresses the default; the binding must then satisfy both, which
|
|
575
|
+
* fails closed with a diagnostic.
|
|
576
|
+
*/
|
|
577
|
+
export function expandFloorPacks(floors, lookupAuthored, providers) {
|
|
578
|
+
const expanded = (floors.floors ?? []).map(source => {
|
|
579
|
+
if ('packRef' in source) {
|
|
580
|
+
const pin = source.packRef;
|
|
581
|
+
const authored = lookupAuthored(pin.id);
|
|
582
|
+
if (authored.metadata.version !== pin.version
|
|
583
|
+
|| artifactDigest(authored) !== pin.digest) {
|
|
584
|
+
throw new GateRegistryError(`project floor pack pin mismatch for ${pinLabel(pin)}`);
|
|
585
|
+
}
|
|
586
|
+
const composed = composeGatePack(authored, lookupAuthored);
|
|
587
|
+
validateResolvedPack(composed, providers);
|
|
588
|
+
return {
|
|
589
|
+
packId: authored.metadata.id,
|
|
590
|
+
gates: composed.spec.gates,
|
|
591
|
+
params: { ...(composed.spec.parameters ?? {}) },
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
const pack = validateGateDocument(source.pack);
|
|
595
|
+
if (pack.kind !== 'GatePack')
|
|
596
|
+
throw new GateRegistryError('project floor pack requires a GatePack document');
|
|
597
|
+
const composed = pack.spec.extends === undefined ? pack : composeGatePack(pack, lookupAuthored);
|
|
598
|
+
validateResolvedPack(composed, providers);
|
|
599
|
+
for (const gate of composed.spec.gates) {
|
|
600
|
+
const level = gate.statistic?.levelBps;
|
|
601
|
+
const refs = [gate.threshold?.param, isParamRef(gate.minimumN) ? gate.minimumN.param : undefined,
|
|
602
|
+
isParamRef(level) ? level.param : undefined];
|
|
603
|
+
for (const name of refs) {
|
|
604
|
+
if (name !== undefined && composed.spec.parameters?.[name]?.default === undefined) {
|
|
605
|
+
throw new GateRegistryError(`project floor gate '${gate.id}' parameter '${name}' requires a default: it is the enforced minimum`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return {
|
|
610
|
+
packId: pack.metadata.id,
|
|
611
|
+
gates: composed.spec.gates,
|
|
612
|
+
params: { ...(composed.spec.parameters ?? {}) },
|
|
613
|
+
};
|
|
614
|
+
});
|
|
615
|
+
const defaultAuthored = lookupAuthored(INTEGRITY_CEILING_FLOOR_PACK_ID);
|
|
616
|
+
const defaultComposed = composeGatePack(defaultAuthored, lookupAuthored);
|
|
617
|
+
validateResolvedPack(defaultComposed, providers);
|
|
618
|
+
const defaultGate = defaultComposed.spec.gates.find(gate => gate.id === 'integrity-ceiling');
|
|
619
|
+
if (defaultGate === undefined) {
|
|
620
|
+
throw new GateRegistryError(`shipped default floor ${INTEGRITY_CEILING_FLOOR_PACK_ID} has no integrity-ceiling gate`);
|
|
621
|
+
}
|
|
622
|
+
if (!expanded.some(floor => governsDefaultCeiling(floor, defaultGate))) {
|
|
623
|
+
expanded.push({
|
|
624
|
+
packId: defaultAuthored.metadata.id,
|
|
625
|
+
gates: defaultComposed.spec.gates,
|
|
626
|
+
params: { ...(defaultComposed.spec.parameters ?? {}) },
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
return expanded;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Resolves a binding gate's threshold parameter to its preregistered binding
|
|
633
|
+
* value, so floor comparison is always value-against-minimum in the gate's
|
|
634
|
+
* declared direction. A binding may parameterize a floor literal: the value,
|
|
635
|
+
* not the spelling, must tighten.
|
|
636
|
+
*/
|
|
637
|
+
function synthesizeFloorCandidate(gate, packId, parameters) {
|
|
638
|
+
const materialize = (ref) => {
|
|
639
|
+
const value = parameters[qualifyGateParameter(packId, ref.param)];
|
|
640
|
+
if (value === undefined) {
|
|
641
|
+
throw new GateRegistryError(`binding gate '${gate.id}' parameter '${ref.param}' has no resolved value`);
|
|
642
|
+
}
|
|
643
|
+
return value;
|
|
644
|
+
};
|
|
645
|
+
let candidate = gate;
|
|
646
|
+
if (gate.threshold !== undefined && gate.threshold.param !== undefined) {
|
|
647
|
+
const { op, param } = gate.threshold;
|
|
648
|
+
candidate = { ...candidate, threshold: { op, value: materialize({ param }) } };
|
|
649
|
+
}
|
|
650
|
+
if (isParamRef(gate.minimumN))
|
|
651
|
+
candidate = { ...candidate, minimumN: materialize(gate.minimumN) };
|
|
652
|
+
const statistic = gate.statistic;
|
|
653
|
+
if (statistic !== undefined && isParamRef(statistic.levelBps)) {
|
|
654
|
+
candidate = { ...candidate, statistic: { ...statistic, levelBps: materialize(statistic.levelBps) } };
|
|
655
|
+
}
|
|
656
|
+
return candidate;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Enforces project floors over already-resolved binding packs. Project policy
|
|
660
|
+
* outranks addon and framework packs: a binding that omits a floor gate, or
|
|
661
|
+
* whose gate loosens any floor gate's threshold, parameter value, scope or
|
|
662
|
+
* outcome (via the shared per-kind `assertGateTightens` and the scope-superset
|
|
663
|
+
* rule), is refused with a diagnostic naming the binding, the gate and the
|
|
664
|
+
* floor pack. Every binding gate sharing a floor gate id must tighten it, so
|
|
665
|
+
* a loosened duplicate cannot evade the floor.
|
|
666
|
+
*/
|
|
667
|
+
export function enforceProjectFloors(binding, packs, parameters, expanded, ceilings) {
|
|
668
|
+
// The '*' key is the project-wide default ceiling for every binding; a
|
|
669
|
+
// per-study key overrides it for that study only (exact match — renames
|
|
670
|
+
// never inherit). The star rule is re-checked here (C6): floors handed in
|
|
671
|
+
// directly, bypassing `validateGatesConfig`/`resolveProjectFloors`, still
|
|
672
|
+
// cannot carry starless or loosening per-study ceilings.
|
|
673
|
+
try {
|
|
674
|
+
assertCeilingDefaults(ceilings);
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
throw new GateRegistryError(error instanceof Error ? error.message : 'invalid project ceilings');
|
|
678
|
+
}
|
|
679
|
+
const configured = ceilings[binding.metadata.id] ?? ceilings['*'];
|
|
680
|
+
if (!projectCeilingSatisfied(binding.spec.ceiling, configured)) {
|
|
681
|
+
throw new GateRegistryError(`binding '${binding.metadata.id}' declares ceiling ${binding.spec.ceiling ?? 'PROMOTE'}`
|
|
682
|
+
+ ` below the project ceiling ${configured} for this study`);
|
|
683
|
+
}
|
|
684
|
+
const union = [];
|
|
685
|
+
for (const pack of packs) {
|
|
686
|
+
for (const gate of pack.resolved.spec.gates)
|
|
687
|
+
union.push({ gate, packId: pack.authored.metadata.id });
|
|
688
|
+
}
|
|
689
|
+
for (const floor of expanded) {
|
|
690
|
+
for (const floorGate of floor.gates) {
|
|
691
|
+
const matches = union.filter(candidate => candidate.gate.id === floorGate.id);
|
|
692
|
+
if (!matches.length) {
|
|
693
|
+
throw new GateRegistryError(`binding '${binding.metadata.id}' omits project floor gate '${floorGate.id}'`
|
|
694
|
+
+ ` (floor pack '${floor.packId}'): every binding in this project must include and tighten it`);
|
|
695
|
+
}
|
|
696
|
+
for (const match of matches) {
|
|
697
|
+
try {
|
|
698
|
+
assertGateTightens(floorGate, synthesizeFloorCandidate(match.gate, match.packId, parameters), floor.params);
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
throw new GateRegistryError(`binding '${binding.metadata.id}' loosens project floor gate '${floorGate.id}'`
|
|
702
|
+
+ ` (floor pack '${floor.packId}'): ${error instanceof Error ? error.message : 'invalid tightening'}`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Standalone snapshot readers. These are module functions, not `GateRegistry`
|
|
710
|
+
* methods, so a subclass override of `resolveBinding`/`resolvePack`/`getPack`
|
|
711
|
+
* cannot intercept them. The evaluator reads registry state only through these
|
|
712
|
+
* and recomposes through the pure functions above.
|
|
713
|
+
*/
|
|
714
|
+
export function authoredGatePacksOf(registry) {
|
|
715
|
+
const packs = registry.packs;
|
|
716
|
+
if (!(packs instanceof Map))
|
|
717
|
+
throw new GateRegistryError('gate registry packs are unavailable');
|
|
718
|
+
return packs;
|
|
719
|
+
}
|
|
720
|
+
export function gateProvidersOf(registry) {
|
|
721
|
+
const providers = registry.providers;
|
|
722
|
+
if (providers === undefined || providers === null)
|
|
723
|
+
throw new GateRegistryError('gate registry providers are unavailable');
|
|
724
|
+
return providers;
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Pure binding resolution over an explicit authored-pack map and provider registry.
|
|
728
|
+
* Project floors (`floors`, loaded from `aiwg.config` by the caller via
|
|
729
|
+
* `resolveProjectFloors`) are an optional trusted input: when present, the
|
|
730
|
+
* binding must include and tighten every floor gate and respect its per-study
|
|
731
|
+
* ceiling. When absent the check is skipped entirely and resolution is
|
|
732
|
+
* byte-identical to the floors-unaware path.
|
|
733
|
+
*/
|
|
734
|
+
export function resolveGateBinding(doc, authoredById, providers, floors) {
|
|
735
|
+
const binding = validateGateDocument(doc);
|
|
736
|
+
if (binding.kind !== 'GateBinding')
|
|
737
|
+
throw new GateRegistryError('resolveBinding requires a GateBinding document');
|
|
738
|
+
const seenPins = new Set();
|
|
739
|
+
for (const pin of binding.spec.packs) {
|
|
740
|
+
if (seenPins.has(pin.id))
|
|
741
|
+
throw new GateRegistryError(`binding ${binding.metadata.id} pins pack ${pin.id} twice`);
|
|
742
|
+
seenPins.add(pin.id);
|
|
743
|
+
}
|
|
744
|
+
const lookupAuthored = (id) => {
|
|
745
|
+
if (id.includes(':')) {
|
|
746
|
+
const entry = authoredById.get(id);
|
|
747
|
+
if (entry === undefined)
|
|
748
|
+
throw new GateRegistryError(`unknown gate pack: ${id}`);
|
|
749
|
+
return entry.authored;
|
|
750
|
+
}
|
|
751
|
+
const matches = [...authoredById.keys()].filter(key => splitPackId(key).rest === id);
|
|
752
|
+
if (!matches.length)
|
|
753
|
+
throw new GateRegistryError(`unknown gate pack: ${id}`);
|
|
754
|
+
if (matches.length > 1) {
|
|
755
|
+
throw new GateRegistryError(`ambiguous gate pack '${id}': ${matches.sort().join(', ')} (pin the full id)`);
|
|
756
|
+
}
|
|
757
|
+
return authoredById.get(matches[0]).authored;
|
|
758
|
+
};
|
|
759
|
+
const packs = binding.spec.packs.map(pin => {
|
|
760
|
+
const entry = pin.id.includes(':') ? authoredById.get(pin.id) : (() => {
|
|
761
|
+
const matches = [...authoredById.keys()].filter(key => splitPackId(key).rest === pin.id);
|
|
762
|
+
if (!matches.length)
|
|
763
|
+
throw new GateRegistryError(`unknown gate pack: ${pin.id}`);
|
|
764
|
+
if (matches.length > 1) {
|
|
765
|
+
throw new GateRegistryError(`ambiguous gate pack '${pin.id}': ${matches.sort().join(', ')} (pin the full id)`);
|
|
766
|
+
}
|
|
767
|
+
return authoredById.get(matches[0]);
|
|
768
|
+
})();
|
|
769
|
+
if (entry === undefined)
|
|
770
|
+
throw new GateRegistryError(`unknown gate pack: ${pin.id}`);
|
|
771
|
+
if (entry.authored.metadata.id !== pin.id) {
|
|
772
|
+
throw new GateRegistryError(`binding ${binding.metadata.id} must pin the full pack id (got '${pin.id}')`);
|
|
773
|
+
}
|
|
774
|
+
if (artifactDigest(entry.authored) !== entry.digest
|
|
775
|
+
|| entry.authored.metadata.version !== pin.version || entry.digest !== pin.digest) {
|
|
776
|
+
throw new GateRegistryError(`binding ${binding.metadata.id} pack pin mismatch for ${pinLabel(pin)}`);
|
|
777
|
+
}
|
|
778
|
+
const resolved = composeGatePack(entry.authored, lookupAuthored);
|
|
779
|
+
if (artifactDigest(resolved) !== pin.resolvedDigest) {
|
|
780
|
+
throw new GateRegistryError(`binding ${binding.metadata.id} composed-pack pin mismatch for ${pinLabel(pin)}`);
|
|
781
|
+
}
|
|
782
|
+
return {
|
|
783
|
+
authored: entry.authored, digest: entry.digest,
|
|
784
|
+
resolvedDigest: pin.resolvedDigest, resolved,
|
|
785
|
+
};
|
|
786
|
+
});
|
|
787
|
+
for (const pin of binding.spec.metricProviders) {
|
|
788
|
+
if (!providers.has(pin.id))
|
|
789
|
+
throw new GateRegistryError(`binding ${binding.metadata.id} pins unknown provider ${pin.id}`);
|
|
790
|
+
const provider = providers.get(pin.id);
|
|
791
|
+
if (provider.version !== pin.version || provider.sourceDigest !== pin.sourceDigest) {
|
|
792
|
+
throw new GateRegistryError(`binding ${binding.metadata.id} provider pin mismatch for ${pin.id}`);
|
|
793
|
+
}
|
|
794
|
+
// Bundle providers (#2831 rework): the pin must carry the loader-computed
|
|
795
|
+
// code digest, verified here against the loaded provider, plus the
|
|
796
|
+
// records digest the evaluator reproduces (records binding, P4). A byte
|
|
797
|
+
// change moves the code digest, so an old pin refuses until re-pinned.
|
|
798
|
+
// Null never verifies. Core pins carry neither digest.
|
|
799
|
+
const expectedCode = provider.codeDigest;
|
|
800
|
+
const pinnedCode = pin.codeDigest;
|
|
801
|
+
const pinnedRecords = pin.recordsDigest;
|
|
802
|
+
if (typeof expectedCode === 'string') {
|
|
803
|
+
if (typeof pinnedCode !== 'string' || pinnedCode !== expectedCode) {
|
|
804
|
+
throw new GateRegistryError(`binding ${binding.metadata.id} provider code pin mismatch for ${pin.id}`);
|
|
805
|
+
}
|
|
806
|
+
if (typeof pinnedRecords !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(pinnedRecords)) {
|
|
807
|
+
throw new GateRegistryError(`binding ${binding.metadata.id} provider records pin mismatch for ${pin.id}`);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
else {
|
|
811
|
+
if (pinnedCode !== undefined) {
|
|
812
|
+
throw new GateRegistryError(`binding ${binding.metadata.id} provider code pin mismatch for ${pin.id}`);
|
|
813
|
+
}
|
|
814
|
+
if (pinnedRecords !== undefined) {
|
|
815
|
+
throw new GateRegistryError(`binding ${binding.metadata.id} provider records pin mismatch for ${pin.id}`);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
const requiredProviders = new Set();
|
|
820
|
+
for (const pack of packs) {
|
|
821
|
+
for (const gate of pack.resolved.spec.gates) {
|
|
822
|
+
if (gate.metric !== undefined)
|
|
823
|
+
requiredProviders.add(gate.metric.provider);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
const pinnedProviders = new Set(binding.spec.metricProviders.map(pin => pin.id));
|
|
827
|
+
for (const id of requiredProviders) {
|
|
828
|
+
if (!pinnedProviders.has(id)) {
|
|
829
|
+
throw new GateRegistryError(`binding ${binding.metadata.id} leaves provider ${id} used by a gate unpinned`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
const declared = new Map();
|
|
833
|
+
for (const pack of packs) {
|
|
834
|
+
for (const [name, parameter] of Object.entries(pack.resolved.spec.parameters ?? {})) {
|
|
835
|
+
declared.set(qualifyGateParameter(pack.authored.metadata.id, name), parameter);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const parameters = {};
|
|
839
|
+
for (const [qualified, parameter] of declared) {
|
|
840
|
+
const value = binding.spec.parameters[qualified] ?? parameter.default;
|
|
841
|
+
if (value === undefined)
|
|
842
|
+
throw new GateRegistryError(`binding ${binding.metadata.id} is missing required parameter ${qualified}`);
|
|
843
|
+
checkBindingValue(qualified, parameter, value);
|
|
844
|
+
parameters[qualified] = value;
|
|
845
|
+
}
|
|
846
|
+
for (const name of Object.keys(binding.spec.parameters)) {
|
|
847
|
+
if (!declared.has(name))
|
|
848
|
+
throw new GateRegistryError(`binding ${binding.metadata.id} sets undeclared parameter ${name}`);
|
|
849
|
+
}
|
|
850
|
+
const slices = new Set(binding.spec.slices);
|
|
851
|
+
for (const [group, members] of Object.entries(binding.spec.sliceGroups ?? {})) {
|
|
852
|
+
for (const slice of members) {
|
|
853
|
+
if (!slices.has(slice)) {
|
|
854
|
+
throw new GateRegistryError(`binding ${binding.metadata.id} sliceGroup '${group}' names slice '${slice}' outside the binding inventory`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
const references = new Set(binding.spec.references.map(reference => reference.name));
|
|
859
|
+
for (const pack of packs) {
|
|
860
|
+
for (const gate of pack.resolved.spec.gates) {
|
|
861
|
+
for (const slice of [...(gate.scope.slices ?? []), ...(gate.scope.except ?? [])]) {
|
|
862
|
+
if (!slices.has(slice))
|
|
863
|
+
throw new GateRegistryError(`gate ${gate.id} names slice '${slice}' outside the binding inventory`);
|
|
864
|
+
}
|
|
865
|
+
if (gate.statistic?.vs !== undefined && !references.has(gate.statistic.vs)) {
|
|
866
|
+
throw new GateRegistryError(`gate ${gate.id} contrasts undeclared reference '${gate.statistic.vs}'`);
|
|
867
|
+
}
|
|
868
|
+
if (gate.scope.mode === 'each') {
|
|
869
|
+
const excluded = new Set(gate.scope.except ?? []);
|
|
870
|
+
if (binding.spec.slices.every(slice => excluded.has(slice))) {
|
|
871
|
+
throw new GateRegistryError(`gate ${gate.id} each-scope excludes every binding slice`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
const registeredAt = Date.parse(binding.spec.registeredAt);
|
|
877
|
+
const frozenAt = Date.parse(binding.spec.frozenAt);
|
|
878
|
+
if (!Number.isFinite(registeredAt) || !Number.isFinite(frozenAt)) {
|
|
879
|
+
throw new GateRegistryError(`binding ${binding.metadata.id} has unparseable preregistration timestamps`);
|
|
880
|
+
}
|
|
881
|
+
if (registeredAt > frozenAt) {
|
|
882
|
+
throw new GateRegistryError(`binding ${binding.metadata.id} registers after it freezes`);
|
|
883
|
+
}
|
|
884
|
+
// Project floors run after every legacy check, so floors-unaware diagnostics
|
|
885
|
+
// keep their exact messages and order. Floor expansion reuses the same pure
|
|
886
|
+
// lookup the binding pins above, never registry instance state.
|
|
887
|
+
if (floors !== undefined) {
|
|
888
|
+
enforceProjectFloors(binding, packs, parameters, expandFloorPacks(floors, lookupAuthored, providers), floors.ceilings ?? {});
|
|
889
|
+
}
|
|
890
|
+
return { binding, packs, parameters };
|
|
891
|
+
}
|
|
892
|
+
export class GateRegistry {
|
|
893
|
+
providers;
|
|
894
|
+
packs = new Map();
|
|
895
|
+
constructor(providers) {
|
|
896
|
+
this.providers = providers;
|
|
897
|
+
}
|
|
898
|
+
registerPack(doc, origin) {
|
|
899
|
+
const pack = validateGateDocument(doc);
|
|
900
|
+
if (pack.kind !== 'GatePack')
|
|
901
|
+
throw new GateRegistryError('registerPack requires a GatePack document');
|
|
902
|
+
const { namespace, rest } = splitPackId(pack.metadata.id);
|
|
903
|
+
if (namespace !== origin.namespace) {
|
|
904
|
+
throw new GateRegistryError(`pack ${pack.metadata.id} namespace does not match contribution origin ${origin.namespace}`);
|
|
905
|
+
}
|
|
906
|
+
if (namespace !== 'project') {
|
|
907
|
+
if (origin.bundle === undefined || !origin.bundle.trim())
|
|
908
|
+
throw new GateRegistryError('bundle origin is required outside project:');
|
|
909
|
+
if (rest !== `${origin.bundle}/${rest.slice(origin.bundle.length + 1)}` || !rest.startsWith(`${origin.bundle}/`)) {
|
|
910
|
+
throw new GateRegistryError(`pack ${pack.metadata.id} does not belong to bundle ${origin.bundle}`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (this.packs.has(pack.metadata.id))
|
|
914
|
+
throw new GateRegistryError(`duplicate gate pack: ${pack.metadata.id}`);
|
|
915
|
+
// Shipped-name protection, order-independent: an aiwg: rest-path can never
|
|
916
|
+
// coexist with the same rest-path from any other namespace, whichever
|
|
917
|
+
// registers first.
|
|
918
|
+
for (const id of this.packs.keys()) {
|
|
919
|
+
if (splitPackId(id).rest !== rest)
|
|
920
|
+
continue;
|
|
921
|
+
const otherShipped = id.startsWith('aiwg:');
|
|
922
|
+
const nextShipped = pack.metadata.id.startsWith('aiwg:');
|
|
923
|
+
if (otherShipped || nextShipped) {
|
|
924
|
+
const shipped = nextShipped ? pack.metadata.id : id;
|
|
925
|
+
const shadow = nextShipped ? id : pack.metadata.id;
|
|
926
|
+
throw new GateRegistryError(`pack ${shadow} shadows shipped pack ${shipped}`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (pack.spec.extends !== undefined) {
|
|
930
|
+
this.requireParent(pack.metadata.id, pack.spec.extends);
|
|
931
|
+
}
|
|
932
|
+
const resolved = this.composeAuthored(pack);
|
|
933
|
+
validateResolvedPack(resolved, this.providers);
|
|
934
|
+
const { digest } = { digest: artifactDigest(pack) };
|
|
935
|
+
this.packs.set(pack.metadata.id, { authored: pack, digest, namespace });
|
|
936
|
+
return pack;
|
|
937
|
+
}
|
|
938
|
+
getPack(id) {
|
|
939
|
+
return this.lookupPack(id).authored;
|
|
940
|
+
}
|
|
941
|
+
resolvePack(id) {
|
|
942
|
+
const entry = this.lookupPack(id);
|
|
943
|
+
const resolved = this.composeAuthored(entry.authored);
|
|
944
|
+
return {
|
|
945
|
+
authored: entry.authored, digest: entry.digest,
|
|
946
|
+
resolvedDigest: artifactDigest(resolved), resolved,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Full-id lookup, or rest-path lookup when the id carries no namespace.
|
|
951
|
+
* A rest-path shared by two namespaces is `ambiguous`: callers must pin the
|
|
952
|
+
* full id. Bindings always pin full ids, so they never hit ambiguity.
|
|
953
|
+
*/
|
|
954
|
+
lookupPack(id) {
|
|
955
|
+
if (id.includes(':')) {
|
|
956
|
+
const entry = this.packs.get(id);
|
|
957
|
+
if (entry === undefined)
|
|
958
|
+
throw new GateRegistryError(`unknown gate pack: ${id}`);
|
|
959
|
+
return entry;
|
|
960
|
+
}
|
|
961
|
+
const matches = [...this.packs.keys()].filter(key => splitPackId(key).rest === id);
|
|
962
|
+
if (!matches.length)
|
|
963
|
+
throw new GateRegistryError(`unknown gate pack: ${id}`);
|
|
964
|
+
if (matches.length > 1) {
|
|
965
|
+
throw new GateRegistryError(`ambiguous gate pack '${id}': ${matches.sort().join(', ')} (pin the full id)`);
|
|
966
|
+
}
|
|
967
|
+
return this.packs.get(matches[0]);
|
|
968
|
+
}
|
|
969
|
+
requireParent(childId, pin) {
|
|
970
|
+
const entry = this.packs.get(pin.id);
|
|
971
|
+
if (entry === undefined)
|
|
972
|
+
throw new GateRegistryError(`pack ${childId} extends unknown pack ${pin.id}`);
|
|
973
|
+
if (entry.authored.metadata.version !== pin.version || entry.digest !== pin.digest) {
|
|
974
|
+
throw new GateRegistryError(`pack ${childId} parent pin mismatch for ${pinLabel(pin)}`);
|
|
975
|
+
}
|
|
976
|
+
return entry;
|
|
977
|
+
}
|
|
978
|
+
composeAuthored(pack) {
|
|
979
|
+
return composeGatePack(pack, id => {
|
|
980
|
+
const parent = this.packs.get(id);
|
|
981
|
+
if (parent === undefined)
|
|
982
|
+
throw new GateRegistryError(`pack ${pack.metadata.id} extends unknown pack ${id}`);
|
|
983
|
+
return parent.authored;
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
resolveBinding(doc, floors) {
|
|
987
|
+
return resolveGateBinding(doc, this.packs, this.providers, floors);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
function checkBindingValue(name, parameter, value) {
|
|
991
|
+
if (!Number.isFinite(value))
|
|
992
|
+
throw new GateRegistryError(`binding parameter ${name} must be finite`);
|
|
993
|
+
if (parameter.type === 'bps' && (value < 0 || value > 10000)) {
|
|
994
|
+
throw new GateRegistryError(`binding parameter ${name} must be within 0..10000 bps`);
|
|
995
|
+
}
|
|
996
|
+
if (parameter.type === 'level' && (!Number.isSafeInteger(value) || value <= 5000 || value >= 9999)) {
|
|
997
|
+
throw new GateRegistryError(`binding parameter ${name} must be an integer strictly between 5000 and 9999`);
|
|
998
|
+
}
|
|
999
|
+
if (parameter.type === 'count' && (!Number.isSafeInteger(value) || value < 0)) {
|
|
1000
|
+
throw new GateRegistryError(`binding parameter ${name} must be a non-negative integer`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
//# sourceMappingURL=registry.js.map
|