@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
@@ -141,7 +141,7 @@ one was caught by the module's own test, not in review.
141
141
  - **No throwing.** Every annotation in `engine.js` is wrapped `try { … } catch (_) {}`. Your annotator must degrade gracefully — set `null` on the field and continue.
142
142
  - **Dead-module test.** `npm run test:lifecycle` fails the build if you export a public symbol from a posture module that no other source file imports. Wire it in `engine.js` (or allowlist it with a written reason in `test/no-dead-modules.test.js`).
143
143
 
144
- ## Assurance contracts, baseline and rollout config — `assurance/` (9 modules)
144
+ ## Assurance contracts, baseline and rollout config — `assurance/` (11 modules)
145
145
 
146
146
  Foundation for the differentiation work (CORE-001, CORE-002, CORE-004). All of it is pure or read-only, and **nothing in it is wired into `engine.js` or the scan/report path**: a default scan is byte-for-byte unaffected, which `test/posture/assurance-config.test.js` pins. Consumers (X-201 and later) import it directly.
147
147
 
@@ -154,6 +154,19 @@ Foundation for the differentiation work (CORE-001, CORE-002, CORE-004). All of i
154
154
  - **`bounded-io.js`**: `guardedModelCall` is the only route for new model/network calls. It owns no HTTP client (the transport is injected): feature gate, then `evaluateEgress` BEFORE the payload exists, then `redactPayload`, then the request-size limit, then deadline plus bounded retries, then output cap, with every decision appended to the existing egress audit chain. `readFileBounded`, `capOutput`, `withDeadline`, `retryBounded` enforce the limits `config.js` claims; `maxMemoryMiB` is carried for a runner and disclosed as not enforced here.
155
155
  - **`baseline.js`, `baseline-inventory.js`**: baseline capture (`scripts/baseline-capture.mjs`, `npm run baseline:capture`). Read-only; records HEAD, dirty paths, exact source and bundle digests, tool versions, entry points, the seven mappings and an 18-entry capability inventory (implemented / partial / unsupported / unmeasured, every path and exported symbol verified at capture time, a missing one is a reported problem). Unknown facts are `{status:'unknown', reason}` with no value. `evaluateBaseline` invalidates exactly the capabilities whose cited files changed and lists unrelated user changes as retained/introduced. The manifest is machine-specific, so it is generated on demand and not committed. When you change a capability's source or tests, update its inventory entry in the same change.
156
156
 
157
+ - **`rollout.js`, `rollback.js` (REL-002).** Staged rollout and rollback. `rollout.js`: the three stages (`offline-fixtures`, `shadow-canary`,
158
+ `opt-in-supported`), the documented gates per feature and stage (`gatesFor`, `describeRollout`), an append-only ledger (`newLedger`), `promote`
159
+ (one stage at a time, every gate needs evidence that names its own suite and carries a sha256; the kill switch and an unsupported platform
160
+ block it; policy versions are never reused) and `effectiveMode` (an enabled feature below opt-in runs as `fixtures` or `shadow` and can never
161
+ change an outcome). `rollback.js`: `READER_POLICY` (per record kind, whether a previous reader is retained; four kinds honestly say no),
162
+ `planRollback`/`applyRollback` (restore the last known-good stage, append a `rollback` event, FLAG evidence and claims `stale` or
163
+ `incompatible`, never delete or rewrite), `EvidenceStore`/`guardedFeatureRun` (append-only, a thrown feature records a failure and costs no
164
+ evidence), and the known break: `verifyWithPreCore003Reader` is a faithful copy of the verifier before the `issuance` block existed, which
165
+ rejects every newly signed bundle; `exportLegacyBundle` is the migration path (verifies the original first, re-signs without issuance,
166
+ returns a sidecar carrying the dropped trust basis). Pure, consumed by tests and `docs/guides/assurance-rollout-and-rollback.md`; nothing in
167
+ the scan path imports them. Suite: `test/release-closure/` (`npm run test:release-closure`). The release closure itself (the plan, the
168
+ commit-bound record, the judge) is `scripts/release-closure.mjs`, not a posture module.
169
+
157
170
  Tests: `test/posture/assurance-{baseline,contracts,config}.test.js`, all in `test:posture`.
158
171
 
159
172
  ## One verification record, trusted oracles and replay (X-201, X-202, X-203)
@@ -313,6 +326,324 @@ receipts, three tamper attempts, an unavailable prerequisite, cancellation, repl
313
326
  (`verification-conformance-static`). Where the boundary cannot run, execution is reported `not-run`, never passed; `--require-execution`
314
327
  makes that a failure. A new adapter must be pinned deliberately (`--update-pins`).
315
328
 
