@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/bin/agentic-security.js +87 -0
  3. package/dist/1340.index.js +1871 -0
  4. package/dist/3477.index.js +727 -0
  5. package/dist/3499.index.js +23 -3
  6. package/dist/4113.index.js +1 -1
  7. package/dist/5014.index.js +833 -0
  8. package/dist/5756.index.js +2 -2
  9. package/dist/6257.index.js +1 -1
  10. package/dist/6907.index.js +248 -0
  11. package/dist/6994.index.js +1 -1
  12. package/dist/7033.index.js +33 -5
  13. package/dist/7439.index.js +1822 -0
  14. package/dist/7838.index.js +1 -1
  15. package/dist/9560.index.js +2318 -23
  16. package/dist/957.index.js +1 -1
  17. package/dist/9999.index.js +43 -7
  18. package/dist/agentic-security.mjs +12 -12
  19. package/dist/agentic-security.mjs.sha256 +1 -1
  20. package/package.json +33 -6
  21. package/src/capabilities/CLAUDE.md +114 -0
  22. package/src/capabilities/address.js +73 -0
  23. package/src/capabilities/attack-coverage.js +93 -0
  24. package/src/capabilities/decide.js +256 -0
  25. package/src/capabilities/delegate.js +69 -0
  26. package/src/capabilities/egress.js +20 -0
  27. package/src/capabilities/hook-advice.js +50 -0
  28. package/src/capabilities/index.js +32 -0
  29. package/src/capabilities/manifest.js +344 -0
  30. package/src/capabilities/outbound.js +189 -0
  31. package/src/capabilities/paths.js +57 -0
  32. package/src/capabilities/probes.js +211 -0
  33. package/src/capabilities/proxy.js +182 -0
  34. package/src/capabilities/reasons.js +67 -0
  35. package/src/capabilities/receipts.js +356 -0
  36. package/src/capabilities/records.js +58 -0
  37. package/src/capabilities/recovery.js +367 -0
  38. package/src/capabilities/report.js +81 -0
  39. package/src/capabilities/runner.js +236 -0
  40. package/src/capabilities/secrets.js +26 -0
  41. package/src/capabilities/tool-gate.js +119 -0
  42. package/src/capabilities/tool-registry.js +60 -0
  43. package/src/dataflow/CLAUDE.md +30 -0
  44. package/src/dataflow/catalog.js +42 -1
  45. package/src/dataflow/engine.js +67 -2
  46. package/src/dataflow/guard-dominance.js +352 -0
  47. package/src/discovery/CLAUDE.md +9 -0
  48. package/src/discovery/index.js +15 -1
  49. package/src/discovery/lenses.js +18 -0
  50. package/src/engine.js +80 -15
  51. package/src/integrations/index.js +5 -2
  52. package/src/ir/CLAUDE.md +14 -0
  53. package/src/ir/implicit-handler-params.js +74 -0
  54. package/src/ir/parser-cs.js +20 -0
  55. package/src/ir/parser-go.js +30 -13
  56. package/src/ir/parser-java.js +11 -0
  57. package/src/language/nix-eval-isolation.js +33 -5
  58. package/src/lineage/CLAUDE.md +31 -0
  59. package/src/lineage/deployment/adapter-cloud.js +202 -0
  60. package/src/lineage/deployment/adapter-compose.js +90 -0
  61. package/src/lineage/deployment/adapter-kit.js +142 -0
  62. package/src/lineage/deployment/adapter-kubernetes.js +247 -0
  63. package/src/lineage/deployment/attack-paths.js +443 -0
  64. package/src/lineage/deployment/boundaries-run.js +223 -0
  65. package/src/lineage/deployment/boundary-graph.js +567 -0
  66. package/src/lineage/deployment/cross-repo.js +253 -0
  67. package/src/lineage/deployment/drift.js +383 -0
  68. package/src/lineage/deployment/ingest.js +221 -0
  69. package/src/lineage/deployment/projection.js +244 -0
  70. package/src/lineage/deployment/trace-correlation.js +541 -0
  71. package/src/lineage/source-registry.js +19 -0
  72. package/src/mcp/CLAUDE.md +5 -2
  73. package/src/mcp/invariant-tools.js +64 -0
  74. package/src/mcp/portfolio-tools.js +79 -0
  75. package/src/mcp/redact.js +15 -0
  76. package/src/mcp/server.js +24 -3
  77. package/src/mcp/tools.js +13 -1
  78. package/src/posture/CLAUDE.md +332 -1
  79. package/src/posture/accuracy-scorecard.js +95 -4
  80. package/src/posture/assurance/rollback.js +214 -0
  81. package/src/posture/assurance/rollout.js +200 -0
  82. package/src/posture/business-logic.js +26 -4
  83. package/src/posture/evaluation/bakeoff.js +148 -0
  84. package/src/posture/evaluation/custody.js +200 -0
  85. package/src/posture/evaluation/deployment-ablation.js +416 -0
  86. package/src/posture/evaluation/dev-cases.js +65 -0
  87. package/src/posture/evaluation/economics.js +85 -0
  88. package/src/posture/evaluation/gates.js +161 -0
  89. package/src/posture/evaluation/grouping.js +101 -0
  90. package/src/posture/evaluation/interval.js +132 -0
  91. package/src/posture/evaluation/invariant-ablation.js +325 -0
  92. package/src/posture/evaluation/labels.js +200 -0
  93. package/src/posture/evaluation/protocol.js +308 -0
  94. package/src/posture/evaluation/release-gate.js +209 -0
  95. package/src/posture/evaluation/report.js +158 -0
  96. package/src/posture/evaluation/runner.js +273 -0
  97. package/src/posture/evaluation/scan-child.js +80 -0
  98. package/src/posture/evaluation/score.js +174 -0
  99. package/src/posture/evaluation/synthetic.js +90 -0
  100. package/src/posture/evaluation/why-missed.js +219 -0
  101. package/src/posture/fleet.js +11 -2
  102. package/src/posture/invariants/cli.js +95 -0
  103. package/src/posture/invariants/coverage.js +164 -0
  104. package/src/posture/invariants/export.js +178 -0
  105. package/src/posture/invariants/expressions.js +46 -0
  106. package/src/posture/invariants/lifecycle.js +289 -0
  107. package/src/posture/invariants/project-input.js +55 -0
  108. package/src/posture/invariants/repair.js +340 -0
  109. package/src/posture/invariants/run.js +45 -0
  110. package/src/posture/invariants/scenarios.js +311 -0
  111. package/src/posture/invariants/schema.js +167 -0
  112. package/src/posture/invariants/shrink.js +407 -0
  113. package/src/posture/invariants/state-assertions.js +165 -0
  114. package/src/posture/logic-claims.js +12 -0
  115. package/src/posture/model-routing.js +40 -0
  116. package/src/posture/oracles/adapters.js +172 -1
  117. package/src/posture/oracles/conformance.js +3 -2
  118. package/src/posture/oracles/oracle.js +23 -3
  119. package/src/posture/oracles/registry.js +1 -1
  120. package/src/posture/portfolio/backend.js +279 -0
  121. package/src/posture/portfolio/bundle.js +314 -0
  122. package/src/posture/portfolio/cli.js +142 -0
  123. package/src/posture/portfolio/manifest.js +263 -0
  124. package/src/posture/portfolio/progress.js +267 -0
  125. package/src/posture/portfolio/resume.js +130 -0
  126. package/src/posture/portfolio/retention.js +207 -0
  127. package/src/posture/portfolio/scheduler.js +488 -0
  128. package/src/posture/portfolio/signing.js +155 -0
  129. package/src/posture/portfolio/wording.js +59 -0
  130. package/src/posture/portfolio/work-units.js +405 -0
  131. package/src/posture/replay/replay.js +1 -0
  132. package/src/posture/routing/adapter.js +140 -0
  133. package/src/posture/routing/calibration.js +278 -0
  134. package/src/posture/routing/canary.js +141 -0
  135. package/src/posture/routing/control.js +35 -0
  136. package/src/posture/routing/decide.js +211 -0
  137. package/src/posture/routing/drift.js +137 -0
  138. package/src/posture/routing/economics.js +185 -0
  139. package/src/posture/routing/feedback.js +207 -0
  140. package/src/posture/routing/index.js +31 -0
  141. package/src/posture/routing/outcomes.js +281 -0
  142. package/src/posture/routing/promotion.js +291 -0
  143. package/src/posture/routing/receipts.js +59 -0
  144. package/src/posture/routing/report.js +142 -0
  145. package/src/posture/routing/shadow.js +53 -0
  146. package/src/posture/specification-mining.js +26 -0
  147. package/src/posture/verification/patch-negative.js +16 -4
  148. package/src/posture/verification/patch-promotion.js +10 -2
  149. package/src/report/index.js +51 -1
  150. package/src/sandbox/CLAUDE.md +10 -0
  151. package/src/sandbox/backend-userspace.js +91 -13
  152. package/dist/5613.index.js +0 -500
  153. package/dist/6239.index.js +0 -469
