From a476b9ad0d1278b445dec4d26e050cbb872f741a Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Fri, 28 Aug 2026 14:16:15 +0200 Subject: [PATCH 1/3] fix(verifier): exigir canal opaco runner-receipt y replay con material Cierra autoridad/binding de RunnerReceipt, cadena causal completa y replay criptografico obligatorio. Enrola los dominios K6b en el baseline para el reconcile posterior. --- CHANGELOG.md | 18 +- ...tative-receipt-derivation-no-blind-copy.md | 6 +- ...ct-causal-chronology-execution-sequence.md | 4 +- ...cryptographic-evidence-replay-integrity.md | 4 +- ...28-014-runner-receipt-authority-binding.md | 23 ++ docs/architecture/harness-evolution.md | 22 +- docs/roadmaps/harness-evolution.md | 31 ++- .../verify-report.md | 28 ++- openspec/config.yaml | 5 +- openspec/specs/_baseline/index.md | 3 + openspec/specs/_baseline/manifest.md | 6 + openspec/specs/assurance-graph/spec.md | 20 +- .../specs/independent-verification/spec.md | 49 +++- schemas/kernel/contract-claims.json | 26 ++ schemas/kernel/manifest.json | 5 + .../fixtures/invalid/missing-evidence-id.json | 14 ++ .../fixtures/valid/minimal.json | 20 ++ schemas/kernel/runner-receipt/v1.schema.json | 124 ++++++++++ .../k6b-verifier-assurance-graph-e2e.test.js | 28 ++- scripts/lib/assurance-graph/index.js | 98 ++++++-- scripts/lib/assurance-graph/index.test.js | 211 +++++++++++----- scripts/lib/independent-verifier/evidence.js | 6 + .../lib/independent-verifier/evidence.test.js | 10 + scripts/lib/independent-verifier/index.js | 130 +++++----- .../lib/independent-verifier/index.test.js | 225 +++++++++++++++++- .../internal/runner-receipt-channel.js | 99 ++++++++ .../independent-verifier/runner-receipt.js | 141 +++++++++++ .../independent-verifier/strategy-policy.js | 187 +++++---------- scripts/lib/k1-scope-guard.test.js | 3 + scripts/lib/k2a-maturity-docs.test.js | 8 +- scripts/lib/k6b-schema-fixtures.test.js | 48 +++- scripts/lib/lifecycle-kernel/k1-compat.js | 3 +- .../lib/test-support/k6b-runner-receipt.js | 83 +++++++ scripts/manifest-sync.test.js | 39 +++ .../k6b-verifier-assurance-graph-e2e.test.js | 36 ++- 35 files changed, 1423 insertions(+), 340 deletions(-) create mode 100644 docs/adr/adr-20260828-014-runner-receipt-authority-binding.md create mode 100644 schemas/kernel/runner-receipt/fixtures/invalid/missing-evidence-id.json create mode 100644 schemas/kernel/runner-receipt/fixtures/valid/minimal.json create mode 100644 schemas/kernel/runner-receipt/v1.schema.json create mode 100644 scripts/lib/independent-verifier/internal/runner-receipt-channel.js create mode 100644 scripts/lib/independent-verifier/runner-receipt.js create mode 100644 scripts/lib/test-support/k6b-runner-receipt.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 833d5720..068a4a2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Security +- **Autoridad y binding exacto de RunnerReceipt**: + - Nuevo contrato `runner-receipt/v1` con `receipt_id` content-addressed y `evidence_id` obligatorio. + - `verifyCandidate` rechaza DTOs caller-owned `runner_receipts`/`receipts` y solo consume un canal opaco emitido por el runtime. + - Se elimina matching por posición/nodo y fallback de role a `node.kind`; Candidate, Evidence y nodo deben coincidir exactamente. + - `outcome: failed` con tokens satisfechos falla con `INVALID_RUNNER_RECEIPT`. +- **Cronología y replay fail-closed completos**: + - Strategies temporales exigen `run_id` único no vacío, ordinales estrictos y `previous_evidence_id` en cada transición posterior a la raíz. + - Replay exige bytes o `observation_blob_id` content-addressed resoluble; sin material de observación retorna `GRAPH_DIVERGENCE`. + +### Changed +- K6b permanece `revise` pendiente de terminal review objetivo; K6c vuelve a `blocked-by-K6b-terminal-review`. +- Verify report de v2.53.1 corregido con errata post-release y guard automático de consistencia entre versión, changelog, roadmap, arquitectura, report y tag. + ## [2.53.1] - 2026-08-28 ### Security @@ -26,7 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `validateReplayRecords` recomputa `digestRawBytes` y `computeEvidenceId(record, bytes)` validando igualdad exacta contra `record.digest` y `record.evidence_id`. - Revalidación obligatoria de procedencia mediante `evaluateProvenanceSufficiency(record, { requireRuntime: true })`, fallando con `GRAPH_DIVERGENCE` ante adulteración de digest, id o insuficiencia de provenance. - ADRs `docs/adr/adr-20260828-010` a `013`. Specs `independent-verification` y `assurance-graph`. - - Cierre definitivo de los hallazgos B1, B2, B3 y H1 de K6b. Archivado en `openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/`. + - Cierre declarado originalmente para B1, B2, B3 y H1. El review terminal post-release reabrió receipt authority/binding, causalidad completa y replay sin material; ver errata del verify report. Archivado en `openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/`. ## [2.53.0] - 2026-08-28 diff --git a/docs/adr/adr-20260828-011-authoritative-receipt-derivation-no-blind-copy.md b/docs/adr/adr-20260828-011-authoritative-receipt-derivation-no-blind-copy.md index 5e2c8685..51b47631 100644 --- a/docs/adr/adr-20260828-011-authoritative-receipt-derivation-no-blind-copy.md +++ b/docs/adr/adr-20260828-011-authoritative-receipt-derivation-no-blind-copy.md @@ -1,6 +1,6 @@ # ADR-002: Derivación autoritativa de satisfacción desde Runner Receipts -- Status: proposed +- Status: superseded by ADR-005 - Change: k6b-trusted-evidence-replay-closure - Date: 2026-08-28 @@ -8,7 +8,9 @@ El verificador independiente permitía un fallback de copia ciega de `node.required_evidence` hacia `evidence_requirements_satisfied` cuando el payload no declaraba cobertura, aprobando obligaciones críticas sin que un recibo de ejecución hubiera atestiguado la prueba efectiva. ## Decision -Derivar `evidence_requirements_satisfied` en `verifyCandidate` exclusivamente a partir de runner receipts confiables (`receipts` / `runner_receipts`) emitidos por el harness de ejecución. Se prohíbe explícitamente la copia automática o por defecto de `node.required_evidence`; si no hay recibo que lo atestigüe, el conjunto de satisfacción es vacío y la obligación MUST falla con `UNFULFILLED_MUST`. +Derivar `evidence_requirements_satisfied` en `verifyCandidate` exclusivamente a partir de runner receipts emitidos por el harness de ejecución. Se prohíbe explícitamente la copia automática o por defecto de `node.required_evidence`; si no hay recibo que lo atestigüe, el conjunto de satisfacción es vacío y la obligación MUST falla con `UNFULFILLED_MUST`. + +La decisión original no definió cómo demostrar la autoridad del receipt ni su binding exacto a Evidence. ADR-005 sustituye esa parte con `runner-receipt/v1` y un canal opaco; conserva la prohibición de blind copy. ## Alternatives - Mantener la copia por defecto de `node.required_evidence` cuando no se especifica satisfacción — rechazado: genera falsos positivos donde la mera existencia de un archivo da por probada una obligación. diff --git a/docs/adr/adr-20260828-012-strict-causal-chronology-execution-sequence.md b/docs/adr/adr-20260828-012-strict-causal-chronology-execution-sequence.md index 97f109d9..01d07ed3 100644 --- a/docs/adr/adr-20260828-012-strict-causal-chronology-execution-sequence.md +++ b/docs/adr/adr-20260828-012-strict-causal-chronology-execution-sequence.md @@ -1,6 +1,6 @@ # ADR-003: Cronología causal estricta mediante execution_sequence -- Status: proposed +- Status: accepted - Change: k6b-trusted-evidence-replay-closure - Date: 2026-08-28 @@ -8,7 +8,7 @@ Las estrategias temporales (`strict-tdd`, `bug`, `refactor`) recurrían al orden posicional de los elementos en el array JSON de `rawEvidence` para evaluar la secuencia cronológica, lo que permitía simular TDD o refactorizaciones invirtiendo el orden de las evidencias en el array sin atestación causal. ## Decision -Exigir obligatoriamente en `assertRoleOrder` un objeto `execution_sequence` válido (`run_id` consistente, `ordinal` monotónico creciente y encadenamiento `previous_evidence_id`) para cada evidencia en estrategias `strict-tdd`, `bug` y `refactor`. Se prohíbe de forma tajante el fallback a índices de array JSON, fallando inmediatamente con `STRATEGY_SEQUENCE_VIOLATION` ante ausencia o violación de orden. +Exigir obligatoriamente en `assertRoleOrder` un `execution_sequence` emitido por el receipt confiable para cada transición temporal de `strict-tdd`, `bug` y `refactor`. Todos los eventos usan un `run_id` no vacío y consistente; los ordinales son enteros positivos, únicos y crecientes; cada evento posterior a la raíz declara `previous_evidence_id` igual al EvidenceId inmediatamente anterior. Se prohíbe el fallback a índices del array JSON y se falla con `STRATEGY_SEQUENCE_VIOLATION` ante ausencia o violación causal. ## Alternatives - Mantener el índice de array como fallback si falta `execution_sequence` — rechazado: la posición en un array JSON no tiene valor criptográfico ni atestación temporal. diff --git a/docs/adr/adr-20260828-013-cryptographic-evidence-replay-integrity.md b/docs/adr/adr-20260828-013-cryptographic-evidence-replay-integrity.md index ae1aeec2..e121be49 100644 --- a/docs/adr/adr-20260828-013-cryptographic-evidence-replay-integrity.md +++ b/docs/adr/adr-20260828-013-cryptographic-evidence-replay-integrity.md @@ -1,6 +1,6 @@ # ADR-004: Replay criptográficamente íntegro en Assurance Graph -- Status: proposed +- Status: accepted - Change: k6b-trusted-evidence-replay-closure - Date: 2026-08-28 @@ -8,7 +8,7 @@ La validación en `replayAssuranceGraph` (`validateReplayRecords`) omitía la recomputación de `computeEvidenceId` y la invocación de `evaluateProvenanceSufficiency` durante la revalidación de evidencias, lo que permitía persistir o reproducir grafos con identificadores desfasados o procedencias débiles sin detección. ## Decision -Extender `validateReplayRecords` para recomputar exhaustivamente el digest de bytes con `digestRawBytes(bytes)`, recomputar `computeEvidenceId(record, bytes)`, contrastar ambos contra `record.digest` y `record.evidence_id`, y ejecutar `evaluateProvenanceSufficiency(record)`. Cualquier discrepancia, mutación de bytes o insuficiencia de procedencia detona inmediatamente un fallo `GRAPH_DIVERGENCE`. +Extender `validateReplayRecords` para exigir bytes inline o un `observation_blob_id` content-addressed resoluble, recomputar exhaustivamente el digest con `digestRawBytes(bytes)`, recomputar `computeEvidenceId(record, bytes)`, contrastar ambos contra `record.digest` y `record.evidence_id`, y ejecutar `evaluateProvenanceSufficiency(record)`. La ausencia de material de observación, una referencia no resoluble, cualquier discrepancia, mutación de bytes o insuficiencia de procedencia detona inmediatamente `GRAPH_DIVERGENCE`; no existe modo de replay criptográfico parcial. ## Alternatives - Revalidar únicamente la conformidad sintáctica contra el esquema JSON — rechazado: no detecta sustitución de hashes ni inconsistencias en identificadores derivados. diff --git a/docs/adr/adr-20260828-014-runner-receipt-authority-binding.md b/docs/adr/adr-20260828-014-runner-receipt-authority-binding.md new file mode 100644 index 00000000..98d2f2af --- /dev/null +++ b/docs/adr/adr-20260828-014-runner-receipt-authority-binding.md @@ -0,0 +1,23 @@ +# ADR-005: Autoridad y binding exacto de RunnerReceipt + +- Status: accepted +- Change: k6b-receipt-binding-and-replay-finalization +- Date: 2026-08-28 + +## Context +`verifyCandidate` aceptaba `runner_receipts` como DTOs ordinarios del mismo caller que aportaba `rawEvidence`. Además, un receipt sin `evidence_id` podía asociarse por nodo o posición. Mover las aserciones semánticas fuera de `rawEvidence` no establecía por sí solo una frontera de confianza. + +## Decision +Introducir `runner-receipt/v1` con `receipt_id` content-addressed, `candidate_id`, `evidence_id` obligatorio, `node_id`, `role`, `satisfied_tokens`, `outcome`, `issuer_id`, `transport` y secuencia temporal cuando aplique. El verifier solo consume receipts desde un `runnerReceiptChannel` opaco registrado en un `WeakMap` privado del runtime; copiar sus campos públicos no reproduce la capacidad. + +Rechazar propiedades caller-owned `receipts` y `runner_receipts`, recomputar `receipt_id`, validar issuer/transport contra el canal y exigir igualdad exacta de Evidence, Candidate y nodo. No se permite matching por posición, por nodo ni fallback de role a `node.kind`. `outcome: failed` con tokens satisfechos es un receipt inválido. + +## Alternatives +- Confiar en `issuer_id` y `transport` como strings del DTO: rechazado porque el caller puede copiarlos. +- Firmar receipts con PKI: rechazado por no existir un trust root operativo y por ser desproporcionado para una frontera in-process. +- Mantener matching por índice dentro del verifier: rechazado porque no demuestra `R proves E`. + +## Consequences +- Facilita: cada claim semántico queda ligado content-addressed a una Evidence concreta y a una capacidad de runtime no serializable. +- Dificulta: runners y tests deben emitir receipts completos mediante el canal; los DTOs legacy fallan cerrados y deben regenerarse. +- Reversibilidad: Media; relajar el canal o `evidence_id` reabriría la frontera de confianza cerrada por K6b. diff --git a/docs/architecture/harness-evolution.md b/docs/architecture/harness-evolution.md index b0ef5184..e80391c1 100644 --- a/docs/architecture/harness-evolution.md +++ b/docs/architecture/harness-evolution.md @@ -1,8 +1,8 @@ # Arquitectura objetivo — harness gobernado por kernel, grafo y evidencia > **Autoridad:** fuente conceptual y estratégica del harness (responsabilidades y límites). -> **Corte documental:** v2.53.0, 2026-08-28 (estado alineado al roadmap; la dirección conceptual no cambia). -> **Estado verificado:** O3, O4+O5/O4.1, O4.2, O6A, O2B, **K1**, **K2**, **K2.1**, **K2a**, **K3**, **`k3-readiness-remediation`**, **K4a**, **K5**, **K6a**, **K4b** y **K6b** están cerrados. OpenSpec/Git/Candidate siguen siendo la única autoridad semántica; el Assurance Graph es proyección. **K6c** queda `next-eligible`. +> **Corte documental:** v2.53.1, 2026-08-28 (estado alineado al roadmap; la dirección conceptual no cambia). +> **Estado verificado:** O3, O4+O5/O4.1, O4.2, O6A, O2B, **K1**, **K2**, **K2.1**, **K2a**, **K3**, **`k3-readiness-remediation`**, **K4a**, **K5**, **K6a** y **K4b** están cerrados. **K6b** permanece `revise` con remediación focal implementada y terminal review pendiente; **K6c** está `blocked`. OpenSpec/Git/Candidate siguen siendo la única autoridad semántica; el Assurance Graph es proyección. > **Roadmap:** orden, estado operativo y done criteria viven en [`../roadmaps/harness-evolution.md`](../roadmaps/harness-evolution.md). > **Precedencia documental:** ante diferencias de **orden o estado**, prevalece el roadmap; ante diferencias **conceptuales**, reconciliar antes de iniciar el slice. > **Investigación no normativa:** la trazabilidad completa P0–P27 vive en [`research/harness-kernel-graph-evidence-roadmap-fusion.md`](research/harness-kernel-graph-evidence-roadmap-fusion.md). La proporcionalidad de proceso y el programa de changes viven en [`research/proportional-process-and-change-program.md`](research/proportional-process-and-change-program.md). @@ -21,7 +21,7 @@ Sin duplicar el backlog: solo responsabilidades y límites alineados al roadmap | Tema | Decisión arquitectónica | | --- | --- | -| Estado | K1+K2+K2.1+K2a+K3+`k3-readiness-remediation`+K4a+K5+K6a+K4b+K6b `done`; **K6c** `next-eligible` | +| Estado | K1+K2+K2.1+K2a+K3+`k3-readiness-remediation`+K4a+K5+K6a+K4b `done`; **K6b** `revise`; **K6c** `blocked-by-K6b-terminal-review` | | Dos grafos | **Execution Graph** (trabajo) ≠ **Assurance Graph** (fiabilidad / evidencia; no “prueba formal”) | | Identidades | `SourceSnapshotId` / `WorkOrderId` / `WorkResultId` / `CandidateId` (sin IDs nuevos por ahora) | | Relación Candidate | Inicial: `exact` / `changed` / `ambiguous` / `unknown`; `compatible-base-advance` experimental hasta K9 | @@ -30,6 +30,7 @@ Sin duplicar el backlog: solo responsabilidades y límites alineados al roadmap | Policy | `PolicySnapshot` digiere bundle/classifier/compiler/runtime/`effectiveRules` | | Cierre | `ArchiveTransactionReceipt` ≠ `CandidateEvaluationAttestation` ≠ `DeliveryAuthorization` | | Schemas de cierre | `receipt/v1` (K1, envelope legacy genérico) permanece; K8 y K10-delivery introducen schemas propios — no reutilizar `receipt/v1` como contrato canónico | +| Receipt de ejecución | K6b usa `runner-receipt/v1`, content-addressed y Evidence-bound; solo un canal opaco de runtime concede autoridad. Strings issuer/transport no bastan. | | Host | Seis targets; **K2a** = Headless Conformance Host + un adapter real + CapabilityProof; **K11a** expande a los cinco restantes | | Obligations | **K4a:** Obligation Manifest como vista determinista del Graph (no tercer grafo) | | Compile vs execute | **K4a** compila; **K6a** ejecuta (primitives); **K4b** orquesta Repair shadow; **K3** identifica | @@ -54,6 +55,19 @@ Añade límites; **no** mueve next-eligible, no reabre `done` y no crea un slice | Contexto | Prompt de worker/fase efímero; contrato, candidate, budgets, findings y evidencia persistentes. Compact/sesión nueva no resetea linaje. `/sdd-continue {nombre}` reanuda un change; no una cola. | | Rechazado | `architect-agent`, fase `architecture`, ruta `epic`, pipeline de cinco agentes, agentes espejo `*-cheap`, milestone paralelo. | +### Corte correctivo 2026-08-28 (fronteras K6b) + +El review terminal de v2.53.1 no cambia la dirección del kernel, pero obliga a materializar dos interfaces que antes eran solo intención arquitectónica. + +| Tema | Decisión arquitectónica | +| --- | --- | +| RunnerReceipt | DTO caller-owned ≠ autoridad. `runner-receipt/v1` requiere EvidenceId y receipt_id; el verifier solo lo acepta desde una capacidad opaca registrada por el runtime. | +| Matching | Solo igualdad de `evidence_id`, `candidate_id` y `node_id`; no posición, no nodo como fallback, no `node.kind` como role. | +| Outcome | Un receipt fallido puede probar RED, pero no declarar `satisfied_tokens`. | +| Chronology | Un run no vacío, ordinales únicos y cada transición enlaza el EvidenceId inmediatamente anterior. | +| Replay | Cada Evidence lleva bytes inline o blob CAS resoluble. Sin material no hay recomputación criptográfica y el replay falla con `GRAPH_DIVERGENCE`. | +| Gate | K6b sigue `revise`; K6c no comienza hasta terminal review objetivo de estas garantías. | + ## Ruta rápida 1. [Modelo de autoridad](#modelo-de-autoridad). @@ -855,7 +869,7 @@ Repositorios fixture reciben 10–30 cambios consecutivos. Se miden duplicación 8. ~~K5: budgets (incl. autoridad/efectos) / failure / recovery~~ — hecho: archivado y publicado en v2.45.13 (remediaciones v2.45.7→v2.45.13). 9. ~~K6a: primitivas de ejecución aislada (`CreateWorkspace`…`DisposeWorkspace`); no conoce Repair~~ — hecho: archivado y publicado en v2.46.7; frontera de procesos cerrada en v2.47.1; endurecimiento de frontera (política inmutable, fs mutante, live-identity, `worker_threads`) en v2.47.2. 10. ~~K4b: orquesta Repair shadow (consume K6a; freeze Candidate vía K3)~~ — hecho: publicado en v2.48.0; corrección en v2.48.1; invariantes de integración en v2.48.2; cierre mode-only/baseline en v2.48.3. -11. ~~K6b: verifier + provenance + Assurance Graph (proyección)~~ — publicado en v2.50.0; integridad semántica B1–B3/H1–H3 cerrada en v2.52.0. K6c ChallengePlan queda `next-eligible`; K6d complexity delta sigue pendiente. +11. K6b: verifier + provenance + Assurance Graph (proyección) — publicado desde v2.50.0 y endurecido hasta v2.53.1; reabierto como `revise` por receipt authority/binding, causal chain y replay material obligatorio. Remediación focal implementada; terminal review pendiente. K6c permanece bloqueado. 12. K7: ReviewAdapter + ReviewReducer + lineage; K8: CandidateEvaluationAttestation (emisión CAS). 13. K9: shadow/replay/A-B; promoción de **un** profile (checkpoints intermedios ya validados). 14. K10-delivery: DeliveryAuthorization **solo** del profile promovido; relación Candidate por etapas; resto fixed/deferred. diff --git a/docs/roadmaps/harness-evolution.md b/docs/roadmaps/harness-evolution.md index 1f3eaa30..4bb57530 100644 --- a/docs/roadmaps/harness-evolution.md +++ b/docs/roadmaps/harness-evolution.md @@ -1,7 +1,7 @@ # Roadmap general — kernel, grafo y evidencia > **Autoridad:** única fuente operativa del backlog transversal. -> **Versión de referencia:** v2.53.0, 2026-08-28. +> **Versión de referencia:** v2.53.1, 2026-08-28. > **Arquitectura:** [`../architecture/harness-evolution.md`](../architecture/harness-evolution.md). > **Investigación no normativa:** [`../architecture/research/harness-kernel-graph-evidence-roadmap-fusion.md`](../architecture/research/harness-kernel-graph-evidence-roadmap-fusion.md) (P0–P27). Proporcionalidad de proceso y Change Program: [`../architecture/research/proportional-process-and-change-program.md`](../architecture/research/proportional-process-and-change-program.md). > **Regla de estado:** los hechos se contrastan con código/OpenSpec; este roadmap no cambia el estado de un change ni sustituye sus artefactos. @@ -78,8 +78,8 @@ Las iniciativas anteriores no se descartan. O20A, O13A–C, O15, O18, O19A/B y R | `done` | **K5** | Budgets (incl. autoridad/efectos), failures y recovery; remediaciones v2.45.7→v2.45.13 (authoritative enforcement, authority boundary/CAS concurrency, reconciliación, remediación técnica del núcleo y blindaje de concurrencia); archivado y publicado en v2.45.13 | | `done` | **K6a** | Worker isolation y work-order capsule; primitivas de ejecución aislada, integración con WorkerTransport, contención de filesystem y WorkResult canónico; archivado en v2.46.0, frontera de procesos cerrada en v2.47.1 y endurecida en v2.47.2 | | `done` | **K4b** | Repair shadow execution (WO→WR→integrate→Candidate); despacho exclusivo K6a, integración estricta, cápsula mínima, base derivada y registro 1:N; remediación de invariantes en v2.48.2 y cierre mode-only/baseline en v2.48.3 (`2026-08-26-k4b-mode-only-and-baseline-projection`) | -| `done` | **K6b** | Verifier independiente, strategies/provenance y Assurance Graph proyección; integridad semántica, estabilidad de esquemas (assessment v2/v1), desacoplamiento rawEvidence, matriz de roles y replay cerrados en v2.53.0 (`2026-08-28-k6b-evidence-binding-and-schema-stability-remediation`) | -| `next-eligible` | **K6c** | ChallengePlan policy-selected; desbloqueado tras archive definitivo de K6b | +| `revise` | **K6b** | v2.53.1 cerró inyección semántica en `rawEvidence`, blind copy y chronology por array, pero el terminal review reabrió receipt authority/binding, causalidad completa y replay criptográfico obligatorio. Remediación implementada en working tree; pendiente terminal review. | +| `blocked-by-K6b-terminal-review` | **K6c** | ChallengePlan policy-selected; no iniciar hasta verdict terminal objetivo de K6b | | `pending` | K6d–K8 | Complexity delta, review authority, **Evaluation Attestation** | | `pending` | K9 | Gate de promoción shadow/replay/A-B (checkpoints intermedios ya validados) | | `pending` | K10-delivery | `DeliveryAuthorization` **acotada al profile K9**; relación Candidate por etapas; fixed/deferred para el resto | @@ -184,9 +184,11 @@ Campo canónico de binding al candidato: **`candidate_id`** (no `candidate_diges ```text Entregado: -G0/G0.1 ─ O2A ─ O3 ─ O4+O5/O4.1 ─ O4.2 ─ O6A ─ O2B → K1 → K2 → K2.1 → K2a → K3 → K4a → K5 → K6a → K4b → K6b +G0/G0.1 ─ O2A ─ O3 ─ O4+O5/O4.1 ─ O4.2 ─ O6A ─ O2B → K1 → K2 → K2.1 → K2a → K3 → K4a → K5 → K6a → K4b ↓ -Next: K6c (next-eligible) +Revise: K6b (terminal review pending) + ↓ +Blocked: K6c ↓ Pending: K6d → K7 → K8 ↓ @@ -1007,11 +1009,11 @@ Take compiled Repair Execution Graph (K4a) Vertical Repair shadow produce Candidate congelado. Gate de invariantes cerrado en v2.48.3: mode-only exige path existente y `old mode` de la base; la comparación baseline es graph-bound sin préstamo del Graph shadow. Desbloquea K6b. El resultado de O20A decide **promover, revisar o rechazar** el kernel común solo tras K9; rechazo conserva fixed. -### K6b — verifier independiente, evidence strategies y Assurance Graph — **done** +### K6b — verifier independiente, evidence strategies y Assurance Graph — **revise** **Dependencias:** K4b + K6a + K3. -**Estado:** `done`. Publicado en v2.50.0 (`k6b-verifier-evidence-assurance-graph`); la remediación semántica focal B1–B3/H1–H3 (`k6b-semantic-integrity-remediation`) cierra cobertura MUST, assessments persistibles `assessment/v1`, `graph_id` canónico y proyección/replay/reconciliación fail-closed en v2.52.0. Este change no reabre el macro-slice. OpenSpec/Git/Candidate siguen siendo la única autoridad semántica. El grafo no concede lifecycle, approval ni delivery. **K6c queda `next-eligible`.** +**Estado:** `revise`. Publicado inicialmente en v2.50.0 y endurecido hasta v2.53.1. El review terminal de v2.53.1 confirmó los cierres de metadata caller-owned, blind copy y array chronology, pero detectó tres contratos aún parciales: receipts sin autoridad/binding exacto, `run_id`/`previous_evidence_id` no obligatorios y replay sin material de observación. La remediación focal implementa `runner-receipt/v1` sobre canal opaco, cadena causal completa y replay obligatorio con bytes/blob CAS. K6b permanece `revise` y **K6c `blocked` hasta un terminal review objetivo**. OpenSpec/Git/Candidate siguen siendo la única autoridad semántica; el grafo no concede lifecycle, approval ni delivery. **Absorbe/rebasa:** P12/P16; O15; separación apply/verify vigente. @@ -1063,6 +1065,9 @@ No se reejecuta “absolutamente todo” por defecto; tampoco se reutiliza evide - verifier consume contract, Execution Graph, **`CandidateId` (no `WorkResultId`)**, repo y raw evidence; - strategies bug/feature/refactor/migration/config-docs; - evidence refs con origin, hash y node binding **como nodos/edges del Assurance Graph**; +- `runner-receipt/v1` content-addressed, Evidence-bound y consumido exclusivamente desde una capacidad opaca de runtime; +- chronology temporal con un único `run_id`, ordinales estrictos y `previous_evidence_id` obligatorio en cada transición; +- replay Evidence bundle con bytes o `observation_blob_id` resoluble; ausencia de material implica `GRAPH_DIVERGENCE`; - **provenance de evidencia** (obligatorio en cada evidence node): ```text @@ -1084,7 +1089,7 @@ external-unverified - convertir el Assurance Graph en autoridad de lifecycle, approval o delivery; - review lenses / findings (K7 consume y extiende edges `reviewed-by`); -- receipt binding completo (K8 digiere roots como Evaluation Attestation; no redefine el grafo); +- Evaluation Attestation de K8 (digiere roots de K6b, pero no redefine receipt authority ni el grafo); - arreglar first-match de la tabla de routing de producto (`standard` sombreando `lite`); es compatibilidad viva, no verifier; - introducir Change Program / cola de OpenSpec changes concatenados; - Quality Attribute identities, gate Architecture Readiness o pipeline de cinco agentes. @@ -1100,12 +1105,16 @@ external-unverified - fixtures de successor invalidan solo el closure afectado y **conservan** evidencia independiente; - fixtures rechazan reusar evidencia bajo un edge `invalidates` transitivo; - Strict TDD sigue disponible y por defecto; +- caller DTOs de receipts y matching posicional fallan cerrados; +- receipt `outcome: failed` no puede satisfacer tokens; +- chronology mixta, sin predecessor o con chain incorrecta falla cerrada; +- replay sin bytes/blob resoluble falla cerrado y el bundle completo conserva `graph_id`; - equivalence manifest queda listo para K9; - ningún consumer trata el Assurance Graph como segunda fuente de verdad frente a OpenSpec/Git/Candidate. -**Gate terminal:** verifier, strategy selector, provenance y Assurance Graph (proyección) conformes; checkpoint evidencia/challenges → `continue` | `revise` | `reject`. Cerrado en v2.52.0. Desbloquea K6c. +**Gate terminal:** pendiente. Requiere validar objetivamente los findings congelados B1 receipt authority/binding, B2 chronology causal y B3 replay obligatorio, además de H1 outcome. Solo un verdict terminal `continue` desbloquea K6c. -### K6c — adversarial challenges (policy-selected) — **next-eligible** +### K6c — adversarial challenges (policy-selected) — **blocked-by-K6b-terminal-review** **Dependencias:** K6b (+ `PolicySnapshot` / strategy de evidencia). @@ -2063,3 +2072,5 @@ Un Change Program (objetivo → children OpenSpec + cursor, ver investigación ` - 2026-08-27: K6b entra en `revise` (`k6b-verification-integrity-remediation`): cobertura MUST, assessments persistibles, provenance de collector, `graph_id` canónico y proyección fail-closed. K6c pasa a `blocked-by-K6b-remediation` hasta archive de esa remediación. - 2026-08-27: K6b (`k6b-semantic-integrity-remediation`) entra en apply para remediar B1–B3/H1–H3; K6b queda `revise` y K6c `blocked-by-K6b-remediation` hasta archive. - 2026-08-28: K6b (`k6b-semantic-integrity-remediation`) cierra con verify PASS, 4R approved y archive transaccional; publicado en v2.52.0. K6c queda next-eligible. +- 2026-08-28: review terminal del tag v2.53.1 reabre K6b como `revise`: RunnerReceipt no demuestra autoridad ni binding exacto, chronology no exige `run_id`/chain completos y replay permite omitir bytes. K6c vuelve a `blocked-by-K6b-terminal-review`. +- 2026-08-28: remediación focal implementada en working tree: `runner-receipt/v1` por canal opaco con EvidenceId obligatorio y outcome coherente; causal chain completa; replay exige bytes o blob content-addressed. Pendiente terminal review, sin promover aún K6b ni iniciar K6c. diff --git a/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/verify-report.md b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/verify-report.md index 75c1b565..5f0bce43 100644 --- a/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/verify-report.md +++ b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/verify-report.md @@ -1,9 +1,11 @@ ## Verification Report **Change**: k6b-trusted-evidence-replay-closure -**Version**: 2.53.0 +**Version**: 2.53.1 **Mode**: Standard (focused) +> **Errata post-release (2026-08-28):** este reporte conserva la ejecución histórica del change, pero su conclusión de compliance fue invalidada por el review terminal del tag v2.53.1. Los tests originales pasaron, aunque no demostraban autoridad/binding de RunnerReceipt, cadena causal completa ni replay criptográfico obligatorio sin bytes. El veredicto efectivo de este reporte es `REVISE`; K6c permanece bloqueado. + ### Completeness | Metric | Value | |--------|-------| @@ -71,28 +73,28 @@ All checks passed (2790+ unit, integration, schema and scope-guard tests passed) | REQ-assurance-graph-006 | Insufficient provenance during evidence replay fails replay | `runtime-test` | `scripts/lib/assurance-graph/index.test.js` > "REQ-assurance-graph-006: replay rejects insufficient provenance (model-reported)" | PASS | Revalidación con evaluateProvenanceSufficiency detecta procedencia no admisible (H1) | | REQ-assurance-graph-006 | Verification v2 referencing non-existent evidence_id fails replay | `runtime-test` | `scripts/lib/assurance-graph/index.test.js` > "REQ-assurance-graph-006: replay rejects evidence and verification mutations" | PASS | Falla con GRAPH_DIVERGENCE | -**Compliance summary**: 28/28 scenarios satisfied at acceptable evidence levels (100% runtime-test) +**Compliance summary**: N/A. La ejecución original obtuvo 28/28 tests verdes, pero el review post-release demostró que la matriz no cubría todas las MUST declaradas y no establece compliance completa. ### Correctness (Static Evidence) | Requirement | Status | Notes | |------------|--------|-------| -| REQ-independent-verification-003 | ✅ Implemented | `normalizeEvidence` en `scripts/lib/independent-verifier/evidence.js` valida ausencia de metadatos semánticos en `rawEvidence` y falla con `UNTRUSTED_CALLER_METADATA`. | +| REQ-independent-verification-003 | ⚠️ Partial en v2.53.1 | `normalizeEvidence` rechaza metadata semántica, pero el tag aceptaba `runner_receipts` caller-owned y matching sin EvidenceId obligatorio. | | REQ-independent-verification-005 | ✅ Implemented | `verifyCandidate` en `scripts/lib/independent-verifier/index.js` y `walkMustObligations` en `obligation-coverage.js` derivan satisfacción exclusivamente desde runner receipts sin copiar `node.required_evidence`. | -| REQ-independent-verification-006 | ✅ Implemented | `assertRoleOrder` en `scripts/lib/independent-verifier/strategy-policy.js` valida orden monotónico y encadenamiento causal sobre `execution_sequence` para estrategias temporales sin fallback a índices de array. | -| REQ-assurance-graph-006 | ✅ Implemented | `validateReplayRecords` / `replayAssuranceGraph` en `scripts/lib/assurance-graph/index.js` recomputa `digestRawBytes`, `computeEvidenceId` y valida `evaluateProvenanceSufficiency`, fallando con `GRAPH_DIVERGENCE`. | +| REQ-independent-verification-006 | ⚠️ Partial en v2.53.1 | El tag eliminó fallback por array, pero no exigía `run_id` no vacío/consistente ni `previous_evidence_id` en cada transición. | +| REQ-assurance-graph-006 | ⚠️ Partial en v2.53.1 | El tag recomputaba digest/EvidenceId solo cuando recibía bytes; Evidence sin material de observación seguía reproduciéndose como válida. | ### Coherence (Design) | Decision | Followed? | Notes | |----------|-----------|-------| | Segregación estricta de rawEvidence con rechazo UNTRUSTED_CALLER_METADATA | ✅ Yes | `evidence.js` inspecciona las propiedades del payload `raw` antes de cualquier procesamiento y rechaza fail-closed si contiene `role`, `obligation_ids`, `obligation_id` o `evidence_requirements_satisfied`. | -| Derivación autoritativa de satisfacción desde Runner Receipts | ✅ Yes | `index.js` deriva satisfacción únicamente de `input.receipts` / `input.runner_receipts`, asignando `[]` si no hay recibo que confirme los tokens requeridos. | -| Cronología causal estricta mediante execution_sequence | ✅ Yes | `strategy-policy.js` exige `execution_sequence` con `run_id`, `ordinal` y `previous_evidence_id` para `strict-tdd`, `bug` y `refactor`, rechazando de plano el fallback a índices de array. | -| Replay criptográficamente íntegro en Assurance Graph con computeEvidenceId y provenance sufficiency | ✅ Yes | `assurance-graph/index.js` recomputa ID y digest de bytes y evalúa la procedencia durante la revalidación de registros de replay. | +| Derivación autoritativa de satisfacción desde Runner Receipts | ❌ No en v2.53.1 | `input.receipts` / `input.runner_receipts` seguían bajo control del caller y no exigían binding exacto a Evidence. | +| Cronología causal estricta mediante execution_sequence | ❌ No en v2.53.1 | Solo se exigía ordinal; `run_id` y chain eran parciales. | +| Replay criptográficamente íntegro en Assurance Graph con computeEvidenceId y provenance sufficiency | ❌ No en v2.53.1 | La recomputación era condicional a la presencia opcional de bytes. | ### Issues Found -**CRITICAL**: None -**WARNING**: None -**SUGGESTION**: None +**CRITICAL**: 3 findings post-release: RunnerReceipt authority/binding, chronology causal completa y observation material obligatorio en replay. +**WARNING**: 1 finding post-release: `outcome: failed` no impedía declarar tokens satisfechos. +**SUGGESTION**: eliminar fallback de role a `node.kind` y añadir guard de consistencia de versión documental. ### Traceability Matrix | REQ | Tasks | Commits | Tests | Status | @@ -103,5 +105,5 @@ All checks passed (2790+ unit, integration, schema and scope-guard tests passed) | REQ-assurance-graph-006 | 4.1, 4.2, 4.3, 5.2, 5.3 | working-tree | `scripts/lib/assurance-graph/index.test.js`, `test/e2e/k6b-verifier-assurance-graph-e2e.test.js` | OK | ### Verdict -PASS -Implementación 100% conforme con las especificaciones y el diseño: segregación física estricta de rawEvidence (B1), derivación autoritativa desde runner receipts sin blind copy (B2), causalidad execution_sequence sin fallback a array (B3), y replay exhaustivo criptográfico con computeEvidenceId y provenance sufficiency (H1); 28/28 escenarios verificados con pruebas automatizadas en runtime-test y suite completa npm test en verde con 0 fallos. +REVISE +La ejecución histórica quedó verde, pero no demostró tres MUST del contrato. Este reporte no autoriza el cierre terminal de K6b ni el inicio de K6c. La remediación posterior debe aportar adversariales para canal confiable y binding por EvidenceId, `run_id`/chain obligatorios y replay fail-closed sin bytes/blob resoluble. diff --git a/openspec/config.yaml b/openspec/config.yaml index 0436a51e..91dd5db9 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -50,8 +50,11 @@ baseline: - sdd-document - contract-lint - orchestrator-evals + - independent-verification + - assurance-graph + - kernel-contract-schemas stale_domains: [] - last_checked: "2026-07-07T03:10:00Z" + last_checked: "2026-08-28T12:09:36Z" rules: foundation: diff --git a/openspec/specs/_baseline/index.md b/openspec/specs/_baseline/index.md index 6079b23e..fc0bf11e 100644 --- a/openspec/specs/_baseline/index.md +++ b/openspec/specs/_baseline/index.md @@ -15,3 +15,6 @@ source: local - marker-hygiene: tagging markers with origin: explore to selectively suppress fail-open no-remote warnings in the atlas loader → ../marker-hygiene/spec.md - sdd-baseline-federation-contract: parameters and adaptations for sdd-baseline in federated mode including member-local spec write target and aggregated state updates → ../sdd-baseline-federation-contract/spec.md - unified-baseline-gate: unified domain-map approval gate (batch-0) covering all brownfield members simultaneously to replace per-member prompts → ../unified-baseline-gate/spec.md +- independent-verification: Independent verifier over frozen CandidateId; evidence strategies, provenance, runner receipts, MUST coverage → ../independent-verification/spec.md +- assurance-graph: Content-addressed Assurance Graph projection, replay, and reconcile → ../assurance-graph/spec.md +- kernel-contract-schemas: Versioned kernel JSON Schema families, manifest, claims, and K1 pins → ../kernel-contract-schemas/spec.md diff --git a/openspec/specs/_baseline/manifest.md b/openspec/specs/_baseline/manifest.md index 0c38b76b..57e6fa6d 100644 --- a/openspec/specs/_baseline/manifest.md +++ b/openspec/specs/_baseline/manifest.md @@ -9,6 +9,9 @@ - skill-registry: Skill discovery, fingerprinting, and JSON cache management used at SessionStart | sources: scripts/lib/skill-registry.js, .ospec/cache/ - install: Per-target installation commands (Claude marketplace, opencode, github-copilot) that build and sync the generated tree into a destination repo | sources: scripts/configure/install-claude.js, scripts/configure/install-target.js - sdd-document: Executor agent responsible for generating project technical wikis following cognitive documentation design | sources: skills/sdd-document/SKILL.md, agents/sdd-document.agent.md, commands/sdd-document.prompt.md, scripts/sdd-document.test.js +- independent-verification: Independent verifier over a frozen CandidateId (evidence strategies, collector provenance, runner-receipt authority, MUST obligation coverage) | sources: scripts/lib/independent-verifier/*.js, scripts/lib/independent-verifier/internal/*.js, scripts/lib/test-support/k6b-runner-receipt.js +- assurance-graph: Content-addressed Assurance Graph projection, cryptographic replay, and stored-payload reconcile | sources: scripts/lib/assurance-graph/*.js +- kernel-contract-schemas: Versioned kernel JSON Schema families, manifest, contract-claims, and K1 compatibility pins | sources: schemas/kernel/**/*.schema.json, schemas/kernel/manifest.json, schemas/kernel/contract-claims.json, scripts/lib/lifecycle-kernel/k1-compat.js ## Entries (append-only log; latest row per domain wins) | domain | status | batch | commit | timestamp (UTC) | @@ -41,4 +44,7 @@ | agents | reconciled | - | 359deff | 2026-08-03T22:09:11Z | | install | reconciled | - | 359deff | 2026-08-03T22:09:11Z | | sdd-document | reconciled | - | 359deff | 2026-08-03T22:09:11Z | +| independent-verification | skipped | - | 71d5114 | 2026-08-28T12:09:36Z | +| assurance-graph | skipped | - | 71d5114 | 2026-08-28T12:09:36Z | +| kernel-contract-schemas | skipped | - | 71d5114 | 2026-08-28T12:09:36Z | diff --git a/openspec/specs/assurance-graph/spec.md b/openspec/specs/assurance-graph/spec.md index b54bb4fd..3ec76aa3 100644 --- a/openspec/specs/assurance-graph/spec.md +++ b/openspec/specs/assurance-graph/spec.md @@ -162,15 +162,15 @@ fail closed. Edges whose `relation` is outside ### Requirement: Replay From Persistable Outputs {#REQ-assurance-graph-006} -The Assurance Graph MUST be reproducible from persistable outputs: canonical input digests, projected nodes and edges, evidence records (`evidence/v2`), verification records (`verification/v2`), and assessment records (`assessment/v2`) that carry `obligation_id`. Replay MUST NOT require ephemeral projector fields. Consumers MUST NOT reinvent `obligation_id` values from vanished fields. `satisfies` edges MUST be rebuildable from persistable assessments where `evidence_requirements_satisfied.length > 0`. +The Assurance Graph MUST be reproducible from persistable outputs: canonical input digests, projected nodes and edges, replay Evidence bundles containing each `evidence/v2` plus raw observation bytes or a resolvable content-addressed `observation_blob_id`, verification records (`verification/v2`), and assessment records (`assessment/v2`) that carry `obligation_id`. Replay MUST NOT require ephemeral projector fields. Consumers MUST NOT reinvent `obligation_id` values from vanished fields. `satisfies` edges MUST be rebuildable from persistable assessments where `evidence_requirements_satisfied.length > 0`. `replayAssuranceGraph` MUST perform comprehensive validation over all replayed records before accepting the replay: -1. `evidence/v2`: schema validity against `evidence/v2.schema.json`; `candidate_id` matching graph subject; recomputed content digest via `digestRawBytes` matching `record.digest`; recomputed `evidence_id` via `computeEvidenceId` matching `record.evidence_id`; evaluation of provenance sufficiency via `evaluateProvenanceSufficiency` verifying admissible provenance against trusted collector or transport metadata; and strict absence of `verdict`. +1. `evidence/v2`: schema validity against `evidence/v2.schema.json`; REQUIRED inline raw bytes or a resolvable `observation_blob_id` equal to `record.digest`; `candidate_id` matching graph subject; recomputed content digest via `digestRawBytes` matching `record.digest`; recomputed `evidence_id` via `computeEvidenceId` matching `record.evidence_id`; evaluation of provenance sufficiency via `evaluateProvenanceSufficiency` verifying admissible provenance against trusted collector or transport metadata; and strict absence of `verdict`. 2. `verification/v2`: schema validity against `verification/v2.schema.json`; recomputed `verification_id`; `candidate_id` matching graph subject; and `evidence_ids` being a strict subset of replayed evidence IDs. 3. `assessment/v2`: schema validity against `assessment/v2.schema.json`; recomputed `assessment_id`; `candidate_id` matching graph subject; bound `policy_snapshot_id` matching graph; referenced `evidence_id` existing in replayed evidence; `obligation_id` existing in Execution Graph; bound `node_id` implementing that obligation; persistable `node_id` matching evidence record; and non-empty `evidence_requirements_satisfied` for satisfaction claims. 4. Obligation coverage: verified satisfaction of all non-deferred MUST obligations by the replayed assessments. -Any check failure, tampering with `assessment_id`, `evidence_id`, `verification_id`, or `digest`, or provenance insufficiency MUST fail closed with `GRAPH_DIVERGENCE` or as an invalid artifact. Tampered evidence, assessments, or verification records MUST NOT replay as valid. +Any check failure, missing or unresolvable observation material, tampering with `assessment_id`, `evidence_id`, `verification_id`, or `digest`, or provenance insufficiency MUST fail closed with `GRAPH_DIVERGENCE` or as an invalid artifact. Cryptographic validation MUST NOT be skipped when bytes are absent. Tampered evidence, assessments, or verification records MUST NOT replay as valid. (Previously: replay validation did not explicitly recompute computeEvidenceId or evaluateProvenanceSufficiency during evidence/v2 revalidation.) #### Scenario: Replay from persisted outputs yields the same graph @@ -181,6 +181,20 @@ Any check failure, tampering with `assessment_id`, `evidence_id`, `verification_ - WHEN the graph is replayed from those persistable outputs only - THEN the recomputed `graph_id` and edge set MUST be byte-identical to the stored graph +#### Scenario: Replay without observation material fails closed + +- GIVEN persisted `evidence/v2` records without inline bytes and without resolvable content-addressed blob references +- WHEN `replayAssuranceGraph` runs +- THEN replay MUST fail closed with `GRAPH_DIVERGENCE` +- AND MUST NOT skip digest or EvidenceId recomputation + +#### Scenario: Content-addressed observation blob replays byte-identically + +- GIVEN every replay Evidence wrapper carries `observation_blob_id === evidence.digest` +- AND every reference resolves to bytes whose digest matches that identifier +- WHEN `replayAssuranceGraph` runs +- THEN the recomputed `graph_id` and edge set MUST be byte-identical to the original projection + #### Scenario: Tampered assessment_id fails replay - GIVEN persisted assessments plus a stored graph that would otherwise replay diff --git a/openspec/specs/independent-verification/spec.md b/openspec/specs/independent-verification/spec.md index 4f68a0ad..d314b4c1 100644 --- a/openspec/specs/independent-verification/spec.md +++ b/openspec/specs/independent-verification/spec.md @@ -78,9 +78,11 @@ fallback. Every evidence node MUST declare provenance as exactly one of `runtime-observed | host-attested | tool-produced | model-reported | human-decision | external-unverified`. Policy MUST decide which classes MAY satisfy each obligation. Strong classes (`runtime-observed`, `host-attested`, `tool-produced`) MUST be derived from trusted collector or transport channel metadata. -The verifier MUST enforce strict physical segregation of raw observations (`rawEvidence`). `rawEvidence` payloads MUST contain only physical observation fields (`bytes`/`rawBytes`, `provenance`, `origin`, `node_id`, and `execution_sequence` containing `{run_id, ordinal, previous_evidence_id}`). If an untrusted caller payload contains semantic assertions or metadata (`role`, `obligation_ids`, `obligation_id`, or `evidence_requirements_satisfied`), the verifier MUST immediately reject the payload and fail closed with `UNTRUSTED_CALLER_METADATA`. +The verifier MUST enforce strict physical segregation of raw observations (`rawEvidence`). `rawEvidence` payloads MUST contain `bytes` or `rawBytes` and MAY contain only physical observation fields (`provenance`, `origin`, `node_id`, and `execution_sequence` containing `{run_id, ordinal, previous_evidence_id}`). If observation material is absent, the verifier MUST fail closed with `FABRICATED_EVIDENCE`. If an untrusted caller payload contains semantic assertions or metadata (`role`, `obligation_ids`, `obligation_id`, or `evidence_requirements_satisfied`), the verifier MUST immediately reject the payload and fail closed with `UNTRUSTED_CALLER_METADATA`. -Trusted evidence metadata (`role`, `obligation_ids`, `evidence_requirements_satisfied`) MUST be derived exclusively by the verifier from the Execution Graph and trusted runner execution receipts (`receipts`/`runner_receipts`). The verifier MUST NOT accept semantic claims or trusted metadata directly from untrusted caller payloads or unverified worker narrative. +Trusted evidence metadata (`role`, `obligation_ids`, `evidence_requirements_satisfied`) MUST be derived exclusively by the verifier from the Execution Graph and a trusted runtime `runnerReceiptChannel`. Direct caller DTO properties named `receipts` or `runner_receipts` MUST fail closed with `UNTRUSTED_RUNNER_RECEIPT`. The channel MUST be an opaque runtime capability whose identity cannot be reconstructed by copying public fields. + +Every receipt obtained from that channel MUST conform to `runner-receipt/v1` and MUST contain a content-addressed `receipt_id`, `candidate_id`, REQUIRED `evidence_id`, `node_id`, `role`, canonical `satisfied_tokens`, `outcome`, `issuer_id`, and `transport`. Temporal receipts MUST also carry `execution_sequence`. The verifier MUST recompute `receipt_id`, validate issuer/transport against the channel, require exact `receipt.evidence_id === evidence.evidence_id`, require matching Candidate and node bindings, and reject orphan, duplicate, positional, or node-only matching with `INVALID_RUNNER_RECEIPT` or `RUNNER_RECEIPT_BINDING_MISMATCH`. `node.kind` MUST NOT substitute for a strategy role. A receipt with `outcome: failed` MUST NOT carry any satisfied token. The verifier MUST NOT accept a strong class solely because the raw payload string claims it. Payload digest MUST NOT be treated as origin. When collector or transport metadata is absent, untrusted, or disagrees with a claimed strong class, sufficiency MUST fail closed. PKI MUST NOT be required. Evidence that is insufficient, stale relative to the frozen Candidate, bound to a foreign subject, or fabricated MUST fail closed. A worker `model-reported` claim MUST NOT satisfy an obligation that requires a strong class. (Previously: raw evidence containing untrusted caller metadata did not trigger an explicit UNTRUSTED_CALLER_METADATA fail-closed rejection.) @@ -132,6 +134,28 @@ The verifier MUST NOT accept a strong class solely because the raw payload strin - THEN verification MUST immediately fail closed with `UNTRUSTED_CALLER_METADATA` - AND MUST NOT process or accept the untrusted payload +#### Scenario: Caller receipt DTO without trusted runtime channel fails closed + +- GIVEN otherwise valid raw Evidence and caller-supplied `runner_receipts` or `receipts` +- AND no opaque runtime-issued `runnerReceiptChannel` +- WHEN the verifier resolves semantic bindings +- THEN verification MUST fail closed with `UNTRUSTED_RUNNER_RECEIPT` +- AND MUST NOT infer authority from issuer or transport strings in the DTO + +#### Scenario: Receipt requires exact Evidence binding + +- GIVEN a trusted-channel `runner-receipt/v1` without `evidence_id`, or whose E1 `evidence_id` is presented beside E2 +- WHEN the verifier resolves receipt bindings +- THEN verification MUST fail closed as an invalid or mismatched receipt +- AND MUST NOT match by array position or `node_id` + +#### Scenario: Failed receipt cannot satisfy tokens + +- GIVEN a structurally valid trusted-channel receipt with `outcome: failed` +- AND non-empty `satisfied_tokens` +- WHEN the verifier validates the receipt +- THEN verification MUST fail closed with `INVALID_RUNNER_RECEIPT` + ### Requirement: Verdict Is Not Evidence {#REQ-independent-verification-004} Verification MUST emit a verification record bound to the frozen `CandidateId` @@ -163,7 +187,7 @@ fallback. ### Requirement: Obligation Manifest MUST Coverage {#REQ-independent-verification-005} -The verifier MUST authoritatively derive obligation satisfaction (`evidence_requirements_satisfied`) and role mapping strictly from trusted runner execution receipts and the Execution Graph. The verifier MUST NOT automatically or blindly copy `node.required_evidence` onto assessments or raw evidence without receipt-proven satisfaction. +The verifier MUST authoritatively derive obligation satisfaction (`evidence_requirements_satisfied`) from `satisfied_tokens` on schema-valid, exact Evidence-bound, successful trusted runner receipts, and derive obligation bindings from the Execution Graph. The verifier MUST NOT automatically or blindly copy `node.required_evidence` onto assessments or raw evidence without receipt-proven satisfaction. After strategy evaluation, the verifier MUST walk every Obligation Manifest item with criticality `must` that is not an approved `deferred` record (`reason` and `approved_by`). For each such obligation, a `PASS` or `PASS WITH WARNINGS` verdict MUST require `required_evidence` ⊆ persistable satisfied tokens (`evidence_requirements_satisfied` with `minItems: 1` per satisfaction assessment), each persistably bound to that `obligation_id` and to a `node_id` listed in `implemented_by`. @@ -228,8 +252,10 @@ The verifier MUST enforce an incompatible roles matrix. The following role combi Non-conflicting roles (such as `integration` + `acceptance`, `invariant` + `integration`, or `smoke` + `acceptance`) MAY share the same `evidence_id` when the observation independently satisfies both requirements. -The verifier MUST enforce strict causal chronology validation using `execution_sequence` for `strict-tdd`, `bug`, and `refactor` strategies: -- Every evidence observation in `strict-tdd`, `bug`, and `refactor` MUST provide an `execution_sequence` containing a consistent `run_id`, a strictly monotonic increasing `ordinal`, and valid `previous_evidence_id` chaining. +The verifier MUST enforce strict causal chronology validation using the trusted receipt `execution_sequence` for `strict-tdd`, `bug`, and `refactor` strategies: +- Every temporal receipt in `strict-tdd`, `bug`, and `refactor` MUST provide an `execution_sequence` containing a non-empty consistent `run_id` and a positive integer `ordinal`. +- The first temporal Evidence is the chain root. Every subsequent temporal Evidence MUST provide `previous_evidence_id` equal to the immediately preceding EvidenceId after sorting by ordinal. +- Ordinals MUST be unique and strictly monotonically increasing within that single run. - The verifier MUST NOT fall back to JSON array index/position order to determine chronological sequence. - For `bug` and `strict-tdd` strategies: RED MUST precede GREEN in `execution_sequence`; GREEN before RED or missing `execution_sequence` MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION`, and RED after PATCH MUST fail closed. - For `refactor` strategy: `characterization-before` MUST precede `characterization-after` in `execution_sequence` (`run_id`, monotonic `ordinal`, and `previous_evidence_id`). `characterization-after` executing before, concurrently with, or without causal sequence linking to `characterization-before` MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION`. @@ -281,6 +307,19 @@ Unique-sort of `verification.evidence_ids` MUST NOT be the assessment identity a - THEN verification MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION` - AND MUST NOT rely on JSON array index order as causal chronology +#### Scenario: Mixed run identifiers fail closed + +- GIVEN temporal receipts whose `execution_sequence.run_id` values are empty or differ +- WHEN the verifier evaluates strategy chronology +- THEN verification MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION` or as an invalid receipt + +#### Scenario: Missing causal predecessor fails closed + +- GIVEN RED and GREEN or characterization-before and characterization-after in increasing ordinal order +- AND the later receipt omits `previous_evidence_id` +- WHEN the verifier evaluates strategy chronology +- THEN verification MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION` + #### Scenario: Negative and acceptance sharing same EvidenceId fails closed - GIVEN an `evidence/v2` observation bound to both `negative` and `acceptance` roles diff --git a/schemas/kernel/contract-claims.json b/schemas/kernel/contract-claims.json index d9d50663..770105cc 100644 --- a/schemas/kernel/contract-claims.json +++ b/schemas/kernel/contract-claims.json @@ -723,6 +723,32 @@ ] }, "command_shapes": [] + }, + "runner-receipt": { + "required_fields": [ + "schema_version", + "kind", + "receipt_id", + "candidate_id", + "evidence_id", + "node_id", + "role", + "satisfied_tokens", + "outcome", + "issuer_id", + "transport" + ], + "enum_values": { + "outcome": [ + "passed", + "failed" + ], + "transport": [ + "tool-execution-transport", + "execution-transport" + ] + }, + "command_shapes": [] } } } diff --git a/schemas/kernel/manifest.json b/schemas/kernel/manifest.json index d9a78b05..10b9b481 100644 --- a/schemas/kernel/manifest.json +++ b/schemas/kernel/manifest.json @@ -230,6 +230,11 @@ "path": "schemas/kernel/assessment/v2.schema.json", "$id": "ospec://schemas/kernel/assessment/v2", "schema_version": 2 + }, + "runner-receipt": { + "path": "schemas/kernel/runner-receipt/v1.schema.json", + "$id": "ospec://schemas/kernel/runner-receipt/v1", + "schema_version": 1 } } } diff --git a/schemas/kernel/runner-receipt/fixtures/invalid/missing-evidence-id.json b/schemas/kernel/runner-receipt/fixtures/invalid/missing-evidence-id.json new file mode 100644 index 00000000..ca1323e3 --- /dev/null +++ b/schemas/kernel/runner-receipt/fixtures/invalid/missing-evidence-id.json @@ -0,0 +1,14 @@ +{ + "schema_version": 1, + "kind": "runner-receipt/v1", + "receipt_id": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "candidate_id": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "node_id": "repair-core", + "role": "green", + "satisfied_tokens": [ + "ev:test-pass" + ], + "outcome": "passed", + "issuer_id": "node-test", + "transport": "tool-execution-transport" +} diff --git a/schemas/kernel/runner-receipt/fixtures/valid/minimal.json b/schemas/kernel/runner-receipt/fixtures/valid/minimal.json new file mode 100644 index 00000000..1427da62 --- /dev/null +++ b/schemas/kernel/runner-receipt/fixtures/valid/minimal.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "kind": "runner-receipt/v1", + "receipt_id": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "candidate_id": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "evidence_id": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "node_id": "repair-core", + "role": "green", + "satisfied_tokens": [ + "ev:test-pass" + ], + "execution_sequence": { + "run_id": "run-1", + "ordinal": 2, + "previous_evidence_id": "sha256:4444444444444444444444444444444444444444444444444444444444444444" + }, + "outcome": "passed", + "issuer_id": "node-test", + "transport": "tool-execution-transport" +} diff --git a/schemas/kernel/runner-receipt/v1.schema.json b/schemas/kernel/runner-receipt/v1.schema.json new file mode 100644 index 00000000..9451c933 --- /dev/null +++ b/schemas/kernel/runner-receipt/v1.schema.json @@ -0,0 +1,124 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "ospec://schemas/kernel/runner-receipt/v1", + "title": "RunnerReceiptV1", + "description": "Recibo del runner ligado a una Evidence concreta y a un canal de emisión confiable.", + "type": "object", + "schema_version": 1, + "required": [ + "schema_version", + "kind", + "receipt_id", + "candidate_id", + "evidence_id", + "node_id", + "role", + "satisfied_tokens", + "outcome", + "issuer_id", + "transport" + ], + "properties": { + "schema_version": { + "type": "integer", + "const": 1 + }, + "kind": { + "type": "string", + "const": "runner-receipt/v1" + }, + "receipt_id": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "candidate_id": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "evidence_id": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "node_id": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "enum": [ + "red", + "green", + "patch", + "acceptance", + "invariants", + "invariant", + "contract", + "integration", + "negative", + "characterization-before", + "characterization-after", + "no-behavior-change", + "behavioral-delta", + "dry-run", + "rollback", + "incompatibility", + "idempotent-re-run", + "schema-parser", + "smoke", + "install", + "consume", + "docs-only", + "annotation" + ] + }, + "satisfied_tokens": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "execution_sequence": { + "type": "object", + "required": [ + "run_id", + "ordinal" + ], + "properties": { + "run_id": { + "type": "string", + "minLength": 1 + }, + "ordinal": { + "type": "integer", + "minimum": 1 + }, + "previous_evidence_id": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + }, + "additionalProperties": false + }, + "outcome": { + "type": "string", + "enum": [ + "passed", + "failed" + ] + }, + "issuer_id": { + "type": "string", + "minLength": 1 + }, + "transport": { + "type": "string", + "enum": [ + "tool-execution-transport", + "execution-transport" + ] + } + }, + "additionalProperties": false +} diff --git a/scripts/k6b-verifier-assurance-graph-e2e.test.js b/scripts/k6b-verifier-assurance-graph-e2e.test.js index 611716fb..7ca71473 100644 --- a/scripts/k6b-verifier-assurance-graph-e2e.test.js +++ b/scripts/k6b-verifier-assurance-graph-e2e.test.js @@ -9,6 +9,7 @@ const { freezeCandidate } = require("./lib/execution-identities/index.js"); const { compileExecutionGraph, createPolicySnapshot } = require("./lib/execution-graph/index.js"); const { computeTreeDigest } = require("./lib/worker-workspace.js"); const { verifyCandidate } = require("./lib/independent-verifier/index.js"); +const { createTestRunnerReceiptChannel } = require("./lib/test-support/k6b-runner-receipt.js"); const { projectAssuranceGraph, computeInvalidationClosure, @@ -92,6 +93,15 @@ test("E2E K6b: K4b-frozen Candidate → verify → project twice → successor i nodes: NODES, obligations: OBLIGATIONS, }); + const rawEvidence = featureEvidence(); + const receiptSpecs = featureReceipts(); + const runnerReceiptChannel = createTestRunnerReceiptChannel({ + candidate: predecessor, + executionGraph, + collector: HARNESS_COLLECTOR, + rawEvidence, + receiptSpecs, + }); const verified = verifyCandidate({ candidate: predecessor, @@ -101,8 +111,8 @@ test("E2E K6b: K4b-frozen Candidate → verify → project twice → successor i repository: { files }, declaredStrategy: "feature", collector: HARNESS_COLLECTOR, - rawEvidence: featureEvidence(), - runner_receipts: featureReceipts(), + rawEvidence, + runnerReceiptChannel, }); assert.equal(verified.ok, true, verified.error || verified.reason_code); assert.equal(verified.verification.verdict, "PASS"); @@ -136,10 +146,11 @@ test("E2E K6b: K4b-frozen Candidate → verify → project twice → successor i const replayed = replayAssuranceGraph({ candidate: predecessor, executionGraph, - evidence: verified.evidence, + evidence: verified.replay_evidence, assessments: verified.assessments, verification: verified.verification, canonical_inputs: verified.assurance_graph.canonical_inputs, + runnerReceiptChannel, }); assert.equal(replayed.ok, true); assert.equal(replayed.graph.graph_id, verified.assurance_graph.graph_id); @@ -196,6 +207,7 @@ test("E2E K6b: K4b-frozen Candidate → verify → project twice → successor i assert.ok(closure.invalidated_node_ids.includes(dependentId)); assert.ok(closure.preserved_evidence_ids.includes(independentId)); + const staleRawEvidence = featureEvidence(); const staleReuse = verifyCandidate({ candidate: predecessor, executionGraph, @@ -204,8 +216,14 @@ test("E2E K6b: K4b-frozen Candidate → verify → project twice → successor i repository: { files }, declaredStrategy: "feature", collector: HARNESS_COLLECTOR, - rawEvidence: featureEvidence(), - runner_receipts: featureReceipts(), + rawEvidence: staleRawEvidence, + runnerReceiptChannel: createTestRunnerReceiptChannel({ + candidate: predecessor, + executionGraph, + collector: HARNESS_COLLECTOR, + rawEvidence: staleRawEvidence, + receiptSpecs: featureReceipts(), + }), priorAssuranceGraph: graphForClosure, }); assert.equal(staleReuse.ok, false); diff --git a/scripts/lib/assurance-graph/index.js b/scripts/lib/assurance-graph/index.js index b96ba746..4ff84848 100644 --- a/scripts/lib/assurance-graph/index.js +++ b/scripts/lib/assurance-graph/index.js @@ -11,6 +11,7 @@ const { evaluateProvenanceSufficiency, } = require("../independent-verifier/evidence.js"); const { validateInstance, loadSchemaById } = require("../kernel-schema-validator.js"); +const { readRunnerReceiptChannel } = require("../independent-verifier/runner-receipt.js"); const DEFAULT_SCHEMA_ROOT = path.resolve(__dirname, "../../.."); const EVIDENCE_V2_ID = "ospec://schemas/kernel/evidence/v2"; @@ -82,6 +83,31 @@ function isApprovedDeferred(obligation) { ); } +function resolveObservationBytes(item, record, persistable) { + const inlineBytes = item && item.rawBytes !== undefined + ? item.rawBytes + : (item && item.bytes !== undefined + ? item.bytes + : (item && item.raw && (item.raw.rawBytes !== undefined ? item.raw.rawBytes : item.raw.bytes))); + if (inlineBytes !== undefined) return { ok: true, bytes: inlineBytes }; + + const blobId = item && item.observation_blob_id; + if (typeof blobId !== "string" || blobId.length === 0) { + return fail("GRAPH_DIVERGENCE", "replay evidence requires raw bytes or an observation blob reference"); + } + if (blobId !== record.digest) { + return fail("GRAPH_DIVERGENCE", "observation blob reference must equal the Evidence digest"); + } + const blobs = persistable.observation_blobs; + const bytes = blobs instanceof Map + ? blobs.get(blobId) + : (blobs && typeof blobs === "object" ? blobs[blobId] : undefined); + if (bytes === undefined) { + return fail("GRAPH_DIVERGENCE", `observation blob ${blobId} is not resolvable`); + } + return { ok: true, bytes }; +} + /** * Comprehensive revalidation for replay: * 1. evidence/v2 schema, candidate_id, digest, computeEvidenceId, provenance sufficiency, no verdict @@ -89,6 +115,9 @@ function isApprovedDeferred(obligation) { * 3. assessment/v2 schema, assessment_id, candidate_id, policy_snapshot_id, evidence_id, obligation_id, node_id, non-empty coverage */ function validateReplayRecords(persistable) { + if (!persistable || typeof persistable !== "object") { + return fail("GRAPH_DIVERGENCE", "persistable replay bundle must be an object"); + } const assessments = Array.isArray(persistable.assessments) ? persistable.assessments : []; const evidence = Array.isArray(persistable.evidence) ? persistable.evidence : []; const graph = persistable.executionGraph; @@ -99,8 +128,18 @@ function validateReplayRecords(persistable) { return fail("GRAPH_DIVERGENCE", "persistable graph, candidate, nodes, and obligations are required for replay"); } + const receiptGate = readRunnerReceiptChannel(persistable.runnerReceiptChannel); + if (!receiptGate.ok) { + return fail("GRAPH_DIVERGENCE", "replay requires trusted runner receipt authority"); + } + const receiptsById = new Map( + receiptGate.receipts.map((receipt) => [receipt.receipt_id, receipt]) + ); + const consumedReceiptIds = new Set(); + // 1. Revalidate evidence/v2 records const evidenceById = new Map(); + const receiptByEvidenceId = new Map(); for (const item of evidence) { const record = item && item.evidence ? item.evidence : item; if (!record || typeof record !== "object") { @@ -116,26 +155,37 @@ function validateReplayRecords(persistable) { if (!evidenceValidation.valid) { return fail("GRAPH_DIVERGENCE", `evidence failed schema validation: ${evidenceValidation.errors.map((e) => e.message).join("; ")}`); } - const rawBytes = item.rawBytes !== undefined - ? item.rawBytes - : (item.bytes !== undefined - ? item.bytes - : (item.raw && (item.raw.rawBytes !== undefined ? item.raw.rawBytes : item.raw.bytes))); - if (rawBytes !== undefined) { - const computedDigest = digestRawBytes(rawBytes); - if (record.digest !== computedDigest) { - return fail("GRAPH_DIVERGENCE", "evidence digest does not match raw bytes"); - } - const recomputedEvidenceId = computeEvidenceId(record, rawBytes); - if (record.evidence_id !== recomputedEvidenceId) { - return fail("GRAPH_DIVERGENCE", "evidence_id does not match recomputed computeEvidenceId"); - } + const observation = resolveObservationBytes(item, record, persistable); + if (!observation.ok) return observation; + const computedDigest = digestRawBytes(observation.bytes); + if (record.digest !== computedDigest) { + return fail("GRAPH_DIVERGENCE", "evidence digest does not match raw bytes"); + } + const recomputedEvidenceId = computeEvidenceId(record, observation.bytes); + if (record.evidence_id !== recomputedEvidenceId) { + return fail("GRAPH_DIVERGENCE", "evidence_id does not match recomputed computeEvidenceId"); + } + const receiptId = item && item.runner_receipt_id; + const receipt = typeof receiptId === "string" ? receiptsById.get(receiptId) : null; + if ( + !receipt || + consumedReceiptIds.has(receiptId) || + receipt.candidate_id !== candidate.candidate_id || + receipt.evidence_id !== record.evidence_id || + receipt.node_id !== record.node_id + ) { + return fail("GRAPH_DIVERGENCE", "replay evidence is not exactly bound to a trusted runner receipt"); } const sufficiency = evaluateProvenanceSufficiency(record, { requireRuntime: true }); if (!sufficiency.ok) { return fail("GRAPH_DIVERGENCE", sufficiency.error || "insufficient provenance during replay"); } + consumedReceiptIds.add(receiptId); evidenceById.set(record.evidence_id, record); + receiptByEvidenceId.set(record.evidence_id, receipt); + } + if (consumedReceiptIds.size !== receiptGate.receipts.length) { + return fail("GRAPH_DIVERGENCE", "trusted runner receipt set contains an orphan replay binding"); } // 2. Revalidate verification/v2 record if present @@ -171,6 +221,7 @@ function validateReplayRecords(persistable) { const record = valid.assessment; const obligation = obligations.get(record.obligation_id); const evidenceRecord = evidenceById.get(record.evidence_id); + const runnerReceipt = receiptByEvidenceId.get(record.evidence_id); if ( !obligation || !evidenceRecord || @@ -190,6 +241,10 @@ function validateReplayRecords(persistable) { if (record.evidence_requirements_satisfied.some((token) => !required.has(token))) { return fail("GRAPH_DIVERGENCE", "assessment coverage contains a token outside the obligation requirement"); } + const receiptTokens = new Set(runnerReceipt.satisfied_tokens); + if (record.evidence_requirements_satisfied.some((token) => !receiptTokens.has(token))) { + return fail("GRAPH_DIVERGENCE", "assessment coverage is not attested by its trusted runner receipt"); + } const coverage = coveredByObligation.get(record.obligation_id) || new Set(); for (const token of record.evidence_requirements_satisfied) coverage.add(token); coveredByObligation.set(record.obligation_id, coverage); @@ -210,14 +265,19 @@ function validateReplayRecords(persistable) { } /** - * Replay a projection from persistable assessments, evidence, verification, and canonical_inputs. - * Never consumes ephemeral projector obligation_ids. + * Reproduce una proyección desde assessments, Evidence con material de observación, + * Verification e inputs canónicos persistibles. Nunca consume obligation_ids efímeros. * - * @param {object} persistable - * @returns {{ ok: true, graph: object } | { ok: false, reason_code: string }} + * @param {object} persistable Bundle persistible con bytes inline o blobs resolubles. + * @returns {{ ok: true, graph: object } | { ok: false, reason_code: string }} Grafo reproducido o divergencia fail-closed. */ function replayAssuranceGraph(persistable = {}) { - const validation = validateReplayRecords(persistable); + let validation; + try { + validation = validateReplayRecords(persistable); + } catch (error) { + return fail("GRAPH_DIVERGENCE", `replay validation failed closed: ${error.message}`); + } if (!validation.ok) return validation; return projectAssuranceGraph({ canonicalInputs: persistable.canonical_inputs || persistable.canonicalInputs, diff --git a/scripts/lib/assurance-graph/index.test.js b/scripts/lib/assurance-graph/index.test.js index b55e4f5d..a9b73b0f 100644 --- a/scripts/lib/assurance-graph/index.test.js +++ b/scripts/lib/assurance-graph/index.test.js @@ -18,6 +18,7 @@ const { } = require("./index.js"); const { verifyCandidate } = require("../independent-verifier/index.js"); const { computeAssessmentId } = require("../independent-verifier/assessment.js"); +const { createTestRunnerReceiptChannel } = require("../test-support/k6b-runner-receipt.js"); const { canonicalize, computeGraphId } = require("./projector.js"); const SAMPLE_NODES = [ @@ -100,6 +101,15 @@ function verifiedProjection() { const files = { "src/index.js": "module.exports = 1;\n" }; const candidate = freezeFromFiles(files); const executionGraph = compileGraph(); + const rawEvidence = featureRaw(); + const receiptSpecs = featureReceipts(); + const runnerReceiptChannel = createTestRunnerReceiptChannel({ + candidate, + executionGraph, + collector: HARNESS_COLLECTOR, + rawEvidence, + receiptSpecs, + }); const verified = verifyCandidate({ candidate, executionGraph, @@ -107,11 +117,24 @@ function verifiedProjection() { repository: { files }, declaredStrategy: "feature", collector: HARNESS_COLLECTOR, - rawEvidence: featureRaw(), - runner_receipts: featureReceipts(), + rawEvidence, + runnerReceiptChannel, }); assert.equal(verified.ok, true, verified.error || verified.reason_code); - return { files, candidate, executionGraph, verified }; + return { files, candidate, executionGraph, runnerReceiptChannel, verified }; +} + +function replayBundle(projection, overrides = {}) { + return { + candidate: projection.candidate, + executionGraph: projection.executionGraph, + evidence: projection.verified.replay_evidence, + assessments: projection.verified.assessments, + verification: projection.verified.verification, + canonical_inputs: projection.verified.assurance_graph.canonical_inputs, + runnerReceiptChannel: projection.runnerReceiptChannel, + ...overrides, + }; } function withAssessmentFields(assessment, fields) { @@ -432,15 +455,9 @@ test("REQ-assurance-graph-001: missing candidate is GRAPH_PROJECTION_FAILED", () }); test("REQ-assurance-graph-006: replay from persistable outputs is byte-identical; contract churn diverges", () => { - const { candidate, executionGraph, verified } = verifiedProjection(); - const persistable = { - candidate, - executionGraph, - evidence: verified.evidence, - assessments: verified.assessments, - verification: verified.verification, - canonical_inputs: verified.assurance_graph.canonical_inputs, - }; + const projection = verifiedProjection(); + const { candidate, executionGraph, verified } = projection; + const persistable = replayBundle(projection); const replayed = replayAssuranceGraph(persistable); assert.equal(replayed.ok, true); assert.equal(replayed.graph.graph_id, verified.assurance_graph.graph_id); @@ -474,16 +491,43 @@ test("REQ-assurance-graph-006: replay from persistable outputs is byte-identical assert.equal(diverged.reason_code, "GRAPH_DIVERGENCE"); }); -test("REQ-assurance-graph-006/008: replay and reconcile reject assessment and stored-payload tampering", () => { - const { candidate, executionGraph, verified } = verifiedProjection(); - const persistable = { - candidate, - executionGraph, +test("REQ-assurance-graph-006 [Adversarial]: replay without observation bytes or blob reference fails closed", () => { + const projection = verifiedProjection(); + const { verified } = projection; + const replayed = replayAssuranceGraph(replayBundle(projection, { evidence: verified.evidence, - assessments: verified.assessments, - verification: verified.verification, - canonical_inputs: verified.assurance_graph.canonical_inputs, - }; + })); + + assert.equal(replayed.ok, false); + assert.equal(replayed.reason_code, "GRAPH_DIVERGENCE"); + assert.match(replayed.error, /bytes|blob/i); +}); + +test("REQ-assurance-graph-006: resolvable content-addressed observation blobs replay byte-identically", () => { + const projection = verifiedProjection(); + const { verified } = projection; + const observationBlobs = {}; + const evidence = verified.replay_evidence.map((item) => { + observationBlobs[item.evidence.digest] = item.bytes; + return { + evidence: item.evidence, + observation_blob_id: item.evidence.digest, + runner_receipt_id: item.runner_receipt_id, + }; + }); + const replayed = replayAssuranceGraph(replayBundle(projection, { + evidence, + observation_blobs: observationBlobs, + })); + + assert.equal(replayed.ok, true, replayed.error || replayed.reason_code); + assert.equal(replayed.graph.graph_id, verified.assurance_graph.graph_id); +}); + +test("REQ-assurance-graph-006/008: replay and reconcile reject assessment and stored-payload tampering", () => { + const projection = verifiedProjection(); + const { candidate, executionGraph, verified } = projection; + const persistable = replayBundle(projection); const tamperedAssessment = replayAssuranceGraph({ ...persistable, assessments: [{ ...verified.assessments[0], assessment_id: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" }, ...verified.assessments.slice(1)], @@ -500,15 +544,9 @@ test("REQ-assurance-graph-006/008: replay and reconcile reject assessment and st }); test("REQ-assurance-graph-006: replay rejects every persisted assessment binding mutation", () => { - const { candidate, executionGraph, verified } = verifiedProjection(); - const persistable = { - candidate, - executionGraph, - evidence: verified.evidence, - assessments: verified.assessments, - verification: verified.verification, - canonical_inputs: verified.assurance_graph.canonical_inputs, - }; + const projection = verifiedProjection(); + const { verified } = projection; + const persistable = replayBundle(projection); const assessment = verified.assessments[0]; const missingEvidenceId = "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; @@ -559,7 +597,13 @@ test("REQ-assurance-graph-006: replay rejects every persisted assessment binding name: "non-implementing node", input: { ...persistable, - evidence: [{ ...verified.evidence[0], node_id: "non-implementing-node" }, ...verified.evidence.slice(1)], + evidence: [ + { + ...verified.replay_evidence[0], + evidence: { ...verified.evidence[0], node_id: "non-implementing-node" }, + }, + ...verified.replay_evidence.slice(1), + ], assessments: [withAssessmentFields(assessment, { node_id: "non-implementing-node" }), ...verified.assessments.slice(1)], }, }, @@ -567,7 +611,13 @@ test("REQ-assurance-graph-006: replay rejects every persisted assessment binding name: "node_id mismatch", input: { ...persistable, - evidence: [{ ...verified.evidence[0], node_id: "evidence-node-mismatch" }, ...verified.evidence.slice(1)], + evidence: [ + { + ...verified.replay_evidence[0], + evidence: { ...verified.evidence[0], node_id: "evidence-node-mismatch" }, + }, + ...verified.replay_evidence.slice(1), + ], }, }, ]; @@ -584,7 +634,7 @@ test("REQ-assurance-graph-008: reconcile rejects stored identity mutations after const canonicalInput = { candidate, executionGraph, - evidence: verified.evidence, + evidence: verified.replay_evidence, assessments: verified.assessments, verification: verified.verification, }; @@ -673,21 +723,21 @@ test("REQ-assurance-graph-002: satisfies edge is emitted only when evidence_requ }); test("REQ-assurance-graph-006: replay rejects evidence and verification mutations", () => { - const { candidate, executionGraph, verified } = verifiedProjection(); - const persistable = { - candidate, - executionGraph, - evidence: verified.evidence, - assessments: verified.assessments, - verification: verified.verification, - canonical_inputs: verified.assurance_graph.canonical_inputs, - }; + const projection = verifiedProjection(); + const { verified } = projection; + const persistable = replayBundle(projection); const foreignCandidateId = "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; // Evidence candidate mismatch const badEvidenceCandidate = replayAssuranceGraph({ ...persistable, - evidence: [{ ...verified.evidence[0], candidate_id: foreignCandidateId }, ...verified.evidence.slice(1)], + evidence: [ + { + ...verified.replay_evidence[0], + evidence: { ...verified.evidence[0], candidate_id: foreignCandidateId }, + }, + ...verified.replay_evidence.slice(1), + ], }); assert.equal(badEvidenceCandidate.ok, false); assert.equal(badEvidenceCandidate.reason_code, "GRAPH_DIVERGENCE"); @@ -695,7 +745,13 @@ test("REQ-assurance-graph-006: replay rejects evidence and verification mutation // Evidence with verdict const evidenceWithVerdict = replayAssuranceGraph({ ...persistable, - evidence: [{ ...verified.evidence[0], verdict: "PASS" }, ...verified.evidence.slice(1)], + evidence: [ + { + ...verified.replay_evidence[0], + evidence: { ...verified.evidence[0], verdict: "PASS" }, + }, + ...verified.replay_evidence.slice(1), + ], }); assert.equal(evidenceWithVerdict.ok, false); assert.equal(evidenceWithVerdict.reason_code, "GRAPH_DIVERGENCE"); @@ -724,18 +780,8 @@ test("REQ-assurance-graph-006: replay rejects evidence and verification mutation }); test("REQ-assurance-graph-006: replay rejects tampered evidence_id and mismatched raw bytes", () => { - const { candidate, executionGraph, verified } = verifiedProjection(); - const persistable = { - candidate, - executionGraph, - evidence: verified.evidence.map((ev, idx) => ({ - evidence: ev, - bytes: ["acceptance", "invariants", "contract", "negative"][idx], - })), - assessments: verified.assessments, - verification: verified.verification, - canonical_inputs: verified.assurance_graph.canonical_inputs, - }; + const projection = verifiedProjection(); + const persistable = replayBundle(projection); // Tampered evidence_id on evidence record const tamperedEvId = replayAssuranceGraph({ @@ -770,25 +816,58 @@ test("REQ-assurance-graph-006: replay rejects tampered evidence_id and mismatche }); test("REQ-assurance-graph-006: replay rejects insufficient provenance (model-reported)", () => { - const { candidate, executionGraph, verified } = verifiedProjection(); - const persistable = { - candidate, - executionGraph, + const projection = verifiedProjection(); + const { verified } = projection; + const persistable = replayBundle(projection, { evidence: [ { - ...verified.evidence[0], - provenance: "model-reported", + ...verified.replay_evidence[0], + evidence: { + ...verified.evidence[0], + provenance: "model-reported", + }, }, - ...verified.evidence.slice(1), + ...verified.replay_evidence.slice(1), ], + }); + + const insufficient = replayAssuranceGraph(persistable); + assert.equal(insufficient.ok, false); + assert.equal(insufficient.reason_code, "GRAPH_DIVERGENCE"); +}); + +test("REQ-assurance-graph-006 [Adversarial]: replay requires trusted runner receipt authority", () => { + const { candidate, executionGraph, verified } = verifiedProjection(); + const persistable = { + candidate, + executionGraph, + evidence: verified.replay_evidence, assessments: verified.assessments, verification: verified.verification, canonical_inputs: verified.assurance_graph.canonical_inputs, }; - const insufficient = replayAssuranceGraph(persistable); - assert.equal(insufficient.ok, false); - assert.equal(insufficient.reason_code, "GRAPH_DIVERGENCE"); + const missingAuthority = replayAssuranceGraph(persistable); + assert.equal(missingAuthority.ok, false); + assert.equal(missingAuthority.reason_code, "GRAPH_DIVERGENCE"); + + const forgedAuthority = replayAssuranceGraph({ + ...persistable, + runnerReceiptChannel: Object.freeze({ + kind: "runner-receipt-channel/v1", + issuer_id: "node-test", + transport: "tool-execution-transport", + }), + }); + assert.equal(forgedAuthority.ok, false); + assert.equal(forgedAuthority.reason_code, "GRAPH_DIVERGENCE"); +}); + +test("REQ-assurance-graph-006 [Adversarial]: null replay bundle fails closed", () => { + const replayed = replayAssuranceGraph(null); + + assert.equal(replayed.ok, false); + assert.equal(replayed.reason_code, "GRAPH_DIVERGENCE"); }); diff --git a/scripts/lib/independent-verifier/evidence.js b/scripts/lib/independent-verifier/evidence.js index e3f801cd..94d29c2b 100644 --- a/scripts/lib/independent-verifier/evidence.js +++ b/scripts/lib/independent-verifier/evidence.js @@ -84,6 +84,12 @@ function normalizeEvidence(raw, candidate, executionGraph, harnessCollector) { return fail("FOREIGN_SUBJECT", "evidence candidate_id does not match frozen subject"); } + if ( + !Object.prototype.hasOwnProperty.call(raw, "bytes") && + !Object.prototype.hasOwnProperty.call(raw, "rawBytes") + ) { + return fail("FABRICATED_EVIDENCE", "raw evidence bytes or rawBytes are required"); + } const bytes = raw.bytes !== undefined ? raw.bytes : raw.rawBytes; const digest = digestRawBytes(bytes); if (raw.digest && raw.digest !== digest) { diff --git a/scripts/lib/independent-verifier/evidence.test.js b/scripts/lib/independent-verifier/evidence.test.js index 5cfb96bb..9474345a 100644 --- a/scripts/lib/independent-verifier/evidence.test.js +++ b/scripts/lib/independent-verifier/evidence.test.js @@ -133,6 +133,16 @@ test("REQ-independent-verification-003: valid physical observation returns norma assert.equal(result.raw, rawObs); }); +test("REQ-independent-verification-003: observation without bytes fails closed", () => { + const rawObs = validRaw(); + delete rawObs.bytes; + const result = normalizeEvidence(rawObs, CANDIDATE, EXECUTION_GRAPH, COLLECTOR); + + assert.equal(result.ok, false); + assert.equal(result.reason_code, "FABRICATED_EVIDENCE"); + assert.match(result.error, /bytes|rawBytes/); +}); + test("REQ-independent-verification-003: evaluateProvenanceSufficiency requires runtime provenance by default", () => { assert.equal(evaluateProvenanceSufficiency({ provenance: "runtime-observed" }).ok, true); assert.equal(evaluateProvenanceSufficiency({ provenance: "host-attested" }).ok, true); diff --git a/scripts/lib/independent-verifier/index.js b/scripts/lib/independent-verifier/index.js index 3991a536..2cf92d45 100644 --- a/scripts/lib/independent-verifier/index.js +++ b/scripts/lib/independent-verifier/index.js @@ -6,6 +6,7 @@ const { normalizeEvidence, computeEvidenceId } = require("./evidence.js"); const { resolveEvidenceProvenance } = require("./collector-provenance.js"); const { emitVerification } = require("./verdict.js"); const { walkMustObligations } = require("./obligation-coverage.js"); +const { readRunnerReceiptChannel } = require("./runner-receipt.js"); const assuranceGraph = require("../assurance-graph/index.js"); function fail(reason_code, error) { @@ -69,19 +70,25 @@ function rejectStaleEvidence(input, bound, evidence, rawBytes) { } function getRunnerReceipts(input) { - const src = input && (input.runner_receipts || input.receipts); - if (!src) return []; - if (Array.isArray(src)) return src; - if (typeof src === "object") return Object.values(src); - return []; + if ( + input && + (Object.prototype.hasOwnProperty.call(input, "runner_receipts") || + Object.prototype.hasOwnProperty.call(input, "receipts")) + ) { + return fail( + "UNTRUSTED_RUNNER_RECEIPT", + "runner receipt DTOs are untrusted; provide the opaque runnerReceiptChannel" + ); + } + return readRunnerReceiptChannel(input && input.runnerReceiptChannel); } /** - * Independently verify a frozen Candidate v2. - * Worker narrative is not authority. Evidence stays distinct from verdict. + * Verifica de forma independiente un Candidate v2 congelado. + * La narrativa del worker no concede autoridad y Evidence permanece separada del veredicto. * - * @param {object} input - * @returns {{ ok: boolean, strategy?: string, evidence?: object[], assessments?: object[], verification?: object, reason_code?: string }} + * @param {object} input Candidate, grafo, observaciones, collector y canal de receipts. + * @returns {{ ok: boolean, strategy?: string, evidence?: object[], replay_evidence?: object[], assessments?: object[], verification?: object, reason_code?: string }} Resultado fail-closed y material persistible de replay. */ function verifyCandidate(input) { const bound = validateBindings(input); @@ -96,7 +103,10 @@ function verifyCandidate(input) { const graphNodesById = new Map((bound.executionGraph.nodes || []).map((n) => [n && n.node_id, n])); const graphObligations = bound.executionGraph.obligations || []; - const runnerReceipts = getRunnerReceipts(input); + const receiptGate = getRunnerReceipts(input); + if (!receiptGate.ok) return receiptGate; + const runnerReceipts = receiptGate.receipts; + const consumedReceiptIds = new Set(); for (let index = 0; index < rawList.length; index += 1) { const raw = rawList[index]; @@ -114,70 +124,64 @@ function verifyCandidate(input) { if (!stale.ok) return stale; const node = graphNodesById.get(normalized.evidence.node_id); - const matchingReceipts = runnerReceipts.filter((r, rIdx) => { - if (!r || typeof r !== "object") return false; - if (r.evidence_id) return r.evidence_id === normalized.evidence.evidence_id; - if (r.node_id && node) { - if (r.node_id !== node.node_id) return false; - if (r.role && runnerReceipts.length === rawList.length) { - return rIdx === index; - } - return true; - } - if (runnerReceipts.length === rawList.length) return rIdx === index; - return false; - }); + const matchingReceipts = runnerReceipts.filter( + (receipt) => receipt.evidence_id === normalized.evidence.evidence_id + ); + if (matchingReceipts.length !== 1) { + return fail( + "RUNNER_RECEIPT_BINDING_MISMATCH", + `evidence_id ${normalized.evidence.evidence_id} requires exactly one bound runner receipt` + ); + } + const receipt = matchingReceipts[0]; + if ( + receipt.candidate_id !== bound.candidate.candidate_id || + receipt.node_id !== normalized.evidence.node_id + ) { + return fail( + "RUNNER_RECEIPT_BINDING_MISMATCH", + "runner receipt Candidate or node binding disagrees with Evidence" + ); + } + consumedReceiptIds.add(receipt.receipt_id); let resolvedRole; if (node && node.role) { + if (node.role !== receipt.role) { + return fail( + "RUNNER_RECEIPT_BINDING_MISMATCH", + "runner receipt role disagrees with the Execution Graph node role" + ); + } resolvedRole = node.role; } else { - const receiptWithRole = matchingReceipts.find((r) => r && typeof r.role === "string" && r.role.length > 0); - if (receiptWithRole) { - resolvedRole = receiptWithRole.role; - } else if (node && node.kind) { - resolvedRole = node.kind; - } - } - - let resolvedObligationIds = []; - const receiptWithObligations = matchingReceipts.find((r) => r && (r.obligation_ids || r.obligation_id)); - if (receiptWithObligations) { - resolvedObligationIds = Array.isArray(receiptWithObligations.obligation_ids) - ? receiptWithObligations.obligation_ids - : (receiptWithObligations.obligation_id ? [receiptWithObligations.obligation_id] : []); - } else if (node) { - resolvedObligationIds = graphObligations - .filter((o) => Array.isArray(o.implemented_by) && o.implemented_by.includes(node.node_id)) - .map((o) => o.id); + resolvedRole = receipt.role; } - const satisfiedTokensSet = new Set(); - for (const receipt of matchingReceipts) { - const tokens = receipt.evidence_requirements_satisfied || receipt.satisfied_tokens; - if (Array.isArray(tokens)) { - for (const token of tokens) { - if (typeof token === "string" && token.length > 0) { - satisfiedTokensSet.add(token); - } - } - } - } - const resolvedSatisfied = [...satisfiedTokensSet].sort(); - - const executionSequence = normalized.execution_sequence || - (matchingReceipts.find((r) => r && r.execution_sequence) || {}).execution_sequence || - null; + const resolvedObligationIds = node + ? graphObligations + .filter((obligation) => ( + Array.isArray(obligation.implemented_by) && obligation.implemented_by.includes(node.node_id) + )) + .map((obligation) => obligation.id) + : []; classified.push({ ...normalized, - execution_sequence: executionSequence, + execution_sequence: receipt.execution_sequence || null, role: resolvedRole, obligation_ids: resolvedObligationIds, - evidence_requirements_satisfied: resolvedSatisfied, + evidence_requirements_satisfied: receipt.satisfied_tokens, }); } + if (consumedReceiptIds.size !== runnerReceipts.length) { + return fail( + "RUNNER_RECEIPT_BINDING_MISMATCH", + "runner receipt set contains an orphan or duplicate Evidence binding" + ); + } + const evaluated = evaluateStrategy(strategy, classified); if (!evaluated.ok) return evaluated; @@ -190,6 +194,13 @@ function verifyCandidate(input) { if (!coverage.ok) return coverage; const evidenceRecords = classified.map((item) => item.evidence); + const replayEvidence = classified.map((item, index) => ({ + evidence: item.evidence, + bytes: item.raw.bytes !== undefined ? item.raw.bytes : item.raw.rawBytes, + runner_receipt_id: runnerReceipts.find( + (receipt) => receipt.evidence_id === item.evidence.evidence_id + ).receipt_id, + })); // human-decision and external-unverified extras keep a passing verification at // PASS WITH WARNINGS. model-reported is omitted here: it cannot satisfy a runtime MUST. const hasNonRuntimeExtra = classified.some( @@ -221,6 +232,7 @@ function verifyCandidate(input) { ok: true, strategy, evidence: evidenceRecords, + replay_evidence: replayEvidence, assessments: coverage.assessments, verification, assurance_graph: projected.graph, diff --git a/scripts/lib/independent-verifier/index.test.js b/scripts/lib/independent-verifier/index.test.js index d4e52a66..360c02c8 100644 --- a/scripts/lib/independent-verifier/index.test.js +++ b/scripts/lib/independent-verifier/index.test.js @@ -8,8 +8,17 @@ const test = require("node:test"); const { freezeCandidate } = require("../execution-identities/index.js"); const { compileExecutionGraph, createPolicySnapshot } = require("../execution-graph/index.js"); const { computeTreeDigest } = require("../worker-workspace.js"); -const { verifyCandidate, selectStrategy } = require("./index.js"); -const { computeEvidenceId, digestRawBytes } = require("./evidence.js"); +const { verifyCandidate: verifyCandidateRuntime, selectStrategy } = require("./index.js"); +const { computeEvidenceId, digestRawBytes, normalizeEvidence } = require("./evidence.js"); +const runnerReceipt = require("./runner-receipt.js"); +const { + computeRunnerReceiptId, + createRunnerReceipt, +} = require("./runner-receipt.js"); +const { + createTestRunnerReceiptChannel, + createTestRunnerReceiptChannelFromReceipts, +} = require("../test-support/k6b-runner-receipt.js"); const { computeVerificationId } = require("./verdict.js"); const { computeAssessmentId } = require("./assessment.js"); const assuranceGraph = require("../assurance-graph/index.js"); @@ -41,11 +50,13 @@ const SAMPLE_OBLIGATIONS = [ }, ]; -function sampleReceipt(role, satisfied = ["ev:test-pass"], extra = {}) { +function sampleReceipt(role, satisfied, extra = {}) { return { role, node_id: extra.node_id || "repair-core", - evidence_requirements_satisfied: satisfied, + evidence_requirements_satisfied: satisfied === undefined + ? (role === "red" ? [] : ["ev:test-pass"]) + : satisfied, ...extra, }; } @@ -130,6 +141,29 @@ function raw(bytes, extra = {}) { return { ...record, ...extra.fields }; } +function withTrustedRunnerReceipts(input) { + if (input.runnerReceiptChannel) return input; + const receiptSpecs = Array.isArray(input.runner_receipts) + ? input.runner_receipts + : (Array.isArray(input.receipts) ? input.receipts : []); + const rawEvidence = Array.isArray(input.rawEvidence) ? input.rawEvidence : []; + const trustedInput = { + ...input, + runnerReceiptChannel: createTestRunnerReceiptChannel({ + ...input, + rawEvidence, + receiptSpecs, + }), + }; + delete trustedInput.runner_receipts; + delete trustedInput.receipts; + return trustedInput; +} + +function verifyCandidate(input) { + return verifyCandidateRuntime(withTrustedRunnerReceipts(input)); +} + function featureEvidence() { return [ raw("acceptance: ok", { origin: "role:acceptance" }), @@ -760,7 +794,7 @@ test("REQ-independent-verification-005: evidence on a non-implementing node fail runner_receipts: featureReceipts().map((r) => ({ ...r, node_id: "other-node", obligation_ids: ["req-repair-001"] })), }); assert.equal(result.ok, false); - assert.equal(result.reason_code, "WRONG_IMPLEMENTING_NODE"); + assert.equal(result.reason_code, "UNFULFILLED_MUST"); }); test("REQ-independent-verification-005: approved deferral skips MUST coverage", () => { @@ -869,7 +903,7 @@ test("REQ-independent-verification-006: one observation cannot satisfy four inco ], }); assert.equal(result.ok, false); - assert.equal(result.reason_code, "STRATEGY_EVIDENCE_ALIAS"); + assert.equal(result.reason_code, "RUNNER_RECEIPT_BINDING_MISMATCH"); assert.equal(Object.prototype.hasOwnProperty.call(result, "verification"), false); }); @@ -971,7 +1005,7 @@ test("REQ-independent-verification-006: incompatible roles red ↔ green, char-b ], }); assert.equal(redGreen.ok, false); - assert.equal(redGreen.reason_code, "STRATEGY_EVIDENCE_ALIAS"); + assert.equal(redGreen.reason_code, "RUNNER_RECEIPT_BINDING_MISMATCH"); // negative ↔ acceptance const negAcc = verifyCandidate({ @@ -991,7 +1025,7 @@ test("REQ-independent-verification-006: incompatible roles red ↔ green, char-b ], }); assert.equal(negAcc.ok, false); - assert.equal(negAcc.reason_code, "STRATEGY_EVIDENCE_ALIAS"); + assert.equal(negAcc.reason_code, "RUNNER_RECEIPT_BINDING_MISMATCH"); // characterization-before ↔ characterization-after const charBeforeAfter = verifyCandidate({ @@ -1009,7 +1043,7 @@ test("REQ-independent-verification-006: incompatible roles red ↔ green, char-b ], }); assert.equal(charBeforeAfter.ok, false); - assert.equal(charBeforeAfter.reason_code, "STRATEGY_EVIDENCE_ALIAS"); + assert.equal(charBeforeAfter.reason_code, "RUNNER_RECEIPT_BINDING_MISMATCH"); }); test("REQ-independent-verification-006: non-conflicting shared evidence (integration + acceptance) passes validation", () => { @@ -1185,3 +1219,176 @@ test("REQ-independent-verification-006 [Adversarial]: strict-tdd previous_eviden assert.equal(result.reason_code, "STRATEGY_SEQUENCE_VIOLATION"); assert.match(result.error, /previous_evidence_id/i); }); + +test("REQ-independent-verification-003 [Adversarial]: caller runner_receipts without trusted channel fail closed", () => { + const harness = buildHarness(); + const result = verifyCandidateRuntime({ + ...harness, + declaredStrategy: "feature", + rawEvidence: featureEvidence(), + runner_receipts: featureReceipts(), + }); + + assert.equal(result.ok, false); + assert.equal(result.reason_code, "UNTRUSTED_RUNNER_RECEIPT"); +}); + +test("REQ-independent-verification-003 [Adversarial]: public verifier facade cannot mint a trusted receipt channel", () => { + assert.equal(runnerReceipt.createRunnerReceiptChannel, undefined); + assert.equal(runnerReceipt.issueRunnerReceiptChannel, undefined); +}); + +test("REQ-independent-verification-003 [Adversarial]: failed receipt cannot satisfy evidence tokens", () => { + const harness = buildHarness({ + runner_receipts: featureReceipts().map((receipt, index) => index === 0 + ? { ...receipt, outcome: "failed" } + : receipt), + }); + const result = verifyCandidate({ + ...harness, + declaredStrategy: "feature", + rawEvidence: featureEvidence(), + }); + + assert.equal(result.ok, false); + assert.equal(result.reason_code, "INVALID_RUNNER_RECEIPT"); +}); + +test("REQ-independent-verification-003 [Adversarial]: failed success role cannot satisfy strategy shape", () => { + const harness = buildHarness({ + runner_receipts: featureReceipts().map((receipt, index) => index === 0 + ? { ...receipt, evidence_requirements_satisfied: [], outcome: "failed" } + : receipt), + }); + const result = verifyCandidate({ + ...harness, + declaredStrategy: "feature", + rawEvidence: featureEvidence(), + }); + + assert.equal(result.ok, false); + assert.equal(result.reason_code, "INVALID_RUNNER_RECEIPT"); +}); + +test("REQ-independent-verification-006 [Adversarial]: temporal evidence requires one run and complete chaining", () => { + const harness = buildHarness(); + const differentRun = verifyCandidate({ + ...harness, + rawEvidence: [ + raw("red fail", { execution_sequence: { run_id: "run-a", ordinal: 1 } }), + raw("green pass", { execution_sequence: { run_id: "run-b", ordinal: 2 } }), + ], + runner_receipts: [sampleReceipt("red"), sampleReceipt("green")], + }); + assert.equal(differentRun.ok, false); + assert.equal(differentRun.reason_code, "STRATEGY_SEQUENCE_VIOLATION"); + + const missingPrevious = verifyCandidate({ + ...harness, + rawEvidence: [ + raw("red fail", { execution_sequence: { run_id: "run-a", ordinal: 1 } }), + raw("green pass", { execution_sequence: { run_id: "run-a", ordinal: 2 } }), + ], + runner_receipts: [ + sampleReceipt("red", [], { execution_sequence: { run_id: "run-a", ordinal: 1 } }), + sampleReceipt("green", ["ev:test-pass"], { execution_sequence: { run_id: "run-a", ordinal: 2 } }), + ], + }); + assert.equal(missingPrevious.ok, false); + assert.equal(missingPrevious.reason_code, "STRATEGY_SEQUENCE_VIOLATION"); + assert.match(missingPrevious.error, /previous_evidence_id/i); +}); + +test("REQ-independent-verification-003 [Adversarial]: receipt without evidence_id fails schema validation", () => { + const harness = buildHarness(); + const rawEvidence = featureEvidence(); + const receipts = featureReceipts().map((spec, index) => { + const normalized = normalizeEvidence(rawEvidence[index], harness.candidate, harness.executionGraph, harness.collector); + return createRunnerReceipt({ + candidate_id: harness.candidate.candidate_id, + evidence_id: normalized.evidence.evidence_id, + node_id: normalized.evidence.node_id, + role: spec.role, + satisfied_tokens: spec.evidence_requirements_satisfied, + outcome: "passed", + issuer_id: "node-test", + transport: "tool-execution-transport", + }); + }); + const invalid = { ...receipts[0] }; + delete invalid.evidence_id; + invalid.receipt_id = computeRunnerReceiptId(invalid); + const input = { + ...harness, + rawEvidence, + declaredStrategy: "feature", + runnerReceiptChannel: createTestRunnerReceiptChannelFromReceipts([invalid, ...receipts.slice(1)]), + }; + delete input.runner_receipts; + const result = verifyCandidateRuntime(input); + + assert.equal(result.ok, false); + assert.equal(result.reason_code, "INVALID_RUNNER_RECEIPT"); +}); + +test("REQ-independent-verification-003 [Adversarial]: receipt for E1 cannot bind E2 by position", () => { + const harness = buildHarness(); + const rawEvidence = featureEvidence(); + const firstEvidence = normalizeEvidence( + rawEvidence[0], + harness.candidate, + harness.executionGraph, + harness.collector + ).evidence; + const receiptSpecs = featureReceipts().map((receipt, index) => index === 1 + ? { ...receipt, evidence_id: firstEvidence.evidence_id } + : receipt); + const input = { + ...harness, + declaredStrategy: "feature", + rawEvidence, + runnerReceiptChannel: createTestRunnerReceiptChannel({ + ...harness, + rawEvidence, + receiptSpecs, + }), + }; + delete input.runner_receipts; + const result = verifyCandidateRuntime(input); + + assert.equal(result.ok, false); + assert.equal(result.reason_code, "RUNNER_RECEIPT_BINDING_MISMATCH"); +}); + +test("REQ-independent-verification-006 [Adversarial]: bug GREEN must chain to PATCH EvidenceId", () => { + const harness = buildHarness(); + const rawEvidence = [ + raw("red", { execution_sequence: { run_id: "bug-run", ordinal: 1 } }), + raw("patch", { provenance: "tool-produced", execution_sequence: { run_id: "bug-run", ordinal: 2 } }), + raw("green", { execution_sequence: { run_id: "bug-run", ordinal: 3 } }), + ]; + const result = verifyCandidate({ + ...harness, + declaredStrategy: "bug", + collectors: [ + trustedCollector("runtime-observed"), + trustedCollector("tool-produced"), + trustedCollector("runtime-observed"), + ], + rawEvidence, + runner_receipts: [ + sampleReceipt("red"), + sampleReceipt("patch"), + sampleReceipt("green", ["ev:test-pass"], { + execution_sequence: { + run_id: "bug-run", + ordinal: 3, + previous_evidence_id: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + }, + }), + ], + }); + + assert.equal(result.ok, false); + assert.equal(result.reason_code, "STRATEGY_SEQUENCE_VIOLATION"); +}); diff --git a/scripts/lib/independent-verifier/internal/runner-receipt-channel.js b/scripts/lib/independent-verifier/internal/runner-receipt-channel.js new file mode 100644 index 00000000..d1df98dc --- /dev/null +++ b/scripts/lib/independent-verifier/internal/runner-receipt-channel.js @@ -0,0 +1,99 @@ +"use strict"; + +const TRUSTED_ISSUERS = Object.freeze([ + Object.freeze({ issuer_id: "node-test", transport: "tool-execution-transport" }), + Object.freeze({ issuer_id: "npm-test", transport: "tool-execution-transport" }), + Object.freeze({ issuer_id: "node:test", transport: "tool-execution-transport" }), + Object.freeze({ issuer_id: "tool-execution", transport: "tool-execution-transport" }), + Object.freeze({ issuer_id: "host-adapter", transport: "execution-transport" }), +]); + +const RUNNER_RECEIPT_AUTHORITY = Symbol("ospec.runnerReceiptAuthority"); +const trustedChannels = new WeakMap(); + +function authorityError(code, message) { + const error = new Error(message); + error.code = code; + return error; +} + +function isTrustedIssuer(issuerId, transport) { + return TRUSTED_ISSUERS.some((identity) => ( + identity.issuer_id === issuerId && identity.transport === transport + )); +} + +function freezeReceipt(receipt) { + if (!receipt || typeof receipt !== "object") return receipt; + const frozen = { + ...receipt, + satisfied_tokens: Array.isArray(receipt.satisfied_tokens) + ? Object.freeze([...receipt.satisfied_tokens]) + : receipt.satisfied_tokens, + }; + if (receipt.execution_sequence && typeof receipt.execution_sequence === "object") { + frozen.execution_sequence = Object.freeze({ ...receipt.execution_sequence }); + } + return Object.freeze(frozen); +} + +/** + * Crea la capacidad nominal que identifica a un runner autorizado. + * + * @param {{issuer_id:string, transport:string}} identity Identidad del runner y transporte. + * @returns {object} Capacidad interna no serializable. + */ +function createRunnerReceiptAuthority(identity) { + if (!identity || typeof identity !== "object") { + throw authorityError("UNTRUSTED_RUNNER_RECEIPT", "runner receipt authority identity is required"); + } + if (!isTrustedIssuer(identity.issuer_id, identity.transport)) { + throw authorityError("UNTRUSTED_RUNNER_RECEIPT", "runner receipt issuer/transport is not trusted"); + } + return Object.freeze({ + [RUNNER_RECEIPT_AUTHORITY]: true, + issuer_id: identity.issuer_id, + transport: identity.transport, + }); +} + +/** + * Emite un canal desde una capacidad creada por el runtime, nunca desde strings del caller. + * + * @param {{authority:object, receipts:object[]}} input Capacidad y recibos emitidos por el runner. + * @returns {object} Canal opaco registrado por identidad. + */ +function issueRunnerReceiptChannel(input) { + if (!input || typeof input !== "object") { + throw authorityError("INVALID_RUNNER_RECEIPT", "runner receipt channel input must be an object"); + } + const authority = input.authority; + if (!authority || authority[RUNNER_RECEIPT_AUTHORITY] !== true) { + throw authorityError("UNTRUSTED_RUNNER_RECEIPT", "runner receipt authority capability is required"); + } + if (!Array.isArray(input.receipts)) { + throw authorityError("INVALID_RUNNER_RECEIPT", "runner receipt channel requires a receipts array"); + } + const channel = Object.freeze({ + kind: "runner-receipt-channel/v1", + issuer_id: authority.issuer_id, + transport: authority.transport, + }); + trustedChannels.set(channel, Object.freeze({ + issuer_id: authority.issuer_id, + transport: authority.transport, + receipts: Object.freeze(input.receipts.map((receipt) => freezeReceipt(receipt))), + })); + return channel; +} + +function readRunnerReceiptAuthority(channel) { + return channel && trustedChannels.get(channel); +} + +module.exports = { + createRunnerReceiptAuthority, + freezeReceipt, + issueRunnerReceiptChannel, + readRunnerReceiptAuthority, +}; diff --git a/scripts/lib/independent-verifier/runner-receipt.js b/scripts/lib/independent-verifier/runner-receipt.js new file mode 100644 index 00000000..992705e0 --- /dev/null +++ b/scripts/lib/independent-verifier/runner-receipt.js @@ -0,0 +1,141 @@ +"use strict"; + +const path = require("node:path"); +const { sha256Fingerprint } = require("../canonical-json.js"); +const { validateInstance, loadSchemaById } = require("../kernel-schema-validator.js"); +const { + freezeReceipt, + readRunnerReceiptAuthority, +} = require("./internal/runner-receipt-channel.js"); + +const RUNNER_RECEIPT_SCHEMA_ID = "ospec://schemas/kernel/runner-receipt/v1"; +const DEFAULT_SCHEMA_ROOT = path.resolve(__dirname, "../../.."); +const FAILURE_SEMANTIC_ROLES = new Set(["red"]); + +let cachedSchema = null; + +function fail(reason_code, error) { + return { ok: false, reason_code, error: error || reason_code }; +} + +function getSchema() { + if (!cachedSchema) { + cachedSchema = loadSchemaById(RUNNER_RECEIPT_SCHEMA_ID, { rootDir: DEFAULT_SCHEMA_ROOT }); + } + return cachedSchema; +} + +function canonicalTokens(tokens) { + if (!Array.isArray(tokens)) return tokens; + return [...new Set(tokens)].sort(); +} + +/** + * Calcula la identidad content-addressed de un RunnerReceipt sin incluir su propio ID. + * + * @param {object} fields Campos persistibles del recibo. + * @returns {string} Identidad SHA-256 canónica. + */ +function computeRunnerReceiptId(fields) { + const payload = { ...fields }; + delete payload.receipt_id; + return sha256Fingerprint("runner-receipt/v1", payload); +} + +/** + * Crea un RunnerReceipt canónico para que el runner lo publique por un canal confiable. + * + * @param {object} fields Campos del recibo, salvo `receipt_id`. + * @returns {object} Recibo inmutable con identidad recomputada. + */ +function createRunnerReceipt(fields) { + const record = { + schema_version: 1, + kind: "runner-receipt/v1", + candidate_id: fields && fields.candidate_id, + evidence_id: fields && fields.evidence_id, + node_id: fields && fields.node_id, + role: fields && fields.role, + satisfied_tokens: canonicalTokens(fields && fields.satisfied_tokens), + outcome: fields && fields.outcome, + issuer_id: fields && fields.issuer_id, + transport: fields && fields.transport, + }; + if (fields && fields.execution_sequence !== undefined) { + record.execution_sequence = { + run_id: fields.execution_sequence && fields.execution_sequence.run_id, + ordinal: fields.execution_sequence && fields.execution_sequence.ordinal, + }; + if (fields.execution_sequence && fields.execution_sequence.previous_evidence_id !== undefined) { + record.execution_sequence.previous_evidence_id = fields.execution_sequence.previous_evidence_id; + } + } + record.receipt_id = computeRunnerReceiptId(record); + return freezeReceipt(record); +} + +function validateReceipt(receipt, authority) { + if (!receipt || typeof receipt !== "object") { + return fail("INVALID_RUNNER_RECEIPT", "runner receipt must be an object"); + } + let validation; + try { + validation = validateInstance(getSchema(), receipt); + } catch (error) { + return fail("INVALID_RUNNER_RECEIPT", `runner receipt schema is unavailable: ${error.message}`); + } + if (!validation.valid) { + return fail( + "INVALID_RUNNER_RECEIPT", + validation.errors.map((error) => `${error.path}: ${error.message}`).join("; ") + ); + } + if (receipt.issuer_id !== authority.issuer_id || receipt.transport !== authority.transport) { + return fail("UNTRUSTED_RUNNER_RECEIPT", "runner receipt identity disagrees with its trusted channel"); + } + if (receipt.receipt_id !== computeRunnerReceiptId(receipt)) { + return fail("INVALID_RUNNER_RECEIPT", "runner receipt_id does not match its canonical fields"); + } + if (JSON.stringify(receipt.satisfied_tokens) !== JSON.stringify(canonicalTokens(receipt.satisfied_tokens))) { + return fail("INVALID_RUNNER_RECEIPT", "runner satisfied_tokens must be unique and canonically sorted"); + } + if (receipt.outcome === "failed" && receipt.satisfied_tokens.length > 0) { + return fail("INVALID_RUNNER_RECEIPT", "failed runner receipt cannot satisfy evidence tokens"); + } + const failureSemanticRole = FAILURE_SEMANTIC_ROLES.has(receipt.role); + if (failureSemanticRole !== (receipt.outcome === "failed")) { + return fail("INVALID_RUNNER_RECEIPT", "runner receipt role and outcome are incoherent"); + } + return { ok: true, receipt }; +} + +/** + * Lee y valida los recibos solo si el canal conserva la identidad opaca emitida por el runtime. + * + * @param {object} channel Canal presentado al verifier. + * @returns {{ok:true, receipts:object[], authority:object}|{ok:false, reason_code:string, error:string}} + */ +function readRunnerReceiptChannel(channel) { + const authority = readRunnerReceiptAuthority(channel); + if (!authority) { + return fail("UNTRUSTED_RUNNER_RECEIPT", "runner receipts require a trusted runtime channel"); + } + const receipts = []; + const receiptIds = new Set(); + for (const candidate of authority.receipts) { + const validated = validateReceipt(candidate, authority); + if (!validated.ok) return validated; + if (receiptIds.has(validated.receipt.receipt_id)) { + return fail("INVALID_RUNNER_RECEIPT", "runner receipt_id must be unique"); + } + receiptIds.add(validated.receipt.receipt_id); + receipts.push(validated.receipt); + } + return { ok: true, receipts, authority }; +} + +module.exports = { + computeRunnerReceiptId, + createRunnerReceipt, + readRunnerReceiptChannel, +}; diff --git a/scripts/lib/independent-verifier/strategy-policy.js b/scripts/lib/independent-verifier/strategy-policy.js index 7556f705..60448687 100644 --- a/scripts/lib/independent-verifier/strategy-policy.js +++ b/scripts/lib/independent-verifier/strategy-policy.js @@ -169,141 +169,86 @@ const assertDistinctRoleEvidence = assertCompatibleRoleSharing; function getExecutionSequence(item) { if (!item) return null; - const seq = item.execution_sequence || (item.raw && item.raw.execution_sequence); + const seq = item.execution_sequence; if (!seq || typeof seq !== "object") return null; - if (typeof seq.ordinal !== "number") return null; + if (typeof seq.run_id !== "string" || seq.run_id.trim() === "") return null; + if (!Number.isInteger(seq.ordinal) || seq.ordinal < 1) return null; return seq; } -function assertRoleOrder(strategyName, items) { - const temporalStrategies = ["strict-tdd", "bug", "refactor"]; - if (!temporalStrategies.includes(strategyName)) { - return { ok: true }; - } - - const roleItems = (role) => (items || []).filter((it) => it && it.role === role); - - if (strategyName === "strict-tdd") { - const redItems = roleItems("red"); - const greenItems = roleItems("green"); - - if (redItems.length > 0 && greenItems.length > 0) { - for (const item of [...redItems, ...greenItems]) { - const seq = getExecutionSequence(item); - if (!seq) { - return fail("STRATEGY_SEQUENCE_VIOLATION", "missing execution_sequence for temporal strategy"); - } - } +function temporalRoles(strategyName) { + if (strategyName === "strict-tdd") return ["red", "green"]; + if (strategyName === "bug") return ["red", "patch", "green"]; + if (strategyName === "refactor") return ["characterization-before", "characterization-after"]; + return []; +} - for (const red of redItems) { - const redSeq = getExecutionSequence(red); - const redId = red.evidence && red.evidence.evidence_id; - for (const green of greenItems) { - const greenSeq = getExecutionSequence(green); - if (greenSeq.ordinal <= redSeq.ordinal) { - return fail("STRATEGY_SEQUENCE_VIOLATION", "green evidence ordinal must be greater than red evidence ordinal"); - } - if (greenSeq.previous_evidence_id && redId && greenSeq.previous_evidence_id !== redId) { - return fail("STRATEGY_SEQUENCE_VIOLATION", "green previous_evidence_id does not link to red evidence"); - } - } - } +function assertCausalChain(items) { + if (items.length === 0) return { ok: true }; + const sequenced = []; + for (const item of items) { + const sequence = getExecutionSequence(item); + if (!sequence) { + return fail("STRATEGY_SEQUENCE_VIOLATION", "missing or invalid execution_sequence for temporal strategy"); } + sequenced.push({ item, sequence }); } - - if (strategyName === "bug") { - const redItems = roleItems("red"); - const patchItems = roleItems("patch"); - const greenItems = roleItems("green"); - - const hasMultipleTemporalRoles = - (redItems.length > 0 && patchItems.length > 0) || - (patchItems.length > 0 && greenItems.length > 0) || - (redItems.length > 0 && greenItems.length > 0); - - if (hasMultipleTemporalRoles) { - const allBugItems = [...redItems, ...patchItems, ...greenItems]; - for (const item of allBugItems) { - const seq = getExecutionSequence(item); - if (!seq) { - return fail("STRATEGY_SEQUENCE_VIOLATION", "missing execution_sequence for temporal strategy"); - } - } + const runId = sequenced[0].sequence.run_id; + if (sequenced.some(({ sequence }) => sequence.run_id !== runId)) { + return fail("STRATEGY_SEQUENCE_VIOLATION", "temporal evidence must use one consistent run_id"); + } + sequenced.sort((left, right) => left.sequence.ordinal - right.sequence.ordinal); + if (sequenced[0].sequence.previous_evidence_id) { + return fail("STRATEGY_SEQUENCE_VIOLATION", "the causal chain root must not declare previous_evidence_id"); + } + for (let index = 1; index < sequenced.length; index += 1) { + const previous = sequenced[index - 1]; + const current = sequenced[index]; + if (current.sequence.ordinal <= previous.sequence.ordinal) { + return fail("STRATEGY_SEQUENCE_VIOLATION", "temporal evidence ordinals must be strictly increasing"); } - - if (redItems.length > 0 && patchItems.length > 0) { - for (const red of redItems) { - const redSeq = getExecutionSequence(red); - const redId = red.evidence && red.evidence.evidence_id; - for (const patch of patchItems) { - const patchSeq = getExecutionSequence(patch); - if (patchSeq.ordinal <= redSeq.ordinal) { - return fail("STRATEGY_SEQUENCE_VIOLATION", "patch evidence ordinal must be greater than red evidence ordinal"); - } - if (patchSeq.previous_evidence_id && redId && patchSeq.previous_evidence_id !== redId) { - return fail("STRATEGY_SEQUENCE_VIOLATION", "patch previous_evidence_id does not link to red evidence"); - } - } - } + const previousEvidenceId = previous.item.evidence && previous.item.evidence.evidence_id; + if (!current.sequence.previous_evidence_id) { + return fail("STRATEGY_SEQUENCE_VIOLATION", "previous_evidence_id is required for every causal transition"); } - - if (patchItems.length > 0 && greenItems.length > 0) { - for (const patch of patchItems) { - const patchSeq = getExecutionSequence(patch); - const patchId = patch.evidence && patch.evidence.evidence_id; - for (const green of greenItems) { - const greenSeq = getExecutionSequence(green); - if (greenSeq.ordinal <= patchSeq.ordinal) { - return fail("STRATEGY_SEQUENCE_VIOLATION", "green evidence ordinal must be greater than patch evidence ordinal"); - } - if (greenSeq.previous_evidence_id && patchId && greenSeq.previous_evidence_id !== patchId) { - return fail("STRATEGY_SEQUENCE_VIOLATION", "green previous_evidence_id does not link to patch evidence"); - } - } - } - } - - if (redItems.length > 0 && greenItems.length > 0) { - for (const red of redItems) { - const redSeq = getExecutionSequence(red); - for (const green of greenItems) { - const greenSeq = getExecutionSequence(green); - if (greenSeq.ordinal <= redSeq.ordinal) { - return fail("STRATEGY_SEQUENCE_VIOLATION", "green evidence ordinal must be greater than red evidence ordinal"); - } - } - } + if (current.sequence.previous_evidence_id !== previousEvidenceId) { + return fail("STRATEGY_SEQUENCE_VIOLATION", "previous_evidence_id does not link to the prior Evidence"); } } + return { ok: true }; +} - if (strategyName === "refactor") { - const beforeItems = roleItems("characterization-before"); - const afterItems = roleItems("characterization-after"); - - if (beforeItems.length > 0 && afterItems.length > 0) { - for (const item of [...beforeItems, ...afterItems]) { - const seq = getExecutionSequence(item); - if (!seq) { - return fail("STRATEGY_SEQUENCE_VIOLATION", "missing execution_sequence for temporal strategy"); - } - } - - for (const before of beforeItems) { - const beforeSeq = getExecutionSequence(before); - const beforeId = before.evidence && before.evidence.evidence_id; - for (const after of afterItems) { - const afterSeq = getExecutionSequence(after); - if (afterSeq.ordinal <= beforeSeq.ordinal) { - return fail("STRATEGY_SEQUENCE_VIOLATION", "characterization-after ordinal must be greater than characterization-before ordinal"); - } - if (afterSeq.previous_evidence_id && beforeId && afterSeq.previous_evidence_id !== beforeId) { - return fail("STRATEGY_SEQUENCE_VIOLATION", "characterization-after previous_evidence_id does not link to characterization-before"); - } - } - } - } +function assertRolePrecedes(items, earlierRole, laterRole) { + const earlier = items.filter((item) => item.role === earlierRole); + const later = items.filter((item) => item.role === laterRole); + if (earlier.length === 0 || later.length === 0) return { ok: true }; + const latestEarlier = Math.max(...earlier.map((item) => getExecutionSequence(item).ordinal)); + const earliestLater = Math.min(...later.map((item) => getExecutionSequence(item).ordinal)); + if (latestEarlier >= earliestLater) { + return fail( + "STRATEGY_SEQUENCE_VIOLATION", + `${laterRole} evidence must execute after ${earlierRole} evidence` + ); } + return { ok: true }; +} +function assertRoleOrder(strategyName, items) { + const relevantRoles = temporalRoles(strategyName); + if (relevantRoles.length === 0) return { ok: true }; + const temporalItems = (items || []).filter((item) => item && relevantRoles.includes(item.role)); + const chain = assertCausalChain(temporalItems); + if (!chain.ok) return chain; + + const transitions = strategyName === "bug" + ? [["red", "patch"], ["patch", "green"]] + : strategyName === "refactor" + ? [["characterization-before", "characterization-after"]] + : [["red", "green"]]; + for (const [earlierRole, laterRole] of transitions) { + const ordered = assertRolePrecedes(temporalItems, earlierRole, laterRole); + if (!ordered.ok) return ordered; + } return { ok: true }; } diff --git a/scripts/lib/k1-scope-guard.test.js b/scripts/lib/k1-scope-guard.test.js index 22e421a4..e1418cfa 100644 --- a/scripts/lib/k1-scope-guard.test.js +++ b/scripts/lib/k1-scope-guard.test.js @@ -205,6 +205,7 @@ const SUCCESSOR_K2_PREFIXES = [ "schemas/kernel/verification/fixtures/invalid/v2-", "schemas/kernel/assurance-graph/", "schemas/kernel/assessment/", + "schemas/kernel/runner-receipt/", ]; const PROTECTED_BASELINE_PATHS = [ @@ -386,6 +387,8 @@ test("K1 scope guard: fixed routing and phase validation remain byte-equivalent .replace(/^(\s*version:\s*)\S+$/m, "$1") .replace(/^strict_tdd:\s*true\n?/gm, "") .replace(/^\s*tdd_mode:\s*focused\n?/gm, "") + .replace(/^ last_checked: ".*"$/m, ' last_checked: ""') + .replace(/(domains_done:\n)(?: - .+\n)+/, "$1 - \n") .replace(/\n{3,}/g, "\n\n"); assert.equal( normalizeConfig(baseline.stdout), diff --git a/scripts/lib/k2a-maturity-docs.test.js b/scripts/lib/k2a-maturity-docs.test.js index bed765dd..6dd53849 100644 --- a/scripts/lib/k2a-maturity-docs.test.js +++ b/scripts/lib/k2a-maturity-docs.test.js @@ -31,8 +31,8 @@ test("K2a maturity docs tag host surfaces implemented; Candidate/attestation/del assert.match(roadmap, /K2a.*\*\*done\*\*|Headless Conformance Host.*implemented/i); assert.match(roadmap, /\|\s*`done`\s*\|\s*\*\*K3\*\*/); assert.match(roadmap, /\|\s*`done`\s*\|\s*\*\*K4b\*\*/); - assert.match(roadmap, /\|\s*`done`\s*\|\s*\*\*K6b\*\*/); - assert.match(roadmap, /\|\s*`next-eligible`\s*\|\s*\*\*K6c\*\*/); + assert.match(roadmap, /\|\s*`revise`\s*\|\s*\*\*K6b\*\*/); + assert.match(roadmap, /\|\s*`blocked-by-K6b-terminal-review`\s*\|\s*\*\*K6c\*\*/); assert.match(arch, /\{implemented\} Independent verifier over frozen CandidateId \(K6b\)/); assert.match(arch, /\{implemented\} Evidence strategies with provenance and Strict TDD fallback \(K6b\)/); assert.match(arch, /\{implemented\} Assurance Graph as derived content-addressed projection/); @@ -44,6 +44,6 @@ test("K2a maturity docs tag host surfaces implemented; Candidate/attestation/del assert.doesNotMatch(roadmap, /\|\s*`in-progress`\s*\|\s*\*\*K6b\*\*/); assert.doesNotMatch(roadmap, /\|\s*`blocked`\s*\|\s*\*\*K6b\*\*/); assert.doesNotMatch(roadmap, /\|\s*`next-eligible`\s*\|\s*\*\*K6b\*\*/); - assert.doesNotMatch(roadmap, /\|\s*`revise`\s*\|\s*\*\*K6b\*\*/); - assert.doesNotMatch(roadmap, /\|\s*`blocked-by-K6b-remediation`\s*\|\s*\*\*K6c\*\*/); + assert.doesNotMatch(roadmap, /\|\s*`done`\s*\|\s*\*\*K6b\*\*/); + assert.doesNotMatch(roadmap, /\|\s*`next-eligible`\s*\|\s*\*\*K6c\*\*/); }); diff --git a/scripts/lib/k6b-schema-fixtures.test.js b/scripts/lib/k6b-schema-fixtures.test.js index 9f679aec..69a01a79 100644 --- a/scripts/lib/k6b-schema-fixtures.test.js +++ b/scripts/lib/k6b-schema-fixtures.test.js @@ -10,6 +10,7 @@ const { digestFile, K1_SCHEMA_BASELINE, } = require("./lifecycle-kernel/k1-compat.js"); +const { createRunnerReceipt } = require("./independent-verifier/runner-receipt.js"); const ROOT = path.resolve(__dirname, "../.."); @@ -17,7 +18,7 @@ function readJson(relPath) { return JSON.parse(fs.readFileSync(path.join(ROOT, relPath), "utf8")); } -test("K6b schema registration: manifest indexes evidence/v2, verification/v2, and assurance-graph/v1", () => { +test("K6b schema registration: manifest indexes evidence, verification, graph, assessment, and runner receipt", () => { const manifest = readJson("schemas/kernel/manifest.json"); assert.ok(manifest.families["evidence-v2"], "manifest must register evidence-v2"); @@ -55,6 +56,13 @@ test("K6b schema registration: manifest indexes evidence/v2, verification/v2, an assert.equal(manifest.families["assessment-v2"].path, "schemas/kernel/assessment/v2.schema.json"); const assessmentV2 = loadSchemaById("ospec://schemas/kernel/assessment/v2", { rootDir: ROOT }); assert.equal(assessmentV2.$id, "ospec://schemas/kernel/assessment/v2"); + + assert.ok(manifest.families["runner-receipt"], "manifest must register runner-receipt"); + assert.equal(manifest.families["runner-receipt"].schema_version, 1); + assert.equal(manifest.families["runner-receipt"].$id, "ospec://schemas/kernel/runner-receipt/v1"); + assert.equal(manifest.families["runner-receipt"].path, "schemas/kernel/runner-receipt/v1.schema.json"); + const runnerReceipt = loadSchemaById("ospec://schemas/kernel/runner-receipt/v1", { rootDir: ROOT }); + assert.equal(runnerReceipt.$id, "ospec://schemas/kernel/runner-receipt/v1"); }); test("K6b contract claims: additive families list required fields without replacing v1 claims", () => { @@ -140,6 +148,44 @@ test("K6b contract claims: additive families list required fields without replac "rollback", "dry-run", ]); + + assert.deepEqual(claims.families["runner-receipt"].required_fields, [ + "schema_version", + "kind", + "receipt_id", + "candidate_id", + "evidence_id", + "node_id", + "role", + "satisfied_tokens", + "outcome", + "issuer_id", + "transport", + ]); +}); + +test("K6b runner-receipt/v1: exact Evidence binding validates and missing evidence_id fails", () => { + const schema = loadSchemaById("ospec://schemas/kernel/runner-receipt/v1", { rootDir: ROOT }); + const receipt = createRunnerReceipt({ + candidate_id: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + evidence_id: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + node_id: "repair-core", + role: "green", + satisfied_tokens: ["ev:test-pass"], + execution_sequence: { + run_id: "run-1", + ordinal: 2, + previous_evidence_id: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + }, + outcome: "passed", + issuer_id: "node-test", + transport: "tool-execution-transport", + }); + assert.equal(validateInstance(schema, receipt).valid, true); + + const missingEvidenceId = { ...receipt }; + delete missingEvidenceId.evidence_id; + assert.equal(validateInstance(schema, missingEvidenceId).valid, false); }); test("K6b evidence/v2: valid fixture passes; verdict and unknown provenance fail closed", () => { diff --git a/scripts/lib/lifecycle-kernel/k1-compat.js b/scripts/lib/lifecycle-kernel/k1-compat.js index 63442c03..e8113d97 100644 --- a/scripts/lib/lifecycle-kernel/k1-compat.js +++ b/scripts/lib/lifecycle-kernel/k1-compat.js @@ -169,7 +169,7 @@ function listK1SchemaFiles(rootDir) { "schemas/kernel/capsule-definition/", "schemas/kernel/work-result-execution-payload/", "schemas/kernel/containment-violation/", - // K6b additive evidence/verification v2 and assurance-graph family + // K6b additive evidence, verification, receipt, assessment, and assurance-graph families "schemas/kernel/evidence/v2.schema.json", "schemas/kernel/evidence/fixtures/valid/v2-", "schemas/kernel/evidence/fixtures/invalid/v2-", @@ -178,6 +178,7 @@ function listK1SchemaFiles(rootDir) { "schemas/kernel/verification/fixtures/invalid/v2-", "schemas/kernel/assurance-graph/", "schemas/kernel/assessment/", + "schemas/kernel/runner-receipt/", // Evolutionary catalog registries (excluded from K1 frozen baseline pin) "schemas/kernel/manifest.json", "schemas/kernel/contract-claims.json", diff --git a/scripts/lib/test-support/k6b-runner-receipt.js b/scripts/lib/test-support/k6b-runner-receipt.js new file mode 100644 index 00000000..54adab17 --- /dev/null +++ b/scripts/lib/test-support/k6b-runner-receipt.js @@ -0,0 +1,83 @@ +"use strict"; + +const { normalizeEvidence } = require("../independent-verifier/evidence.js"); +const { + createRunnerReceipt, +} = require("../independent-verifier/runner-receipt.js"); +const { + createRunnerReceiptAuthority, + issueRunnerReceiptChannel, +} = require("../independent-verifier/internal/runner-receipt-channel.js"); + +const MISSING_EVIDENCE_ID = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; +const TEST_RUNNER_AUTHORITY = createRunnerReceiptAuthority({ + issuer_id: "node-test", + transport: "tool-execution-transport", +}); + +/** + * Registra recibos preconstruidos mediante la autoridad interna del runner de prueba. + * + * @param {object[]} receipts Recibos usados por un caso adversarial. + * @returns {object} Canal opaco aceptado por el verifier. + */ +function createTestRunnerReceiptChannelFromReceipts(receipts) { + return issueRunnerReceiptChannel({ authority: TEST_RUNNER_AUTHORITY, receipts }); +} + +/** + * Emite un canal de receipts confiable para fixtures K6b sin relajar el facade productivo. + * + * @param {object} input Candidate, grafo, observaciones y claims del runner de prueba. + * @returns {object} Capacidad opaca aceptada por `verifyCandidate`. + */ +function createTestRunnerReceiptChannel(input) { + const receipts = []; + let previousReceipt = null; + for (let index = 0; index < input.receiptSpecs.length; index += 1) { + const spec = input.receiptSpecs[index]; + const collector = Array.isArray(input.collectors) ? input.collectors[index] : input.collector; + const normalized = normalizeEvidence( + input.rawEvidence[index], + input.candidate, + input.executionGraph, + collector + ); + const evidence = normalized.ok ? normalized.evidence : null; + const hasExplicitSequence = Object.prototype.hasOwnProperty.call(spec, "execution_sequence"); + let sequence = hasExplicitSequence + ? spec.execution_sequence + : (normalized.ok ? normalized.execution_sequence : undefined); + // Solo los fixtures positivos sintetizan el enlace; las secuencias adversariales explícitas quedan intactas. + if ( + !hasExplicitSequence && + sequence && + !sequence.previous_evidence_id && + previousReceipt && + previousReceipt.execution_sequence && + previousReceipt.execution_sequence.run_id === sequence.run_id && + previousReceipt.execution_sequence.ordinal < sequence.ordinal + ) { + sequence = { ...sequence, previous_evidence_id: previousReceipt.evidence_id }; + } + const receipt = createRunnerReceipt({ + candidate_id: spec.candidate_id || input.candidate.candidate_id, + evidence_id: spec.evidence_id || (evidence && evidence.evidence_id) || MISSING_EVIDENCE_ID, + node_id: spec.node_id || (evidence && evidence.node_id) || "unknown-node", + role: spec.role, + satisfied_tokens: spec.satisfied_tokens || spec.evidence_requirements_satisfied || [], + execution_sequence: sequence || undefined, + outcome: spec.outcome || (spec.role === "red" ? "failed" : "passed"), + issuer_id: "node-test", + transport: "tool-execution-transport", + }); + receipts.push(receipt); + previousReceipt = receipt; + } + return createTestRunnerReceiptChannelFromReceipts(receipts); +} + +module.exports = { + createTestRunnerReceiptChannel, + createTestRunnerReceiptChannelFromReceipts, +}; diff --git a/scripts/manifest-sync.test.js b/scripts/manifest-sync.test.js index c6dfae99..c23ca7b0 100644 --- a/scripts/manifest-sync.test.js +++ b/scripts/manifest-sync.test.js @@ -27,6 +27,9 @@ const CANONICAL = path.join(ROOT, ".plugin.json"); const CLAUDE_COPY = path.join(ROOT, ".claude-plugin", "plugin.json"); const PACKAGE_JSON = path.join(ROOT, "package.json"); const OPENSPEC_CONFIG = path.join(ROOT, "openspec", "config.yaml"); +const CHANGELOG = path.join(ROOT, "CHANGELOG.md"); +const ROADMAP = path.join(ROOT, "docs", "roadmaps", "harness-evolution.md"); +const ARCHITECTURE = path.join(ROOT, "docs", "architecture", "harness-evolution.md"); function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); @@ -66,3 +69,39 @@ test("openspec/config.yaml version matches the plugin manifest version", () => { "openspec/config.yaml version must match .plugin.json (bump both together)", ); }); + +test("release version matches changelog, roadmap, architecture, verify report, and tag", () => { + const packageVersion = readJson(PACKAGE_JSON).version; + const changelogText = fs.readFileSync(CHANGELOG, "utf8"); + const roadmapText = fs.readFileSync(ROADMAP, "utf8"); + const architectureText = fs.readFileSync(ARCHITECTURE, "utf8"); + const latestRelease = changelogText.match(/^## \[(\d+\.\d+\.\d+)\].*$/m); + assert.ok(latestRelease, "CHANGELOG.md must start with a semantic release section"); + assert.equal(latestRelease[1], packageVersion, "latest changelog release must match package.json"); + + const nextReleaseOffset = changelogText.indexOf("\n## [", latestRelease.index + latestRelease[0].length); + const latestSection = changelogText.slice( + latestRelease.index, + nextReleaseOffset === -1 ? changelogText.length : nextReleaseOffset + ); + const archiveMatch = latestSection.match(/`(openspec\/changes\/archive\/[^`/]+)\/`/); + assert.ok(archiveMatch, "latest changelog release must reference its archived change"); + const verifyReport = fs.readFileSync( + path.join(ROOT, ...archiveMatch[1].split("/"), "verify-report.md"), + "utf8" + ); + const reportVersion = verifyReport.match(/^\*\*Version\*\*:\s*(\d+\.\d+\.\d+)\s*$/m); + assert.ok(reportVersion, "release verify-report must declare Version"); + + const roadmapVersion = roadmapText.match(/^> \*\*Versión de referencia:\*\* v(\d+\.\d+\.\d+),/m); + const architectureVersion = architectureText.match(/^> \*\*Corte documental:\*\* v(\d+\.\d+\.\d+),/m); + assert.ok(roadmapVersion, "roadmap must declare its reference version"); + assert.ok(architectureVersion, "architecture must declare its document version"); + assert.equal(reportVersion[1], packageVersion, "release verify-report version must match package.json"); + assert.equal(roadmapVersion[1], packageVersion, "roadmap reference must match package.json"); + assert.equal(architectureVersion[1], packageVersion, "architecture cut must match package.json"); + + if (process.env.GITHUB_REF_TYPE === "tag") { + assert.equal(process.env.GITHUB_REF_NAME, `v${packageVersion}`, "published tag must match package.json"); + } +}); diff --git a/test/e2e/k6b-verifier-assurance-graph-e2e.test.js b/test/e2e/k6b-verifier-assurance-graph-e2e.test.js index 587f3686..a392e8c4 100644 --- a/test/e2e/k6b-verifier-assurance-graph-e2e.test.js +++ b/test/e2e/k6b-verifier-assurance-graph-e2e.test.js @@ -8,6 +8,7 @@ const { freezeCandidate } = require("../../scripts/lib/execution-identities/inde const { compileExecutionGraph, createPolicySnapshot } = require("../../scripts/lib/execution-graph/index.js"); const { computeTreeDigest } = require("../../scripts/lib/worker-workspace.js"); const { verifyCandidate } = require("../../scripts/lib/independent-verifier/index.js"); +const { createTestRunnerReceiptChannel } = require("../../scripts/lib/test-support/k6b-runner-receipt.js"); const { reconcileAssuranceGraph, replayAssuranceGraph, @@ -74,6 +75,16 @@ function buildHarness(files = { "src/index.js": "module.exports = 1;\n" }) { }; } +function trustedReceiptChannel(harness, rawEvidence, receiptSpecs) { + return createTestRunnerReceiptChannel({ + candidate: harness.candidate, + executionGraph: harness.executionGraph, + collector: harness.collector, + rawEvidence, + receiptSpecs, + }); +} + test("E2E: complete lifecycle - verification, projection, reconciliation, and cryptographic replay", () => { const harness = buildHarness(); @@ -88,13 +99,14 @@ test("E2E: complete lifecycle - verification, projection, reconciliation, and cr { role: "red", node_id: "repair-core" }, { role: "green", node_id: "repair-core", evidence_requirements_satisfied: ["ev:test-pass"] }, ]; + const runnerReceiptChannel = trustedReceiptChannel(harness, rawEvidence, runnerReceipts); // 1. Verification const verificationResult = verifyCandidate({ ...harness, declaredStrategy: "strict-tdd", rawEvidence, - runner_receipts: runnerReceipts, + runnerReceiptChannel, }); assert.equal(verificationResult.ok, true, verificationResult.error || verificationResult.reason_code); @@ -128,13 +140,11 @@ test("E2E: complete lifecycle - verification, projection, reconciliation, and cr executionGraph: harness.executionGraph, contract: harness.contract, policySnapshot: harness.policySnapshot, - evidence: [ - { evidence: verificationResult.evidence[0], bytes: rawEvidence[0].bytes }, - { evidence: verificationResult.evidence[1], bytes: rawEvidence[1].bytes }, - ], + evidence: verificationResult.replay_evidence, assessments: verificationResult.assessments, verification: verificationResult.verification, canonical_inputs: projectedGraph.canonical_inputs, + runnerReceiptChannel, }); assert.equal(replayResult.ok, true, replayResult.error || replayResult.reason_code); assert.equal(replayResult.graph.graph_id, projectedGraph.graph_id); @@ -158,7 +168,7 @@ test("E2E Adversarial: caller metadata injection in raw evidence is rejected bef ...harness, declaredStrategy: "strict-tdd", rawEvidence: injectedEvidence, - runner_receipts: [{ role: "green", node_id: "repair-core" }], + runnerReceiptChannel: trustedReceiptChannel(harness, injectedEvidence, [{ role: "green", node_id: "repair-core" }]), }); assert.equal(result.ok, false); @@ -178,10 +188,10 @@ test("E2E Adversarial: causality tampering (inverted ordinals) fails closed", () ...harness, declaredStrategy: "strict-tdd", rawEvidence: invertedEvidence, - runner_receipts: [ + runnerReceiptChannel: trustedReceiptChannel(harness, invertedEvidence, [ { role: "green", node_id: "repair-core" }, { role: "red", node_id: "repair-core" }, - ], + ]), }); assert.equal(result.ok, false); @@ -206,7 +216,7 @@ test("E2E Adversarial: missing runner receipt leaves MUST unfulfilled", () => { ...harness, declaredStrategy: "strict-tdd", rawEvidence, - runner_receipts: ungroundedReceipts, + runnerReceiptChannel: trustedReceiptChannel(harness, rawEvidence, ungroundedReceipts), }); assert.equal(result.ok, false); @@ -224,12 +234,13 @@ test("E2E Adversarial: replayed evidence tampering (modified bytes) fails replay { role: "red", node_id: "repair-core" }, { role: "green", node_id: "repair-core", evidence_requirements_satisfied: ["ev:test-pass"] }, ]; + const runnerReceiptChannel = trustedReceiptChannel(harness, rawEvidence, runnerReceipts); const verificationResult = verifyCandidate({ ...harness, declaredStrategy: "strict-tdd", rawEvidence, - runner_receipts: runnerReceipts, + runnerReceiptChannel, }); assert.equal(verificationResult.ok, true); @@ -240,12 +251,13 @@ test("E2E Adversarial: replayed evidence tampering (modified bytes) fails replay contract: harness.contract, policySnapshot: harness.policySnapshot, evidence: [ - { evidence: verificationResult.evidence[0], bytes: "tampered-red-bytes" }, - { evidence: verificationResult.evidence[1], bytes: rawEvidence[1].bytes }, + { ...verificationResult.replay_evidence[0], bytes: "tampered-red-bytes" }, + verificationResult.replay_evidence[1], ], assessments: verificationResult.assessments, verification: verificationResult.verification, canonical_inputs: verificationResult.assurance_graph.canonical_inputs, + runnerReceiptChannel, }); assert.equal(replayResult.ok, false); From 37cbc4b0418a7e8339dc4bc512363d97d214e6d6 Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Fri, 28 Aug 2026 14:27:03 +0200 Subject: [PATCH 2/3] docs(spec): reconciliar K6b con runner-receipt, canal opaco y replay Documenta de forma aditiva el comportamiento ya publicado en a476b9a para independent-verification, assurance-graph y kernel-contract-schemas. --- openspec/specs/_baseline/manifest.md | 3 ++ openspec/specs/assurance-graph/spec.md | 48 ++++++++++++++++--- .../specs/independent-verification/spec.md | 46 ++++++++++++++++-- .../specs/kernel-contract-schemas/spec.md | 41 +++++++++++++++- 4 files changed, 127 insertions(+), 11 deletions(-) diff --git a/openspec/specs/_baseline/manifest.md b/openspec/specs/_baseline/manifest.md index 57e6fa6d..e376d8b1 100644 --- a/openspec/specs/_baseline/manifest.md +++ b/openspec/specs/_baseline/manifest.md @@ -47,4 +47,7 @@ | independent-verification | skipped | - | 71d5114 | 2026-08-28T12:09:36Z | | assurance-graph | skipped | - | 71d5114 | 2026-08-28T12:09:36Z | | kernel-contract-schemas | skipped | - | 71d5114 | 2026-08-28T12:09:36Z | +| independent-verification | reconciled | - | a476b9a | 2026-08-28T12:25:32Z | +| assurance-graph | reconciled | - | a476b9a | 2026-08-28T12:25:32Z | +| kernel-contract-schemas | reconciled | - | a476b9a | 2026-08-28T12:25:32Z | diff --git a/openspec/specs/assurance-graph/spec.md b/openspec/specs/assurance-graph/spec.md index 3ec76aa3..ff0cef63 100644 --- a/openspec/specs/assurance-graph/spec.md +++ b/openspec/specs/assurance-graph/spec.md @@ -162,15 +162,17 @@ fail closed. Edges whose `relation` is outside ### Requirement: Replay From Persistable Outputs {#REQ-assurance-graph-006} -The Assurance Graph MUST be reproducible from persistable outputs: canonical input digests, projected nodes and edges, replay Evidence bundles containing each `evidence/v2` plus raw observation bytes or a resolvable content-addressed `observation_blob_id`, verification records (`verification/v2`), and assessment records (`assessment/v2`) that carry `obligation_id`. Replay MUST NOT require ephemeral projector fields. Consumers MUST NOT reinvent `obligation_id` values from vanished fields. `satisfies` edges MUST be rebuildable from persistable assessments where `evidence_requirements_satisfied.length > 0`. +The Assurance Graph MUST be reproducible from persistable outputs: canonical input digests, projected nodes and edges, replay Evidence bundles containing each `evidence/v2` plus raw observation bytes or a resolvable content-addressed `observation_blob_id` plus a bound `runner_receipt_id`, a trusted opaque `runnerReceiptChannel`, verification records (`verification/v2`), and assessment records (`assessment/v2`) that carry `obligation_id`. Replay MUST NOT require ephemeral projector fields. Consumers MUST NOT reinvent `obligation_id` values from vanished fields. `satisfies` edges MUST be rebuildable from persistable assessments where `evidence_requirements_satisfied.length > 0`. `replayAssuranceGraph` MUST perform comprehensive validation over all replayed records before accepting the replay: -1. `evidence/v2`: schema validity against `evidence/v2.schema.json`; REQUIRED inline raw bytes or a resolvable `observation_blob_id` equal to `record.digest`; `candidate_id` matching graph subject; recomputed content digest via `digestRawBytes` matching `record.digest`; recomputed `evidence_id` via `computeEvidenceId` matching `record.evidence_id`; evaluation of provenance sufficiency via `evaluateProvenanceSufficiency` verifying admissible provenance against trusted collector or transport metadata; and strict absence of `verdict`. -2. `verification/v2`: schema validity against `verification/v2.schema.json`; recomputed `verification_id`; `candidate_id` matching graph subject; and `evidence_ids` being a strict subset of replayed evidence IDs. -3. `assessment/v2`: schema validity against `assessment/v2.schema.json`; recomputed `assessment_id`; `candidate_id` matching graph subject; bound `policy_snapshot_id` matching graph; referenced `evidence_id` existing in replayed evidence; `obligation_id` existing in Execution Graph; bound `node_id` implementing that obligation; persistable `node_id` matching evidence record; and non-empty `evidence_requirements_satisfied` for satisfaction claims. -4. Obligation coverage: verified satisfaction of all non-deferred MUST obligations by the replayed assessments. - -Any check failure, missing or unresolvable observation material, tampering with `assessment_id`, `evidence_id`, `verification_id`, or `digest`, or provenance insufficiency MUST fail closed with `GRAPH_DIVERGENCE` or as an invalid artifact. Cryptographic validation MUST NOT be skipped when bytes are absent. Tampered evidence, assessments, or verification records MUST NOT replay as valid. +1. Persistable bundle: the replay argument MUST be a non-null object. Unexpected exceptions during validation MUST fail closed with `GRAPH_DIVERGENCE`. +2. Trusted runner receipts: replay MUST present an opaque `runnerReceiptChannel` issued by runtime authority. A missing channel, a reconstructed public-field object, or untrusted authority MUST fail closed with `GRAPH_DIVERGENCE`. +3. `evidence/v2`: schema validity against `evidence/v2.schema.json`; REQUIRED inline raw bytes or a resolvable `observation_blob_id` equal to `record.digest`; `candidate_id` matching graph subject; recomputed content digest via `digestRawBytes` matching `record.digest`; recomputed `evidence_id` via `computeEvidenceId` matching `record.evidence_id`; evaluation of provenance sufficiency via `evaluateProvenanceSufficiency` verifying admissible provenance against trusted collector or transport metadata; strict absence of `verdict`; and exact 1:1 binding of each wrapper `runner_receipt_id` to a trusted receipt whose `candidate_id`, `evidence_id`, and `node_id` match the Evidence record. Duplicate or orphan receipt bindings MUST fail closed. +4. `verification/v2`: schema validity against `verification/v2.schema.json`; recomputed `verification_id`; `candidate_id` matching graph subject; and `evidence_ids` being a strict subset of replayed evidence IDs. +5. `assessment/v2`: schema validity against `assessment/v2.schema.json`; recomputed `assessment_id`; `candidate_id` matching graph subject; bound `policy_snapshot_id` matching graph; referenced `evidence_id` existing in replayed evidence; `obligation_id` existing in Execution Graph; bound `node_id` implementing that obligation; persistable `node_id` matching evidence record; non-empty `evidence_requirements_satisfied` for satisfaction claims; and those tokens attested by the bound receipt `satisfied_tokens`. +6. Obligation coverage: verified satisfaction of all non-deferred MUST obligations by the replayed assessments. + +Any check failure, missing or unresolvable observation material, missing trusted receipt authority, tampering with `assessment_id`, `evidence_id`, `verification_id`, or `digest`, or provenance insufficiency MUST fail closed with `GRAPH_DIVERGENCE` or as an invalid artifact. Cryptographic validation MUST NOT be skipped when bytes are absent. Tampered evidence, assessments, or verification records MUST NOT replay as valid. (Previously: replay validation did not explicitly recompute computeEvidenceId or evaluateProvenanceSufficiency during evidence/v2 revalidation.) #### Scenario: Replay from persisted outputs yields the same graph @@ -238,6 +240,38 @@ Any check failure, missing or unresolvable observation material, tampering with - WHEN `replayAssuranceGraph` runs - THEN replay MUST fail closed with `GRAPH_DIVERGENCE` +#### Scenario: Replay without trusted runner receipt authority fails closed + +- GIVEN persistable evidence, assessments, verification, and canonical inputs that would otherwise replay +- AND no opaque runtime-issued `runnerReceiptChannel` +- WHEN `replayAssuranceGraph` runs +- THEN replay MUST fail closed with `GRAPH_DIVERGENCE` + +#### Scenario: Forged runnerReceiptChannel public fields fail closed + +- GIVEN a persistable replay bundle +- AND a caller-constructed object copying `kind`, `issuer_id`, and `transport` without runtime-issued identity +- WHEN `replayAssuranceGraph` runs +- THEN replay MUST fail closed with `GRAPH_DIVERGENCE` + +#### Scenario: Replay Evidence not exactly bound to a trusted receipt fails closed + +- GIVEN replay Evidence whose `runner_receipt_id` is missing, reused, or whose receipt `candidate_id`, `evidence_id`, or `node_id` disagrees with the Evidence record +- WHEN `replayAssuranceGraph` runs +- THEN replay MUST fail closed with `GRAPH_DIVERGENCE` + +#### Scenario: Assessment coverage not attested by the bound receipt fails closed + +- GIVEN a persistable assessment whose `evidence_requirements_satisfied` contains a token absent from the bound receipt `satisfied_tokens` +- WHEN `replayAssuranceGraph` runs +- THEN replay MUST fail closed with `GRAPH_DIVERGENCE` + +#### Scenario: Null or non-object replay bundle fails closed + +- GIVEN a `null` or non-object persistable argument +- WHEN `replayAssuranceGraph` runs +- THEN replay MUST fail closed with `GRAPH_DIVERGENCE` + ### Requirement: Projector Fail-Closed On Contradictory Canonical Inputs {#REQ-assurance-graph-007} `projectAssuranceGraph` and `resolveCanonicalInputDigests()` MUST fail closed with `GRAPH_DIVERGENCE` when supplied `canonicalInputs` contradict the Graph, contract, policy, or OpenSpec inputs they claim to project. diff --git a/openspec/specs/independent-verification/spec.md b/openspec/specs/independent-verification/spec.md index d314b4c1..1318f4a1 100644 --- a/openspec/specs/independent-verification/spec.md +++ b/openspec/specs/independent-verification/spec.md @@ -82,7 +82,7 @@ The verifier MUST enforce strict physical segregation of raw observations (`rawE Trusted evidence metadata (`role`, `obligation_ids`, `evidence_requirements_satisfied`) MUST be derived exclusively by the verifier from the Execution Graph and a trusted runtime `runnerReceiptChannel`. Direct caller DTO properties named `receipts` or `runner_receipts` MUST fail closed with `UNTRUSTED_RUNNER_RECEIPT`. The channel MUST be an opaque runtime capability whose identity cannot be reconstructed by copying public fields. -Every receipt obtained from that channel MUST conform to `runner-receipt/v1` and MUST contain a content-addressed `receipt_id`, `candidate_id`, REQUIRED `evidence_id`, `node_id`, `role`, canonical `satisfied_tokens`, `outcome`, `issuer_id`, and `transport`. Temporal receipts MUST also carry `execution_sequence`. The verifier MUST recompute `receipt_id`, validate issuer/transport against the channel, require exact `receipt.evidence_id === evidence.evidence_id`, require matching Candidate and node bindings, and reject orphan, duplicate, positional, or node-only matching with `INVALID_RUNNER_RECEIPT` or `RUNNER_RECEIPT_BINDING_MISMATCH`. `node.kind` MUST NOT substitute for a strategy role. A receipt with `outcome: failed` MUST NOT carry any satisfied token. +Every receipt obtained from that channel MUST conform to `runner-receipt/v1` and MUST contain a content-addressed `receipt_id`, `candidate_id`, REQUIRED `evidence_id`, `node_id`, `role`, canonical uniquely-sorted `satisfied_tokens`, `outcome`, `issuer_id`, and `transport`. Temporal receipts MUST also carry `execution_sequence`. The verifier MUST recompute `receipt_id` from canonical fields excluding `receipt_id` itself, validate issuer/transport against the channel, require exact `receipt.evidence_id === evidence.evidence_id`, require matching Candidate and node bindings, and reject orphan, duplicate, positional, or node-only matching with `INVALID_RUNNER_RECEIPT` or `RUNNER_RECEIPT_BINDING_MISMATCH`. `node.kind` MUST NOT substitute for a strategy role. When an Execution Graph node declares `role`, that value MUST equal the bound receipt `role` or fail closed with `RUNNER_RECEIPT_BINDING_MISMATCH`. `obligation_ids` MUST be derived from Execution Graph `implemented_by`, never copied from receipt fields. A receipt with `outcome: failed` MUST NOT carry any satisfied token. A receipt whose `role` is `red` MUST have `outcome: failed`; every other role MUST have `outcome: passed`; role/outcome incoherence MUST fail closed with `INVALID_RUNNER_RECEIPT`. The productive `runner-receipt` facade MUST NOT export channel-minting operations; only internal runtime authority MAY issue `runnerReceiptChannel`. On `ok: true`, `verifyCandidate` MUST emit persistable `replay_evidence` items each containing the `evidence/v2` record, raw observation bytes, and the bound `runner_receipt_id`. The verifier MUST NOT accept a strong class solely because the raw payload string claims it. Payload digest MUST NOT be treated as origin. When collector or transport metadata is absent, untrusted, or disagrees with a claimed strong class, sufficiency MUST fail closed. PKI MUST NOT be required. Evidence that is insufficient, stale relative to the frozen Candidate, bound to a foreign subject, or fabricated MUST fail closed. A worker `model-reported` claim MUST NOT satisfy an obligation that requires a strong class. (Previously: raw evidence containing untrusted caller metadata did not trigger an explicit UNTRUSTED_CALLER_METADATA fail-closed rejection.) @@ -156,6 +156,33 @@ The verifier MUST NOT accept a strong class solely because the raw payload strin - WHEN the verifier validates the receipt - THEN verification MUST fail closed with `INVALID_RUNNER_RECEIPT` +#### Scenario: Role and outcome incoherence fails closed + +- GIVEN a trusted-channel receipt whose `role` is `red` with `outcome: passed`, or a non-red role with `outcome: failed` +- WHEN the verifier validates the receipt +- THEN verification MUST fail closed with `INVALID_RUNNER_RECEIPT` + +#### Scenario: Receipt role disagrees with Execution Graph node role + +- GIVEN a graph node that declares `role` +- AND a bound trusted receipt whose `role` differs from that node +- WHEN the verifier resolves receipt bindings +- THEN verification MUST fail closed with `RUNNER_RECEIPT_BINDING_MISMATCH` + +#### Scenario: Productive facade cannot mint a trusted receipt channel + +- GIVEN the public `runner-receipt` module consumed by `verifyCandidate` +- WHEN a caller inspects exported operations +- THEN channel-minting operations MUST be absent +- AND copying public `kind`, `issuer_id`, and `transport` fields MUST NOT reconstruct authority + +#### Scenario: Successful verification emits persistable replay_evidence + +- GIVEN a frozen Candidate whose strategy, receipt bindings, and MUST coverage pass +- WHEN `verifyCandidate` returns `ok: true` +- THEN the result MUST include `replay_evidence` +- AND each item MUST carry the `evidence/v2` record, raw observation bytes, and the bound `runner_receipt_id` + ### Requirement: Verdict Is Not Evidence {#REQ-independent-verification-004} Verification MUST emit a verification record bound to the frozen `CandidateId` @@ -254,10 +281,10 @@ Non-conflicting roles (such as `integration` + `acceptance`, `invariant` + `inte The verifier MUST enforce strict causal chronology validation using the trusted receipt `execution_sequence` for `strict-tdd`, `bug`, and `refactor` strategies: - Every temporal receipt in `strict-tdd`, `bug`, and `refactor` MUST provide an `execution_sequence` containing a non-empty consistent `run_id` and a positive integer `ordinal`. -- The first temporal Evidence is the chain root. Every subsequent temporal Evidence MUST provide `previous_evidence_id` equal to the immediately preceding EvidenceId after sorting by ordinal. +- The first temporal Evidence is the chain root and MUST NOT declare `previous_evidence_id`. Every subsequent temporal Evidence MUST provide `previous_evidence_id` equal to the immediately preceding EvidenceId after sorting by ordinal. - Ordinals MUST be unique and strictly monotonically increasing within that single run. - The verifier MUST NOT fall back to JSON array index/position order to determine chronological sequence. -- For `bug` and `strict-tdd` strategies: RED MUST precede GREEN in `execution_sequence`; GREEN before RED or missing `execution_sequence` MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION`, and RED after PATCH MUST fail closed. +- For `bug` and `strict-tdd` strategies: RED MUST precede GREEN in `execution_sequence`; GREEN before RED or missing `execution_sequence` MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION`, and RED after PATCH MUST fail closed. For `bug`, RED, PATCH, and GREEN form one causal chain; GREEN `previous_evidence_id` MUST equal the PATCH EvidenceId when PATCH is the immediate predecessor. - For `refactor` strategy: `characterization-before` MUST precede `characterization-after` in `execution_sequence` (`run_id`, monotonic `ordinal`, and `previous_evidence_id`). `characterization-after` executing before, concurrently with, or without causal sequence linking to `characterization-before` MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION`. Unique-sort of `verification.evidence_ids` MUST NOT be the assessment identity and MUST NOT hide distinct role or obligation bindings. @@ -320,6 +347,19 @@ Unique-sort of `verification.evidence_ids` MUST NOT be the assessment identity a - WHEN the verifier evaluates strategy chronology - THEN verification MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION` +#### Scenario: Causal chain root declaring previous_evidence_id fails closed + +- GIVEN temporal receipts whose lowest ordinal declares `previous_evidence_id` +- WHEN the verifier evaluates strategy chronology +- THEN verification MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION` + +#### Scenario: Bug GREEN that does not chain to PATCH fails closed + +- GIVEN bug-strategy RED, PATCH, and GREEN with increasing ordinals in one `run_id` +- AND GREEN `previous_evidence_id` does not equal the PATCH EvidenceId +- WHEN the verifier evaluates strategy chronology +- THEN verification MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION` + #### Scenario: Negative and acceptance sharing same EvidenceId fails closed - GIVEN an `evidence/v2` observation bound to both `negative` and `acceptance` roles diff --git a/openspec/specs/kernel-contract-schemas/spec.md b/openspec/specs/kernel-contract-schemas/spec.md index 9526867a..b64582cf 100644 --- a/openspec/specs/kernel-contract-schemas/spec.md +++ b/openspec/specs/kernel-contract-schemas/spec.md @@ -10,7 +10,7 @@ consumer tags — without executing the lifecycle reducer. ### Requirement:### Requirement: Versioned Schema Families With Id And Version {#REQ-kernel-contract-schemas-001} -The contract suite MUST publish a versioned JSON Schema for each family: state/transition, classification, contract, graph/node, work order/result, candidate, SourceSnapshot, WorkOrder, WorkResult, Candidate, evidence, verification, finding/review, failure/recovery, receipt, event, OperationPermit, OperationReceipt, effect-class, HostCapabilities, HostAdapter, ExecutionTransport, QuestionTransport, WorkerTransport, ToolExecutionTransport, DeliveryGateTransport, CapabilityProof, transport-request, transport-outcome, transport-failure, execution-graph, policy-snapshot, clarify-event, execution-budget, authority-effect-budget, causal-failure, failure-recovery-transition, workspace-descriptor, capsule-definition, work-result-execution-payload, containment-violation, assurance-graph, and assessment/binding. Every schema MUST declare a stable `$id` and an explicit version field (`schema_version` or equivalent). Consumers MUST be able to pin a schema by `$id`/version. +The contract suite MUST publish a versioned JSON Schema for each family: state/transition, classification, contract, graph/node, work order/result, candidate, SourceSnapshot, WorkOrder, WorkResult, Candidate, evidence, verification, finding/review, failure/recovery, receipt, event, OperationPermit, OperationReceipt, effect-class, HostCapabilities, HostAdapter, ExecutionTransport, QuestionTransport, WorkerTransport, ToolExecutionTransport, DeliveryGateTransport, CapabilityProof, transport-request, transport-outcome, transport-failure, execution-graph, policy-snapshot, clarify-event, execution-budget, authority-effect-budget, causal-failure, failure-recovery-transition, workspace-descriptor, capsule-definition, work-result-execution-payload, containment-violation, assurance-graph, assessment/binding, and runner-receipt. Every schema MUST declare a stable `$id` and an explicit version field (`schema_version` or equivalent). Consumers MUST be able to pin a schema by `$id`/version. (Previously: K6b closed the inventory at assurance-graph; this remediation adds an additive assessment/binding family without mutating evidence/v2, verification/v2, or K1 v1 pins.) #### Scenario: Every required family has $id and version @@ -84,6 +84,13 @@ The contract suite MUST publish a versioned JSON Schema for each family: state/t - THEN it MUST be present as a pinned versioned family with a distinct non-empty `$id` - AND evidence/v2, verification/v2, and K1 v1 pins MUST remain byte-identical +#### Scenario: Runner-receipt family is included without mutating K6b or K1 pins + +- GIVEN the required schema family inventory +- WHEN runner-receipt is checked +- THEN it MUST be present as a pinned versioned family with a distinct non-empty `$id` +- AND evidence/v2, verification/v2, and K1 v1 pins MUST remain byte-identical + ### Requirement:### Requirement: Valid And Invalid Fixtures Per Schema Family {#REQ-kernel-contract-schemas-002} Each schema family MUST ship at least one fixture that validates successfully @@ -858,3 +865,35 @@ The contract suite MUST publish `assessment/v2.schema.json` (`$id: "ospec://sche - WHEN assessment schema entries are inspected - THEN `assessment/v2.schema.json` MUST be registered with `$id: "ospec://schemas/kernel/assessment/v2"` - AND `assessment/v1.schema.json` MUST remain registered with `$id: "ospec://schemas/kernel/assessment/v1"` + +### Requirement: Runner Receipt V1 Family With Content-Addressed Identity {#REQ-kernel-contract-schemas-028} + +The contract suite MUST publish `runner-receipt/v1.schema.json` (`$id: "ospec://schemas/kernel/runner-receipt/v1"`, `schema_version: 1`, `kind: "runner-receipt/v1"`). Required fields MUST be `schema_version`, `kind`, `receipt_id` (`^sha256:[a-f0-9]{64}$`), `candidate_id` (`^sha256:[a-f0-9]{64}$`), `evidence_id` (`^sha256:[a-f0-9]{64}$`), `node_id` (non-empty string), `role` (closed strategy-role enumeration), `satisfied_tokens` (array of unique non-empty strings), `outcome` (`passed | failed`), `issuer_id` (non-empty string), and `transport` (`tool-execution-transport | execution-transport`). `evidence_id` MUST be required. `receipt_id` MUST be a content-addressed SHA-256 identifier matching `^sha256:[a-f0-9]{64}$`. Optional `execution_sequence` MAY include required `run_id` and `ordinal` (integer ≥ 1) and optional `previous_evidence_id` matching `^sha256:[a-f0-9]{64}$`. The schema MUST enforce `additionalProperties: false`. The family MUST be registered in `schemas/kernel/manifest.json` and `schemas/kernel/contract-claims.json`. `schemas/kernel/runner-receipt/` MUST be excluded from the K1 frozen baseline pin. `evidence/v2`, `verification/v2`, and K1 v1 schema bytes and `K1_SCHEMA_BASELINE` pins MUST remain byte-identical. The family MUST remain distinct from `receipt/v1` and `OperationReceipt`. +(Previously: the kernel inventory had no runner-receipt/v1 family; receipts were untyped caller DTOs.) + +#### Scenario: Valid runner-receipt v1 payload exposes required identity fields + +- GIVEN a complete `runner-receipt/v1` payload with `receipt_id`, required `evidence_id`, `candidate_id`, `node_id`, `role`, `satisfied_tokens`, `outcome`, `issuer_id`, and `transport` +- WHEN validated against `runner-receipt/v1.schema.json` +- THEN validation MUST succeed +- AND `$id` MUST be `ospec://schemas/kernel/runner-receipt/v1` + +#### Scenario: Runner-receipt missing evidence_id fails closed + +- GIVEN a `runner-receipt/v1` payload that omits `evidence_id` +- WHEN validated against `runner-receipt/v1.schema.json` +- THEN validation MUST fail closed identifying the missing required `evidence_id` + +#### Scenario: Manifest and contract-claims register runner-receipt v1 + +- GIVEN `schemas/kernel/manifest.json` and `contract-claims.json` +- WHEN the `runner-receipt` family is inspected +- THEN it MUST be registered at `schemas/kernel/runner-receipt/v1.schema.json` with `$id: "ospec://schemas/kernel/runner-receipt/v1"` +- AND `schema_version` MUST be `1` + +#### Scenario: Evidence v2, verification v2, and K1 v1 pins remain frozen after runner-receipt publication + +- GIVEN `evidence/v2.schema.json`, `verification/v2.schema.json`, K1 v1 schemas, and `K1_SCHEMA_BASELINE` +- WHEN verified after `runner-receipt/v1.schema.json` publication +- THEN those schema bytes and K1 pins MUST remain byte-identical +- AND `schemas/kernel/runner-receipt/` MUST NOT be included in the K1 frozen baseline pin From 06796c3fe3e1596d2aa8889fe0d5338c201a8d4c Mon Sep 17 00:00:00 2001 From: Manuel Retamozo Date: Fri, 28 Aug 2026 14:32:23 +0200 Subject: [PATCH 3/3] chore(release): actualizar version a 2.54.0 y changelog Publica runner-receipt/v1, canal opaco, causalidad completa y replay con material. K6b permanece revise. --- .claude-plugin/plugin.json | 2 +- .plugin.json | 2 +- CHANGELOG.md | 18 ++++--- docs/architecture/harness-evolution.md | 2 +- docs/roadmaps/harness-evolution.md | 6 +-- .../verify-report.md | 51 +++++++++++++++++++ openspec/config.yaml | 2 +- package.json | 2 +- 8 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 openspec/changes/archive/2026-08-28-k6b-receipt-binding-and-replay-finalization/verify-report.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1778dab1..6c11f5b5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ospec-workflow", "description": "Spec-Driven Development workflow for VS Code Agent Customization with OpenSpec, strict TDD, phase agents, skills, hooks, and verification contracts.", - "version": "2.53.1", + "version": "2.54.0", "author": { "name": "Manuel Michael Retamozo García" }, diff --git a/.plugin.json b/.plugin.json index 1778dab1..6c11f5b5 100644 --- a/.plugin.json +++ b/.plugin.json @@ -1,7 +1,7 @@ { "name": "ospec-workflow", "description": "Spec-Driven Development workflow for VS Code Agent Customization with OpenSpec, strict TDD, phase agents, skills, hooks, and verification contracts.", - "version": "2.53.1", + "version": "2.54.0", "author": { "name": "Manuel Michael Retamozo García" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 068a4a2a..52cb1c8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,19 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.54.0] - 2026-08-28 + ### Security -- **Autoridad y binding exacto de RunnerReceipt**: - - Nuevo contrato `runner-receipt/v1` con `receipt_id` content-addressed y `evidence_id` obligatorio. - - `verifyCandidate` rechaza DTOs caller-owned `runner_receipts`/`receipts` y solo consume un canal opaco emitido por el runtime. - - Se elimina matching por posición/nodo y fallback de role a `node.kind`; Candidate, Evidence y nodo deben coincidir exactamente. +- **Autoridad y binding exacto de RunnerReceipt (`runner-receipt/v1`)**: + - Nuevo contrato kernel `ospec://schemas/kernel/runner-receipt/v1` con `receipt_id` content-addressed y `evidence_id` obligatorio. + - `verifyCandidate` rechaza DTOs caller-owned `runner_receipts`/`receipts` (`UNTRUSTED_RUNNER_RECEIPT`) y solo consume un canal opaco `runnerReceiptChannel` emitido por el runtime. + - Se elimina matching por posición/nodo y fallback de role a `node.kind`; Candidate, Evidence y nodo deben coincidir exactamente (`INVALID_RUNNER_RECEIPT` / `RUNNER_RECEIPT_BINDING_MISMATCH`). - `outcome: failed` con tokens satisfechos falla con `INVALID_RUNNER_RECEIPT`. - **Cronología y replay fail-closed completos**: - Strategies temporales exigen `run_id` único no vacío, ordinales estrictos y `previous_evidence_id` en cada transición posterior a la raíz. - - Replay exige bytes o `observation_blob_id` content-addressed resoluble; sin material de observación retorna `GRAPH_DIVERGENCE`. + - Replay exige bytes o `observation_blob_id` content-addressed resoluble, más el canal de receipts; sin material de observación retorna `GRAPH_DIVERGENCE`. ### Changed -- K6b permanece `revise` pendiente de terminal review objetivo; K6c vuelve a `blocked-by-K6b-terminal-review`. -- Verify report de v2.53.1 corregido con errata post-release y guard automático de consistencia entre versión, changelog, roadmap, arquitectura, report y tag. +- K6b permanece `revise` pendiente de terminal review objetivo; K6c sigue `blocked-by-K6b-terminal-review`. +- Dominios `independent-verification`, `assurance-graph` y `kernel-contract-schemas` enrolados en el baseline (skip) y reconciliados contra `a476b9a`. +- ADR `docs/adr/adr-20260828-014-runner-receipt-authority-binding.md`. Specs `independent-verification`, `assurance-graph` y `kernel-contract-schemas`. +- Remediación directa post-v2.53.1, documentada con `sdd-baseline` (skip) y `sdd-reconcile`. Verificación: focused K6b 115 pass; `npm test` PASS. Archivado en `openspec/changes/archive/2026-08-28-k6b-receipt-binding-and-replay-finalization/`. ## [2.53.1] - 2026-08-28 diff --git a/docs/architecture/harness-evolution.md b/docs/architecture/harness-evolution.md index e80391c1..df676851 100644 --- a/docs/architecture/harness-evolution.md +++ b/docs/architecture/harness-evolution.md @@ -1,7 +1,7 @@ # Arquitectura objetivo — harness gobernado por kernel, grafo y evidencia > **Autoridad:** fuente conceptual y estratégica del harness (responsabilidades y límites). -> **Corte documental:** v2.53.1, 2026-08-28 (estado alineado al roadmap; la dirección conceptual no cambia). +> **Corte documental:** v2.54.0, 2026-08-28 (estado alineado al roadmap; la dirección conceptual no cambia). > **Estado verificado:** O3, O4+O5/O4.1, O4.2, O6A, O2B, **K1**, **K2**, **K2.1**, **K2a**, **K3**, **`k3-readiness-remediation`**, **K4a**, **K5**, **K6a** y **K4b** están cerrados. **K6b** permanece `revise` con remediación focal implementada y terminal review pendiente; **K6c** está `blocked`. OpenSpec/Git/Candidate siguen siendo la única autoridad semántica; el Assurance Graph es proyección. > **Roadmap:** orden, estado operativo y done criteria viven en [`../roadmaps/harness-evolution.md`](../roadmaps/harness-evolution.md). > **Precedencia documental:** ante diferencias de **orden o estado**, prevalece el roadmap; ante diferencias **conceptuales**, reconciliar antes de iniciar el slice. diff --git a/docs/roadmaps/harness-evolution.md b/docs/roadmaps/harness-evolution.md index 4bb57530..62c85afa 100644 --- a/docs/roadmaps/harness-evolution.md +++ b/docs/roadmaps/harness-evolution.md @@ -1,7 +1,7 @@ # Roadmap general — kernel, grafo y evidencia > **Autoridad:** única fuente operativa del backlog transversal. -> **Versión de referencia:** v2.53.1, 2026-08-28. +> **Versión de referencia:** v2.54.0, 2026-08-28. > **Arquitectura:** [`../architecture/harness-evolution.md`](../architecture/harness-evolution.md). > **Investigación no normativa:** [`../architecture/research/harness-kernel-graph-evidence-roadmap-fusion.md`](../architecture/research/harness-kernel-graph-evidence-roadmap-fusion.md) (P0–P27). Proporcionalidad de proceso y Change Program: [`../architecture/research/proportional-process-and-change-program.md`](../architecture/research/proportional-process-and-change-program.md). > **Regla de estado:** los hechos se contrastan con código/OpenSpec; este roadmap no cambia el estado de un change ni sustituye sus artefactos. @@ -78,7 +78,7 @@ Las iniciativas anteriores no se descartan. O20A, O13A–C, O15, O18, O19A/B y R | `done` | **K5** | Budgets (incl. autoridad/efectos), failures y recovery; remediaciones v2.45.7→v2.45.13 (authoritative enforcement, authority boundary/CAS concurrency, reconciliación, remediación técnica del núcleo y blindaje de concurrencia); archivado y publicado en v2.45.13 | | `done` | **K6a** | Worker isolation y work-order capsule; primitivas de ejecución aislada, integración con WorkerTransport, contención de filesystem y WorkResult canónico; archivado en v2.46.0, frontera de procesos cerrada en v2.47.1 y endurecida en v2.47.2 | | `done` | **K4b** | Repair shadow execution (WO→WR→integrate→Candidate); despacho exclusivo K6a, integración estricta, cápsula mínima, base derivada y registro 1:N; remediación de invariantes en v2.48.2 y cierre mode-only/baseline en v2.48.3 (`2026-08-26-k4b-mode-only-and-baseline-projection`) | -| `revise` | **K6b** | v2.53.1 cerró inyección semántica en `rawEvidence`, blind copy y chronology por array, pero el terminal review reabrió receipt authority/binding, causalidad completa y replay criptográfico obligatorio. Remediación implementada en working tree; pendiente terminal review. | +| `revise` | **K6b** | v2.53.1 cerró inyección semántica en `rawEvidence`, blind copy y chronology por array, pero el terminal review reabrió receipt authority/binding, causalidad completa y replay criptográfico obligatorio. Remediación publicada en v2.54.0; pendiente terminal review. | | `blocked-by-K6b-terminal-review` | **K6c** | ChallengePlan policy-selected; no iniciar hasta verdict terminal objetivo de K6b | | `pending` | K6d–K8 | Complexity delta, review authority, **Evaluation Attestation** | | `pending` | K9 | Gate de promoción shadow/replay/A-B (checkpoints intermedios ya validados) | @@ -2073,4 +2073,4 @@ Un Change Program (objetivo → children OpenSpec + cursor, ver investigación ` - 2026-08-27: K6b (`k6b-semantic-integrity-remediation`) entra en apply para remediar B1–B3/H1–H3; K6b queda `revise` y K6c `blocked-by-K6b-remediation` hasta archive. - 2026-08-28: K6b (`k6b-semantic-integrity-remediation`) cierra con verify PASS, 4R approved y archive transaccional; publicado en v2.52.0. K6c queda next-eligible. - 2026-08-28: review terminal del tag v2.53.1 reabre K6b como `revise`: RunnerReceipt no demuestra autoridad ni binding exacto, chronology no exige `run_id`/chain completos y replay permite omitir bytes. K6c vuelve a `blocked-by-K6b-terminal-review`. -- 2026-08-28: remediación focal implementada en working tree: `runner-receipt/v1` por canal opaco con EvidenceId obligatorio y outcome coherente; causal chain completa; replay exige bytes o blob content-addressed. Pendiente terminal review, sin promover aún K6b ni iniciar K6c. +- 2026-08-28: remediación focal publicada en v2.54.0: `runner-receipt/v1` por canal opaco con EvidenceId obligatorio y outcome coherente; causal chain completa; replay exige bytes o blob content-addressed. Dominios K6b enrolados y reconciliados. Pendiente terminal review, sin promover aún K6b ni iniciar K6c. diff --git a/openspec/changes/archive/2026-08-28-k6b-receipt-binding-and-replay-finalization/verify-report.md b/openspec/changes/archive/2026-08-28-k6b-receipt-binding-and-replay-finalization/verify-report.md new file mode 100644 index 00000000..79fc3cba --- /dev/null +++ b/openspec/changes/archive/2026-08-28-k6b-receipt-binding-and-replay-finalization/verify-report.md @@ -0,0 +1,51 @@ +## Verification Report + +**Change**: k6b-receipt-binding-and-replay-finalization +**Version**: 2.54.0 +**Mode**: Direct remediación + sdd-reconcile (focused) + +Remediación post-v2.53.1 implementada fuera de un change SDD estándar, después enrolada (`sdd-baseline` skip) y documentada (`sdd-reconcile`) sobre `independent-verification`, `assurance-graph` y `kernel-contract-schemas`. K6b permanece `revise` hasta terminal review objetivo. + +### Completeness +| Metric | Value | +|--------|-------| +| Runtime remediación | committed `a476b9a` | +| Spec reconcile | committed `37cbc4b` | +| Baseline enrollment | skip rows at `71d5114`; reconciled rows at `a476b9a` | + +### Build & Tests Execution +**Build**: ✅ Passed (CommonJS Node.js 22+ / No build step required) + +**Tests**: ✅ 115 passed in focused k6b + docs suites / full `npm test` PASS / ❌ 0 failed +```text +node --test scripts/lib/k6b-schema-fixtures.test.js scripts/lib/independent-verifier/evidence.test.js scripts/lib/independent-verifier/index.test.js scripts/lib/assurance-graph/index.test.js scripts/k6b-verifier-assurance-graph-e2e.test.js scripts/lib/k2a-maturity-docs.test.js scripts/lib/k1-scope-guard.test.js scripts/manifest-sync.test.js test/e2e/k6b-verifier-assurance-graph-e2e.test.js +ℹ tests 115 +ℹ pass 115 +ℹ fail 0 + +npm test +All checks passed. +``` + +**Manual verification**: not performed (automated runtime tests provide authoritative proof) + +**Coverage**: ➖ Not configured (testing.coverage.available: false) + +### Spec Compliance Matrix +Covered by baseline specs after reconcile: runner-receipt/v1 authority channel, exact Evidence binding, temporal chaining, cryptographic replay with observation material, additive kernel family without mutating evidence/v2, verification/v2, or K1 v1 pins. See `openspec/specs/independent-verification/spec.md`, `openspec/specs/assurance-graph/spec.md`, and `openspec/specs/kernel-contract-schemas/spec.md`. + +### Correctness (Static Evidence) +| Requirement | Status | Notes | +|------------|--------|-------| +| Opaque `runnerReceiptChannel` | PASS | WeakMap authority; public DTOs fail `UNTRUSTED_RUNNER_RECEIPT` | +| `runner-receipt/v1` schema | PASS | `evidence_id` required; `receipt_id` recomputed | +| Replay observation material | PASS | bytes or resolvable `observation_blob_id` | + +### Issues Found +No CRITICAL/WARNING findings from runtime tests. Residual product risk: K6b stays `revise` until an independent terminal review. + +### Verdict +**PASS WITH WARNINGS** — runtime and specs close the v2.53.1 errata; K6c remains blocked pending terminal review of K6b. + +### Assumption Reconciliation +Not applicable (no change-local assumption ledger). Direct remediación folded via `sdd-reconcile`. diff --git a/openspec/config.yaml b/openspec/config.yaml index 91dd5db9..d7615232 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -11,7 +11,7 @@ context: | project: name: ospec-workflow - version: 2.53.1 + version: 2.54.0 status: active artifact_store: diff --git a/package.json b/package.json index a01c90b9..f7a4b1e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ospec-workflow", - "version": "2.53.1", + "version": "2.54.0", "private": true, "description": "Spec-Driven Development workflow with OpenSpec, strict TDD, phase agents, skills, hooks, and verification contracts.", "license": "MIT",