329
+ ## Frozen evaluation protocol, label custody and layer ablations: `evaluation/` (QA-001, QA-002, QA-003)
330
+
331
+ Offline measurement tooling, consumed by `scripts/evaluation.mjs` (`npm run evaluation -- <cmd>`, `npm run evaluation:synthetic`) and
332
+ `test:evaluation`. Nothing here is wired into `engine.js` or the scan path, and a test pins that no module outside `evaluation/`
333
+ imports it. **It builds machinery, not evidence.** No real adjudicated population, no real sealed set and no independent human
334
+ review exist in this repository, so every real-code gate reads `insufficient-population` or `unmeasured`; the exercised suite
335
+ (`synthetic.js`, fixtures in `test/fixtures/evaluation-synthetic/`) is authored by the tooling's developers and flagged
336
+ `synthetic: true` everywhere, and `gates.js` refuses to count it toward any minimum. Do not quote a score from the synthetic suite
337
+ as engine accuracy.
338
+
339
+ - **`protocol.js` (QA-001).** `freezeProtocol` validates a draft and returns a deep-frozen protocol with `protocolHash` (a digest over
340
+ every semantic field). It pins engine/bundle digest, a clean measurement tree, tool and model versions, dataset licences, per-target
341
+ pre/post commits and tree digests, scope, matching policy, limits, thresholds and split membership. Thresholds are the PRD section 5
342
+ floor (`PREREGISTERED_THRESHOLDS`) and may be stricter, never looser or missing. `amendProtocol` yields a new versioned protocol with a
343
+ field diff and empty `comparableWith`; with ANY result bound to the protocol's hash it refuses (`POST_RESULT_CHANGE`), so a threshold,
344
+ matching rule or denominator cannot move after results are observed. `deriveSuccessorProtocol` is the only way forward and rejects a
345
+ sealed target a result already consumed (`SEALED_REUSE`). A target removed from the population must appear in `retired` with a reason
346
+ and stays in `originalTargetIds`. `assertBoundToProtocol` rejects a run scored under any other hash.
347
+ - **`grouping.js`.** Union-find over pair id, normalised upstream (a fork resolves to its parent), advisory ids, commits and template
348
+ fingerprints; `assignSplits` places whole groups by a salted hash, `splitStraddles` is the check the protocol validator runs.
349
+ - **`labels.js` (QA-002).** Defect labels need a root-cause id, affected commit, language, family, a reviewed location RANGE (a CWE
350
+ alone cannot be a label), evidence, and adjudication with at least two distinct reviewers who are not the proposer, recorded as
351
+ independent, plus non-model evidence. A candidate (advisory-only) label is valid data and never scoreable. Negatives are `safe-real`,
352
+ `patched` (post variant, paired to a defect) and `near-miss`. `matchFinding` matches by location plus family or CWE; CWE-only,
353
+ wrong-file, no-line and out-of-range each fail with a distinct reason. A finding with no line cannot be localised, so it never
354
+ matches (this is why the pattern-only layer scores zero on the synthetic suite: it reports no line, not a defect of the harness).
355
+ This module can validate that a label SAYS it was independently adjudicated, not that it was.
356
+ - **`custody.js` (QA-002.AC03).** The custodian (verifier domain, role `custodian`) is the only writer of the label store; read needs the
357
+ trust-domain `sealed-labels` read grant and the sealed hash to match. `protectedTermsFrom` / `guardPrompt` / `auditWorkspace` /
358
+ `stageWorkspace` keep advisory prose, evidence refs, benchmark names, fixed-source hints, answer-key file names, advisory ids and
359
+ symlinks out of anything the engine or a prompt sees; a leaking tree is quarantined (removed, recorded as an outcome, never scanned).
360
+ These are PATTERN checks over known terms, not proof against a paraphrase. Process-level read denial of the label directory is the
361
+ sandbox boundary's job (`runInBoundary({ labelDirs })`, which reports `blocked` where the host cannot prove the control).
362
+ - **`runner.js`, `scan-child.js` (QA-003).** `runEvaluation` records, per target and variant, status (`completed|timeout|error|unavailable|
363
+ quarantined`), findings, duration, cost (null when unmeasured, never zero), failure reason and input/output hashes. The engine runs in a
364
+ child process (killed by process group on timeout) over a staged copy, with a scrubbed environment. Run identity covers protocol,
365
+ engine, layer, provider, model, settings, cache, budget, seed, replicate, split and every target digest; labels are not part of it.
366
+ A tree that differs from the digest pinned in the protocol is refused. `runReplicates` checks deterministic reproduction and requires
367
+ at least three replicates for the stochastic (model-assisted) layer; `runAblations` runs `deterministic-only`, `deep-taint` and
368
+ `model-assisted` over identical targets and refuses incomparable configurations. `model-assisted` is `unavailable` without
369
+ `AGENTIC_SECURITY_LLM_ENDPOINT`, recorded as such.
370
+ - **`score.js`.** End-to-end scoring: a timeout, error, unavailable or quarantined known positive is a MISS. Conditional-on-completion
371
+ metrics are reported beside it, labelled, never instead of it. False positives are counted only on adjudicated negatives. Findings no
372
+ adjudicated label accounts for go to a review queue and `unscored` (with counts and reasons), never auto-FP. Targets with no
373
+ adjudicated label are `unlabeled-target`.
374
+ - **`gates.js`.** Judges a sealed-split score against the protocol's own thresholds. `pass` needs the population minimums (>=100 real,
375
+ adjudicated, non-synthetic sealed positives and negatives per core language, >=30 positives per family) AND a measured value over the
376
+ threshold; otherwise `insufficient-population` or `unmeasured`. A protocol failing its hash check, or a score from another protocol, is
377
+ `rejected`. The interval-lower-bound gate is `unmeasured` until the interval method is implemented (QA-004), so `overall` cannot be
378
+ `pass` in this build. `accuracy-scorecard.js` relays a gate report in an `evaluationGates` section (`summarizeEvaluationGates`),
379
+ forcing `unmeasured` for a synthetic or unmarked report; `scripts/scorecard.mjs` reads the gate report from the independent bench directory
380
+ when one has been written (none exists today).
381
+
382
+ Tests: `test/evaluation/{protocol,grouping,labels,custody,runner,gates-scorecard}.test.js` (`npm run test:evaluation`).
383
+ ## Executable invariants, approval, scenarios and the business-state oracle: `invariants/` (X-401 to X-404)
384
+
385
+ Same posture as the verification directories above: consumed, not wired into the default scan, every output addition additive, and
386
+ everything off unless the **`invariant-scenarios`** feature (high-risk execution, operator-only, off by default) is enabled; running a
387
+ scenario also needs `verification-oracles`. Suite: `npm run test:invariants` (`test/invariants/`, executed tests skip loudly where the
388
+ trust boundary cannot run). Linux stays `unverified`: nothing here claims a Linux outcome. X-405 to X-408 (shrinking, repair
389
+ verification, coverage and export, the held-out ablation) are described in the section after this one.
390
+
391
+ - **`schema.js` + `expressions.js` (X-401).** The versioned invariant document: scope (an explicit application, entry, factory and
392
+ environment), tenants, actors, resources, allowed transitions, FORBIDDEN outcomes and an oracle binding. Five classes
393
+ (tenant-isolation, privilege-constraint, value-conservation, workflow-order, idempotency). Declarative only: forbidden outcomes are
394
+ expressions from a closed grammar (`EXPRESSIONS`), every string in them matches a conservative charset, so no field can carry code;
395
+ an op outside the grammar is rejected. Rejects ambiguous identities (duplicate actor/resource/transition/store key), dangling
396
+ references, a missing, unregistered or wrong-class oracle binding, and preserves author, revision and review state. The id is a
397
+ hash of the CONTENT (not the review), so an approval binds to exactly what was reviewed and an edit is a different contract.
398
+ `expressions.js` exists so the oracle adapter can validate expressions without importing the schema (the schema imports the
399
+ oracle registry; the registry imports the adapters).
400
+ - **`lifecycle.js` (X-402).** Inferred versus approved. `inferredInvariant` builds a PROPOSED skeleton (inferred, with source
401
+ evidence and an uncertainty, authored by a model or by code); miners reuse it: `specification-mining.js` (`mineInvariantProposals`),
402
+ `business-logic.js` (`mineWorkflowInvariants`), `logic-claims.js` (`invariantFromLogicClaim`) and the discovery lenses
403
+ (`discovery/lenses.js` `invariantProposalFor`, surfaced by `runDiscovery` as `invariantProposals` only when asked AND the feature is
404
+ on). The approval ledger is append-only, hash-chained and HMAC-signed under the per-install key (`integrity.js`): a transition
405
+ records who, why, from, to, the authority it rests on, and the previous record. Only a HUMAN named by an explicit local reviewer
406
+ policy (or verified by `fix/approver-registry.js`) can approve, reject or supersede; a model, code or the proposer cannot, and with
407
+ no policy nothing can be approved. `classifyViolation` calls a settled `confirmed` result an APPROVED violation (may gate) only
408
+ when the ledger verifies and says approved, else a CANDIDATE violation (advisory); a document that merely claims approval is a
409
+ candidate, and a result without a receipt `runOracle` issued is `unverified`. `violationReport` keeps the two groups apart.
410
+ Limits, stated: symmetric HMAC is tamper-evidence for the operator, not third-party non-repudiation; the reviewer identity is a
411
+ claim checked against the operator's list, not authenticated; dropping the LAST record is not detectable; persistence is the
412
+ caller's (the ledger is plain data).
413
+ - **`scenarios.js` (X-403).** `generateScenarios(invariant, { fixture, seed, bounds, config })`: five families (cross-tenant access,
414
+ privilege change, reordered workflow, duplicate request, concurrent operations), each with synthetic tenants and actors, a seeded
415
+ setup, a control sequence, an attack sequence, the forbidden outcomes and a cleanup. Bounded by actors, depth, requests,
416
+ scheduling (a parallel group is at most 4 steps with a SEEDED start order) and time (the budget becomes the run deadline); every
417
+ bound has a hard ceiling and an over-ceiling request is rejected, not clamped. Deterministic given the seed (no clock; ids are
418
+ hashes). Confined to contracts scoped to a `disposable-fixture` and to fixture files the caller supplies; a shared/production
419
+ scope, missing setup or a scenario that cannot be built is `unsupported` with its reason. `runScenario` builds a replay manifest
420
+ (`replay/replay.js`) so the environment, toolchain and oracle logic digest are pinned, then runs it. `run.js` (`verifyInvariant`)
421
+ is the one orchestration: generate, replay each scenario, classify against the ledger, report.
422
+ - **`business-state` oracle (X-404)** in `oracles/adapters.js` (class `business-state`, `requiresFeature: 'invariant-scenarios'`,
423
+ fixtures `test/fixtures/oracles/business-state/`, pinned like every adapter). The harness gives the application an in-memory
424
+ durable store and an effect recorder (`ctx.store`, `ctx.emit`), plays a control sequence and the attack sequence TWICE (a
425
+ disagreement is `inconclusive`, never a verdict), and logs every durable write and emitted effect. `state-assertions.js`
426
+ evaluates the forbidden outcomes VERIFIER-SIDE over that log after the tree is dead: durable writes, leaked content, totals,
427
+ state moves and repeated effects decide; the response status is recorded in the event sequence and never decides, so a handler
428
+ answering 403 after writing another tenant's record is a violation. A forbidden outcome reached by the control flow counts too.
429
+ `refuted` needs the control flow to have visibly worked (durable effect, no error, clean, cleaned up). The result carries a
430
+ bounded `report` (preconditions, sanitized snapshots with secret-looking fields redacted, the event sequence, per-assertion
431
+ results with stable `ievd:` evidence ids); the ids are record evidence and the report digest is bound into the receipt.
432
+ Two small additive changes to shared code: `oracle.js` supports an optional per-adapter `requiresFeature` and optional
433
+ `judged.report`/`judged.evidenceItems`; `conformance.js` enables an adapter's `requiresFeature` for its execution checks.
434
+ - **Limits, not hidden.** The application is a factory returning `(ctx, key, payload)` actions over an in-memory store: durable state
435
+ held in a real database, queue or file system is not observed. Concurrency is cooperative scheduling in one process (interleavings
436
+ at await points), not parallel execution. A clean result covers only the declared bounded scenario. A target that recognises the
437
+ oracle and forges its own log entries is not distinguishable from a real violation (the limit every adapter states).
438
+ - **`deployment-ablation.js` (X-308).** A frozen, SYNTHETIC deployment-context ablation: the same source deployed under an exploitable and a
439
+ non-exploitable boundary configuration (`test/fixtures/deployment-ablation/`, seven cases, fourteen instances, kubernetes and compose
440
+ deployments). `freezeAblationSet` hash-pins each source tree, each deployment tree, the labels and the graph arm's decision rule and binds
441
+ the cases into a QA-001 protocol (`synthetic: true`, never counted toward a real-population gate); `verifyFrozenSet` refuses any drift
442
+ before a measurement. `runPairedAblation` scores a source-only arm (the QA-003 child-process scan) and a graph-enabled arm (the shipped
443
+ `runBoundaries` path plus a frozen demotion rule: demote only on `blocked`, or `none-found` in a graph with no gap) on the same
444
+ instances and reports paired counts (false positives reduced, confirmed defects added, baseline confirmed defects lost, false positives
445
+ added), precision, recall, runtime and coverage (unresolved, stale and unbound reported apart). Uncertainty is a SIMPLE paired case
446
+ bootstrap, labelled as such: the grouped bootstrap of QA-004 is not in this base. `adapterValidation` marks an adapter validated only with
447
+ a positive and a negative fixture and no lost defect; `docs/guides/deployment-aware-support.md` may claim only those (a test compares).
448
+ CLI: `npm run evaluation -- deployment-ablation [--json] [--write-frozen]`. The cases, labels and rule are authored by the tooling's
449
+ developers and one case exists because an earlier rule lost a defect on it, so the set is a mechanism check, not an estimate.
450
+
451
+ Tests: `test/evaluation/{protocol,grouping,labels,custody,runner,gates-scorecard}.test.js` (`npm run test:evaluation`); the X-308 ablation is tested in `test/deployment/differentiation-ablation.test.js` (`npm run test:deployment`).
452
+
453
+ ## Shrinking, repair verification, coverage and the held-out ablation (X-405 to X-408)
454
+
455
+ Same posture as above: consumed, not wired into the default scan, behind `invariant-scenarios`, every output addition additive. Suites:
456
+ `test/invariants/{scenario-shrinking,invariant-repair,business-coverage,invariant-ablation}.test.js`. Guide: `docs/guides/business-logic-invariants.md`.
457
+
458
+ - **`invariants/shrink.js` (X-405).** `shrinkScenario(scenario, { fixture, commit, config, budget })`: a greedy removal search (an attack step,
459
+ one member of a parallel group, a control step, a seed record), each candidate replayed through `runScenario` (so manifest, boundary,
460
+ receipt), kept only if it settles `confirmed` with every precondition held AND the same authoritative failing assertion (the first
461
+ violated outcome in the contract's order; identified by forbidden-outcome id and phase). Units a surviving step addresses, or that
462
+ carry an asserted marker, are guarded (recorded with a reason, never silently skipped). Budget: `maxRuns` and `timeBudgetMs`, each with
463
+ a hard ceiling and rejected, not clamped, above it; `minimal: true` only when a full pass kept nothing more. One confirming replay
464
+ sits outside the budget. `reproductionSignals` is a PATTERN check over the fixture text for clocks, randomness, timers, network and
465
+ package imports; `reproducibility.blockedBy` names concurrency (cooperative scheduling, two fresh runs disagreed), external
466
+ dependencies and prerequisites. `checkMinimization` re-derives from the two scenarios that the minimized one is a removal-subset
467
+ (invariant, fixture, actors, forbidden outcomes untouched; no step added; no sequence emptied; no referenced record dropped);
468
+ `verifyShrinkRecord` recomputes the digest, id and receipt links. `scenarios.js` gained `deriveScenario` (same pinned scenario, new
469
+ inputs, recomputed limits and id) and `exerciseOf`; `run.js` gained an optional `shrink` input and an `exercise` field per result.
470
+ - **`invariants/repair.js` (X-406).** `verifyInvariantRepair` composes `verifyPatchNegative` (business-state as the declared behaviour
471
+ check: `req.functional.oracleId` and `promotePatch`'s `functionalOracle` are the two small additive changes in `verification/`) with
472
+ preservation legs for every other APPROVED contract on the fixture (baseline and patched, a pre-existing violation is not blamed on
473
+ the patch). Needs the violated contract approved in a verifying ledger or nothing runs. Blockers carry codes from `REASON_CODES`;
474
+ `reasonCodeFor` maps every patch-negative failure code and sends an unknown one to `setup-failed`. A preservation failure after a
475
+ promotion appends a `rejected` repair record. `patch-negative.js` now also counts a failed business-state control flow
476
+ (`observed.preconditions`) as a broken revision. A verified repair builds a regression artifact (exact patch, invariant document and
477
+ approval evidence, scenario, one pinned manifest per leg with its expected outcome, secret-checked); `runRegressionArtifact`
478
+ re-executes it from the artifact alone, `validateRegressionArtifact` detects an edit without running. Receipts do not cross a
479
+ process; the artifact crosses by re-execution. The approval evidence in it is a digest and a transition id: checking the HMAC needs
480
+ the operator's key.
481
+ - **`invariants/coverage.js`, `export.js`, `project-input.js`, `cli.js` (X-407).** `businessCoverage` reports inventory by the VERIFIED
482
+ ledger, exercised actors/states/transitions from executed scenarios only, and gaps (`GAP_CODES`) that running more of the same cannot
483
+ remove; it always states it is a bounded sample. `invariantCoverageFields` is the one place a surface asks for the additive
484
+ `invariantCoverage` field (`report/index.js` JSON and CLI); flag off or nothing attached returns `{}`, pinned by
485
+ `test/fixtures/invariant-compat/`. `exportScenarios` builds the reproducible package (no fixture source; a scenario holding
486
+ secret-looking content is withheld by path; claims `exhaustive: false`); `verifyScenarioExport` detects edits. CLI:
487
+ `agentic-security invariants export|coverage|regress`; MCP: `invariant_scenario_export` (`mcp/invariant-tools.js`, read-only).
488
+ - **`evaluation/invariant-ablation.js` (X-408).** Three arms (source-only engine scan, inferred contract, approved contract) over a frozen
489
+ synthetic benchmark (`test/fixtures/invariant-benchmark/`, `manifest.json` + `pin.json`, loader fails closed), one denominator and one
490
+ budget, flags produced before any label is read, Wilson intervals, paired counts, unique findings, scenario cost, and per-class
491
+ claims that are `supported` only from executed evidence meeting the registered policy (nothing executed is `unmeasured`). Driver:
492
+ `npm run bench:invariant-ablation -- run|verify|pin` (`scripts/invariant-ablation.mjs`). SYNTHETIC: authored and labelled by the
493
+ developers, no independent adjudication; a figure from it is not engine accuracy and not a real-world benefit claim.
494
+ - **Limits, not hidden.** Same as the section above (in-memory store, cooperative concurrency, only declared outcomes). A shrunk
495
+ scenario is a smaller reproduction, not a root cause. A verified repair covers the declared scenario, workflows and the other
496
+ contracts' bounded scenarios only. Linux is not claimed anywhere here.
497
+ - **`interval.js`, `report.js` (QA-004).** `groupedBootstrap` is the registered `grouped-bootstrap-95` method: percentile bootstrap over GROUPS
498
+ (grouping.js), 2000 resamples, seed = first 32 bits of sha256(protocolHash + statistic name), `unmeasured` with the reason below 10 independent
499
+ groups. `wilsonInterval` covers the completion rate. `buildAccuracyReport` publishes raw counts first, then P/R/F1 by language and family,
500
+ micro and macro, completion with an interval, and every disclosed gap (unscored labels and findings, negatives whose scan failed). A stratum below
501
+ the protocol's floor is `unmeasured` with raw counts and a separate `descriptive` figure. `score.js` now carries `perTarget` tallies (what the
502
+ bootstrap resamples) and scores repeated alerts of one flaw as ONE false positive (`clusterAlerts`, `alertsCollapsed`); `alertBurden` reports the
503
+ raw alerts a reviewer reads beside the root causes, so deduplication never hides burden. `checkPopulationsDisjoint` refuses a target in two of
504
+ regression, development, sealed, operational.
505
+ - **`economics.js` (QA-004.AC03).** `accountCosts` keeps model, tool, cache (USD) and human-review (MINUTES) apart, treats an entry with no amount as
506
+ unmeasured (never zero; the ratios become lower bounds), and divides ALL machine spend, failed and duplicate attempts included, by DISTINCT confirmed
507
+ root causes and validated fixes. A confirmed outcome must name its root cause so a duplicate cannot be counted twice.
508
+ - **`why-missed.js` (QA-005).** `classifyMiss` names the earliest failed stage for a labelled DEVELOPMENT miss (scan-execution, parser-ir,
509
+ source-modelling, propagation, sink-modelling, filters, suppression, deduplication, attribution) or `unknown` with a reason. A candidate observed
510
+ downstream (a reported finding, a dedupe loser, a ledger drop) is checked first because it proves the earlier stages worked. Evidence comes from
511
+ `scan-child.js` in `diagnose` mode (the suppression ledger incl. guard-recognized drops with finding id, CWE, family and the dominating guard line;
512
+ dedupe and rejected-guard stage evidence behind `AGENTIC_SECURITY_STAGE_EVIDENCE=1`, diagnostic only; sources, sinks, IR lowering).
513
+ `collectDiagnostics` refuses any sealed target. `compareMisses` gives before and after stage counts, the recovered defects with the stage that lost
514
+ each, and names new false positives instead of netting them. `dev-cases.js` builds the development case suite (`test/fixtures/engine-mechanisms/`,
515
+ every label flagged synthetic) that `scripts/dev-recovery.mjs` runs against ANY engine checkout, so a before and after is a measurement.
516
+ - **`release-gate.js` (QA-007).** `evaluateReleaseGates` wraps `gates.js` (which now computes the per-language F1 lower bound from the grouped bootstrap
517
+ and adds the integrity gates `answer-key-leaks` and `denominator-intact`, and `no-regression-vs-baseline`) and decides whether a NEW accuracy claim is
518
+ allowed. The baseline comes only from a verified ledger: `appendSealedEvaluation` / `verifyLedger` (hash chain) / `signLedger` (the custodian signs the
519
+ head, so a rebuilt chain does not verify). `assessReleaseClaim` blocks a different detector digest (`detectorDigestOf`, evaluation tooling excluded) on
520
+ already-consumed sealed targets (`fresh-sealed-population-required`); old results are retained as evaluation-only. `signGateReport` signs an AGGREGATE
521
+ verdict (no per-case outcome) with a local Ed25519 key, trust basis `self-issued-local-key`, never independent certification.
522
+ - **`bakeoff.js` (QA-008.AC02).** The bake-off manifest, generic comparator slots (no tool is named anywhere), adapters that mark an unavailable slot
523
+ `not-evaluated` with a typed reason, a comparability check (identical workload digest and verification standard) and a claim guard: accuracy and
524
+ cost claims only between evaluated, comparable participants; `superiority` is always `none claimed`. Scoring is `comparison.js`, unchanged.
525
+ Docs: `docs/guides/bakeoff-recipe.md`; the offline reproduction is `scripts/public-reproduction.mjs` (`npm run reproduce:mini`).
526
+
527
+ Tests: `test/evaluation/{protocol,grouping,labels,custody,runner,gates-scorecard,reporting,why-missed,guard-dominance,engine-mechanisms,release-gate,publication}.test.js` (`npm run test:evaluation`; the two engine files are also in `test:dataflow`). Guides: `docs/guides/{evaluation-reporting,miniature-reproduction,bakeoff-recipe,engine-mechanism-evidence}.md`.
528
+
529
+ ## Release assurance, portable evidence and resumable portfolios: `portfolio/` (X-701 to X-708)
530
+
531
+ Same posture as the sections above: consumed, not wired into the default scan. The one place it touches shipped output is the verdict
532
+ wording, and that is additive behind the `portfolio-assurance` feature (`assurance/config.js`, default off, so flag-off output is
533
+ byte-identical and no pinned fixture changed). **It builds machinery, not evidence**: no real release or portfolio exists, every
534
+ fixture is flagged `synthetic: true`, and nothing here claims real-world assurance. Linux enforcement stays `unverified`; no Linux
535
+ outcome is asserted. Suite: `npm run test:portfolio` (`test/portfolio/`).
536
+
537
+ - **`manifest.js` (X-701).** `buildManifest` / `validateManifest`: the release assurance manifest on the assurance schema kit and
538
+ `identity.js` (id prefix `ram`, allowlisted fields, no clock). Binds subject repository and commit, dependency revisions, build
539
+ artifact digests, scope, graph snapshot digest, invariant versions and verification receipts. Every mandatory check is in exactly one
540
+ of completed / incomplete / unsupported / waived (incomplete and unsupported name their gaps, a waiver names its approver and reason),
541
+ and the blocking policy (id, digest, severity) is part of the record. Validation rejects evidence not bound to a declared receipt,
542
+ a receipt whose commit is not the bound commit of its repository, a mandatory check listed nowhere, and coverage or `complete` that
543
+ disagrees with the lists. Residual risks and coverage are machine-readable fields.
544
+ - **`bundle.js` (X-702).** The portable bundle: an index file (bundle.json) plus `blobs/<sha256 hex>`. `exportBundle` writes the sanitized
545
+ findings (closed field set, snippets stay behind, secret shapes redacted, then refused if any survive), provenance, replay manifests,
546
+ toolchain identities and the cited receipts, index last. `verifyBundle` is the OFFLINE verifier (fs and crypto only, executes nothing
547
+ from the bundle): re-hashes every blob, rejects symlinks, oversize, bad names, missing roles, a modified index, a manifest that fails
548
+ validation and a cited receipt that is absent, and DISCLOSES replay prerequisites without attempting a replay. `importBundle`
549
+ verifies first, confines every destination, never overwrites. Limits are `BUNDLE_LIMITS`.
550
+ - **`signing.js` (X-703).** `signAssuranceClaim` is a signer-domain act (`sandbox/trust-domains.js`, default deny: worker, target and
551
+ verifier are refused before a key is touched). The key lives in its own `assurance-signing/` directory (reusing `ensureKeyPair`) and may
552
+ be required to differ from the scan evidence key. The signed claim names manifest id and digest, bundle digest, commit, blocking policy,
553
+ coverage, the bounded headline, signer id, key id and the assurance policy. `verifyAssuranceClaim` is offline against an explicit
554
+ trust-root policy (`buildTrustPolicy`): unknown root, revoked root, a basis this build cannot verify, a tampered claim, a tampered or
555
+ missing bundle and a claim that does not match the bundle's manifest are each typed failures. The only verifiable basis is
556
+ `self-issued-local-key`, `independentlyCertified` is always false and inside the signed bytes. Private key material is never in an
557
+ envelope, policy, error or output.
558
+ - **`wording.js` (X-703.AC03).** `assuranceStatement` renders "No blocking findings in completed supported checks", then scope, then
559
+ gaps, then the not-a-guarantee line. `report/index.js` `toShipVerdict` and `integrations/index.js` digests use it only when
560
+ `portfolioAssuranceEnabled` (option, else the assurance configuration); otherwise the old "Safe to deploy" strings are untouched.
561
+ - **`work-units.js` (X-704).** `planPortfolio` (authorized repositories only, exact commits, stable `wu:` ids), a seven-state machine with
562
+ lease expiry, retry count and a hash-chained attempt record per unit (rewriting an attempt fails `verifyStore`), idempotent lease /
563
+ start / complete / fail by attempt id (a duplicate completion verifies once; a stale attempt, such as a zombie after lease expiry,
564
+ is refused), `progressOf` counting only verified units, locked atomic persistence (`openStore`, `mutateStore`; a corrupt store is an
565
+ error, never a reset) and `runPortfolio` (bounded concurrency, an executor that throws fails its unit). `fleet.js` `runFleet` accepts
566
+ `portfolioVerified` (from `fullyVerifiedRepositories`) and skips those repositories like a resumed completed one.
567
+ - **`resume.js` (X-705).** Every verified result records one digest per dimension (code, policy, graph, invariant, oracle, toolchain;
568
+ `scan-checkpoint.js` `computeRunKey` / `computeGlobalKey` supply the code and toolchain digests). `planResume` reuses a unit only on a
569
+ full match; any other change `applyResume` invalidates: the old result moves to `stale` (inspectable with `inspectStale`), the unit
570
+ returns to pending in a new generation, `assessClaims` marks dependent claims stale. A graph-only change is narrowed by the drift
571
+ module (`lineage/deployment/drift.js` `planRescan`): a unit bound to hypotheses is reused only if the plan is complete and all its
572
+ hypotheses are in `skipped`; anything else fails closed.
573
+
574
+ Tests: `test/portfolio/{manifest,bundle,signing,work-units,resume}.test.js` (`npm run test:portfolio`).
575
+ ## Calibrated model routing: `routing/` (X-601 to X-608)
576
+ - **`scheduler.js` (X-706).** Bounded scheduling over the store. `scheduleNext` admits and leases ONE unit in a single locked transaction
577
+ (`mutateStore`), after checking five limits at two levels (portfolio and per repository: wall time, provider spend, requests, storage,
578
+ plus concurrency) against what is used PLUS what in-flight attempts have reserved PLUS the unit's own estimate. A unit with no
579
+ estimate is not leased (unknown is never zero). A refusal is `at-capacity` (a wait) or `exhausted` (will not recover), reported per
580
+ unit and per scope, and a refusal changes nothing. An attempt that ends with no usage report is charged its whole reservation; a
581
+ reported overrun is recorded (`overruns`) and shrinks what remains. Wall time is also enforced while a unit runs (the executor's
582
+ `signal` aborts at the unit's wall estimate). Fairness: priority is a WEIGHT (integer 1..8, default 1), never an order; the next lease
583
+ goes to the admissible repository with the lowest `leasesGranted / weight` (ties: repository name, then unit id), so a 120-unit
584
+ repository cannot starve a 1-unit one, weight 8 against 1 is served about eight times as often with the light one still served early,
585
+ and a repository that was blocked is owed service when it returns. A blocked or over-budget repository is skipped with a reason, never
586
+ waited on; an unaffordable unit does not block a cheaper sibling. `routingBudgetFor` hands the routing policy (`routing/decide.js`) the
587
+ smaller of the portfolio and repository dollar remainders, less reservations; `chargeModelSpend` charges a routing decision at its
588
+ upper cost bound and refuses a blocked or unbounded decision. No provider is called here. `createCancelScope().cancel` takes a scope
589
+ (`{unitId}`, `{repository}` or `{all}`; an empty scope is refused, never read as cancel-all), records it in the ledger so a process
590
+ that did not receive the call still honours it, aborts in-process attempts (work started with `ctx.spawn` runs under
591
+ `sandbox/supervise.js`, which ends the whole process tree and reports survivors), releases a lease ONLY when the attempt settled with no
592
+ survivor, otherwise leaves it to expire (so no other worker can start the unit while a descendant may live) and never re-leases it.
593
+ Verified units and receipts are untouched; the report is `incomplete` with the reason. `heartbeat` renews the lease and records a
594
+ heartbeat in one transaction; `runScheduled` heartbeats every `HEARTBEAT.intervalMs` (5 s) while an attempt runs and takes an optional
595
+ `backend`, probed before every lease (an unavailable one stops the run as `blocked`). Limits: the driver's heartbeat proves the
596
+ controlling process is alive, not that an in-process executor is making progress (the wall bound handles that); tree termination is
597
+ verified on the macOS userspace backend only and Linux enforcement stays `unverified`.
598
+ - **`progress.js` (X-707).** ONE projection (`buildProgressView`) for the CLI, the MCP tool and the fleet summary, in the pattern of
599
+ `lineage/deployment/projection.js`. It keeps apart verified units (the only progress), blocked / failed / canceled / stale units with
600
+ reasons, per-repository coverage (fully-verified, partial, incomplete, not-started), per-limit budget (limit, used, reserved,
601
+ remaining; an unenforced limit says so), pending human review (blocked units plus `reviewItemsFromInvariantCoverage` and
602
+ `reviewItemsFromBoundaryContexts`), and every worker with its age, `live` or `stale` (stale after `HEARTBEAT.staleAfterMs`, 15 s;
603
+ silent workers are measured from their lease). `aggregateFindings` deduplicates by `stableId` ONLY, keeps every occurrence
604
+ (repository, environment, release, commit, file, line, severity) and lists affected releases separately; a finding with no stable id
605
+ is listed apart, never merged. `completion` states that a finished controller is not every unit verified and not any repository
606
+ passing (`passAssessment: 'not-implied'`). Additive behind `portfolio-assurance`: `attachPortfolioProgress` returns the SAME rollup
607
+ object when the feature is off, `renderFleetSummary` adds a PORTFOLIO clause only when the field is present, and
608
+ `test/fixtures/portfolio/pre-change-pins.json` (generated by `test/portfolio/make-pins.mjs` from code before the change) pins the
609
+ fleet rollup, summary and HTML. CLI `agentic-security portfolio progress`; MCP `portfolio_progress` (read-only, `mcp/portfolio-tools.js`).
610
+ - **`backend.js` (X-708).** The storage backend interface (`probe`, `storeFile`, `withExclusive`, fenced `acquireLease` / `renewLease` /
611
+ `releaseLease`) and a file-lock reference implementation. `createLocalBackend` makes its directory; `createSharedBackend` requires a
612
+ marker written by an explicit `initSharedBackend`, so an unmounted path, an empty mount point, a symlink or a damaged marker is a typed
613
+ `blocked` (`openBackend` returns `{ ok: false, state: 'blocked', code }` and no backend; there is deliberately no fallback argument,
614
+ and a blocked open creates nothing). A backend that disappears mid-run stops `runScheduled` as `blocked` without recreating the
615
+ directory or writing anywhere else. Lease semantics, each tested with two REAL processes: mutual exclusion, monotonic fence, expiry
616
+ takeover, a replaced holder refused on renew and release. NOT claimed: network-filesystem safety (`describe()` reports it
617
+ `unverified`; the operator must verify exclusive-create atomicity on that mount), cross-host clock agreement, or race-free stale-lock
618
+ recovery (narrow window, documented). `exportState` / `verifyStateExport` / `importState`: one self-checking, secret-checked file for an
619
+ air-gapped machine; import verifies first and never overwrites.
620
+ - **`retention.js` (X-708.AC01).** Retention by class (`replay-evidence`, `metadata`, `model-trace`, `secret`; defaults 365 / 730 / 30 / 0
621
+ days, ceilings 1095 / 1825 / 90 / 7, an over-ceiling setting is clamped and disclosed). Order: a required current receipt (named in
622
+ `currentReceiptIds`, or `requiredBy` a unit that is `verified` in the store NOW) is never deleted and an expired one is reported as
623
+ `expiredButRequired`; then a legal hold (validated and checked with `legal-hold.js`'s `isUnderHold`; by id, class or repository; a
624
+ malformed hold refuses the whole plan); then expiry. An unclassified or malformed record is kept and reported. `applyRetention` re-plans
625
+ against the live store, confines paths to the root (traversal, absolute paths, symlinks and directories refused), and logs each deletion
626
+ to a hash-chained log BEFORE removing the file (no log, no deletion; a broken log blocks every deletion). The log holds digests and sizes,
627
+ never content. CLI `agentic-security portfolio retention plan|apply|verify-log`.
628
+
629
+ Tests: `test/portfolio/{manifest,bundle,signing,work-units,resume,scheduler,progress,retention,backend}.test.js` (`npm run test:portfolio`).
630
+ `test/portfolio/proc-worker.mjs` is the child process the multi-process consistency tests spawn; it is not a test file.
631
+ ## Calibrated model routing: `routing/` (X-601 to X-604)
632
+
633
+ Extends `model-routing.js`, `model-trust.js`, the provider catalog and cache economics; consumed, not wired into the scan or the interactive advisor. Everything is behind the **`model-routing`** feature (`assurance/config.js`, model-network risk, off by default, kill switch `AGENTIC_SECURITY_NO_MODEL_ROUTING`). With it off, `routeModelWithPolicy` returns exactly what `routeModelWithTrust` returns, and `test/routing/unchanged-when-off.test.js` recomputes the routing, trust, cache-economics, catalog and advisor outputs against `test/fixtures/routing/pre-change-pins.json` (generated from the code before any routing edit). Suite: `npm run test:routing`. **Machinery, not evidence:** no real routing outcome or adjudicated task exists in this repository, every population in the tests is SYNTHETIC (`test/helpers/routing-fixtures.js`), and the section 5 routing gate reads `unmeasured` (synthetic) or `insufficient-population`, never `pass`. No cost or quality improvement is claimed anywhere.
634
+
635
+ - **`outcomes.js` (X-601).** Routing task record (kind: discovery, triage, verification-planning, repair; language; vulnerability class; context tokens and bucket; required capabilities; data class), the stratum key `kind|language|class|bucket` and its coarser parents, and the outcome record. `deriveRoutingLabel` is the only way to a decided label: independent adjudication (a human or verifier service that is not the model or its provider, with the adjudication version) or trusted execution (a `vrec:` verification record). Provider agreement, self-reported confidence, accepted suggestions, model judgement and majority vote are refused (`NOT_A_TRUTH_LABEL`) and the outcome is recorded `unknown`. Outcome records keep every status (failed, timeout, cancelled, provider-error, partial, blocked) and unknown or delayed correctness; an unreported count is `null`, never 0. `createOutcomeLedger` has no remove or overwrite.
636
+ - **`economics.js` (X-602).** `priceBookFromCatalog` builds the versioned price book from `provider-catalog.js` (the catalog's dated snapshot stays the single price table). `costOf` splits input, cached input, output, retries and tool execution, and names price version, currency and billing basis. Unknown is `null` or a bounded `[lower, upper]` estimate, never 0 (an unpriced model, a subscription basis, an unreported token count, an unknown cache split, an unmeasured tool cost). A prediction goes in `predicted` and never fills `measured`. `cache-economics.js` (which prices Claude Code's own transcript) and `hooks/model-cost-advisor.js` are deliberately unchanged and pinned.
637
+ - **`adapter.js` (X-602.AC03).** `createProviderAdapter(...).invoke` has no HTTP client: it goes through `guardedModelCall` (feature gate, egress policy before the prompt exists, redaction, request cap, deadline, bounded retries, audit chain) with an INJECTED transport. Telemetry is built from an allowlist (provider, model, endpoint host only, status, a fixed failure code, token counts, cache state, latency, byte counts); it never carries the prompt, the response, an error message or a credential. Provider failures, partial responses, cancellation, timeouts and cache hits each yield an outcome record.
638
+ - **`calibration.js` (X-603).** `buildCalibration` fits per (model, version, stratum) quality on HELD-OUT outcomes with a time split, a model-version split (versions are never pooled) and group disjointness (a group on both sides is removed from the held-out side and counted). Uncertainty is `groupedBootstrap` from `evaluation/interval.js` (`unmeasured` below 10 groups). The label `reliable` needs at least 200 paired adjudicated tasks overall, 30 per stratum (`ROUTING_MINIMUMS`, a config cannot lower them), a measured interval and a stable development-versus-held-out rate; a synthetic population only ever gets `reliable-synthetic`. Otherwise the stratum carries `fallback-parent` (a reliable coarser stratum, named), `shifted` or `insufficient-evidence`, and an explicit fallback (`parent-stratum` or `baseline-model`). The artifact records the dataset hash, config hash, adjudication versions, counts and every exclusion by reason, and `verifyCalibration` rebuilds it byte for byte. `routingPopulationGate` is the population half of the PRD section 5 routing row only; the paired quality, cost and latency comparison is `promotion.js` (X-605).
639
+ - **`decide.js` (X-604).** `routeConstrained` applies availability, capability, privacy (the egress policy, or a capability-manifest egress check), context window, reliable and fresh quality evidence whose LOWER bound meets the minimum, interval width and budget BEFORE minimising measured cost or latency. Every candidate lists all of its failing constraints. When nothing is acceptable the result is `fallback` (the existing capability route, only if it passes capability, privacy, context and budget, with quality marked UNVERIFIED) or `blocked` with a code; a constraint is never relaxed to find a route. `routeModelWithPolicy` in `model-routing.js` is the entry point; a blocked decision returns `model: null, blocked: true`.
640
+ - **Limits, not hidden.** The provider catalog carries prices and cache models but not capabilities or context windows, so `candidateFromCatalog` takes them from the operator and an unknown window is rejected. Catalog prices are a dated snapshot. Cost bounds cover model tokens only. Telemetry redaction depends on the egress redaction patterns, which are pattern checks, not proof against every secret shape.
641
+ - **`shadow.js` (X-605.AC01).** `createShadowRecorder().shadow()` records what `routeConstrained` WOULD pick and returns the production route it was given, the same object. It holds no transport or adapter (a test pins that the source cannot call a provider), so no paid call is possible by construction. A disabled routing control records nothing.
642
+ - **`promotion.js` (X-605.AC02, AC03).** `freezeTaskSet` fixes the evaluation tasks by hash; `replayPaired` pairs each frozen task's baseline arm with the arm the shadow decision chose, from recorded outcomes. It issues a provider call only with BOTH an injected `invoke` and a bounded authorization (`authorizedBy`, finite `maxCalls`, `maxUsd`, `perCallUsdCeiling`, all under `REPLAY_LIMITS`); an `invoke` without them is refused (`UNBOUNDED_REPLAY`), an unknown cost is charged at the ceiling, and a replay that hits its bound is `truncated`. `evaluatePromotion` judges the PRD section 5 routing row (200 paired adjudicated overall, 30 per promoted stratum, quality difference lower bound above -0.02, median cost 20% lower or quality +0.05 at no higher median cost, p95 latency 1.2x) with the grouped bootstrap. Verdict precedence: `invalid` > `unmeasured` (synthetic) > `insufficient-population` > `fail` > `pass`. Invalidations: `cherry-picked` (pairs differ from the frozen set), `dropped-tasks` (no pair: no shadow record, missing or ambiguous outcome, bound reached), `unbounded-replay`, `truncated-replay`, `replay-edited`, `frozen-set-edited`. A failure status, a timeout, a blocked proposal or an unserved task is not-correct in the comparison and is reported, never dropped; an unadjudicated pair is counted apart and never imputed; an unknown cost or latency leaves the criterion unmet. The quality-gain path is stricter than the PRD wording (the lower bound must clear zero too).
643
+ - **`drift.js`, `canary.js` (X-606).** `snapshotRoutingBasis` records what a policy was promoted on; `assessDrift` compares it with the present (model version, price entry, schema, recent decided quality) and returns a documented `fallback` or `shadow` state; `applyInvalidations` derives a calibration whose affected estimates are `invalidated` (quality, version, schema) or lose their measured cost (pricing), so `routeConstrained` refuses them. A cost-invalidated model ranks behind one with a valid measured cost, by the existing ranking rule. `createCanary` is finite by construction (no limits, no canary): task count, spend budget, error rate, consecutive-error outage, incorrect rate; a trip restores the known-good policy and only appends receipts. `activate` needs a `pass` promotion verdict. `routeUnderDrift` is the state-aware entry point.
644
+ - **`feedback.js` (X-607).** `createFeedbackStore`: actors (human or verifier-service) registered with a key sign each record (HMAC); the first accepted label is the immutable original, a change is a correction citing the entry it corrects; an allowlist of fields is kept and the names of dropped fields only; unauthorized, unverifiable, tampered, duplicate and poisoned feedback is quarantined with a count and reason, and `applyToOutcomes` is what a calibration should be built from. `authorizePayload` / `sendToProvider` need the task policy AND the egress policy (or a capability manifest check) to allow source or evidence, and send only through the guarded adapter. Limit: a trusted-execution label cannot be recomputed from the outcome alone, so a flip of one is not detected here.
645
+ - **`receipts.js`, `control.js`, `report.js` (X-608).** `createReceiptLog` is a hash-linked log of every shadow decision, replay, promotion, drift, canary, rollback and feedback decision (`exportReceipts`, `verifyReceiptChain`; tamper evidence, not a signature). `resolveRoutingControl` (option, then `AGENTIC_SECURITY_ROUTING`, then default; a bad value fails closed to `disabled`) is honoured by `routeModelWithPolicy` (disabled keeps the capability route, `pin:<model>` selects that model). `buildRoutingReplayReport` / `renderRoutingReport` / `buildPolicyCard` restate the verdict with exact denominators, intervals, measured cost, cache states and latency percentiles, restrict claims to the strata and model versions the gate passed, and keep unsupported strata and unmet gates listed. Reproduce offline with `npm run reproduce:routing` (`scripts/routing-replay.mjs`, controls, `--fault`); guide `docs/guides/routing-replay.md`. Everything in the tests and the script is generated and `synthetic`; the gate reads `unmeasured`, never `pass`, on it, and no advantage is claimed.
646
+
316
647
  ## Execution-proof tiers (R2)
317
648
 
318
649
  `proof-tier.js` + `execution-proof.js` add a fourth axis to a finding's
@@ -111,6 +111,55 @@ export function aggregateCorpus(detail) {
111
111
  };
112
112
  }
113
113
 
114
+ /**
115
+ * Real-code evaluation gates (programme QA-001/QA-003). Takes the committed gate
116
+ * report written by `scripts/evaluation.mjs gates` (or nothing) and returns the
117
+ * section the scorecard publishes. It can only RELAY a status: a synthetic
118
+ * protocol is forced to `unmeasured`, and an absent report is `unmeasured` with
119
+ * the reason, never an empty pass. Pure.
120
+ */
121
+ export function summarizeMeasurement(provenance, independent) {
122
+ const p = provenance || {};
123
+ const clean = p.worktreeClean === true;
124
+ const hist = independent && independent.engineVersion && independent.engineVersion !== p.engineVersion;
125
+ return {
126
+ engineVersion: p.engineVersion || null, commit: p.commit || null, bundleSha256: p.bundleSha256 || null,
127
+ cleanMeasurement: clean,
128
+ identity: clean
129
+ ? 'the commit and bundle below identify exactly the tree that was measured'
130
+ : p.worktreeClean === false ? 'the worktree was dirty: the commit below does NOT identify what was measured, so this is not a clean measurement' : 'cleanliness of the measured tree was not recorded',
131
+ currentEngineIndependentAccuracy: 'unmeasured',
132
+ currentEngineIndependentReason: 'no adjudicated real-code population has been run against this engine; the independent record in this document, if any, describes an older engine',
133
+ historicalIndependentRecord: hist
134
+ ? { engineVersion: independent.engineVersion, measuredAt: independent.measuredAt || null, usage: 'historical record only; not a measurement of the engine above and not combined with the corpus figures' }
135
+ : null,
136
+ };
137
+ }
138
+
139
+ export function summarizeEvaluationGates(report) {
140
+ if (!report || typeof report !== 'object') {
141
+ return {
142
+ measuredThisRun: false, status: 'unmeasured', synthetic: null, gates: [],
143
+ reason: 'no frozen real-code evaluation protocol and no sealed adjudicated population exist yet; no real-code gate has been evaluated',
144
+ };
145
+ }
146
+ const gates = Array.isArray(report.gates) ? report.gates.map((g) => ({ id: g.id, status: g.status, threshold: g.threshold ?? null, measured: g.measured ?? null, reason: g.reason || null })) : [];
147
+ const synthetic = report.synthetic !== false;
148
+ const counts = {};
149
+ for (const g of gates) counts[g.status] = (counts[g.status] || 0) + 1;
150
+ return {
151
+ measuredThisRun: false,
152
+ source: 'bench/independent/evaluation-gates.json',
153
+ protocolHash: report.protocolHash || null,
154
+ synthetic,
155
+ // A synthetic protocol can exercise the machinery; it can never publish a gate result.
156
+ status: synthetic ? 'unmeasured' : (report.overall || 'unmeasured'),
157
+ reason: synthetic ? 'the recorded protocol is synthetic; its results are not real-code evidence' : (report.population?.reason || null),
158
+ gates: synthetic ? [] : gates,
159
+ gateCounts: synthetic ? {} : counts,
160
+ };
161
+ }
162
+
114
163
  /**
115
164
  * Build the machine-readable scorecard model. `inputs`:
116
165
  * provenance { engineVersion, bundleSha256, commit, nodeVersion, generatedAt,
@@ -227,6 +276,10 @@ export function buildScorecard(inputs) {
227
276
  source: 'bench/independent/RESULT.json',
228
277
  measuredAt: committed.independent.measuredAt || null,
229
278
  engineVersion: committed.independent.engineVersion || null,
279
+ // QA-008.AC01: this record belongs to the engine it names. When that is not the engine this scorecard measured, it is a
280
+ // HISTORICAL record: kept, dated, labelled, and never combined with the curated-corpus figures into one accuracy claim.
281
+ historical: !!committed.independent.engineVersion && committed.independent.engineVersion !== (inputs.provenance || {}).engineVersion,
282
+ comparableToCurrentEngine: !!committed.independent.engineVersion && committed.independent.engineVersion === (inputs.provenance || {}).engineVersion,
230
283
  population: committed.independent.population || null,
231
284
  overall: committed.independent.overall || null,
232
285
  wide: committed.independent.wide || null,
@@ -293,6 +346,10 @@ export function buildScorecard(inputs) {
293
346
  // frameworks" from "this scorecard predates the metric" the same way
294
347
  // every other section here distinguishes absence from zero.
295
348
  complianceMappingCoverage: inputs.complianceMappingCoverage || [],
349
+ // QA-001/QA-003: the real-code gates registered in the evaluation protocol. Relayed, never computed here.
350
+ evaluationGates: summarizeEvaluationGates(inputs.evaluation),
351
+ // QA-008.AC01: what this scorecard measured, stated once. `clean` is what lets the commit and bundle identify the measured tree.
352
+ measurement: summarizeMeasurement(inputs.provenance, committed.independent),
296
353
  };
297
354
  }
298
355
 
@@ -344,6 +401,10 @@ export function renderScorecardMarkdown(m) {
344
401
  if (p.worktreeClean !== undefined) {
345
402
  L.push(`| Worktree at measurement time | ${p.worktreeClean ? 'clean' : 'DIRTY — the commit above does not fully describe what was measured'} |`);
346
403
  }
404
+ if (m.measurement) {
405
+ L.push(`| Measurement identity | ${m.measurement.cleanMeasurement ? 'clean measurement: the commit and bundle above identify the measured tree' : 'NOT a clean measurement: the commit and bundle above do not identify the measured tree'} |`);
406
+ L.push('| Independent accuracy of THIS engine | unmeasured (no adjudicated real-code population has been run; see the historical record below, if present) |');
407
+ }
347
408
  L.push(`| Node | ${p.nodeVersion || 'unknown'} |`);
348
409
  L.push(`| Corpus entries | ${c.totalEntries} (${c.scoredEntries} scored) |`);
349
410
  // FR-901: "Published results identify engine version, corpus version,
@@ -674,8 +735,17 @@ export function renderScorecardMarkdown(m) {
674
735
 
675
736
  const ind = m.committedInputs.independent;
676
737
  if (ind && ind.overall) {
677
- L.push('## Independent evaluation population — the number that matters');
738
+ const hist = ind.historical === true;
739
+ L.push(hist
740
+ ? `## Independent evaluation population: historical record (engine ${ind.engineVersion}, ${ind.measuredAt || 'undated'})`
741
+ : '## Independent evaluation population — the number that matters');
678
742
  L.push('');
743
+ if (hist) {
744
+ L.push(`**This is a historical record of engine ${ind.engineVersion}, measured ${ind.measuredAt || 'on an unrecorded date'}.** It is not a measurement of the`);
745
+ L.push(`engine this scorecard describes (${p.engineVersion || 'unknown'}), it is a different population from the curated corpus above, and the two are`);
746
+ L.push('never combined into one accuracy figure. The current engine has no independent figure: it is **unmeasured**.');
747
+ L.push('');
748
+ }
679
749
  L.push('Everything above is a **regression net**: its fixtures and its labels are both');
680
750
  L.push('written here, which is why its detection rate sits at the ceiling by');
681
751
  L.push('construction. `bench/independent/` is the other instrument — real upstream code');
@@ -715,9 +785,14 @@ export function renderScorecardMarkdown(m) {
715
785
  L.push('class at all, a question with a much easier yes. It is kept because it is the');
716
786
  L.push('only way to tell whether a change moved the engine or moved the benchmark.');
717
787
  L.push('');
718
- L.push('Against ~100% on the curated corpus above. **That gap is the most useful number');
719
- L.push('in this document**, and publishing it is the point of the exercise. The figure');
720
- L.push('went DOWN when the benchmark was corrected, and is published that way.');
788
+ if (hist) {
789
+ L.push(`This record is kept, dated and labelled because it is the last independent measurement the project made. It describes engine ${ind.engineVersion} only.`);
790
+ L.push('It does not describe the engine above, and the curated-corpus rates earlier in this document are a different population that must not be read beside it as one claim.');
791
+ } else {
792
+ L.push('Against ~100% on the curated corpus above. **That gap is the most useful number');
793
+ L.push('in this document**, and publishing it is the point of the exercise. The figure');
794
+ L.push('went DOWN when the benchmark was corrected, and is published that way.');
795
+ }
721
796
  L.push('');
722
797
  // FR-904 (assurance-hardening PRD): "rule authors cannot optimize
723
798
  // against the full scored population." T0.7's held-out slice is a
@@ -771,6 +846,22 @@ export function renderScorecardMarkdown(m) {
771
846
  }
772
847
  }
773
848
  }
849
+ const eg = m.evaluationGates || summarizeEvaluationGates(null);
850
+ L.push('## Real-code evaluation gates');
851
+ L.push('');
852
+ L.push(`Status: **${eg.status}**${eg.reason ? ` (${eg.reason})` : ''}.`);
853
+ L.push('');
854
+ L.push('The gates (per-language F1, micro and macro F1, pooled precision and recall, completion) are registered');
855
+ L.push('in a frozen protocol before any result is observed, and can read `pass` only on an independently');
856
+ L.push('adjudicated sealed population that meets the registered minimums. `insufficient-population` and');
857
+ L.push('`unmeasured` are honest blockers, not failures and not passes.');
858
+ if (eg.gates.length) {
859
+ L.push('');
860
+ L.push('| Gate | Status | Threshold | Measured |');
861
+ L.push('| --- | --- | --- | --- |');
862
+ for (const g of eg.gates) L.push(`| ${g.id} | ${g.status} | ${g.threshold ?? 'n/a'} | ${g.measured ?? 'n/a'} |`);
863
+ }
864
+ L.push('');
774
865
  L.push('## Committed artifacts referenced (not re-run by this command)');
775
866
  L.push('');
776
867
  const cb = m.committedInputs.corpusBaseline;