@@ -0,0 +1,207 @@
1
+ // Protected routing feedback (X-607): who may label an outcome, what is kept, what may leave the machine, and what is refused.
2
+ //
3
+ // AC01 Only authorized actors can change a quality label. An actor is either a human adjudicator or a trusted verifier service, is
4
+ // registered by the operator with a secret, and signs each feedback record (HMAC-SHA256 over the record). The first accepted
5
+ // label for an outcome is its ORIGINAL and is never modified or removed; a change is a CORRECTION that cites the entry it
6
+ // corrects and a reason code, and the full history is kept (`history`). The effective label is the latest accepted entry.
7
+ // AC02 A feedback record is an allowlist of fields. Anything else (source text, evidence, free text) is dropped and only the
8
+ // NAMES of the dropped fields are kept. Source code or evidence reaches a provider only through `sendToProvider`, which needs
9
+ // the task policy to allow that kind of payload AND the egress policy (or a capability-manifest egress check) to allow it, and
10
+ // then sends through the guarded adapter. This module holds no network client and no transport of its own.
11
+ // AC03 Feedback that is unauthorized, unverifiable (missing or unknown fields, unknown outcome), tampered (signature mismatch, or a
12
+ // stored label that no longer matches its own id), duplicated or poisoned (conflicts with the original, flood from one actor,
13
+ // wrong task, a reviewer that is not independent) is QUARANTINED: kept out of calibration, counted, with the reason visible.
14
+ //
15
+ // Limit, stated plainly: a trusted-execution label carries its verification record id only at labelling time, so a flipped label on
16
+ // an outcome that ALREADY carries a trusted-execution label cannot be recomputed here; adjudicated labels can be (labelId is a
17
+ // function of the label). The operator's actor keys are the trust root; this is tamper evidence, not independent certification.
18
+
19
+ import * as crypto from 'node:crypto';
20
+ import { digestOf, canonicalize } from '../assurance/identity.js';
21
+ import { routingLabelId } from '../assurance/contracts.js';
22
+ import { deriveRoutingLabel, buildRoutingOutcome, validateRoutingOutcome } from './outcomes.js';
23
+ import { evaluateEgress } from '../../egress/policy.js';
24
+
25
+ export const FEEDBACK_FIELDS = Object.freeze(['outcomeId', 'taskId', 'verdict', 'evidenceKind', 'adjudicationVersion', 'verificationRecordId', 'reasonCode', 'correctsFeedbackId', 'actorId', 'submittedAt', 'sig']);
26
+ export const FEEDBACK_REASON_CODES = Object.freeze(['initial-label', 'mislabelled', 'reviewer-error', 'new-evidence']);
27
+ export const QUARANTINE_REASONS = Object.freeze(['unauthorized', 'unverifiable', 'tampered', 'duplicate', 'poisoned']);
28
+ export const ACTOR_KINDS = Object.freeze(['human', 'verifier-service']);
29
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
30
+
31
+ /** The signature an actor attaches: HMAC-SHA256 over the canonical record without `sig`. */
32
+ export function signFeedback(key, fields) {
33
+ const { sig: _omit, ...rest } = fields;
34
+ return crypto.createHmac('sha256', String(key)).update(canonicalize(rest)).digest('hex');
35
+ }
36
+
37
+ /**
38
+ * @param {object} o
39
+ * @param {Object<string,{kind:'human'|'verifier-service', key:string}>} o.actors registered by the operator; keys stay in this closure
40
+ * @param {object[]} o.outcomes the base routing outcomes feedback may refer to
41
+ * @param {number} [o.maxPerActor] submissions from one actor beyond this are quarantined as a flood
42
+ * @param {object} [o.log] receipt log; each accepted and quarantined submission appends a sanitized receipt
43
+ */
44
+ export function createFeedbackStore({ actors, outcomes, maxPerActor = 1000, log = null }) {
45
+ const registry = new Map();
46
+ for (const [id, a] of Object.entries(actors || {})) {
47
+ if (SAFE_ID.test(id) && a && ACTOR_KINDS.includes(a.kind) && typeof a.key === 'string' && a.key.length >= 16) registry.set(id, { kind: a.kind, key: a.key });
48
+ }
49
+ const byOutcome = new Map((outcomes || []).filter((o) => validateRoutingOutcome(o).ok).map((o) => [o.id, o]));
50
+ const accepted = [];
51
+ const quarantined = [];
52
+ const seenDigests = new Set();
53
+ const perActor = new Map();
54
+
55
+ const quarantine = (fb, stripped, reason, detail) => {
56
+ const item = Object.freeze({
57
+ id: `q:${digestOf({ fb, reason }).slice(7, 23)}`, reason, detail,
58
+ actorId: typeof fb.actorId === 'string' && SAFE_ID.test(fb.actorId) ? fb.actorId : null,
59
+ outcomeId: typeof fb.outcomeId === 'string' && fb.outcomeId.length <= 80 ? fb.outcomeId : null, strippedFields: stripped,
60
+ });
61
+ quarantined.push(item);
62
+ if (log) log.append('feedback', { event: 'quarantined', reason, id: item.id, actorId: item.actorId });
63
+ return { ok: false, quarantined: true, reason, detail, id: item.id };
64
+ };
65
+
66
+ function submit(raw) {
67
+ const input = raw && typeof raw === 'object' ? raw : {};
68
+ const stripped = Object.keys(input).filter((k) => !FEEDBACK_FIELDS.includes(k)).sort();
69
+ const fb = {};
70
+ for (const k of FEEDBACK_FIELDS) if (input[k] !== undefined) fb[k] = typeof input[k] === 'string' ? input[k].slice(0, 200) : input[k];
71
+
72
+ // flood control counts every submission from an actor, accepted or not
73
+ if (typeof fb.actorId === 'string') {
74
+ const n = (perActor.get(fb.actorId) || 0) + 1;
75
+ perActor.set(fb.actorId, n);
76
+ if (n > maxPerActor) return quarantine(fb, stripped, 'poisoned', `more than ${maxPerActor} submissions from one actor`);
77
+ }
78
+ const missing = ['outcomeId', 'taskId', 'verdict', 'evidenceKind', 'reasonCode', 'actorId', 'submittedAt'].filter((k) => typeof fb[k] !== 'string' || !fb[k]);
79
+ if (missing.length) return quarantine(fb, stripped, 'unverifiable', `missing fields: ${missing.join(', ')}`);
80
+ if (!['correct', 'incorrect'].includes(fb.verdict)) return quarantine(fb, stripped, 'unverifiable', 'verdict must be correct or incorrect');
81
+ if (!FEEDBACK_REASON_CODES.includes(fb.reasonCode)) return quarantine(fb, stripped, 'unverifiable', 'unknown reason code');
82
+
83
+ const actor = registry.get(fb.actorId);
84
+ if (!actor) return quarantine(fb, stripped, 'unauthorized', 'the actor is not a registered adjudicator or verifier service');
85
+ if (fb.evidenceKind === 'trusted-execution' && actor.kind !== 'verifier-service') return quarantine(fb, stripped, 'unauthorized', 'only a trusted verifier service may supply trusted-execution labels');
86
+ if (fb.evidenceKind === 'independent-adjudication' && !ACTOR_KINDS.includes(actor.kind)) return quarantine(fb, stripped, 'unauthorized', 'not an adjudicator');
87
+
88
+ if (typeof fb.sig !== 'string' || !/^[0-9a-f]{64}$/.test(fb.sig)) return quarantine(fb, stripped, 'unverifiable', 'the record is not signed');
89
+ const expected = signFeedback(actor.key, fb);
90
+ if (!crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(fb.sig, 'hex'))) return quarantine(fb, stripped, 'tampered', 'the signature does not match the record');
91
+
92
+ const outcome = byOutcome.get(fb.outcomeId);
93
+ if (!outcome) return quarantine(fb, stripped, 'unverifiable', 'the outcome is not one this store knows');
94
+ if (outcome.taskId !== fb.taskId) return quarantine(fb, stripped, 'poisoned', 'the feedback names a different task than the outcome');
95
+
96
+ const contentDigest = digestOf({ ...fb, sig: undefined });
97
+ if (seenDigests.has(contentDigest)) return quarantine(fb, stripped, 'duplicate', 'an identical feedback record was already accepted');
98
+
99
+ const history = accepted.filter((e) => e.outcomeId === fb.outcomeId);
100
+ const isCorrection = typeof fb.correctsFeedbackId === 'string' && fb.correctsFeedbackId.length > 0;
101
+ if (!isCorrection) {
102
+ if (fb.reasonCode !== 'initial-label') return quarantine(fb, stripped, 'poisoned', 'a label that corrects nothing must be an initial label');
103
+ if (history.length) {
104
+ return history[history.length - 1].verdict === fb.verdict
105
+ ? quarantine(fb, stripped, 'duplicate', 'the outcome already has this label')
106
+ : quarantine(fb, stripped, 'poisoned', 'conflicts with the original label; a change must be a correction that cites the entry it corrects');
107
+ }
108
+ } else {
109
+ if (fb.reasonCode === 'initial-label') return quarantine(fb, stripped, 'poisoned', 'a correction cannot be marked as an initial label');
110
+ if (!history.some((e) => e.id === fb.correctsFeedbackId)) return quarantine(fb, stripped, 'unverifiable', 'the entry being corrected is not in this outcome\'s history');
111
+ }
112
+
113
+ const derived = deriveRoutingLabel({
114
+ task: { taskId: outcome.taskId, taskKind: outcome.taskKind, stratum: outcome.stratum }, model: outcome.model, outcome: fb.verdict,
115
+ evidence: { kind: fb.evidenceKind, reviewerKind: actor.kind, reviewerId: fb.actorId, adjudicationVersion: fb.adjudicationVersion, verificationRecordId: fb.verificationRecordId },
116
+ });
117
+ if (!derived.accepted) return quarantine(fb, stripped, derived.code === 'NOT_INDEPENDENT' ? 'poisoned' : 'unverifiable', derived.reason);
118
+
119
+ seenDigests.add(contentDigest);
120
+ const entry = Object.freeze({
121
+ id: `fb:${contentDigest.slice(7, 23)}`, kind: isCorrection ? 'correction' : 'original', outcomeId: fb.outcomeId, taskId: fb.taskId, verdict: fb.verdict,
122
+ evidenceKind: fb.evidenceKind, adjudicationVersion: fb.adjudicationVersion ?? null, verificationRecordId: fb.verificationRecordId ?? null,
123
+ reasonCode: fb.reasonCode, correctsFeedbackId: isCorrection ? fb.correctsFeedbackId : null, actorId: fb.actorId, actorKind: actor.kind, submittedAt: fb.submittedAt,
124
+ strippedFields: Object.freeze(stripped), contentDigest,
125
+ });
126
+ accepted.push(entry);
127
+ if (log) log.append('feedback', { event: entry.kind === 'correction' ? 'correction-accepted' : 'label-accepted', id: entry.id, outcomeId: entry.outcomeId, verdict: entry.verdict, actorId: entry.actorId });
128
+ return { ok: true, quarantined: false, entry };
129
+ }
130
+
131
+ const history = (outcomeId) => accepted.filter((e) => e.outcomeId === outcomeId);
132
+ const effective = (outcomeId) => { const h = history(outcomeId); return h.length ? h[h.length - 1] : null; };
133
+
134
+ const report = () => {
135
+ const byReason = Object.fromEntries(QUARANTINE_REASONS.map((r) => [r, 0]));
136
+ for (const q of quarantined) byReason[q.reason] += 1;
137
+ return { count: quarantined.length, byReason, items: quarantined.slice() };
138
+ };
139
+
140
+ /**
141
+ * Build the outcome list a calibration may use: outcomes that carry a label are re-checked, outcomes with an effective accepted
142
+ * feedback label get that label, and everything unusable is quarantined and EXCLUDED, with the count and reasons returned.
143
+ */
144
+ function applyToOutcomes(base) {
145
+ const out = []; const dropped = []; const seen = new Set();
146
+ for (const o of Array.isArray(base) ? base : []) {
147
+ const v = validateRoutingOutcome(o);
148
+ if (!v.ok) { dropped.push({ id: o?.id ?? null, reason: v.errors.some((e) => e.code === 'ID_MISMATCH') ? 'tampered' : 'unverifiable', detail: v.errors.map((e) => e.code).join(',') }); continue; }
149
+ if (seen.has(o.id)) { dropped.push({ id: o.id, reason: 'duplicate', detail: 'an outcome with this id was already taken' }); continue; }
150
+ seen.add(o.id);
151
+ if (o.labelSource === 'adjudication') {
152
+ const recomputed = routingLabelId({ taskId: o.taskId, taskKind: o.taskKind, stratum: o.stratum, model: o.model, outcome: o.outcome, labelSource: 'adjudication', verificationRecordId: null });
153
+ if (recomputed !== o.labelId) { dropped.push({ id: o.id, reason: 'tampered', detail: 'the stored label no longer matches its own id' }); continue; }
154
+ }
155
+ const eff = effective(o.id);
156
+ if (!eff || (o.outcome === eff.verdict && o.labelSource !== 'none')) { out.push(o); continue; }
157
+ const label = deriveRoutingLabel({
158
+ task: { taskId: o.taskId, taskKind: o.taskKind, stratum: o.stratum }, model: o.model, outcome: eff.verdict,
159
+ evidence: { kind: eff.evidenceKind, reviewerKind: eff.actorKind, reviewerId: eff.actorId, adjudicationVersion: eff.adjudicationVersion, verificationRecordId: eff.verificationRecordId },
160
+ });
161
+ if (!label.accepted) { dropped.push({ id: o.id, reason: 'unverifiable', detail: label.reason }); continue; }
162
+ const rebuilt = buildRoutingOutcome({
163
+ runId: o.runId, task: { taskId: o.taskId, taskKind: o.taskKind, stratum: o.stratum, synthetic: o.synthetic }, model: o.model, modelVersion: o.modelVersion, status: o.status,
164
+ outcome: label.label.outcome, label: label.label, adjudicationVersion: label.adjudicationVersion ?? null, group: o.group ?? null, usage: o.usage, costUsd: o.costUsd,
165
+ costStatus: o.costStatus, latencyMs: o.latencyMs, cacheState: o.cacheState, observedAt: o.observedAt ?? null, synthetic: o.synthetic,
166
+ });
167
+ if (rebuilt.ok) out.push(rebuilt.outcome); else dropped.push({ id: o.id, reason: 'unverifiable', detail: 'the relabelled outcome failed validation' });
168
+ }
169
+ const byReason = Object.fromEntries(QUARANTINE_REASONS.map((r) => [r, 0]));
170
+ for (const d of dropped) byReason[d.reason] += 1;
171
+ return { outcomes: out, excluded: dropped, quarantine: { count: dropped.length + quarantined.length, outcomes: { count: dropped.length, byReason }, feedback: report() } };
172
+ }
173
+
174
+ return { submit, history, effective, original: (id) => history(id).find((e) => e.kind === 'original') ?? null, quarantineReport: report, applyToOutcomes, entries: () => accepted.slice() };
175
+ }
176
+
177
+ // ---------------------------------------------------------------- what may leave the machine (AC02)
178
+
179
+ const PAYLOAD_KINDS = Object.freeze(['metadata', 'source', 'evidence']);
180
+ const DATA_CLASS_OF = Object.freeze({ metadata: 'public', source: 'source-code', evidence: 'sensitive' });
181
+
182
+ /**
183
+ * May this kind of payload go to this endpoint for this task? Both must allow: the task policy (explicit `allowSourceEgress` /
184
+ * `allowEvidenceEgress`, default false) and the egress policy (or the capability manifest's egress check). Never sends anything.
185
+ */
186
+ export function authorizePayload({ taskPolicy = {}, kind, task = null, endpoint, model = null, scanRoot = null, egress = evaluateEgress, manifestEgress = null }) {
187
+ if (!PAYLOAD_KINDS.includes(kind)) return { allowed: false, by: 'input', code: 'UNKNOWN_PAYLOAD_KIND', reason: `kind must be one of ${PAYLOAD_KINDS.join(', ')}` };
188
+ if (kind === 'source' && taskPolicy.allowSourceEgress !== true) return { allowed: false, by: 'task-policy', code: 'TASK_POLICY_DENIES_SOURCE', reason: 'the task policy does not allow source code to leave the machine' };
189
+ if (kind === 'evidence' && taskPolicy.allowEvidenceEgress !== true) return { allowed: false, by: 'task-policy', code: 'TASK_POLICY_DENIES_EVIDENCE', reason: 'the task policy does not allow evidence to leave the machine' };
190
+ const dataClass = kind === 'source' && task?.dataClass === 'sensitive' ? 'sensitive' : DATA_CLASS_OF[kind];
191
+ const ctx = { scanRoot, purpose: 'routing-feedback', endpoint, model, dataClass };
192
+ let e;
193
+ try { e = manifestEgress ? manifestEgress(ctx) : egress(ctx); } catch { e = { allowed: false, code: 'egress-error' }; }
194
+ if (!e || e.allowed !== true) return { allowed: false, by: 'egress-policy', code: 'EGRESS_DENIED', reason: (e && (e.reason || e.code)) || 'egress policy denied' };
195
+ return { allowed: true, by: null, code: 'allowed', reason: 'task policy and egress policy both allow this payload' };
196
+ }
197
+
198
+ /**
199
+ * Send a payload through a guarded adapter (createProviderAdapter) only when `authorizePayload` allows it. There is no other path:
200
+ * this module has no transport. A denial never touches the adapter.
201
+ */
202
+ export async function sendToProvider({ adapter, taskPolicy, kind, task, endpoint, model, scanRoot = null, egress = evaluateEgress, manifestEgress = null, invoke }) {
203
+ const auth = authorizePayload({ taskPolicy, kind, task, endpoint, model, scanRoot, egress, manifestEgress });
204
+ if (!auth.allowed) return { sent: false, authorization: auth };
205
+ const result = await adapter.invoke(invoke);
206
+ return { sent: true, authorization: auth, result };
207
+ }
@@ -0,0 +1,31 @@
1
+ // Calibrated model routing (X-601 to X-604). One import surface for the routing modules; see posture/CLAUDE.md for the contract.
2
+ export {
3
+ ROUTING_TASK_SCHEMA, ROUTING_OUTCOME_SCHEMA, REQUIRED_CAPABILITIES, DATA_CLASSES, CONTEXT_BUCKETS, OUTCOME_STATUSES, FAILURE_STATUSES, CACHE_STATES,
4
+ USAGE_SOURCES, COST_STATUSES, TRUTH_SOURCES, NON_TRUTH_SIGNALS, INDEPENDENT_REVIEWER_KINDS,
5
+ contextBucketOf, stratumKey, stratumParents, buildRoutingTask, validateRoutingTask, deriveRoutingLabel, buildRoutingOutcome, validateRoutingOutcome,
6
+ createOutcomeLedger,
7
+ } from './outcomes.js';
8
+ export {
9
+ PRICE_BOOK_SCHEMA, BILLING_BASES, COST_STATUS, priceBookFromCatalog, validatePriceBook, cacheEligibility, costOf, medianKnown,
10
+ } from './economics.js';
11
+ export { PROVIDER_FAILURE_CODES, endpointHost, createProviderAdapter } from './adapter.js';
12
+ export {
13
+ CALIBRATION_SCHEMA, ROUTING_MINIMUMS, ROUTING_PROMOTION_TARGETS, CALIBRATION_LABELS, EXCLUSION_REASONS,
14
+ validateCalibrationConfig, buildCalibration, verifyCalibration, routingPopulationGate,
15
+ } from './calibration.js';
16
+ export { DECISION_SCHEMA, CONSTRAINTS, BLOCK_CODES, validateRoutingPolicy, candidateFromCatalog, routeConstrained } from './decide.js';
17
+ export { routeModelWithPolicy } from '../model-routing.js';
18
+ export { ROUTING_RECEIPT_SCHEMA, ROUTING_RECEIPT_KINDS, createReceiptLog, verifyReceiptChain, exportReceipts } from './receipts.js';
19
+ export { ROUTING_MODES, ROUTING_ENV, resolveRoutingControl } from './control.js';
20
+ export { SHADOW_SCHEMA, createShadowRecorder } from './shadow.js';
21
+ export {
22
+ FROZEN_SET_SCHEMA, REPLAY_SCHEMA, PROMOTION_SCHEMA, REPLAY_LIMITS, INVALIDATION_CODES, freezeTaskSet, replayPaired, evaluatePromotion,
23
+ } from './promotion.js';
24
+ export {
25
+ BASIS_SCHEMA, DRIFT_KINDS, DRIFT_STATES, DRIFT_DEFAULTS, SCHEMA_VERSIONS, snapshotRoutingBasis, assessDrift, applyInvalidations,
26
+ } from './drift.js';
27
+ export { CANARY_STATES, CANARY_CEILINGS, TRIP_CODES, createCanary, routeUnderDrift } from './canary.js';
28
+ export {
29
+ FEEDBACK_FIELDS, FEEDBACK_REASON_CODES, QUARANTINE_REASONS, ACTOR_KINDS, signFeedback, createFeedbackStore, authorizePayload, sendToProvider,
30
+ } from './feedback.js';
31
+ export { REPORT_SCHEMA, POLICY_CARD_SCHEMA, buildRoutingReplayReport, renderRoutingReport, buildPolicyCard } from './report.js';
@@ -0,0 +1,281 @@
1
+ // Routing tasks, truth labels and outcome records (X-601).
2
+ //
3
+ // Extends the routing label contract (posture/assurance/contracts.js) with the two records routing needs and that contract does
4
+ // not describe: the TASK being routed (what kind of work, in which language, for which vulnerability class, how much context, which
5
+ // model capabilities it needs) and the OUTCOME of running it on one model (what happened, what it cost, how long it took, what the
6
+ // cache did, and whether anyone has decided if the answer was right).
7
+ //
8
+ // Three rules carry the module:
9
+ //
10
+ // 1. Four task kinds, never pooled. Discovery, triage, verification planning and repair fail differently and are priced
11
+ // differently, so a stratum always begins with the kind and a quality estimate for one never stands in for another.
12
+ // 2. A truth label has exactly two sources: independent adjudication (a human or a verifier service that is not the model under
13
+ // test) and trusted execution (a verification record produced by the protected verifier). Provider agreement, a model's own
14
+ // confidence, an accepted suggestion and any model-only judgement are SIGNALS, not truth: `deriveRoutingLabel` refuses them and
15
+ // records the outcome as `unknown`, so a popular answer can never become a correct one.
16
+ // 3. Nothing is dropped. An outcome whose status is failed, timed out, cancelled, a provider error or partial is a first-class
17
+ // record, and so is one whose correctness is unknown or delayed. The ledger has no remove operation, and its summary reports
18
+ // every status and every outcome so the denominator a later estimate uses is visible.
19
+ //
20
+ // Pure: no fs, no clock, no network.
21
+
22
+ import { SCHEMA_VERSION, makeCtx, result, guardObject, checkHeader, checkFields, checkEnum, checkString, checkId, isPlainObject } from '../assurance/schema-kit.js';
23
+ import { semanticId, digestOf } from '../assurance/identity.js';
24
+ import { ROUTING_TASK_KINDS, ROUTING_OUTCOMES, ROUTING_LABEL_SCHEMA, routingLabelId, validateRoutingLabel } from '../assurance/contracts.js';
25
+
26
+ export { ROUTING_TASK_KINDS, ROUTING_OUTCOMES };
27
+
28
+ export const ROUTING_TASK_SCHEMA = 'agentic-security/routing-task';
29
+ export const ROUTING_OUTCOME_SCHEMA = 'agentic-security/routing-outcome';
30
+
31
+ /** Capabilities a task can require of a model. A closed vocabulary: an unknown capability is rejected, never matched loosely. */
32
+ export const REQUIRED_CAPABILITIES = Object.freeze(['code-reasoning', 'code-edit', 'long-context', 'structured-output', 'tool-use', 'execution-planning']);
33
+ /** Sensitivity of what the prompt carries; the privacy constraint of a routing decision reads it. */
34
+ export const DATA_CLASSES = Object.freeze(['public', 'source-code', 'sensitive']);
35
+ /** Upper bounds of the context buckets, in tokens. The bucket, not the exact count, is part of the stratum. */
36
+ export const CONTEXT_BUCKETS = Object.freeze([
37
+ Object.freeze({ id: 'xs', max: 4_000 }), Object.freeze({ id: 's', max: 16_000 }), Object.freeze({ id: 'm', max: 64_000 }),
38
+ Object.freeze({ id: 'l', max: 200_000 }), Object.freeze({ id: 'xl', max: Infinity }),
39
+ ]);
40
+
41
+ export const OUTCOME_STATUSES = Object.freeze(['completed', 'partial', 'failed', 'timeout', 'cancelled', 'provider-error', 'blocked']);
42
+ /** Statuses that are operational failures. They are kept, counted and reported; they are never filtered out of a denominator. */
43
+ export const FAILURE_STATUSES = Object.freeze(['failed', 'timeout', 'cancelled', 'provider-error', 'blocked']);
44
+ export const CACHE_STATES = Object.freeze(['hit', 'partial', 'miss', 'ineligible', 'unknown']);
45
+ export const USAGE_SOURCES = Object.freeze(['measured', 'estimated', 'unknown']);
46
+
47
+ export function contextBucketOf(tokens) {
48
+ if (!Number.isFinite(tokens) || tokens < 0) return null;
49
+ return CONTEXT_BUCKETS.find((b) => tokens <= b.max).id;
50
+ }
51
+
52
+ const KEY_PART = /^[A-Za-z0-9_.+:-]+$/;
53
+ export function stratumKey({ taskKind, language, vulnClass, contextBucket }) {
54
+ return `${taskKind}|${language}|${vulnClass}|${contextBucket}`;
55
+ }
56
+
57
+ /** Coarser strata a thinly-sampled stratum may fall back to, finest first: kind|language|class, kind|language, kind. */
58
+ export function stratumParents(stratum) {
59
+ const parts = String(stratum).split('|');
60
+ const out = [];
61
+ for (let n = parts.length - 1; n >= 1; n--) out.push(parts.slice(0, n).join('|'));
62
+ return out;
63
+ }
64
+
65
+ // ---------------------------------------------------------------- task
66
+
67
+ const TASK_ALLOWED = [
68
+ 'schema', 'schemaVersion', 'id', 'taskId', 'taskKind', 'language', 'vulnClass', 'contextTokens', 'contextBucket', 'requiredCapabilities',
69
+ 'dataClass', 'stratum', 'synthetic', 'createdAt',
70
+ ];
71
+ const TASK_REQUIRED = ['schema', 'schemaVersion', 'id', 'taskId', 'taskKind', 'language', 'vulnClass', 'contextTokens', 'contextBucket', 'requiredCapabilities', 'dataClass', 'stratum', 'synthetic'];
72
+ const ROUTING_TASK_ID_FIELDS = Object.freeze(['taskId', 'taskKind', 'language', 'vulnClass', 'contextBucket', 'requiredCapabilities', 'dataClass']);
73
+
74
+ function routingTaskId(t) { return semanticId('rtask', t, ROUTING_TASK_ID_FIELDS); }
75
+
76
+ /**
77
+ * Build a task record. Returns `{ ok, task, errors }`; an invalid input yields no task, never a half-filled one.
78
+ * `requiredCapabilities` is sorted and de-duplicated so two callers that list the same needs in a different order get the same id.
79
+ */
80
+ export function buildRoutingTask({ taskId, taskKind, language, vulnClass, contextTokens, requiredCapabilities = [], dataClass = 'source-code', synthetic = false } = {}) {
81
+ const caps = Array.isArray(requiredCapabilities) ? [...new Set(requiredCapabilities)].sort() : requiredCapabilities;
82
+ const bucket = contextBucketOf(contextTokens);
83
+ const lang = typeof language === 'string' ? language.trim().toLowerCase() : language;
84
+ const t = {
85
+ schema: ROUTING_TASK_SCHEMA, schemaVersion: SCHEMA_VERSION, taskId, taskKind, language: lang, vulnClass, contextTokens,
86
+ contextBucket: bucket, requiredCapabilities: caps, dataClass, synthetic,
87
+ };
88
+ t.stratum = bucket && ROUTING_TASK_KINDS.includes(taskKind) && KEY_PART.test(String(lang)) && KEY_PART.test(String(vulnClass))
89
+ ? stratumKey(t) : null;
90
+ t.id = routingTaskId(t);
91
+ const v = validateRoutingTask(t);
92
+ return v.ok ? { ok: true, task: Object.freeze(t), errors: [] } : { ok: false, task: null, errors: v.errors };
93
+ }
94
+
95
+ export function validateRoutingTask(t) {
96
+ const g = guardObject(t);
97
+ const ctx = g.ctx;
98
+ if (!g.ok) return result(ctx);
99
+ if (!checkHeader(ctx, t, ROUTING_TASK_SCHEMA)) return result(ctx);
100
+ checkFields(ctx, t, TASK_ALLOWED, TASK_REQUIRED);
101
+ checkString(ctx, 'taskId', t.taskId);
102
+ checkEnum(ctx, 'taskKind', t.taskKind, ROUTING_TASK_KINDS);
103
+ if (!checkString(ctx, 'language', t.language) || !KEY_PART.test(t.language)) ctx.err('BAD_TYPE', 'language', 'must be a simple language name (letters, digits, . _ + : -)');
104
+ if (!checkString(ctx, 'vulnClass', t.vulnClass) || !KEY_PART.test(t.vulnClass)) ctx.err('BAD_TYPE', 'vulnClass', 'must be a simple class id such as CWE-89 (letters, digits, . _ + : -)');
105
+ if (!Number.isInteger(t.contextTokens) || t.contextTokens < 0) ctx.err('BAD_TYPE', 'contextTokens', 'must be a non-negative integer');
106
+ else if (t.contextBucket !== contextBucketOf(t.contextTokens)) ctx.err('RULE_VIOLATION', 'contextBucket', 'does not match contextTokens');
107
+ if (!Array.isArray(t.requiredCapabilities)) ctx.err('BAD_TYPE', 'requiredCapabilities', 'must be an array');
108
+ else {
109
+ t.requiredCapabilities.forEach((c, i) => checkEnum(ctx, `requiredCapabilities[${i}]`, c, REQUIRED_CAPABILITIES));
110
+ if (new Set(t.requiredCapabilities).size !== t.requiredCapabilities.length) ctx.err('DUPLICATE_ID', 'requiredCapabilities', 'lists a capability twice');
111
+ }
112
+ checkEnum(ctx, 'dataClass', t.dataClass, DATA_CLASSES);
113
+ if (typeof t.synthetic !== 'boolean') ctx.err('BAD_TYPE', 'synthetic', 'must be a boolean');
114
+ if (ctx.errors.length === 0 && t.stratum !== stratumKey(t)) ctx.err('RULE_VIOLATION', 'stratum', 'does not match kind, language, class and context bucket');
115
+ checkId(ctx, t, routingTaskId(t));
116
+ return result(ctx);
117
+ }
118
+
119
+ // ---------------------------------------------------------------- truth labels
120
+
121
+ /** Evidence kinds that may decide correctness, and the routing-label source each one maps to. */
122
+ export const TRUTH_SOURCES = Object.freeze({ 'independent-adjudication': 'adjudication', 'trusted-execution': 'trusted-execution' });
123
+ /** Signals that look like labels and are not. Named so a refusal can say exactly what was offered. */
124
+ export const NON_TRUTH_SIGNALS = Object.freeze(['provider-agreement', 'self-reported-confidence', 'accepted-suggestion', 'model-judgement', 'majority-vote']);
125
+ export const INDEPENDENT_REVIEWER_KINDS = Object.freeze(['human', 'verifier-service']);
126
+
127
+ /**
128
+ * Turn an asserted outcome plus its evidence into a routing label, or refuse.
129
+ *
130
+ * `evidence` is `{ kind, reviewerKind, reviewerId, adjudicationVersion, verificationRecordId }`. A decided outcome (`correct` or
131
+ * `incorrect`) is accepted only with:
132
+ * - `independent-adjudication`: a reviewer of kind human or verifier-service whose id is neither the model under test nor its
133
+ * provider, plus the adjudication protocol version; or
134
+ * - `trusted-execution`: a verification record id (`vrec:...`) produced by the protected verifier.
135
+ * Anything else, including every NON_TRUTH_SIGNALS kind, is refused and the outcome is recorded as `unknown` with no source. The
136
+ * refusal is returned (`accepted: false`, `code`, `reason`) so the caller cannot mistake a downgrade for a decision.
137
+ *
138
+ * `unknown` and `delayed` need no evidence and are accepted as they are: not knowing is a legitimate, retained outcome.
139
+ */
140
+ export function deriveRoutingLabel({ task, model, provider = null, outcome, evidence = null } = {}) {
141
+ const base = { schema: ROUTING_LABEL_SCHEMA, schemaVersion: SCHEMA_VERSION, taskId: task?.taskId, taskKind: task?.taskKind, stratum: task?.stratum, model };
142
+ const finish = (o, source, extra = {}) => {
143
+ const label = { ...base, outcome: o, labelSource: source, ...(extra.verificationRecordId ? { verificationRecordId: extra.verificationRecordId } : {}) };
144
+ label.id = routingLabelId(label);
145
+ const v = validateRoutingLabel(label);
146
+ return { label: v.ok ? Object.freeze(label) : null, errors: v.errors, ...extra.meta };
147
+ };
148
+ if (!isPlainObject(task) || typeof model !== 'string' || !model) return { accepted: false, code: 'BAD_INPUT', reason: 'a task record and a model id are required', label: null, errors: [] };
149
+ if (!ROUTING_OUTCOMES.includes(outcome)) return { accepted: false, code: 'BAD_INPUT', reason: `outcome must be one of ${ROUTING_OUTCOMES.join(', ')}`, label: null, errors: [] };
150
+ if (outcome === 'unknown' || outcome === 'delayed') {
151
+ return { accepted: true, ...finish(outcome, 'none') };
152
+ }
153
+ const refuse = (code, reason) => ({ accepted: false, code, reason, downgradedTo: 'unknown', offeredKind: evidence?.kind ?? null, ...finish('unknown', 'none') });
154
+ if (!isPlainObject(evidence) || typeof evidence.kind !== 'string') return refuse('NOT_A_TRUTH_LABEL', `a '${outcome}' outcome needs adjudication or trusted-execution evidence; none was given`);
155
+ if (NON_TRUTH_SIGNALS.includes(evidence.kind)) return refuse('NOT_A_TRUTH_LABEL', `'${evidence.kind}' is a signal, not a truth label: it cannot decide correctness`);
156
+ const source = TRUTH_SOURCES[evidence.kind];
157
+ if (!source) return refuse('NOT_A_TRUTH_LABEL', `unknown evidence kind '${evidence.kind}'`);
158
+ if (evidence.kind === 'independent-adjudication') {
159
+ if (!INDEPENDENT_REVIEWER_KINDS.includes(evidence.reviewerKind)) return refuse('NOT_INDEPENDENT', `reviewer kind '${evidence.reviewerKind}' is not independent of the model (allowed: ${INDEPENDENT_REVIEWER_KINDS.join(', ')})`);
160
+ if (typeof evidence.reviewerId !== 'string' || !evidence.reviewerId) return refuse('NOT_INDEPENDENT', 'an adjudication names its reviewer');
161
+ if (evidence.reviewerId === model || (provider && evidence.reviewerId === provider)) return refuse('NOT_INDEPENDENT', 'the reviewer is the model under test or its provider');
162
+ if (typeof evidence.adjudicationVersion !== 'string' || !evidence.adjudicationVersion) return refuse('MISSING_PROVENANCE', 'an adjudication records the adjudication protocol version');
163
+ return { accepted: true, adjudicationVersion: evidence.adjudicationVersion, ...finish(outcome, source) };
164
+ }
165
+ // trusted execution
166
+ if (typeof evidence.verificationRecordId !== 'string' || !evidence.verificationRecordId.startsWith('vrec:')) return refuse('MISSING_PROVENANCE', 'trusted execution cites the verification record it came from (vrec:...)');
167
+ return { accepted: true, adjudicationVersion: typeof evidence.adjudicationVersion === 'string' ? evidence.adjudicationVersion : null, ...finish(outcome, source, { verificationRecordId: evidence.verificationRecordId }) };
168
+ }
169
+
170
+ // ---------------------------------------------------------------- outcome records
171
+
172
+ const OUT_ALLOWED = [
173
+ 'schema', 'schemaVersion', 'id', 'runId', 'taskId', 'taskKind', 'stratum', 'model', 'modelVersion', 'status', 'outcome', 'labelId', 'labelSource',
174
+ 'adjudicationVersion', 'group', 'usage', 'costUsd', 'costStatus', 'latencyMs', 'cacheState', 'observedAt', 'synthetic', 'createdAt',
175
+ ];
176
+ const OUT_REQUIRED = ['schema', 'schemaVersion', 'id', 'runId', 'taskId', 'taskKind', 'stratum', 'model', 'modelVersion', 'status', 'outcome', 'labelSource', 'usage', 'costUsd', 'costStatus', 'latencyMs', 'cacheState', 'synthetic'];
177
+ const ROUTING_OUTCOME_ID_FIELDS = Object.freeze(['runId', 'taskId', 'model', 'modelVersion']);
178
+ export const COST_STATUSES = Object.freeze(['measured', 'bounded-estimate', 'unknown']);
179
+
180
+ function routingOutcomeId(o) { return semanticId('rout', o, ROUTING_OUTCOME_ID_FIELDS); }
181
+
182
+ const countOrNull = (v) => v === null || (Number.isInteger(v) && v >= 0);
183
+
184
+ /**
185
+ * Build an outcome record. `usage` fields that were not observed MUST be `null`: a missing count is unknown, not zero. `label` is the
186
+ * (accepted) result of `deriveRoutingLabel`, or omitted when the outcome is unknown or delayed.
187
+ */
188
+ export function buildRoutingOutcome({ runId, task, model, modelVersion = null, status, outcome = 'unknown', label = null, adjudicationVersion = null, group = null, usage = {}, costUsd = null, costStatus = 'unknown', latencyMs = null, cacheState = 'unknown', observedAt = null, synthetic = false } = {}) {
189
+ const u = {
190
+ inputTokens: usage.inputTokens ?? null, outputTokens: usage.outputTokens ?? null, cachedInputTokens: usage.cachedInputTokens ?? null,
191
+ retries: usage.retries ?? 0, source: usage.source ?? 'unknown',
192
+ };
193
+ const o = {
194
+ schema: ROUTING_OUTCOME_SCHEMA, schemaVersion: SCHEMA_VERSION, runId, taskId: task?.taskId, taskKind: task?.taskKind, stratum: task?.stratum, model,
195
+ modelVersion, status, outcome: label ? label.outcome : outcome, labelSource: label ? label.labelSource : 'none', usage: u, costUsd, costStatus, latencyMs, cacheState, synthetic: !!(synthetic || task?.synthetic),
196
+ };
197
+ if (label?.id) o.labelId = label.id;
198
+ if (adjudicationVersion) o.adjudicationVersion = adjudicationVersion;
199
+ if (group) o.group = group;
200
+ if (observedAt) o.observedAt = observedAt;
201
+ o.id = routingOutcomeId(o);
202
+ const v = validateRoutingOutcome(o);
203
+ return v.ok ? { ok: true, outcome: Object.freeze(o), errors: [] } : { ok: false, outcome: null, errors: v.errors };
204
+ }
205
+
206
+ export function validateRoutingOutcome(o) {
207
+ const g = guardObject(o);
208
+ const ctx = g.ctx;
209
+ if (!g.ok) return result(ctx);
210
+ if (!checkHeader(ctx, o, ROUTING_OUTCOME_SCHEMA)) return result(ctx);
211
+ checkFields(ctx, o, OUT_ALLOWED, OUT_REQUIRED);
212
+ for (const f of ['runId', 'taskId', 'stratum', 'model']) checkString(ctx, f, o[f]);
213
+ checkEnum(ctx, 'taskKind', o.taskKind, ROUTING_TASK_KINDS);
214
+ if (o.modelVersion !== null && typeof o.modelVersion !== 'string') ctx.err('BAD_TYPE', 'modelVersion', 'must be a string, or null when unknown');
215
+ checkEnum(ctx, 'status', o.status, OUTCOME_STATUSES);
216
+ const outOk = checkEnum(ctx, 'outcome', o.outcome, ROUTING_OUTCOMES);
217
+ checkEnum(ctx, 'labelSource', o.labelSource, ['adjudication', 'trusted-execution', 'none']);
218
+ checkEnum(ctx, 'costStatus', o.costStatus, COST_STATUSES);
219
+ checkEnum(ctx, 'cacheState', o.cacheState, CACHE_STATES);
220
+ if (!isPlainObject(o.usage)) ctx.err('BAD_TYPE', 'usage', 'must be an object');
221
+ else {
222
+ for (const f of ['inputTokens', 'outputTokens', 'cachedInputTokens']) if (!countOrNull(o.usage[f])) ctx.err('BAD_TYPE', `usage.${f}`, 'must be a non-negative integer, or null when unknown (never 0 for unknown)');
223
+ if (!Number.isInteger(o.usage.retries) || o.usage.retries < 0) ctx.err('BAD_TYPE', 'usage.retries', 'must be a non-negative integer');
224
+ checkEnum(ctx, 'usage.source', o.usage.source, USAGE_SOURCES);
225
+ if (o.usage.cachedInputTokens !== null && o.usage.inputTokens !== null && o.usage.cachedInputTokens > o.usage.inputTokens) ctx.err('RULE_VIOLATION', 'usage.cachedInputTokens', 'cannot exceed inputTokens');
226
+ }
227
+ if (o.costUsd !== null && !(typeof o.costUsd === 'number' && Number.isFinite(o.costUsd) && o.costUsd >= 0)) ctx.err('BAD_TYPE', 'costUsd', 'must be a non-negative number, or null when unknown');
228
+ if (o.costUsd === null && o.costStatus === 'measured') ctx.err('RULE_VIOLATION', 'costStatus', 'a measured cost has a value');
229
+ if (o.costUsd !== null && o.costStatus === 'unknown') ctx.err('RULE_VIOLATION', 'costStatus', 'a cost with a value is measured or a bounded estimate');
230
+ if (o.latencyMs !== null && !(typeof o.latencyMs === 'number' && Number.isFinite(o.latencyMs) && o.latencyMs >= 0)) ctx.err('BAD_TYPE', 'latencyMs', 'must be a non-negative number, or null when unknown');
231
+ if (typeof o.synthetic !== 'boolean') ctx.err('BAD_TYPE', 'synthetic', 'must be a boolean');
232
+ if (o.observedAt !== undefined && (typeof o.observedAt !== 'string' || Number.isNaN(Date.parse(o.observedAt)))) ctx.err('BAD_TYPE', 'observedAt', 'must be an ISO timestamp');
233
+ if (o.group !== undefined) checkString(ctx, 'group', o.group);
234
+ // A decided outcome names the source that decided it; unknown and delayed name none. The same rule the label contract enforces.
235
+ if (outOk) {
236
+ if (['correct', 'incorrect'].includes(o.outcome) && o.labelSource === 'none') ctx.err('RULE_VIOLATION', 'labelSource', `a '${o.outcome}' outcome needs an adjudication or trusted-execution source`);
237
+ if (['unknown', 'delayed'].includes(o.outcome) && o.labelSource !== 'none') ctx.err('RULE_VIOLATION', 'labelSource', `an '${o.outcome}' outcome has no decided source`);
238
+ if (['correct', 'incorrect'].includes(o.outcome) && o.labelSource === 'adjudication' && !o.adjudicationVersion) ctx.err('MISSING_FIELD', 'adjudicationVersion', 'an adjudicated outcome records the adjudication version');
239
+ if (['correct', 'incorrect'].includes(o.outcome) && !o.labelId) ctx.err('MISSING_FIELD', 'labelId', 'a decided outcome cites its label');
240
+ }
241
+ checkId(ctx, o, routingOutcomeId(o));
242
+ return result(ctx);
243
+ }
244
+
245
+ // ---------------------------------------------------------------- the ledger
246
+
247
+ /**
248
+ * An append-only collection of outcome records. There is no remove, no filter-in-place and no overwrite: `add` rejects an invalid or
249
+ * duplicate record with a reason instead of discarding it quietly, and `summary` reports every status and outcome so the population
250
+ * a calibration draws from can be seen whole.
251
+ */
252
+ export function createOutcomeLedger() {
253
+ const byId = new Map();
254
+ const rejected = [];
255
+ return {
256
+ add(o) {
257
+ const v = validateRoutingOutcome(o);
258
+ if (!v.ok) { rejected.push({ id: o?.id ?? null, reason: 'invalid', errors: v.errors }); return { ok: false, code: 'INVALID', errors: v.errors }; }
259
+ if (byId.has(o.id)) { rejected.push({ id: o.id, reason: 'duplicate' }); return { ok: false, code: 'DUPLICATE_ID', errors: [{ code: 'DUPLICATE_ID', path: 'id', message: `outcome ${o.id} is already in the ledger` }] }; }
260
+ byId.set(o.id, o);
261
+ return { ok: true, errors: [] };
262
+ },
263
+ all() { return [...byId.values()]; },
264
+ rejected() { return rejected.map((r) => ({ ...r })); },
265
+ summary() {
266
+ const rows = [...byId.values()];
267
+ const tally = (key, vocab) => Object.fromEntries(vocab.map((k) => [k, rows.filter((r) => r[key] === k).length]));
268
+ return {
269
+ total: rows.length,
270
+ byStatus: tally('status', OUTCOME_STATUSES),
271
+ byOutcome: tally('outcome', ROUTING_OUTCOMES),
272
+ byTaskKind: tally('taskKind', ROUTING_TASK_KINDS),
273
+ labelled: rows.filter((r) => r.labelSource !== 'none').length,
274
+ unlabelled: rows.filter((r) => r.labelSource === 'none').length,
275
+ failures: rows.filter((r) => FAILURE_STATUSES.includes(r.status)).length,
276
+ rejectedAtIntake: rejected.length,
277
+ digest: digestOf(rows.map((r) => r.id).sort()),
278
+ };
279
+ },
280
+ };
281
+ }