@aiwg/cli 2026.9.21 → 2026.10.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.
Files changed (210) hide show
  1. package/agentic/code/providers/capability-matrix.yaml +2 -1
  2. package/dist/src/artifacts/cli.js +23 -18
  3. package/dist/src/artifacts/discovery-shadow-evaluator.js +177 -0
  4. package/dist/src/artifacts/discovery-shadow.js +195 -0
  5. package/dist/src/artifacts/fortemi-core-query-adapter.js +6 -2
  6. package/dist/src/artifacts/index-builder.js +47 -9
  7. package/dist/src/artifacts/query-engine.js +108 -5
  8. package/dist/src/artifacts/types.js +1 -0
  9. package/dist/src/cli/handlers/decision.js +88 -0
  10. package/dist/src/cli/handlers/gates.js +154 -0
  11. package/dist/src/cli/handlers/index.js +5 -1
  12. package/dist/src/cli/handlers/sessions.js +10 -7
  13. package/dist/src/cli/handlers/utilities.js +20 -0
  14. package/dist/src/config/aiwg-config.js +15 -2
  15. package/dist/src/decision/acceptance.js +25 -0
  16. package/dist/src/decision/adapters/jev.js +24 -2
  17. package/dist/src/decision/calibration/registry.js +9 -0
  18. package/dist/src/decision/compose.js +74 -15
  19. package/dist/src/decision/context-live-promotion.js +344 -0
  20. package/dist/src/decision/context-live-qualification.js +591 -0
  21. package/dist/src/decision/context-pruning.js +847 -0
  22. package/dist/src/decision/driver.js +430 -0
  23. package/dist/src/decision/egress-live-qualification.js +874 -0
  24. package/dist/src/decision/ensemble/contract.js +64 -1
  25. package/dist/src/decision/ensemble/index.js +1 -0
  26. package/dist/src/decision/ensemble/report.js +8 -42
  27. package/dist/src/decision/ensemble/runtime.js +571 -0
  28. package/dist/src/decision/ensemble-study/artifacts.js +85 -0
  29. package/dist/src/decision/ensemble-study/calibration.js +443 -0
  30. package/dist/src/decision/ensemble-study/corpus.js +229 -0
  31. package/dist/src/decision/ensemble-study/multifact-artifacts.js +23 -0
  32. package/dist/src/decision/ensemble-study/multifact-study.js +596 -0
  33. package/dist/src/decision/ensemble-study/multifact.js +241 -0
  34. package/dist/src/decision/ensemble-study/protocol.js +77 -0
  35. package/dist/src/decision/ensemble-study/score.js +462 -0
  36. package/dist/src/decision/ensemble-study/staged.js +87 -0
  37. package/dist/src/decision/ensemble-study/statistics.js +84 -0
  38. package/dist/src/decision/evaluate.js +227 -14
  39. package/dist/src/decision/export.js +4 -0
  40. package/dist/src/decision/feature-export.js +1074 -0
  41. package/dist/src/decision/graph-index.js +16 -0
  42. package/dist/src/decision/graph-live-qualification.js +1041 -0
  43. package/dist/src/decision/graph-live-workload.js +245 -0
  44. package/dist/src/decision/graph-skill-bridge.js +21 -3
  45. package/dist/src/decision/heldout/calibration.js +140 -0
  46. package/dist/src/decision/heldout/collector.js +430 -0
  47. package/dist/src/decision/heldout/contract.js +309 -0
  48. package/dist/src/decision/heldout/d29-generator-ids.js +12 -0
  49. package/dist/src/decision/heldout/d29-passage-baseline-v2.js +93 -0
  50. package/dist/src/decision/heldout/d29-passage-baseline-v3.js +100 -0
  51. package/dist/src/decision/heldout/d29-passage-baseline.js +66 -0
  52. package/dist/src/decision/heldout/d29-pools-v8.js +575 -0
  53. package/dist/src/decision/heldout/d29-pools.js +288 -0
  54. package/dist/src/decision/heldout/d29-v6.js +169 -0
  55. package/dist/src/decision/heldout/d29-v7.js +202 -0
  56. package/dist/src/decision/heldout/d29-v8.js +586 -0
  57. package/dist/src/decision/heldout/generator-registry.js +67 -0
  58. package/dist/src/decision/heldout/generators.js +696 -0
  59. package/dist/src/decision/heldout/index.js +8 -0
  60. package/dist/src/decision/heldout/journal.js +346 -0
  61. package/dist/src/decision/heldout/study-generators.js +64 -0
  62. package/dist/src/decision/heldout/types.js +2 -0
  63. package/dist/src/decision/host-policy.js +61 -0
  64. package/dist/src/decision/index.js +11 -0
  65. package/dist/src/decision/interop-profile.v1.json +112 -0
  66. package/dist/src/decision/interop.js +653 -0
  67. package/dist/src/decision/issue-triage/index.js +3 -0
  68. package/dist/src/decision/issue-triage/runtime.js +1069 -0
  69. package/dist/src/decision/issue-triage/types.js +2 -0
  70. package/dist/src/decision/patterns/catalog.js +28 -8
  71. package/dist/src/decision/patterns/playground.js +28 -11
  72. package/dist/src/decision/patterns/specs.js +19 -10
  73. package/dist/src/decision/preprocessed-evidence.js +724 -0
  74. package/dist/src/decision/qualification/capture.js +8 -2
  75. package/dist/src/decision/qualification/gate-evidence.js +19 -4
  76. package/dist/src/decision/qualification/load.js +8 -1
  77. package/dist/src/decision/qualification/quality.js +57 -49
  78. package/dist/src/decision/qualification/release.js +76 -3
  79. package/dist/src/decision/qualification/runner.js +5 -2
  80. package/dist/src/decision/routing/contract.js +175 -0
  81. package/dist/src/decision/routing/index.js +5 -0
  82. package/dist/src/decision/routing/report.js +216 -0
  83. package/dist/src/decision/routing/runtime.js +685 -0
  84. package/dist/src/decision/routing/types.js +2 -0
  85. package/dist/src/decision/sdlc-screening.js +1157 -0
  86. package/dist/src/decision/sensitivity/comparative.js +154 -0
  87. package/dist/src/decision/sensitivity/contract.js +218 -0
  88. package/dist/src/decision/sensitivity/index.js +5 -0
  89. package/dist/src/decision/sensitivity/runtime.js +912 -0
  90. package/dist/src/decision/sensitivity/types.js +2 -0
  91. package/dist/src/decision/validate.js +26 -0
  92. package/dist/src/extensions/commands/definitions.js +38 -0
  93. package/dist/src/extensions/manifest.js +44 -0
  94. package/dist/src/gates/discovery.js +315 -0
  95. package/dist/src/gates/driver.js +388 -0
  96. package/dist/src/gates/evaluate.js +719 -0
  97. package/dist/src/gates/floors.js +255 -0
  98. package/dist/src/gates/index.js +17 -0
  99. package/dist/src/gates/providers/evidence.js +38 -0
  100. package/dist/src/gates/providers/index.js +21 -0
  101. package/dist/src/gates/providers/isolated-child-source.js +446 -0
  102. package/dist/src/gates/providers/loader.js +1069 -0
  103. package/dist/src/gates/providers/paired.js +52 -0
  104. package/dist/src/gates/providers/proportion.js +60 -0
  105. package/dist/src/gates/providers/registry.js +40 -0
  106. package/dist/src/gates/providers/scalar.js +41 -0
  107. package/dist/src/gates/providers/screening.js +53 -0
  108. package/dist/src/gates/providers/types.js +25 -0
  109. package/dist/src/gates/registry.js +1003 -0
  110. package/dist/src/gates/report.js +102 -0
  111. package/dist/src/gates/schema.js +56 -0
  112. package/dist/src/gates/stats/binomial.js +26 -0
  113. package/dist/src/gates/stats/bootstrap.js +62 -0
  114. package/dist/src/gates/stats/clopper-pearson.js +95 -0
  115. package/dist/src/gates/stats/digest.js +26 -0
  116. package/dist/src/gates/stats/error.js +5 -0
  117. package/dist/src/gates/stats/index.js +9 -0
  118. package/dist/src/gates/stats/normal.js +35 -0
  119. package/dist/src/gates/stats/paired.js +120 -0
  120. package/dist/src/gates/stats/splits.js +30 -0
  121. package/dist/src/gates/types.js +8 -0
  122. package/dist/src/governance/redaction.js +14 -4
  123. package/dist/src/mcp/muse-native-config.mjs +123 -0
  124. package/dist/src/mcp/tools/decision.mjs +188 -0
  125. package/dist/src/mcp/tools/subsystems.mjs +2 -0
  126. package/dist/src/providers/capability-matrix.yaml +2 -1
  127. package/dist/src/providers/provider-definitions.js +2 -1
  128. package/dist/src/sessions/adapters/muse.js +336 -39
  129. package/dist/src/sessions/batch-import.js +7 -0
  130. package/dist/src/sessions/workspace-discovery.js +13 -2
  131. package/package.json +1 -1
  132. package/schemas/decision/ConformalFrozenOpenData.v2.schema.json +371 -0
  133. package/schemas/decision/ConformalOpenDataReport.v2.schema.json +421 -0
  134. package/schemas/decision/ConformalPreregistration.v2.schema.json +529 -0
  135. package/schemas/decision/ContextPruningCandidate.v1.schema.json +47 -0
  136. package/schemas/decision/ContextPruningEvaluationReport.v1.schema.json +758 -0
  137. package/schemas/decision/ContextPruningPreregistration.v1.schema.json +175 -0
  138. package/schemas/decision/ContextPruningReceipt.v1.schema.json +72 -0
  139. package/schemas/decision/D17Multifact.v1.schema.json +1851 -0
  140. package/schemas/decision/D17StudyAnalysis.v1.schema.json +42 -0
  141. package/schemas/decision/D17StudyAnalysis.v2.schema.json +51 -0
  142. package/schemas/decision/D17StudyCalibration.v1.schema.json +819 -0
  143. package/schemas/decision/D17StudyDryRun.v1.schema.json +169 -0
  144. package/schemas/decision/D17StudyDryRun.v2.schema.json +239 -0
  145. package/schemas/decision/D17StudyGold.v1.schema.json +108 -0
  146. package/schemas/decision/D17StudyGuide.v1.schema.json +63 -0
  147. package/schemas/decision/D17StudyNativeTemplates.v1.schema.json +619 -0
  148. package/schemas/decision/D17StudyProtocol.v1.schema.json +241 -0
  149. package/schemas/decision/D17StudyReport.v1.schema.json +902 -0
  150. package/schemas/decision/D17StudyReport.v2.schema.json +1319 -0
  151. package/schemas/decision/D17StudyReview.v1.schema.json +175 -0
  152. package/schemas/decision/D17StudySplits.v1.schema.json +217 -0
  153. package/schemas/decision/D29Study.v1.schema.json +1903 -0
  154. package/schemas/decision/D29Study.v2.schema.json +1344 -0
  155. package/schemas/decision/D29Study.v3.schema.json +609 -0
  156. package/schemas/decision/D29Study.v4.schema.json +1133 -0
  157. package/schemas/decision/D29Study.v6.schema.json +1205 -0
  158. package/schemas/decision/D29Study.v7.schema.json +1471 -0
  159. package/schemas/decision/D29Study.v8.schema.json +671 -0
  160. package/schemas/decision/D29Study.v9.schema.json +778 -0
  161. package/schemas/decision/DecisionComparativeReplayAuditSample.v1.schema.json +137 -0
  162. package/schemas/decision/DecisionComparativeReplayCorpus.v1.schema.json +258 -0
  163. package/schemas/decision/DecisionComparativeReplayFreeze.v1.schema.json +74 -0
  164. package/schemas/decision/DecisionComparativeReplayGold.v1.schema.json +50 -0
  165. package/schemas/decision/DecisionComparativeReplayGoldAsset.v1.schema.json +37 -0
  166. package/schemas/decision/DecisionComparativeReplayIntegrity.v1.schema.json +81 -0
  167. package/schemas/decision/DecisionComparativeReplayMembers.v1.schema.json +52 -0
  168. package/schemas/decision/DecisionComparativeReplayPreregistration.v1.schema.json +113 -0
  169. package/schemas/decision/DecisionComparativeReplayReport.v1.schema.json +407 -0
  170. package/schemas/decision/DecisionComparativeReplayReviewPackets.v1.schema.json +260 -0
  171. package/schemas/decision/DecisionComparativeReplaySplit.v1.schema.json +84 -0
  172. package/schemas/decision/DecisionFeatureObservation.v1.schema.json +564 -0
  173. package/schemas/decision/DecisionFeatureSet.v1.schema.json +155 -0
  174. package/schemas/decision/DecisionInteropMapping.v1.schema.json +85 -0
  175. package/schemas/decision/DecisionIssueTriageEvaluationManifest.v1.schema.json +174 -0
  176. package/schemas/decision/DecisionIssueTriageEvaluationReport.v1.schema.json +280 -0
  177. package/schemas/decision/DecisionIssueTriagePilotPack.v1.schema.json +114 -0
  178. package/schemas/decision/DecisionRouteReceipt.v1.schema.json +155 -0
  179. package/schemas/decision/DecisionRoutingPolicy.v1.schema.json +132 -0
  180. package/schemas/decision/DecisionRoutingShadowReport.v1.schema.json +170 -0
  181. package/schemas/decision/DecisionRoutingTask.v1.schema.json +49 -0
  182. package/schemas/decision/DecisionRuleset.schema.json +8 -2
  183. package/schemas/decision/DecisionRuleset.v1alpha2.schema.json +8 -2
  184. package/schemas/decision/DecisionSensitivityPlan.v1.schema.json +191 -0
  185. package/schemas/decision/DecisionSensitivityReport.v1.schema.json +163 -0
  186. package/schemas/decision/HeldoutApproval.v1.schema.json +312 -0
  187. package/schemas/decision/HeldoutAttempt.v1.schema.json +211 -0
  188. package/schemas/decision/HeldoutBaseline.v1.schema.json +90 -0
  189. package/schemas/decision/HeldoutCalibrationPhase.v1.schema.json +87 -0
  190. package/schemas/decision/HeldoutCorpus.v1.schema.json +167 -0
  191. package/schemas/decision/HeldoutEvent.v1.schema.json +41 -0
  192. package/schemas/decision/HeldoutFrozen.v1.schema.json +142 -0
  193. package/schemas/decision/HeldoutPreregistration.v1.schema.json +222 -0
  194. package/schemas/decision/HeldoutSpendEvent.v1.schema.json +122 -0
  195. package/schemas/decision/HeldoutSpendHead.v1.schema.json +25 -0
  196. package/schemas/decision/HeldoutSpendHead.v2.schema.json +46 -0
  197. package/schemas/decision/HeldoutSummary.v1.schema.json +101 -0
  198. package/schemas/decision/OpaDecisionLogExport.v1.schema.json +65 -0
  199. package/schemas/decision/PreprocessedEvidence.v1.schema.json +306 -0
  200. package/schemas/decision/RulesetResult.v1alpha2.schema.json +175 -0
  201. package/schemas/decision/SdlcEvidenceScreening.v1.schema.json +499 -0
  202. package/schemas/decision/SdlcScreeningPreregistration.v1.schema.json +132 -0
  203. package/schemas/decision/SdlcScreeningPreregistration.v2.schema.json +138 -0
  204. package/schemas/decision/SdlcScreeningRelease.v1.schema.json +654 -0
  205. package/schemas/decision/SdlcScreeningRelease.v2.schema.json +654 -0
  206. package/tools/agents/deploy-agents.mjs +1 -1
  207. package/tools/agents/providers/base.mjs +26 -7
  208. package/tools/agents/providers/factory.mjs +10 -2
  209. package/tools/agents/providers/muse-hooks.mjs +1 -29
  210. package/tools/agents/providers/muse.mjs +6 -3
@@ -0,0 +1,1157 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { DECISION_API_VERSION, DECISION_API_VERSION_STRUCTURED, } from './types.js';
3
+ import { reviewDigest } from './review/validate.js';
4
+ import { qualificationIntegrityAllowlistProblems } from './qualification/release.js';
5
+ import { evaluateBinaryHeldout, pairedBinaryDifferenceInterval, pairedNonInferiority, verifyQualificationSplits, wilsonScoreInterval, } from './qualification/quality.js';
6
+ import { applyPrimitiveAcceptance } from './acceptance.js';
7
+ import { composeRuleset } from './compose.js';
8
+ import { validateProjectionPolicy, } from './projection.js';
9
+ import { artifactPin, assertArtifactPin, validateDistribution } from './validate.js';
10
+ import { CalibrationRegistry } from './calibration/registry.js';
11
+ import { canonicalJson } from '../security/artifact-trust.js';
12
+ export const SDLC_SCREENING_SCHEMA_VERSION = 'decision-sdlc-evidence-screening/v1';
13
+ export const SDLC_SCREENING_PREREGISTRATION_VERSION = 'decision-sdlc-screening-preregistration/v1';
14
+ export const SDLC_SCREENING_PREREGISTRATION_VERSION_V2 = 'decision-sdlc-screening-preregistration/v2';
15
+ export const SDLC_SCREENING_RELEASE_VERSION = 'decision-sdlc-screening-release/v1';
16
+ /** Release reports over a v2 preregistration (nullable NI); v1 stays bound to v1 preregistrations. */
17
+ export const SDLC_SCREENING_RELEASE_VERSION_V2 = 'decision-sdlc-screening-release/v2';
18
+ export const SDLC_SCREENING_HELDOUT_RECORDS_VERSION = 'decision-sdlc-screening-heldout-records/v1';
19
+ export const SDLC_SCREENING_HELDOUT_REPORT_VERSION = 'decision-sdlc-screening-heldout-report/v1';
20
+ export const SDLC_GATE_EVIDENCE_POLICY_KIND = 'SdlcGateEvidencePolicy';
21
+ export class SdlcScreeningValidationError extends Error {
22
+ reason;
23
+ status;
24
+ id;
25
+ constructor(message, reason = 'invalid-request', status = 'fail', id = 'validation') {
26
+ super(message);
27
+ this.reason = reason;
28
+ this.status = status;
29
+ this.id = id;
30
+ this.name = 'SdlcScreeningValidationError';
31
+ }
32
+ }
33
+ const SCREENING_MODES = ['disabled', 'shadow', 'advisory'];
34
+ const SUPPORT_LABELS = ['supports', 'does-not-support', 'contradicts', 'unclear'];
35
+ const CRITERION_OPTIONS = {
36
+ relevance: ['relevant', 'irrelevant', 'unclear'],
37
+ completeness: ['complete', 'incomplete', 'unclear'],
38
+ contradiction: ['none', 'present', 'unclear'],
39
+ ambiguity: ['low', 'high', 'unclear'],
40
+ reviewerAttention: ['needed', 'not-needed'],
41
+ };
42
+ const EVIDENCE_TYPES = ['artifact', 'test-result', 'approval', 'signature', 'schema'];
43
+ const TRUST_VALUES = ['untrusted', 'verified'];
44
+ const SENSITIVITY_VALUES = ['public', 'internal', 'confidential', 'restricted'];
45
+ const ROUTES = ['ADVISORY_READY', 'REVIEW', 'FAIL', 'INSUFFICIENT_EVIDENCE'];
46
+ /** An injection "no" must be at least this confident; anything else routes to review. */
47
+ const INJECTION_CLEAR_MINIMUM_BPS = 8_000;
48
+ const SUPPORT_STRENGTH_MINIMUM_BPS = 8_000;
49
+ const digestPattern = /^sha256:[a-f0-9]{64}$/;
50
+ const routeSchema = {
51
+ type: 'object',
52
+ additionalProperties: false,
53
+ required: ['route', 'reason'],
54
+ properties: {
55
+ route: { enum: ['ADVISORY_READY', 'REVIEW', 'FAIL'] },
56
+ reason: { type: 'string' },
57
+ },
58
+ };
59
+ const subjectInputSchema = {
60
+ type: 'object',
61
+ additionalProperties: false,
62
+ required: ['subjectId'],
63
+ properties: { subjectId: { type: 'string', minLength: 1 } },
64
+ };
65
+ function digest(value) {
66
+ return `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}`;
67
+ }
68
+ function isRecord(value) {
69
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
70
+ }
71
+ function isNonEmptyString(value) {
72
+ return typeof value === 'string' && value.trim().length > 0;
73
+ }
74
+ function isStringArray(value) {
75
+ return Array.isArray(value) && value.every(isNonEmptyString);
76
+ }
77
+ function hasOwn(value, key) {
78
+ return Object.prototype.hasOwnProperty.call(value, key);
79
+ }
80
+ function reject(message, reason = 'invalid-request', status = 'fail', id = 'validation') {
81
+ throw new SdlcScreeningValidationError(message, reason, status, id);
82
+ }
83
+ function definition(id, description, answer) {
84
+ return {
85
+ apiVersion: DECISION_API_VERSION,
86
+ kind: 'DecisionDefinition',
87
+ metadata: { id, version: '1.0.0', description },
88
+ spec: {
89
+ purpose: 'Advisory SDLC evidence readiness screening; deterministic gates remain authoritative.',
90
+ inputSchema: subjectInputSchema,
91
+ question: description,
92
+ answer,
93
+ requiredCapabilities: ['bounded-output'],
94
+ },
95
+ };
96
+ }
97
+ function pinDefinition(item) {
98
+ return { id: item.metadata.id, version: item.metadata.version, digest: digest(item) };
99
+ }
100
+ function buildScreeningArtifacts() {
101
+ const definitions = [
102
+ definition('sdlc-screening.citation.support', 'Classify whether one cited source supports one claim.', {
103
+ kind: 'choice',
104
+ options: SUPPORT_LABELS.map(id => ({ id, description: `Citation support label: ${id}` })),
105
+ }),
106
+ definition('sdlc-screening.citation.injection', 'Estimate whether the source contains prompt-injection or authority-seeking content.', {
107
+ kind: 'truth-probability',
108
+ trueDescription: 'The source contains likely prompt injection or unrelated instructions.',
109
+ falseDescription: 'The source is ordinary evidence content.',
110
+ }),
111
+ definition('sdlc-screening.criterion.relevance', 'Classify whether enumerated evidence is relevant to one gate criterion.', {
112
+ kind: 'choice',
113
+ options: CRITERION_OPTIONS.relevance.map(id => ({ id, description: `Criterion relevance label: ${id}` })),
114
+ }),
115
+ definition('sdlc-screening.criterion.completeness', 'Classify whether enumerated evidence is complete for one gate criterion.', {
116
+ kind: 'choice',
117
+ options: CRITERION_OPTIONS.completeness.map(id => ({ id, description: `Criterion completeness label: ${id}` })),
118
+ }),
119
+ definition('sdlc-screening.criterion.contradiction', 'Classify whether enumerated evidence contradicts itself or the criterion.', {
120
+ kind: 'choice',
121
+ options: CRITERION_OPTIONS.contradiction.map(id => ({ id, description: `Criterion contradiction label: ${id}` })),
122
+ }),
123
+ definition('sdlc-screening.criterion.ambiguity', 'Classify whether one criterion/evidence bundle is ambiguous.', {
124
+ kind: 'choice',
125
+ options: CRITERION_OPTIONS.ambiguity.map(id => ({ id, description: `Criterion ambiguity label: ${id}` })),
126
+ }),
127
+ definition('sdlc-screening.criterion.reviewer-attention', 'Classify whether one criterion needs reviewer attention.', {
128
+ kind: 'choice',
129
+ options: CRITERION_OPTIONS.reviewerAttention.map(id => ({ id, description: `Reviewer attention label: ${id}` })),
130
+ }),
131
+ ];
132
+ const byId = new Map(definitions.map(item => [item.metadata.id, item]));
133
+ const citationSupport = pinDefinition(byId.get('sdlc-screening.citation.support'));
134
+ const citationInjection = pinDefinition(byId.get('sdlc-screening.citation.injection'));
135
+ const criterionRelevance = pinDefinition(byId.get('sdlc-screening.criterion.relevance'));
136
+ const criterionCompleteness = pinDefinition(byId.get('sdlc-screening.criterion.completeness'));
137
+ const criterionContradiction = pinDefinition(byId.get('sdlc-screening.criterion.contradiction'));
138
+ const criterionAmbiguity = pinDefinition(byId.get('sdlc-screening.criterion.ambiguity'));
139
+ const criterionAttention = pinDefinition(byId.get('sdlc-screening.criterion.reviewer-attention'));
140
+ const rulesets = [
141
+ {
142
+ apiVersion: DECISION_API_VERSION,
143
+ kind: 'DecisionRuleset',
144
+ metadata: { id: 'sdlc-screening.citation', version: '1.1.0', description: 'Closed citation support screening ruleset.' },
145
+ spec: {
146
+ purpose: 'Route one claim/source pair to advisory-ready or review evidence.',
147
+ inputSchema: subjectInputSchema,
148
+ evaluations: [
149
+ { alias: 'support', decision: citationSupport, inputPointer: '' },
150
+ { alias: 'injection', decision: citationInjection, inputPointer: '' },
151
+ ],
152
+ rules: [
153
+ // The injection value is P(injection). Only a confident "no" (P <= 0.2) is clear; "unclear"
154
+ // and "yes" are encoded as 1. A failed evaluation takes the failureOutcome (review).
155
+ { id: 'injection-review', priority: 300, when: { op: 'gt', left: { source: 'decision', alias: 'injection', pointer: '/value' }, right: 0.2 }, outcome: { route: 'REVIEW', reason: 'prompt-injection-flagged' } },
156
+ { id: 'contradiction-review', priority: 200, when: { op: 'eq', left: { source: 'decision', alias: 'support', pointer: '/value' }, right: 'contradicts' }, outcome: { route: 'REVIEW', reason: 'citation-contradicts' } },
157
+ { id: 'support-ready', priority: 100, when: { op: 'eq', left: { source: 'decision', alias: 'support', pointer: '/value' }, right: 'supports' }, outcome: { route: 'ADVISORY_READY', reason: 'citation-supported' } },
158
+ ],
159
+ composition: 'first-match',
160
+ conflict: 'review',
161
+ defaultOutcome: { route: 'REVIEW', reason: 'citation-unclear' },
162
+ failureOutcome: { route: 'REVIEW', reason: 'evidence-not-accepted' },
163
+ outputSchema: routeSchema,
164
+ },
165
+ },
166
+ {
167
+ apiVersion: DECISION_API_VERSION,
168
+ kind: 'DecisionRuleset',
169
+ metadata: { id: 'sdlc-screening.phase-criterion', version: '1.0.0', description: 'Closed phase-criterion screening ruleset.' },
170
+ spec: {
171
+ purpose: 'Route one criterion/evidence bundle to advisory-ready or review evidence.',
172
+ inputSchema: subjectInputSchema,
173
+ evaluations: [
174
+ { alias: 'relevance', decision: criterionRelevance, inputPointer: '' },
175
+ { alias: 'completeness', decision: criterionCompleteness, inputPointer: '' },
176
+ { alias: 'contradiction', decision: criterionContradiction, inputPointer: '' },
177
+ { alias: 'ambiguity', decision: criterionAmbiguity, inputPointer: '' },
178
+ { alias: 'attention', decision: criterionAttention, inputPointer: '' },
179
+ ],
180
+ rules: [
181
+ { id: 'needs-review', priority: 200, when: { any: [
182
+ { op: 'ne', left: { source: 'decision', alias: 'relevance', pointer: '/value' }, right: 'relevant' },
183
+ { op: 'ne', left: { source: 'decision', alias: 'completeness', pointer: '/value' }, right: 'complete' },
184
+ { op: 'ne', left: { source: 'decision', alias: 'contradiction', pointer: '/value' }, right: 'none' },
185
+ { op: 'ne', left: { source: 'decision', alias: 'ambiguity', pointer: '/value' }, right: 'low' },
186
+ { op: 'eq', left: { source: 'decision', alias: 'attention', pointer: '/value' }, right: 'needed' },
187
+ ] }, outcome: { route: 'REVIEW', reason: 'criterion-needs-review' } },
188
+ { id: 'ready-indicator', priority: 100, when: { all: [
189
+ { op: 'eq', left: { source: 'decision', alias: 'relevance', pointer: '/value' }, right: 'relevant' },
190
+ { op: 'eq', left: { source: 'decision', alias: 'completeness', pointer: '/value' }, right: 'complete' },
191
+ { op: 'eq', left: { source: 'decision', alias: 'contradiction', pointer: '/value' }, right: 'none' },
192
+ { op: 'eq', left: { source: 'decision', alias: 'ambiguity', pointer: '/value' }, right: 'low' },
193
+ { op: 'eq', left: { source: 'decision', alias: 'attention', pointer: '/value' }, right: 'not-needed' },
194
+ ] }, outcome: { route: 'ADVISORY_READY', reason: 'criterion-ready-indicator' } },
195
+ ],
196
+ composition: 'first-match',
197
+ conflict: 'review',
198
+ defaultOutcome: { route: 'REVIEW', reason: 'criterion-needs-review' },
199
+ failureOutcome: { route: 'REVIEW', reason: 'evidence-not-accepted' },
200
+ outputSchema: routeSchema,
201
+ },
202
+ },
203
+ ];
204
+ return { definitions, rulesets };
205
+ }
206
+ function deepFreeze(value) {
207
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
208
+ Object.freeze(value);
209
+ for (const child of Object.values(value))
210
+ deepFreeze(child);
211
+ }
212
+ return value;
213
+ }
214
+ let cachedArtifacts = null;
215
+ function screeningArtifacts() {
216
+ cachedArtifacts ??= deepFreeze(buildScreeningArtifacts());
217
+ return cachedArtifacts;
218
+ }
219
+ const artifactPins = new WeakMap();
220
+ /** Pins of the module-owned screening artifacts, computed once per immutable artifact object. */
221
+ function cachedPin(value) {
222
+ let pin = artifactPins.get(value);
223
+ if (!pin) {
224
+ pin = artifactPin(value);
225
+ artifactPins.set(value, pin);
226
+ }
227
+ return pin;
228
+ }
229
+ /** Closed portable definitions/rulesets for the D29 screening pack. They are data, not authority. */
230
+ export function sdlcScreeningDecisionArtifacts() {
231
+ return structuredClone(screeningArtifacts());
232
+ }
233
+ function isDigest(value) {
234
+ return typeof value === 'string' && digestPattern.test(value);
235
+ }
236
+ function isPin(value) {
237
+ return isRecord(value) && isNonEmptyString(value.id) && isNonEmptyString(value.version) && isDigest(value.digest);
238
+ }
239
+ function assertBps(value, label) {
240
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0 || value > 10_000) {
241
+ throw new SdlcScreeningValidationError(`Invalid basis points: ${label}`, 'invalid-observation');
242
+ }
243
+ }
244
+ function requireKnown(ids, value) {
245
+ if (!ids.includes(value))
246
+ reject(`unknown-id: ${value}`, 'unknown-id', 'review', value);
247
+ }
248
+ function rejectUnknownKeys(value, allowed, label, reason = 'invalid-request') {
249
+ const unknown = Object.keys(value).filter(key => !allowed.includes(key));
250
+ if (unknown.length)
251
+ reject(`${label} contains unsupported fields: ${unknown.join(', ')}`, reason);
252
+ }
253
+ /** Resolves the gate policy by host pin through `assertArtifactPin`; the request can only reference it. */
254
+ function resolveTrustedGatePolicy(request, trust) {
255
+ if (!isRecord(trust) || !isPin(trust.gatePolicyPin) || !Array.isArray(trust.gatePolicies)) {
256
+ reject('Trusted gate policy context is required', 'gate-policy-untrusted', 'review');
257
+ }
258
+ const pin = trust.gatePolicyPin;
259
+ const matches = trust.gatePolicies.filter(policy => isRecord(policy) && isRecord(policy.metadata)
260
+ && policy.metadata.id === pin.id && policy.metadata.version === pin.version);
261
+ if (matches.length !== 1)
262
+ reject('Pinned gate policy is not uniquely registered', 'gate-policy-untrusted', 'review', pin.id);
263
+ const policy = matches[0];
264
+ try {
265
+ assertArtifactPin(policy, pin, 'SDLC gate policy');
266
+ }
267
+ catch {
268
+ reject('Gate policy does not match its trusted pin', 'gate-policy-untrusted', 'review', pin.id);
269
+ }
270
+ const requested = request.gatePolicyPin;
271
+ if (!isPin(requested) || Object.keys(requested).length !== 3
272
+ || requested.id !== pin.id || requested.version !== pin.version || requested.digest !== pin.digest) {
273
+ reject('Request gate policy does not match the trusted pinned policy', 'gate-policy-mismatch', 'review', pin.id);
274
+ }
275
+ // Freeze a private copy so later mutation of the registry cannot change this evaluation.
276
+ const resolved = structuredClone(policy);
277
+ validateGatePolicy(resolved, request.inventory);
278
+ return resolved;
279
+ }
280
+ function validateGatePolicy(policy, inventory) {
281
+ const invalid = (id) => reject(`Invalid gate policy entry: ${id}`, 'gate-policy-invalid', 'review', id);
282
+ if (policy.kind !== SDLC_GATE_EVIDENCE_POLICY_KIND || !isRecord(policy.spec)
283
+ || !isRecord(policy.spec.requiredEvidenceByCriterion) || !isRecord(policy.spec.evidenceSubjects))
284
+ invalid('policy');
285
+ rejectUnknownKeys(policy, ['kind', 'metadata', 'spec'], 'Gate policy', 'gate-policy-invalid');
286
+ rejectUnknownKeys(policy.spec, ['requiredEvidenceByCriterion', 'evidenceSubjects'], 'Gate policy spec', 'gate-policy-invalid');
287
+ const criteria = new Set(inventory.criterionIds);
288
+ const evidence = new Set(inventory.evidenceIds);
289
+ for (const [evidenceId, owner] of Object.entries(policy.spec.evidenceSubjects)) {
290
+ if (!isRecord(owner))
291
+ invalid(evidenceId);
292
+ if (owner.kind === 'phase-criterion') {
293
+ rejectUnknownKeys(owner, ['kind', 'criterionId'], 'Gate policy owner', 'gate-policy-invalid');
294
+ if (!isNonEmptyString(owner.criterionId))
295
+ invalid(evidenceId);
296
+ if (!evidence.has(evidenceId) || !criteria.has(owner.criterionId))
297
+ reject(`unknown-id: ${evidenceId}`, 'unknown-id', 'review', evidenceId);
298
+ }
299
+ else if (owner.kind === 'citation') {
300
+ rejectUnknownKeys(owner, ['kind', 'claimId', 'sourceId', 'locator', 'trust', 'sensitivity'], 'Gate policy owner', 'gate-policy-invalid');
301
+ if (evidenceId !== owner.sourceId || !isNonEmptyString(owner.claimId) || !isNonEmptyString(owner.locator)
302
+ || !TRUST_VALUES.includes(owner.trust) || !SENSITIVITY_VALUES.includes(owner.sensitivity))
303
+ invalid(evidenceId);
304
+ requireKnown(inventory.claimIds, owner.claimId);
305
+ requireKnown(inventory.sourceIds, owner.sourceId);
306
+ requireKnown(inventory.locators, owner.locator);
307
+ }
308
+ else {
309
+ invalid(evidenceId);
310
+ }
311
+ }
312
+ for (const [criterionId, ids] of Object.entries(policy.spec.requiredEvidenceByCriterion)) {
313
+ if (!criteria.has(criterionId))
314
+ reject(`criterion-missing: ${criterionId}`, 'criterion-missing', 'review', criterionId);
315
+ if (!isStringArray(ids) || !ids.length || new Set(ids).size !== ids.length)
316
+ invalid(criterionId);
317
+ for (const id of ids) {
318
+ const owner = hasOwn(policy.spec.evidenceSubjects, id) ? policy.spec.evidenceSubjects[id] : undefined;
319
+ if (!evidence.has(id))
320
+ reject(`unknown-id: ${id}`, 'unknown-id', 'review', id);
321
+ if (!owner || owner.kind !== 'phase-criterion' || owner.criterionId !== criterionId)
322
+ invalid(id);
323
+ }
324
+ }
325
+ }
326
+ function validateInventory(inventory) {
327
+ if (!isRecord(inventory))
328
+ reject('Screening inventory is required');
329
+ rejectUnknownKeys(inventory, ['claimIds', 'requirementIds', 'sourceIds', 'locators', 'criterionIds', 'evidenceIds'], 'Screening inventory');
330
+ for (const key of ['claimIds', 'requirementIds', 'sourceIds', 'locators', 'criterionIds', 'evidenceIds']) {
331
+ if (!isStringArray(inventory[key]))
332
+ reject(`Screening inventory ${key} must be a string array`);
333
+ }
334
+ }
335
+ function validateSourceEvidence(source) {
336
+ if (!isRecord(source))
337
+ reject('Citation source evidence must be an object', 'invalid-evidence');
338
+ rejectUnknownKeys(source, ['id', 'version', 'digest', 'locator', 'locatorExists', 'retrieved', 'contentDigest', 'content',
339
+ 'provenanceVerified', 'publicationAuthorized', 'trust', 'sensitivity'], 'Citation source evidence', 'invalid-evidence');
340
+ if (!isPin(source) || !isNonEmptyString(source.locator) || !isDigest(source.contentDigest)
341
+ || (source.content !== undefined && typeof source.content !== 'string')
342
+ || [source.locatorExists, source.retrieved, source.provenanceVerified, source.publicationAuthorized].some(value => typeof value !== 'boolean')
343
+ || !TRUST_VALUES.includes(source.trust) || !SENSITIVITY_VALUES.includes(source.sensitivity)) {
344
+ reject('Invalid citation source evidence', 'invalid-evidence', 'fail', isNonEmptyString(source.id) ? source.id : 'validation');
345
+ }
346
+ }
347
+ function validateGateEvidenceItem(item) {
348
+ if (!isRecord(item))
349
+ reject('Gate evidence item must be an object', 'invalid-evidence');
350
+ const id = isNonEmptyString(item.id) ? item.id : 'validation';
351
+ rejectUnknownKeys(item, ['id', 'version', 'digest', 'type', 'required', 'present', 'passed', 'expiresAtEpochMs'], 'Gate evidence item', 'invalid-evidence');
352
+ if (!isPin(item) || !EVIDENCE_TYPES.includes(item.type)
353
+ || [item.required, item.present, item.passed].some(value => typeof value !== 'boolean')) {
354
+ reject('Invalid gate evidence item', 'invalid-evidence', 'fail', id);
355
+ }
356
+ if (item.expiresAtEpochMs !== undefined
357
+ && (typeof item.expiresAtEpochMs !== 'number' || !Number.isSafeInteger(item.expiresAtEpochMs) || item.expiresAtEpochMs < 0)) {
358
+ reject('Evidence expiry must be a non-negative integer epoch', 'invalid-evidence', 'fail', id);
359
+ }
360
+ }
361
+ function validateSubject(request) {
362
+ const { inventory, subject } = request;
363
+ if (!isRecord(subject) || (subject.kind !== 'citation' && subject.kind !== 'phase-criterion'))
364
+ reject('Screening subject kind is required');
365
+ if (!isNonEmptyString(subject.subjectId) || !isStringArray(subject.requirementIds) || !subject.requirementIds.length) {
366
+ reject('Screening subject identity is required');
367
+ }
368
+ for (const requirementId of subject.requirementIds)
369
+ requireKnown(inventory.requirementIds, requirementId);
370
+ if (!Array.isArray(subject.evidence) || !subject.evidence.length)
371
+ reject('Screening subject evidence is required', 'required-evidence-missing', 'review');
372
+ if (subject.kind === 'citation') {
373
+ rejectUnknownKeys(subject, ['kind', 'subjectId', 'claimId', 'requirementIds', 'sourceId', 'locator', 'evidence'], 'Citation subject');
374
+ if (!isNonEmptyString(subject.claimId) || !isNonEmptyString(subject.sourceId) || !isNonEmptyString(subject.locator)) {
375
+ reject('Citation subject identity is required');
376
+ }
377
+ if (subject.evidence.length !== 1)
378
+ reject('Citation screening requires exactly one source', 'subject-isolation-violated', 'review');
379
+ requireKnown(inventory.claimIds, subject.claimId);
380
+ requireKnown(inventory.sourceIds, subject.sourceId);
381
+ requireKnown(inventory.locators, subject.locator);
382
+ const [source] = subject.evidence;
383
+ validateSourceEvidence(source);
384
+ if (source.id !== subject.sourceId || source.locator !== subject.locator) {
385
+ reject('Citation subject and source evidence mismatch', 'subject-isolation-violated', 'review', source.id);
386
+ }
387
+ }
388
+ else {
389
+ rejectUnknownKeys(subject, ['kind', 'subjectId', 'criterionId', 'requirementIds', 'evidence'], 'Criterion subject');
390
+ if (!isNonEmptyString(subject.criterionId))
391
+ reject('Criterion subject identity is required');
392
+ requireKnown(inventory.criterionIds, subject.criterionId);
393
+ const seen = new Set();
394
+ for (const item of subject.evidence) {
395
+ validateGateEvidenceItem(item);
396
+ // Duplicates are rejected outright; there is no "latest version wins" resolution.
397
+ if (seen.has(item.id))
398
+ reject(`Duplicate evidence ID: ${item.id}`, 'duplicate-evidence-id', 'fail', item.id);
399
+ seen.add(item.id);
400
+ requireKnown(inventory.evidenceIds, item.id);
401
+ }
402
+ }
403
+ }
404
+ function validateRequest(request, trust) {
405
+ rejectUnknownKeys(request, ['schemaVersion', 'mode', 'inventory', 'gatePolicyPin', 'subject', 'observation', 'nowEpochMs'], 'Screening request');
406
+ if (request.schemaVersion !== SDLC_SCREENING_SCHEMA_VERSION)
407
+ reject('Unsupported screening schema');
408
+ if (typeof request.nowEpochMs !== 'number' || !Number.isSafeInteger(request.nowEpochMs) || request.nowEpochMs < 0) {
409
+ reject('Invalid screening clock');
410
+ }
411
+ validateInventory(request.inventory);
412
+ const policy = resolveTrustedGatePolicy(request, trust);
413
+ validateSubject(request);
414
+ return policy;
415
+ }
416
+ function validateObservation(subject, observation) {
417
+ if (observation === undefined)
418
+ return;
419
+ if (!isRecord(observation))
420
+ reject('Observation must be an object', 'invalid-observation');
421
+ assertBps(observation.confidenceBps, 'confidence');
422
+ if (typeof observation.attempts !== 'number' || !Number.isSafeInteger(observation.attempts) || observation.attempts < 1) {
423
+ reject('Observation attempts must be positive', 'invalid-observation');
424
+ }
425
+ if (!isNonEmptyString(observation.model))
426
+ reject('Observation model is required', 'invalid-observation');
427
+ if (subject.kind === 'citation') {
428
+ rejectUnknownKeys(observation, ['kind', 'claimId', 'sourceId', 'locator', 'support', 'supportDistribution', 'supportStrengthBps',
429
+ 'injection', 'confidenceBps', 'model', 'attempts'], 'Citation observation', 'invalid-observation');
430
+ if (!SUPPORT_LABELS.includes(observation.support)
431
+ || !['yes', 'no', 'unclear'].includes(observation.injection)
432
+ || (observation.supportDistribution !== undefined && !isRecord(observation.supportDistribution))) {
433
+ reject('Citation observation is outside the closed answer domain', 'invalid-observation');
434
+ }
435
+ assertBps(observation.supportStrengthBps, 'support strength');
436
+ if (observation.kind !== 'citation' || observation.claimId !== subject.claimId ||
437
+ observation.sourceId !== subject.sourceId || observation.locator !== subject.locator) {
438
+ reject('Observation subject mismatch', 'subject-isolation-violated', 'review');
439
+ }
440
+ }
441
+ else {
442
+ rejectUnknownKeys(observation, ['kind', 'criterionId', 'evidenceIds', 'relevance', 'completeness', 'contradiction', 'ambiguity',
443
+ 'reviewerAttention', 'distributions', 'confidenceBps', 'model', 'attempts'], 'Criterion observation', 'invalid-observation');
444
+ for (const [field, options] of Object.entries(CRITERION_OPTIONS)) {
445
+ if (!options.includes(observation[field])) {
446
+ reject('Criterion observation is outside the closed answer domain', 'invalid-observation');
447
+ }
448
+ }
449
+ if (observation.distributions !== undefined && (!isRecord(observation.distributions)
450
+ || Object.keys(observation.distributions).some(key => !hasOwn(CRITERION_OPTIONS, key)))) {
451
+ reject('Criterion observation distributions are invalid', 'invalid-observation');
452
+ }
453
+ if (observation.kind !== 'phase-criterion' || observation.criterionId !== subject.criterionId
454
+ || !isStringArray(observation.evidenceIds)
455
+ || canonicalJson([...observation.evidenceIds].sort()) !== canonicalJson(subject.evidence.map(item => item.id).sort())) {
456
+ reject('Observation subject mismatch', 'subject-isolation-violated', 'review');
457
+ }
458
+ }
459
+ }
460
+ /**
461
+ * Deterministic preflight over one validated subject and the trusted gate policy.
462
+ * Every bundle item is inspected, not only policy-required ones.
463
+ */
464
+ export function sdlcScreeningPreflight(subject, nowEpochMs, gatePolicy) {
465
+ const findings = [];
466
+ const owners = gatePolicy.spec.evidenceSubjects;
467
+ if (subject.kind === 'citation') {
468
+ const [source] = subject.evidence;
469
+ const owner = hasOwn(owners, source.id) ? owners[source.id] : undefined;
470
+ const citationOwner = owner?.kind === 'citation' && owner.claimId === subject.claimId
471
+ && owner.sourceId === subject.sourceId && owner.locator === subject.locator ? owner : null;
472
+ if (!citationOwner)
473
+ findings.push({ reason: 'subject-isolation-violated', id: source.id, status: 'review' });
474
+ if (source.id !== subject.sourceId)
475
+ findings.push({ reason: 'source-not-found', id: subject.sourceId, status: 'review' });
476
+ if (source.locator !== subject.locator || !source.locatorExists)
477
+ findings.push({ reason: 'locator-not-found', id: subject.locator, status: 'review' });
478
+ if (!source.retrieved)
479
+ findings.push({ reason: 'source-not-retrieved', id: subject.locator, status: 'review' });
480
+ if (!source.provenanceVerified)
481
+ findings.push({ reason: 'source-provenance-unverified', id: source.id, status: 'review' });
482
+ if (!source.publicationAuthorized)
483
+ findings.push({ reason: 'publication-not-authorized', id: source.id, status: 'review' });
484
+ // Trust and sensitivity come from the trusted policy; a caller claim that differs is itself a finding.
485
+ const trust = citationOwner?.trust ?? 'untrusted';
486
+ const sensitivity = citationOwner?.sensitivity ?? 'restricted';
487
+ if (citationOwner && (source.trust !== citationOwner.trust || source.sensitivity !== citationOwner.sensitivity)) {
488
+ findings.push({ reason: 'evidence-trust-mismatch', id: source.id, status: 'review' });
489
+ }
490
+ if (trust !== 'verified')
491
+ findings.push({ reason: 'evidence-untrusted', id: source.id, status: 'review' });
492
+ if (sensitivity === 'restricted')
493
+ findings.push({ reason: 'evidence-restricted', id: source.id, status: 'review' });
494
+ findings.push(...projectionBoundaryFindings(subject, trust, sensitivity));
495
+ if (source.content === undefined) {
496
+ findings.push({ reason: 'content-unverified', id: source.id, status: 'review' });
497
+ }
498
+ else if (digest(source.content) !== source.contentDigest) {
499
+ findings.push({ reason: 'content-digest-mismatch', id: source.id, status: 'review' });
500
+ }
501
+ return uniqueFindings(findings);
502
+ }
503
+ const required = hasOwn(gatePolicy.spec.requiredEvidenceByCriterion, subject.criterionId)
504
+ ? gatePolicy.spec.requiredEvidenceByCriterion[subject.criterionId] : undefined;
505
+ if (!required?.length)
506
+ findings.push({ reason: 'criterion-missing', id: subject.criterionId, status: 'review' });
507
+ const counts = new Map();
508
+ for (const item of subject.evidence)
509
+ counts.set(item.id, (counts.get(item.id) ?? 0) + 1);
510
+ for (const item of subject.evidence) {
511
+ if ((counts.get(item.id) ?? 0) > 1)
512
+ findings.push({ reason: 'duplicate-evidence-id', id: item.id, status: 'fail' });
513
+ const owner = hasOwn(owners, item.id) ? owners[item.id] : undefined;
514
+ if (!owner || owner.kind !== 'phase-criterion' || owner.criterionId !== subject.criterionId) {
515
+ findings.push({ reason: 'foreign-evidence', id: item.id, status: 'review' });
516
+ }
517
+ if (!item.present) {
518
+ const reason = item.type === 'artifact' ? 'artifact-missing'
519
+ : item.type === 'test-result' ? 'test-failed'
520
+ : item.type === 'approval' ? 'approval-absent'
521
+ : item.type === 'signature' ? 'signature-invalid' : 'schema-invalid';
522
+ findings.push({ reason, id: item.id, status: item.type === 'test-result' ? 'fail' : 'review' });
523
+ }
524
+ else if (!item.passed) {
525
+ const reason = item.type === 'test-result' ? 'test-failed'
526
+ : item.type === 'signature' ? 'signature-invalid'
527
+ : item.type === 'schema' ? 'schema-invalid'
528
+ : item.type === 'approval' ? 'approval-absent' : 'artifact-missing';
529
+ findings.push({ reason, id: item.id, status: item.type === 'test-result' ? 'fail' : 'review' });
530
+ }
531
+ if (item.expiresAtEpochMs !== undefined) {
532
+ if (!Number.isSafeInteger(item.expiresAtEpochMs) || item.expiresAtEpochMs < 0) {
533
+ findings.push({ reason: 'invalid-evidence', id: item.id, status: 'fail' });
534
+ }
535
+ else if (item.expiresAtEpochMs <= nowEpochMs) {
536
+ findings.push({ reason: 'evidence-expired', id: item.id, status: 'review' });
537
+ }
538
+ }
539
+ }
540
+ for (const id of required ?? []) {
541
+ if (!counts.has(id))
542
+ findings.push({ reason: 'required-evidence-missing', id, status: 'review' });
543
+ }
544
+ return uniqueFindings(findings);
545
+ }
546
+ function uniqueFindings(findings) {
547
+ const seen = new Set();
548
+ return findings.filter(item => {
549
+ const key = `${item.reason}\0${item.id}\0${item.status}`;
550
+ if (seen.has(key))
551
+ return false;
552
+ seen.add(key);
553
+ return true;
554
+ });
555
+ }
556
+ function projectionBoundaryFindings(subject, trust, sensitivity) {
557
+ const [source] = subject.evidence;
558
+ const policy = {
559
+ version: 'sdlc-screening-d10/v1', provider: 'jev', model: 'sdlc-screening',
560
+ origin: 'https://decision.invalid', region: 'offline', purpose: 'sdlc-evidence-screening',
561
+ allowIncompleteContext: false, maxSensitivity: 'confidential',
562
+ fields: [{
563
+ pointer: '/evidence/0/content', output: 'sourceContent', source: source.id,
564
+ subject: subject.subjectId, trust, sensitivity,
565
+ purpose: 'sdlc-evidence-screening', retentionClass: 'metadata-only',
566
+ accessScopes: ['decision:sdlc-screening'], exportPolicy: sensitivity === 'restricted' ? 'denied' : 'sanitized',
567
+ deletionPolicy: 'erase', backupPolicy: sensitivity === 'restricted' ? 'not-persisted' : 'expire-with-primary',
568
+ allowedProviders: ['jev'], allowedModels: ['sdlc-screening'],
569
+ allowedOrigins: ['https://decision.invalid'], allowedRegions: ['offline'],
570
+ }],
571
+ };
572
+ try {
573
+ validateProjectionPolicy(policy);
574
+ return [];
575
+ }
576
+ catch {
577
+ return [{ reason: sensitivity === 'restricted' ? 'evidence-restricted' : 'source-provenance-unverified',
578
+ id: source.id, status: 'review' }];
579
+ }
580
+ }
581
+ function deterministicStatus(findings) {
582
+ return findings.some(item => item.status === 'fail') ? 'fail' : findings.length ? 'review' : 'pass';
583
+ }
584
+ const act = { disposition: 'act' };
585
+ const review = { disposition: 'review' };
586
+ const screeningAcceptancePolicy = {
587
+ mode: 'primitive-policy',
588
+ version: '1.0.0',
589
+ compatibleUncertaintyProfiles: ['sdlc-screening-choice-v1'],
590
+ precedence: 'first-match',
591
+ calibration: 'advisory',
592
+ rules: [{ id: 'strong-choice', primitive: 'choice', all: [
593
+ { metric: 'selected-probability', op: 'gte', thresholdBps: 8_000 },
594
+ { metric: 'native-confidence', op: 'gte', thresholdBps: 8_000 },
595
+ { metric: 'top-two-margin', op: 'gte', thresholdBps: 1_500 },
596
+ ], route: act }],
597
+ defaultRoute: review,
598
+ missingEvidenceRoute: review,
599
+ invalidEvidenceRoute: review,
600
+ tieRoute: review,
601
+ };
602
+ function calibrationBlock(model, calibration) {
603
+ if (!calibration)
604
+ return 'calibration-missing';
605
+ if (!(calibration.registry instanceof CalibrationRegistry))
606
+ return 'calibration-invalid';
607
+ try {
608
+ const decision = calibration.registry.resolve(calibration.request, calibration.policy);
609
+ if (decision.actualModel !== model)
610
+ return 'calibration-model-mismatch';
611
+ return decision.action === 'allow' ? null : `calibration-${decision.action}`;
612
+ }
613
+ catch {
614
+ return 'calibration-invalid';
615
+ }
616
+ }
617
+ function semanticDecision(subject, observation, trust) {
618
+ if (!observation)
619
+ return { observed: false, accepted: false, reason: 'semantic-evidence-missing', model: null, attempts: 0, confidenceBps: null };
620
+ const base = { observed: true, model: observation.model, attempts: observation.attempts, confidenceBps: observation.confidenceBps };
621
+ const notAccepted = (reason) => ({ ...base, accepted: false, reason });
622
+ const calibration = calibrationBlock(observation.model, trust?.calibration);
623
+ if (calibration)
624
+ return notAccepted(calibration);
625
+ const { definitions, rulesets } = screeningArtifacts();
626
+ const ruleset = rulesets.find(item => item.metadata.id === (subject.kind === 'citation'
627
+ ? 'sdlc-screening.citation' : 'sdlc-screening.phase-criterion'));
628
+ const byId = new Map(definitions.map(item => [item.metadata.id, item]));
629
+ const evaluations = {};
630
+ const definitionFor = (alias) => byId.get(ruleset.spec.evaluations.find(item => item.alias === alias).decision.id);
631
+ // Only native provider distributions are accepted (D08); a single confidence number is never expanded into one.
632
+ const distributionProblem = (alias, distribution) => {
633
+ if (!isRecord(distribution))
634
+ return 'distribution-missing';
635
+ try {
636
+ validateDistribution(definitionFor(alias), distribution);
637
+ return null;
638
+ }
639
+ catch {
640
+ return 'distribution-invalid';
641
+ }
642
+ };
643
+ const add = (alias, value, distribution) => {
644
+ const target = definitionFor(alias);
645
+ const raw = {
646
+ status: 'success', reason: 'none', value, actualModel: observation.model, requestId: null,
647
+ usage: { inputTokens: null, outputTokens: null, costUsd: null },
648
+ uncertainty: distribution ? {
649
+ source: 'provider', profile: 'sdlc-screening-choice-v1', calibration: 'uncalibrated',
650
+ confidence: observation.confidenceBps / 10_000, distribution, calibrationRef: null,
651
+ } : {
652
+ source: 'provider', profile: 'sdlc-screening-truth-v1', calibration: 'uncalibrated',
653
+ confidence: null, distribution: null, calibrationRef: null,
654
+ },
655
+ };
656
+ const accepted = distribution ? applyPrimitiveAcceptance(target, screeningAcceptancePolicy, raw) : raw;
657
+ evaluations[alias] = decisionResultForScreening(target, ruleset, alias, accepted, observation.model);
658
+ };
659
+ if (observation.kind === 'citation' && subject.kind === 'citation') {
660
+ const problem = distributionProblem('support', observation.supportDistribution);
661
+ if (problem)
662
+ return notAccepted(problem);
663
+ add('support', observation.support, observation.supportDistribution);
664
+ // P(injection): only an explicit "no" carries (1 - confidence); "yes" and "unclear" are 1.
665
+ add('injection', observation.injection === 'no' ? (10_000 - observation.confidenceBps) / 10_000 : 1, null);
666
+ }
667
+ else if (observation.kind === 'phase-criterion' && subject.kind === 'phase-criterion') {
668
+ const fields = [
669
+ ['relevance', 'relevance'], ['completeness', 'completeness'], ['contradiction', 'contradiction'],
670
+ ['ambiguity', 'ambiguity'], ['attention', 'reviewerAttention'],
671
+ ];
672
+ for (const [alias, field] of fields) {
673
+ const problem = distributionProblem(alias, observation.distributions?.[field]);
674
+ if (problem)
675
+ return notAccepted(`${problem}:${field}`);
676
+ }
677
+ for (const [alias, field] of fields)
678
+ add(alias, observation[field], observation.distributions[field]);
679
+ }
680
+ else {
681
+ reject('Observation subject kind mismatch', 'subject-isolation-violated', 'review');
682
+ }
683
+ const composition = composeRuleset(ruleset, { subjectId: subject.subjectId }, evaluations);
684
+ const outcome = isRecord(composition.outcome) ? composition.outcome : {};
685
+ const route = outcome.route === 'ADVISORY_READY' || outcome.route === 'REVIEW' || outcome.route === 'FAIL'
686
+ ? outcome.route : 'REVIEW';
687
+ const reason = typeof outcome.reason === 'string' ? outcome.reason : composition.reason;
688
+ if (route !== 'ADVISORY_READY')
689
+ return notAccepted(reason);
690
+ // Code-level guards independent of the published ruleset data.
691
+ if (observation.kind === 'citation') {
692
+ if (observation.injection !== 'no' || observation.confidenceBps < INJECTION_CLEAR_MINIMUM_BPS)
693
+ return notAccepted('prompt-injection-flagged');
694
+ if (observation.supportStrengthBps < SUPPORT_STRENGTH_MINIMUM_BPS)
695
+ return notAccepted('support-strength-low');
696
+ }
697
+ return { ...base, accepted: true, reason };
698
+ }
699
+ function decisionResultForScreening(target, ruleset, alias, observation, model) {
700
+ const pin = cachedPin(target);
701
+ const rulesetPin = cachedPin(ruleset);
702
+ const bindingPin = { id: 'sdlc-screening.synthetic-binding', version: '1.0.0', digest: digest('sdlc-screening.synthetic-binding') };
703
+ return {
704
+ apiVersion: DECISION_API_VERSION_STRUCTURED,
705
+ kind: 'DecisionResult',
706
+ metadata: { id: `sdlc-screening-${alias}`, version: '1.0.0', description: `SDLC screening result for ${alias}` },
707
+ spec: {
708
+ decision: pin, ruleset: rulesetPin, binding: bindingPin, alias, runId: 'sdlc-screening', invocationId: 'sdlc-screening',
709
+ status: observation.status, ...(observation.status === 'success' ? { value: observation.value } : {}),
710
+ reason: observation.reason, uncertainty: observation.uncertainty,
711
+ ...(observation.acceptance ? { acceptance: observation.acceptance } : {}),
712
+ attempts: [{
713
+ ordinal: 1, adapter: 'jev', adapterVersion: 'sdlc-screening-offline', requestedModel: model, actualModel: model,
714
+ subagent: null, status: observation.status, reason: observation.reason, durationMs: 0,
715
+ usage: observation.usage, requestId: null,
716
+ }],
717
+ },
718
+ };
719
+ }
720
+ function subjectSummary(subject) {
721
+ const value = isRecord(subject) ? subject : {};
722
+ const kind = value.kind === 'citation' || value.kind === 'phase-criterion' ? value.kind : 'unknown';
723
+ const text = (item) => typeof item === 'string' ? item : '';
724
+ const evidence = Array.isArray(value.evidence) ? value.evidence : [];
725
+ const summary = {
726
+ kind, subjectId: text(value.subjectId),
727
+ requirementIds: Array.isArray(value.requirementIds) ? value.requirementIds.filter(isNonEmptyString) : [],
728
+ evidenceIds: kind === 'citation' ? [text(value.sourceId)].filter(Boolean)
729
+ : evidence.filter(isRecord).map(item => item.id).filter(isNonEmptyString),
730
+ ...(kind === 'citation' ? { claimId: text(value.claimId), sourceId: text(value.sourceId), locator: text(value.locator) }
731
+ : kind === 'phase-criterion' ? { criterionId: text(value.criterionId) } : {}),
732
+ };
733
+ const pins = evidence.filter(isPin).map(pin => ({ id: pin.id, version: pin.version, digest: pin.digest }));
734
+ return { summary, pins };
735
+ }
736
+ function optionalDigest(value) {
737
+ if (value === undefined)
738
+ return null;
739
+ try {
740
+ return digest(value);
741
+ }
742
+ catch {
743
+ return null;
744
+ }
745
+ }
746
+ function validationFinding(error) {
747
+ return { reason: error.reason, id: error.id, status: error.status };
748
+ }
749
+ /**
750
+ * Advisory screening for one subject. `disabled` returns null without reading anything else;
751
+ * an invalid `mode` throws. In shadow/advisory mode every other malformed input produces a
752
+ * non-ready receipt instead of an exception.
753
+ */
754
+ export function evaluateSdlcEvidenceScreening(request, trust) {
755
+ if (!isRecord(request) || !SCREENING_MODES.includes(request.mode)) {
756
+ throw new SdlcScreeningValidationError('Invalid screening mode', 'invalid-request');
757
+ }
758
+ if (request.mode === 'disabled')
759
+ return null;
760
+ const mode = request.mode;
761
+ let findings = [];
762
+ let semantic = null;
763
+ let gatePolicy = null;
764
+ const notObserved = (reason) => ({ observed: false, accepted: false, reason, model: null, attempts: 0, confidenceBps: null });
765
+ try {
766
+ const policy = validateRequest(request, trust);
767
+ gatePolicy = { ...trust.gatePolicyPin };
768
+ validateObservation(request.subject, request.observation);
769
+ findings = sdlcScreeningPreflight(request.subject, request.nowEpochMs, policy);
770
+ semantic = semanticDecision(request.subject, request.observation, trust);
771
+ }
772
+ catch (error) {
773
+ const finding = error instanceof SdlcScreeningValidationError ? validationFinding(error)
774
+ : { reason: 'invalid-request', id: 'validation', status: 'fail' };
775
+ findings = uniqueFindings([...findings, finding]);
776
+ semantic ??= notObserved(finding.reason);
777
+ }
778
+ const status = deterministicStatus(findings);
779
+ const route = status === 'fail' ? 'FAIL'
780
+ : status === 'review' ? 'REVIEW'
781
+ : semantic.accepted ? 'ADVISORY_READY' : 'REVIEW';
782
+ const reviewReasons = uniqueStrings([...findings.map(item => item.reason), ...(semantic.accepted ? [] : [semantic.reason])]);
783
+ const { summary, pins } = subjectSummary(request.subject);
784
+ return {
785
+ schemaVersion: SDLC_SCREENING_SCHEMA_VERSION,
786
+ mode,
787
+ subject: summary,
788
+ deterministic: { status, findings },
789
+ semantic,
790
+ route,
791
+ reviewRequired: route !== 'ADVISORY_READY',
792
+ reviewReasons,
793
+ gatePolicy,
794
+ pins,
795
+ trace: { redaction: 'metadata-only', subjectDigest: optionalDigest(request.subject), observationDigest: optionalDigest(request.observation) },
796
+ action: { status: 'unexecuted' },
797
+ };
798
+ }
799
+ function uniqueStrings(values) {
800
+ return [...new Set(values)];
801
+ }
802
+ /**
803
+ * Pass-through over a host-owned gate outcome. AIWG has no programmatic SDLC phase-gate
804
+ * evaluator (flow-gate-check is an agent-executed skill), so this adapter only guarantees
805
+ * that screening never alters the outcome value or publication state a host passes in.
806
+ */
807
+ export function applySdlcScreeningToGateOutcome(currentOutcome, mode, auditReceipts = [], receipt = null) {
808
+ if (!SCREENING_MODES.includes(mode))
809
+ throw new SdlcScreeningValidationError('Invalid screening mode', 'invalid-request');
810
+ const base = { outcome: structuredClone(currentOutcome), publication: 'unchanged',
811
+ auditReceipts: auditReceipts.map(item => ({ ...item })) };
812
+ return mode === 'disabled' || !receipt ? base : { ...base, alternateScreening: structuredClone(receipt) };
813
+ }
814
+ export function sdlcScreeningReviewInputFromReceipt(receipt, options) {
815
+ if (!receipt.reviewRequired)
816
+ return null;
817
+ const policyDigest = reviewDigest({ schemaVersion: receipt.schemaVersion, route: receipt.route, reasons: receipt.reviewReasons });
818
+ const requesterPresentationDigest = options.requesterPresentation
819
+ ? reviewDigest(options.requesterPresentation) : null;
820
+ return {
821
+ reviewId: options.reviewId,
822
+ sourceReceipt: { id: receipt.subject.subjectId || 'sdlc-screening-invalid-subject', digest: reviewDigest(receipt) },
823
+ evidencePins: receipt.pins,
824
+ policyPins: [
825
+ { id: 'sdlc-evidence-screening', version: '1.0.0', digest: policyDigest },
826
+ ...(receipt.gatePolicy ? [{ ...receipt.gatePolicy }] : []),
827
+ ],
828
+ reasonCodes: [...receipt.reviewReasons],
829
+ riskTier: options.riskTier,
830
+ presentation: {
831
+ redaction: 'metadata-only',
832
+ subject: receipt.subject,
833
+ route: receipt.route,
834
+ trace: receipt.trace,
835
+ requesterPresentationDigest,
836
+ reviewBridgeEnabledFlag: options.enabled,
837
+ },
838
+ action: { kind: 'sdlc-screening-review', subject: receipt.subject, route: receipt.route },
839
+ rationale: options.rationale,
840
+ expiresAtEpochMs: options.expiresAtEpochMs,
841
+ continuationId: options.continuationId,
842
+ resumeToken: options.resumeToken,
843
+ };
844
+ }
845
+ /** Canonical digest a host anchors separately before held-out access. */
846
+ export function sdlcScreeningPreregistrationDigest(preregistration) {
847
+ return digest(preregistration);
848
+ }
849
+ function validatePreregistration(preregistration) {
850
+ const invalid = (message = 'Invalid screening preregistration') => { throw new SdlcScreeningValidationError(message); };
851
+ if (!isRecord(preregistration) || (preregistration.schemaVersion !== SDLC_SCREENING_PREREGISTRATION_VERSION
852
+ && preregistration.schemaVersion !== SDLC_SCREENING_PREREGISTRATION_VERSION_V2)
853
+ || !isNonEmptyString(preregistration.planId) || !isDigest(preregistration.heldoutSplitDigest))
854
+ invalid();
855
+ rejectUnknownKeys(preregistration, ['schemaVersion', 'planId', 'frozenAt', 'heldoutSplitDigest', 'slices', 'gateBlockingSlices',
856
+ 'maximumFalseSupportRateBps', 'maximumFalseReadyRateBps', 'minimumTotalSupport', 'minimumSliceSupport',
857
+ 'minimumGateBlockingSliceSupport', 'confidenceInterval', 'qualityNonInferiorityBps', 'efficiencyClaim'], 'Screening preregistration');
858
+ const frozenAt = typeof preregistration.frozenAt === 'string' ? Date.parse(preregistration.frozenAt) : Number.NaN;
859
+ if (!Number.isFinite(frozenAt))
860
+ invalid();
861
+ if (!isStringArray(preregistration.slices) || !preregistration.slices.length
862
+ || new Set(preregistration.slices).size !== preregistration.slices.length
863
+ || !isStringArray(preregistration.gateBlockingSlices) || !preregistration.gateBlockingSlices.length
864
+ || new Set(preregistration.gateBlockingSlices).size !== preregistration.gateBlockingSlices.length
865
+ || preregistration.gateBlockingSlices.some(slice => !preregistration.slices.includes(slice))) {
866
+ invalid('Invalid screening preregistration slices');
867
+ }
868
+ for (const [label, value] of [
869
+ ['maximumFalseSupportRateBps', preregistration.maximumFalseSupportRateBps],
870
+ ['maximumFalseReadyRateBps', preregistration.maximumFalseReadyRateBps],
871
+ ['qualityNonInferiorityBps', preregistration.qualityNonInferiorityBps],
872
+ ]) {
873
+ if (value === null) {
874
+ if (label !== 'qualityNonInferiorityBps'
875
+ || preregistration.schemaVersion !== SDLC_SCREENING_PREREGISTRATION_VERSION_V2)
876
+ invalid(`Invalid basis points: ${label}`);
877
+ continue;
878
+ }
879
+ if (!Number.isSafeInteger(value) || value < 0 || value > 10_000)
880
+ invalid(`Invalid basis points: ${label}`);
881
+ }
882
+ if (!isRecord(preregistration.confidenceInterval) || !['wilson', 'exact-binomial'].includes(preregistration.confidenceInterval.method)
883
+ || !Number.isSafeInteger(preregistration.confidenceInterval.levelBps)
884
+ || preregistration.confidenceInterval.levelBps <= 0 || preregistration.confidenceInterval.levelBps >= 10_000) {
885
+ invalid('Invalid confidence interval level');
886
+ }
887
+ const claim = preregistration.efficiencyClaim;
888
+ if (!Number.isSafeInteger(preregistration.minimumTotalSupport) || preregistration.minimumTotalSupport < 1
889
+ || !Number.isSafeInteger(preregistration.minimumSliceSupport) || preregistration.minimumSliceSupport < 1
890
+ || !Number.isSafeInteger(preregistration.minimumGateBlockingSliceSupport) || preregistration.minimumGateBlockingSliceSupport < 1
891
+ || !isRecord(claim) || typeof claim.enabled !== 'boolean'
892
+ || (claim.minimumPositiveTotalEconomicsUsd !== null
893
+ && (typeof claim.minimumPositiveTotalEconomicsUsd !== 'number' || !Number.isFinite(claim.minimumPositiveTotalEconomicsUsd)
894
+ || claim.minimumPositiveTotalEconomicsUsd < 0))) {
895
+ invalid('Invalid screening preregistration minimums');
896
+ }
897
+ return frozenAt;
898
+ }
899
+ function validateHeldoutSample(sample) {
900
+ const invalid = () => { throw new SdlcScreeningValidationError(`Invalid held-out sample: ${String(sample?.id)}`); };
901
+ if (!isRecord(sample) || !isNonEmptyString(sample.id) || !isNonEmptyString(sample.slice)
902
+ || (sample.kind !== 'citation' && sample.kind !== 'phase-criterion')
903
+ || !isRecord(sample.gold) || typeof sample.gold.ready !== 'boolean'
904
+ || !isRecord(sample.candidate) || !ROUTES.includes(sample.candidate.route)
905
+ || !isRecord(sample.baseline) || typeof sample.baseline.correct !== 'boolean'
906
+ || (sample.baseline.costUsd !== null && (typeof sample.baseline.costUsd !== 'number' || !Number.isFinite(sample.baseline.costUsd) || sample.baseline.costUsd < 0))
907
+ || !(sample.reviewer === null || (isRecord(sample.reviewer) && typeof sample.reviewer.agreed === 'boolean'
908
+ && typeof sample.reviewer.overridden === 'boolean')))
909
+ invalid();
910
+ rejectUnknownKeys(sample, ['id', 'kind', 'slice', 'gold', 'candidate', 'baseline', 'reviewer'], 'Held-out sample');
911
+ rejectUnknownKeys(sample.gold, ['ready', 'support'], 'Held-out gold label');
912
+ rejectUnknownKeys(sample.candidate, ['route', 'support', 'readyProbability', 'latencyMs', 'inputTokens', 'outputTokens',
913
+ 'costUsd', 'calls', 'retries', 'fallbacks'], 'Held-out candidate');
914
+ rejectUnknownKeys(sample.baseline, ['correct', 'costUsd'], 'Held-out baseline');
915
+ if (sample.reviewer)
916
+ rejectUnknownKeys(sample.reviewer, ['agreed', 'overridden'], 'Held-out reviewer');
917
+ const citation = sample.kind === 'citation';
918
+ for (const label of [sample.gold.support, sample.candidate.support]) {
919
+ if (citation ? !SUPPORT_LABELS.includes(label) : label !== null)
920
+ invalid();
921
+ }
922
+ }
923
+ function bps(events, n) {
924
+ return n > 0 ? Math.round(events / n * 10_000) : null;
925
+ }
926
+ function rateEvidence(events, n, levelBps) {
927
+ if (n === 0 || levelBps === null)
928
+ return { events, n, rateBps: bps(events, n), upperBps: null };
929
+ const [, upper] = wilsonScoreInterval({ events, n, levelBps });
930
+ return { events, n, rateBps: bps(events, n), upperBps: Math.ceil(upper * 10_000) };
931
+ }
932
+ /**
933
+ * Computes every held-out metric from per-sample records with the #1585 quality helpers.
934
+ * With a two-sided `levelBps`, false-rate upper bounds use the Wilson score interval and the
935
+ * paired candidate-minus-baseline interval uses Newcombe method 10 at that level; without one
936
+ * (or for an unsupported level) those bounds are null.
937
+ */
938
+ export function computeSdlcScreeningHeldoutReport(records, levelBps = null) {
939
+ if (!isRecord(records) || records.schemaVersion !== SDLC_SCREENING_HELDOUT_RECORDS_VERSION
940
+ || typeof records.evaluatedAt !== 'string' || !Array.isArray(records.samples) || !Array.isArray(records.splits)) {
941
+ throw new SdlcScreeningValidationError('Invalid held-out records');
942
+ }
943
+ rejectUnknownKeys(records, ['schemaVersion', 'evaluatedAt', 'splits', 'samples'], 'Held-out records');
944
+ verifyQualificationSplits(records.splits);
945
+ records.samples.forEach(validateHeldoutSample);
946
+ const samples = records.samples;
947
+ const binary = evaluateBinaryHeldout(records.splits, samples.map(sample => ({
948
+ id: sample.id, slice: sample.slice, label: sample.gold.ready ? 1 : 0,
949
+ probability: sample.candidate.readyProbability, accepted: sample.candidate.route === 'ADVISORY_READY',
950
+ latencyMs: sample.candidate.latencyMs, inputTokens: sample.candidate.inputTokens, outputTokens: sample.candidate.outputTokens,
951
+ costUsd: sample.candidate.costUsd, calls: sample.candidate.calls, retries: sample.candidate.retries, fallbacks: sample.candidate.fallbacks,
952
+ })));
953
+ const citations = samples.filter(sample => sample.kind === 'citation');
954
+ const classMetrics = (label) => {
955
+ const truePositive = citations.filter(sample => sample.gold.support === label && sample.candidate.support === label).length;
956
+ const predicted = citations.filter(sample => sample.candidate.support === label).length;
957
+ const actual = citations.filter(sample => sample.gold.support === label).length;
958
+ return { n: actual, precisionBps: bps(truePositive, predicted), recallBps: bps(truePositive, actual) };
959
+ };
960
+ const ready = (sample) => sample.candidate.route === 'ADVISORY_READY';
961
+ const reviewed = samples.filter(sample => sample.reviewer !== null);
962
+ const sum = (values) => values.some(value => value === null) ? null : values.reduce((total, value) => total + value, 0);
963
+ const baselineCost = sum(samples.map(sample => sample.baseline.costUsd));
964
+ const candidateCost = binary.overall.costUsd;
965
+ const test = records.splits.find(split => split.name === 'test');
966
+ // An item is correct only when the readiness route and, for citations, the support label both match gold.
967
+ const correct = (sample) => ready(sample) === sample.gold.ready
968
+ && (sample.kind !== 'citation' || sample.candidate.support === sample.gold.support);
969
+ const paired = {
970
+ n: samples.length,
971
+ both: samples.filter(sample => sample.baseline.correct && correct(sample)).length,
972
+ candidateOnly: samples.filter(sample => !sample.baseline.correct && correct(sample)).length,
973
+ baselineOnly: samples.filter(sample => sample.baseline.correct && !correct(sample)).length,
974
+ neither: samples.filter(sample => !sample.baseline.correct && !correct(sample)).length,
975
+ };
976
+ return {
977
+ schemaVersion: SDLC_SCREENING_HELDOUT_REPORT_VERSION,
978
+ evaluatedAt: records.evaluatedAt,
979
+ heldoutSplitDigest: test.digest,
980
+ recordsDigest: digest(records),
981
+ totalSupport: samples.length,
982
+ gateBlockingSliceSupport: 0,
983
+ classes: { support: classMetrics('supports'), contradiction: classMetrics('contradicts'), unclear: classMetrics('unclear') },
984
+ falseSupport: rateEvidence(citations.filter(sample => ready(sample) && sample.candidate.support === 'supports'
985
+ && sample.gold.support !== 'supports').length, citations.length, levelBps),
986
+ falseReady: rateEvidence(samples.filter(sample => ready(sample) && !sample.gold.ready).length, samples.length, levelBps),
987
+ reviewer: {
988
+ n: reviewed.length,
989
+ agreementRateBps: bps(reviewed.filter(sample => sample.reviewer.agreed).length, reviewed.length),
990
+ overrideRateBps: bps(reviewed.filter(sample => sample.reviewer.overridden).length, reviewed.length),
991
+ },
992
+ paired: { ...paired, interval: levelBps === null ? null
993
+ : pairedBinaryDifferenceInterval({ counts: { both: paired.both, candidateOnly: paired.candidateOnly,
994
+ baselineOnly: paired.baselineOnly, neither: paired.neither }, levelBps, method: 'newcombe-10' }) },
995
+ calibrationRiskCoverage: {
996
+ brier: binary.overall.brier, expectedCalibrationError: binary.overall.expectedCalibrationError,
997
+ coverage: binary.overall.coverage, selectiveRisk: binary.overall.selectiveRisk,
998
+ },
999
+ slices: Object.fromEntries(Object.entries(binary.slices).map(([slice, metrics]) => [slice,
1000
+ { n: metrics.sampleN, coverage: metrics.coverage, selectiveRisk: metrics.selectiveRisk }])),
1001
+ latencyMs: { ...binary.overall.latencyMs },
1002
+ tokens: { input: binary.overall.inputTokens, output: binary.overall.outputTokens },
1003
+ costUsd: { candidate: candidateCost, baseline: baselineCost,
1004
+ netSavings: candidateCost === null || baselineCost === null ? null : baselineCost - candidateCost },
1005
+ reviewLoad: { reviewRate: binary.overall.reviewRate },
1006
+ };
1007
+ }
1008
+ /** Explicit reason-code dispositions; unknown codes fail closed as `fail`. */
1009
+ const REASON_DISPOSITIONS = Object.freeze({
1010
+ 'preregistration-digest-mismatch': 'fail',
1011
+ 'preregistration-frozen-in-future': 'fail',
1012
+ 'heldout-records-missing': 'insufficient-evidence',
1013
+ 'heldout-evaluated-at-invalid': 'fail',
1014
+ 'heldout-not-after-preregistration': 'fail',
1015
+ 'heldout-evaluated-in-future': 'fail',
1016
+ 'heldout-split-mismatch': 'fail',
1017
+ 'heldout-records-invalid': 'fail',
1018
+ 'heldout-slice-unregistered': 'fail',
1019
+ 'minimum-total-support-missing': 'insufficient-evidence',
1020
+ 'slice-support-missing': 'insufficient-evidence',
1021
+ 'class-support-missing': 'insufficient-evidence',
1022
+ 'gate-blocking-slice-support-missing': 'insufficient-evidence',
1023
+ 'confidence-interval-unsupported': 'insufficient-evidence',
1024
+ 'false-support-bound-exceeded': 'fail',
1025
+ 'false-ready-bound-exceeded': 'fail',
1026
+ 'quality-not-non-inferior': 'fail',
1027
+ 'quality-non-inferiority-insufficient': 'insufficient-evidence',
1028
+ 'total-economics-unknown': 'insufficient-evidence',
1029
+ 'total-economics-not-positive': 'fail',
1030
+ });
1031
+ function reasonDisposition(reason) {
1032
+ const code = reason.split(':')[0];
1033
+ return hasOwn(REASON_DISPOSITIONS, code) ? REASON_DISPOSITIONS[code] : 'fail';
1034
+ }
1035
+ /**
1036
+ * Evaluates held-out records against a preregistration anchored by a separately trusted digest.
1037
+ * Structurally invalid preregistrations throw; every evidence problem is a reason code.
1038
+ */
1039
+ export function evaluateSdlcScreeningPreregistration(preregistration, trustedPreregistrationDigest, records, nowEpochMs) {
1040
+ const frozenAt = validatePreregistration(preregistration);
1041
+ if (!Number.isSafeInteger(nowEpochMs) || nowEpochMs < 0)
1042
+ throw new SdlcScreeningValidationError('Invalid preregistration clock');
1043
+ const reasons = [];
1044
+ const finish = (heldout) => {
1045
+ const unique = uniqueStrings(reasons);
1046
+ const dispositions = unique.map(reasonDisposition);
1047
+ return { decision: dispositions.includes('fail') ? 'fail' : unique.length ? 'insufficient-evidence' : 'pass', reasons: unique, heldout };
1048
+ };
1049
+ if (!isDigest(trustedPreregistrationDigest) || sdlcScreeningPreregistrationDigest(preregistration) !== trustedPreregistrationDigest) {
1050
+ reasons.push('preregistration-digest-mismatch');
1051
+ }
1052
+ if (frozenAt > nowEpochMs)
1053
+ reasons.push('preregistration-frozen-in-future');
1054
+ if (!records) {
1055
+ reasons.push('heldout-records-missing');
1056
+ return finish(null);
1057
+ }
1058
+ const evaluatedAt = isRecord(records) && typeof records.evaluatedAt === 'string' ? Date.parse(records.evaluatedAt) : Number.NaN;
1059
+ if (!Number.isFinite(evaluatedAt))
1060
+ reasons.push('heldout-evaluated-at-invalid');
1061
+ else {
1062
+ if (evaluatedAt < frozenAt)
1063
+ reasons.push('heldout-not-after-preregistration');
1064
+ if (evaluatedAt > nowEpochMs)
1065
+ reasons.push('heldout-evaluated-in-future');
1066
+ }
1067
+ // Wilson score and Newcombe method 10 bounds are implemented for levels the paired helpers accept.
1068
+ const { method, levelBps } = preregistration.confidenceInterval;
1069
+ const intervalSupported = method === 'wilson' && levelBps > 5_000 && levelBps < 9_999;
1070
+ let report;
1071
+ try {
1072
+ report = computeSdlcScreeningHeldoutReport(records, intervalSupported ? levelBps : null);
1073
+ }
1074
+ catch {
1075
+ reasons.push('heldout-records-invalid');
1076
+ return finish(null);
1077
+ }
1078
+ if (report.heldoutSplitDigest !== preregistration.heldoutSplitDigest)
1079
+ reasons.push('heldout-split-mismatch');
1080
+ for (const slice of Object.keys(report.slices)) {
1081
+ if (!preregistration.slices.includes(slice))
1082
+ reasons.push(`heldout-slice-unregistered:${slice}`);
1083
+ }
1084
+ report.gateBlockingSliceSupport = preregistration.gateBlockingSlices
1085
+ .reduce((total, slice) => total + (report.slices[slice]?.n ?? 0), 0);
1086
+ if (report.totalSupport < preregistration.minimumTotalSupport)
1087
+ reasons.push('minimum-total-support-missing');
1088
+ for (const slice of preregistration.slices) {
1089
+ if ((report.slices[slice]?.n ?? 0) < preregistration.minimumSliceSupport)
1090
+ reasons.push(`slice-support-missing:${slice}`);
1091
+ }
1092
+ if (report.gateBlockingSliceSupport < preregistration.minimumGateBlockingSliceSupport)
1093
+ reasons.push('gate-blocking-slice-support-missing');
1094
+ for (const [label, metric] of Object.entries(report.classes)) {
1095
+ if (metric.n < preregistration.minimumSliceSupport)
1096
+ reasons.push(`class-support-missing:${label}`);
1097
+ }
1098
+ if (!intervalSupported)
1099
+ reasons.push('confidence-interval-unsupported');
1100
+ else {
1101
+ if (report.falseSupport.upperBps === null)
1102
+ reasons.push('class-support-missing:false-support');
1103
+ else if (report.falseSupport.upperBps > preregistration.maximumFalseSupportRateBps)
1104
+ reasons.push('false-support-bound-exceeded');
1105
+ if (report.falseReady.upperBps === null)
1106
+ reasons.push('minimum-total-support-missing');
1107
+ else if (report.falseReady.upperBps > preregistration.maximumFalseReadyRateBps)
1108
+ reasons.push('false-ready-bound-exceeded');
1109
+ }
1110
+ // Paired non-inferiority of per-item readiness correctness against the baseline screening path.
1111
+ // The preregistered margin is a tolerated loss in bps; the helper's margin is candidate minus
1112
+ // baseline, so it is negated (200 bps tolerated loss => marginBps -200).
1113
+ // A null v2 margin skips the NI gate entirely: the paired contrast stays a
1114
+ // reported diagnostic and contributes no gate reason.
1115
+ if (preregistration.qualityNonInferiorityBps !== null) {
1116
+ const nonInferiority = report.paired.interval
1117
+ ? pairedNonInferiority({ interval: report.paired.interval, marginBps: -preregistration.qualityNonInferiorityBps })
1118
+ : { decision: 'insufficient', reason: 'interval-unavailable' };
1119
+ if (nonInferiority.decision === 'not-non-inferior')
1120
+ reasons.push('quality-not-non-inferior');
1121
+ else if (nonInferiority.decision !== 'non-inferior')
1122
+ reasons.push('quality-non-inferiority-insufficient');
1123
+ }
1124
+ if (preregistration.efficiencyClaim.enabled) {
1125
+ const net = report.costUsd.netSavings;
1126
+ if (net === null)
1127
+ reasons.push('total-economics-unknown');
1128
+ else if (net <= 0 || (preregistration.efficiencyClaim.minimumPositiveTotalEconomicsUsd !== null
1129
+ && net < preregistration.efficiencyClaim.minimumPositiveTotalEconomicsUsd))
1130
+ reasons.push('total-economics-not-positive');
1131
+ }
1132
+ return finish(report);
1133
+ }
1134
+ export function buildSdlcScreeningReleaseReport(input) {
1135
+ const preregistered = evaluateSdlcScreeningPreregistration(input.preregistration, input.trustedPreregistrationDigest, input.heldout, input.nowEpochMs);
1136
+ const integrityProblems = qualificationIntegrityAllowlistProblems(input.integrity);
1137
+ const readable = !integrityProblems.includes('integrity-invalid');
1138
+ const compromised = readable && (input.integrity.release_gate.decision === 'ROLLBACK'
1139
+ || input.integrity.integrity_state === 'compromised' || input.integrity.compromise_labels.length > 0);
1140
+ const decision = compromised ? 'ROLLBACK'
1141
+ : readable && integrityProblems.length === 0 && preregistered.decision === 'pass'
1142
+ && input.integrity.release_gate.decision === 'PROMOTE' ? 'PROMOTE' : 'HOLD';
1143
+ const unsigned = {
1144
+ schemaVersion: input.preregistration.schemaVersion === SDLC_SCREENING_PREREGISTRATION_VERSION_V2
1145
+ ? SDLC_SCREENING_RELEASE_VERSION_V2 : SDLC_SCREENING_RELEASE_VERSION,
1146
+ preregistration: input.preregistration,
1147
+ preregistrationDigest: sdlcScreeningPreregistrationDigest(input.preregistration),
1148
+ trustedPreregistrationDigest: input.trustedPreregistrationDigest,
1149
+ heldout: preregistered.heldout,
1150
+ integrity: input.integrity,
1151
+ preregisteredDecision: preregistered.decision,
1152
+ decision,
1153
+ reasons: uniqueStrings([...preregistered.reasons, ...integrityProblems]),
1154
+ };
1155
+ return { ...unsigned, digest: digest(unsigned) };
1156
+ }
1157
+ //# sourceMappingURL=sdlc-screening.js.map