diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4d2a0e31..1778dab1 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.0", + "version": "2.53.1", "author": { "name": "Manuel Michael Retamozo García" }, diff --git a/.plugin.json b/.plugin.json index 4d2a0e31..1778dab1 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.0", + "version": "2.53.1", "author": { "name": "Manuel Michael Retamozo García" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index f607e4cf..833d5720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ 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). +## [2.53.1] - 2026-08-28 + +### Security +- **Segregación física de `rawEvidence` y rechazo explícito de metadatos de caller (`UNTRUSTED_CALLER_METADATA`)**: + - `normalizeEvidence` rechaza de forma fail-closed (`UNTRUSTED_CALLER_METADATA`) cualquier observación `rawEvidence` que contenga propiedades semánticas (`role`, `obligation_ids`, `obligation_id`, `evidence_requirements_satisfied`). + - Las observaciones físicas admiten estrictamente: `bytes` / `rawBytes`, `provenance`, `origin`, `node_id`, `execution_sequence`. + - Se eliminan atributos semánticos del payload devuelto por `normalizeEvidence`. + +### Changed +- **Derivación autoritativa desde Runner Receipts y eliminación de copia ciega**: + - `verifyCandidate` resuelve `role` y `obligation_ids` consultando el Execution Graph y runner receipts (`node.role`, `node.kind`, `receipt.role`, `graphObligations.implemented_by`). + - `evidence_requirements_satisfied` se deriva exclusivamente de los tokens atestiguados en `runner_receipts` o `receipts` del harness; se erradica por completo la copia automática de `node.required_evidence`. + - Ausencia de receipts para una obligación MUST produce `UNFULFILLED_MUST` sin generar afirmaciones espurias. +- **Validación cronológica causal obligatoria por `execution_sequence`**: + - `assertRoleOrder` exige la presencia de `execution_sequence` (`run_id`, `ordinal` monotónico creciente y `previous_evidence_id`) en estrategias temporales (`strict-tdd`, `bug`, `refactor`). + - Se prohíbe explícitamente el fallback al orden posicional del array JSON. + - Verificación causal estricta: `red.ordinal < green.ordinal` (y enlace `previous_evidence_id`) en TDD, `red < patch < green` en `bug`, y `before < after` en `refactor`. Violaciones emiten `STRATEGY_SEQUENCE_VIOLATION`. +- **Replay criptográficamente íntegro de Evidence en Assurance Graph**: + - `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/`. + ## [2.53.0] - 2026-08-28 ### Added diff --git a/docs/adr/adr-20260828-010-untrusted-caller-metadata-rejection.md b/docs/adr/adr-20260828-010-untrusted-caller-metadata-rejection.md new file mode 100644 index 00000000..3a0c8f25 --- /dev/null +++ b/docs/adr/adr-20260828-010-untrusted-caller-metadata-rejection.md @@ -0,0 +1,20 @@ +# ADR-001: Segregación física estricta de rawEvidence (UNTRUSTED_CALLER_METADATA) + +- Status: proposed +- Change: k6b-trusted-evidence-replay-closure +- Date: 2026-08-28 + +## Context +Los llamadores y workers no confiables podían inyectar propiedades semánticas (`role`, `obligation_ids`, `evidence_requirements_satisfied`) directamente en el objeto `rawEvidence`, eludiendo la verificación independiente y comprometiendo la frontera de confianza. + +## Decision +Rechazar de forma inmediata y fail-closed con `UNTRUSTED_CALLER_METADATA` en `normalizeEvidence` cualquier payload `rawEvidence` que contenga `role`, `obligation_ids`, `obligation_id` o `evidence_requirements_satisfied`. La observación física sólo admite `bytes`/`rawBytes`, `provenance`, `origin`, `node_id` y `execution_sequence`. + +## Alternatives +- Filtrar o ignorar silenciosamente las propiedades semánticas del caller — rechazado: enmascara bugs en el harness y potenciales intentos de inyección maliciosa. +- Aceptar metadatos de caller como sugerencias no vinculantes — rechazado: debilita la frontera de confianza del verificador independiente. + +## Consequences +- Facilita: Garantía estricta de que las observaciones físicas no contienen aserciones semánticas no verificadas. +- Dificulta: Requiere que todos los harnesses y generadores de fixtures envíen exclusivamente cargas físicas puras. +- Reversibilidad: Alta (ajustable en `normalizeEvidence`). 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 new file mode 100644 index 00000000..5e2c8685 --- /dev/null +++ b/docs/adr/adr-20260828-011-authoritative-receipt-derivation-no-blind-copy.md @@ -0,0 +1,20 @@ +# ADR-002: Derivación autoritativa de satisfacción desde Runner Receipts + +- Status: proposed +- Change: k6b-trusted-evidence-replay-closure +- Date: 2026-08-28 + +## Context +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`. + +## 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. +- Asumir satisfacción completa si el nodo del grafo está vinculado — rechazado: viola el principio de evidencia fail-closed sin atestación. + +## Consequences +- Facilita: Verificación infalsificable donde cada token satisfecho está respaldado por un recibo de ejecución real. +- Dificulta: Exige que el harness y los tests pasen explícitamente recibos de runner para que las obligaciones MUST alcancen `PASS`. +- Reversibilidad: Alta (ajustable en `verifyCandidate`). 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 new file mode 100644 index 00000000..97f109d9 --- /dev/null +++ b/docs/adr/adr-20260828-012-strict-causal-chronology-execution-sequence.md @@ -0,0 +1,20 @@ +# ADR-003: Cronología causal estricta mediante execution_sequence + +- Status: proposed +- Change: k6b-trusted-evidence-replay-closure +- Date: 2026-08-28 + +## Context +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. + +## 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. +- Comparar timestamps de archivo (mtime/ctime) — rechazado: no deterministas y fácilmente manipulables por el sistema operativo o workers. + +## Consequences +- Facilita: Garantía causal matemática de que RED precedió a GREEN y que `characterization-before` precedió a `after`. +- Dificulta: Requiere que todos los pipelines y tests emitan metadatos de secuencia causal en sus observaciones de prueba. +- Reversibilidad: Alta (ajustable en `strategy-policy.js`). diff --git a/docs/adr/adr-20260828-013-cryptographic-evidence-replay-integrity.md b/docs/adr/adr-20260828-013-cryptographic-evidence-replay-integrity.md new file mode 100644 index 00000000..ae1aeec2 --- /dev/null +++ b/docs/adr/adr-20260828-013-cryptographic-evidence-replay-integrity.md @@ -0,0 +1,20 @@ +# ADR-004: Replay criptográficamente íntegro en Assurance Graph + +- Status: proposed +- Change: k6b-trusted-evidence-replay-closure +- Date: 2026-08-28 + +## Context +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`. + +## Alternatives +- Revalidar únicamente la conformidad sintáctica contra el esquema JSON — rechazado: no detecta sustitución de hashes ni inconsistencias en identificadores derivados. +- Asumir que los identificadores persistidos son válidos por construcción — rechazado: ignora posibles corrupciones o modificaciones en el medio de persistencia. + +## Consequences +- Facilita: Replay determinista e inmutable donde cualquier manipulación o degradación de evidencias es detectada y rechazada. +- Dificulta: El proceso de replay debe recibir o tener acceso a los bytes de las evidencias para la recomputación criptográfica. +- Reversibilidad: Alta (ajustable en `assurance-graph/index.js`). diff --git a/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/apply-progress.md b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/apply-progress.md new file mode 100644 index 00000000..e1f91d61 --- /dev/null +++ b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/apply-progress.md @@ -0,0 +1,43 @@ +# Apply Progress: K6b Trusted Evidence and Replay Closure + +## Phase 1: Physical Observation Segregation +- **Tasks Completed**: 1.1, 1.2, 1.3 +- **Changes**: + - In `scripts/lib/independent-verifier/evidence.js` (`normalizeEvidence`), added fail-closed validation rejecting caller-supplied semantic metadata (`role`, `obligation_ids`, `obligation_id`, `evidence_requirements_satisfied`) with `UNTRUSTED_CALLER_METADATA`. + - Stripped caller semantic attributes from normalized return payload, returning only physical observation properties (`evidence`, `execution_sequence`, `raw`). + - Added dedicated unit test suite in `scripts/lib/independent-verifier/evidence.test.js` validating individual and combined injection attempts. +- **Verification**: `node --test scripts/lib/independent-verifier/evidence.test.js` passed (7 tests). + +## Phase 2: Authoritative Derivation from Receipts +- **Tasks Completed**: 2.1, 2.2, 2.3, 2.4 +- **Changes**: + - In `scripts/lib/independent-verifier/index.js` (`verifyCandidate`), implemented authoritative resolution of `role` and `obligation_ids` from Execution Graph and runner receipts. + - Resolved `evidence_requirements_satisfied` strictly from trusted execution receipts (`input.receipts` or `input.runner_receipts`) matching `node_id` or `evidence_id`, setting `[]` when no receipt confirms satisfaction and eliminating blind copying of `node.required_evidence`. + - In `scripts/lib/independent-verifier/obligation-coverage.js`, updated `walkMustObligations` to evaluate receipt satisfaction strictly and emit assessments only when coverage is non-empty. + - Added tests in `scripts/lib/independent-verifier/obligation-coverage.test.js` verifying `UNFULFILLED_MUST` upon receipt absence and elimination of blind copying. +- **Verification**: `node --test scripts/lib/independent-verifier/obligation-coverage.test.js` passed (16 tests). + +## Phase 3: Strict Causal Chronology +- **Tasks Completed**: 3.1, 3.2, 3.3 +- **Changes**: + - In `scripts/lib/independent-verifier/strategy-policy.js` (`assertRoleOrder`), enforced strict `execution_sequence` validation (`run_id`, strictly monotonic increasing `ordinal`, and valid `previous_evidence_id` chaining) for temporal strategies (`strict-tdd`, `bug`, `refactor`). + - Eliminated fallback to JSON array index comparisons. + - Enforced causal sequence rules: RED < GREEN (and RED < PATCH < GREEN in bug strategy; before < after and `previous_evidence_id` linking in refactor strategy). + - Added test coverage in `scripts/lib/independent-verifier/index.test.js` for reversed ordinals, missing execution sequences, and broken chaining. +- **Verification**: `node --test scripts/lib/independent-verifier/index.test.js` passed (45 tests). + +## Phase 4: Full Cryptographic Replay +- **Tasks Completed**: 4.1, 4.2, 4.3 +- **Changes**: + - In `scripts/lib/assurance-graph/index.js` (`validateReplayRecords`), enhanced replay verification to recompute `digestRawBytes` and `computeEvidenceId(record, bytes)`, asserting exact equality against persisted digests and IDs. + - Added runtime provenance sufficiency verification via `evaluateProvenanceSufficiency(record, { requireRuntime: true })` failing closed on insufficient provenance with `GRAPH_DIVERGENCE`. + - In `scripts/lib/assurance-graph/index.test.js`, added replay tests for tampered `evidence_id`, modified raw bytes, mismatched candidate subjects, and insufficient provenance. +- **Verification**: `node --test scripts/lib/assurance-graph/index.test.js` passed (21 tests). + +## Phase 5: Harness Fixtures Update, E2E Suite & Adversarials +- **Tasks Completed**: 5.1, 5.2, 5.3 +- **Changes**: + - Refactored test harness and fixture generators across `scripts/lib/independent-verifier/index.test.js`, `scripts/lib/assurance-graph/index.test.js`, and `scripts/k6b-verifier-assurance-graph-e2e.test.js` to separate physical observations from runner receipts and supply canonical `execution_sequence` objects. + - Created end-to-end integration and adversarial test suite in `test/e2e/k6b-verifier-assurance-graph-e2e.test.js` validating the full verification, projection, and replay lifecycle with caller injection attacks (B1), ungrounded MUST attacks (B2), causality tampering (B3), and replayed evidence tampering (H1). + - Executed full repository test suite `npm test` across all 2790+ tests with 0 failures. +- **Verification**: `npm test` exited 0 (all checks passed). diff --git a/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/archive-plan.json b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/archive-plan.json new file mode 100644 index 00000000..3913e569 --- /dev/null +++ b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/archive-plan.json @@ -0,0 +1,62 @@ +{ + "schema_version": 1, + "change": "k6b-trusted-evidence-replay-closure", + "source_fingerprint": "sha256:8288fa2aecb9a4e12a8984ffd80cc7a1100274682bc79817e01b6f07cf2b1aa1", + "spec_writes": [ + { + "domain": "independent-verification", + "source_delta": "specs/independent-verification/spec.md", + "target": "openspec/specs/independent-verification/spec.md", + "target_before_sha256": "sha256:9f1a6009b5693407d91da43f8911a2b3bdabeafa6b3a6a339a9b881bb6c75267", + "content_sha256": "sha256:e126b0bb2a160602992fe3b4386d86c4da1c6bdbe1d4dd7fb3ccbe94dc517aa4" + }, + { + "domain": "assurance-graph", + "source_delta": "specs/assurance-graph/spec.md", + "target": "openspec/specs/assurance-graph/spec.md", + "target_before_sha256": "sha256:5c34c40d7c4020665701e32ddd0df35a97954d83b6d6f413dcbdb38988a723f8", + "content_sha256": "sha256:698c734aa1e89729b873ef313368e9e59fdf8cdc90aaa0c78fce6f8709600ccc" + } + ], + "adr_promotions": [ + { + "source": "decisions/adr-001.md", + "target": "docs/adr/adr-20260828-010-untrusted-caller-metadata-rejection.md", + "content_sha256": "sha256:a57128d1a63d501af13514d1c34feb61cbecdc1f2408e8c4ff43f86aefa86c02" + }, + { + "source": "decisions/adr-002.md", + "target": "docs/adr/adr-20260828-011-authoritative-receipt-derivation-no-blind-copy.md", + "content_sha256": "sha256:8911c196a45611ddfb295ce1f465ed030a3836c48da35b33acc5e8507de0583c" + }, + { + "source": "decisions/adr-003.md", + "target": "docs/adr/adr-20260828-012-strict-causal-chronology-execution-sequence.md", + "content_sha256": "sha256:6254e66c3a9047c4d64e6eaa50576c4e885be38453118c54d04963ae64faeda5" + }, + { + "source": "decisions/adr-004.md", + "target": "docs/adr/adr-20260828-013-cryptographic-evidence-replay-integrity.md", + "content_sha256": "sha256:22b0797ed6b038d5c3f2e5f8401265d7ac3b59d85cc24fab17c792e71983f59c" + } + ], + "archive_inventory": [ + "apply-progress.md", + "archive-report.md", + "decisions/adr-001.md", + "decisions/adr-002.md", + "decisions/adr-003.md", + "decisions/adr-004.md", + "design.md", + "proposal.md", + "specs/assurance-graph/spec.md", + "specs/independent-verification/spec.md", + "state.yaml", + "tasks.md", + "verify-report.md" + ], + "accepted_warnings": [], + "rollback": { + "strategy": "staging-rename" + } +} diff --git a/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/archive-report.md b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/archive-report.md new file mode 100644 index 00000000..5a5d9991 --- /dev/null +++ b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/archive-report.md @@ -0,0 +1,102 @@ +# Archive Report: K6b Trusted Evidence and Replay Closure + +**Change**: `k6b-trusted-evidence-replay-closure` +**Date**: 2026-08-28 +**Status**: Ready for Archive Transaction Commit (Plan-and-Report) +**Verification Verdict**: `PASS` (0 critical issues, 0 warnings, 0 suggestions) + +--- + +## Executive Summary + +El cambio `k6b-trusted-evidence-replay-closure` implementa el cierre definitivo de las brechas de seguridad, causalidad e integridad criptográfica en el subsistema K6b (mitigando los bloqueadores B1, B2, B3 y el hallazgo H1): + +1. **Segregación Física Estricta de rawEvidence (B1)**: + - Normalización fail-closed en `normalizeEvidence` ante cualquier metadato o aserción semántica inyectada por el llamador (`role`, `obligation_ids`, `obligation_id`, `evidence_requirements_satisfied`), rechazando de inmediato con `UNTRUSTED_CALLER_METADATA`. + - La observación física sólo admite propiedades de observación puras (`bytes`/`rawBytes`, `provenance`, `origin`, `node_id`, `execution_sequence`). + +2. **Derivación Autoritativa de Satisfacción desde Runner Receipts (B2)**: + - Erradicación total de la copia automática o ciega de `node.required_evidence` hacia `evidence_requirements_satisfied`. + - Derivación exclusiva y verificada de satisfacción a partir de `receipts` / `runner_receipts` de ejecución y el Execution Graph. + +3. **Cronología Causal Estricta sin Fallback a Arrays (B3)**: + - Exigencia obligatoria de metadatos causales `execution_sequence` (`run_id`, `ordinal` monotónico creciente y enlace `previous_evidence_id`) en estrategias temporales (`strict-tdd`, `bug`, `refactor`). + - Prohibición estricta de fallback al orden de elementos en arrays JSON, fallando inmediatamente con `STRATEGY_SEQUENCE_VIOLATION`. + +4. **Replay Criptográfico Íntegro en Assurance Graph (H1)**: + - Recomputación exhaustiva en `validateReplayRecords` / `replayAssuranceGraph` de `digestRawBytes`, `computeEvidenceId` y evaluación de suficiencia de procedencia mediante `evaluateProvenanceSufficiency`. + - Detección y rechazo inmediato de cualquier discrepancia, mutación de bytes o procedencia insuficiente mediante `GRAPH_DIVERGENCE`. + +--- + +## Verification & Quality Gates Summary + +- **Verdict**: `PASS` +- **Tasks Complete**: 15 / 15 (100%) +- **Scenarios Satisfied**: 28 / 28 (100% de cumplimiento con pruebas automatizadas `runtime-test`) +- **Focal Automated Tests**: 95 passed / 0 failed (suites `independent-verifier`, `assurance-graph` y suites e2e) +- **Full Repository Test Suite (`npm test`)**: Exit code 0 (2790+ tests passed) +- **Contract Lint**: 0 offenders +- **Accepted Warnings**: Ninguno (0 warnings) + +--- + +## Merged Specifications Summary (Change-Local Preparation) + +Se prepararon las siguientes especificaciones principales integrando los deltas del cambio sobre las especificaciones maestras correspondientes: + +| Domain | Action | Requirements Modified / Preserved | Status | +|--------|--------|-----------------------------------|--------| +| `independent-verification` | Prepared (Merged) | `REQ-independent-verification-003` (segregación física rawEvidence y UNTRUSTED_CALLER_METADATA), `REQ-005` (cobertura MUST derivada de receipts sin copia ciega), `REQ-006` (causalidad execution_sequence sin fallback a array); REQ-001, 002, 004, 007, 008 preservados intactos. | ✅ Ready for runtime commit | +| `assurance-graph` | Prepared (Merged) | `REQ-assurance-graph-006` (replay integral con computeEvidenceId, digestRawBytes y evaluateProvenanceSufficiency); REQ-001, 002, 003, 004, 005, 007, 008 preservados intactos. | ✅ Ready for runtime commit | + +--- + +## Proposed ADR Promotions + +Se proponen las siguientes decisiones arquitectónicas para su promoción formal a `docs/adr/` durante la ejecución de la transacción de archivo: + +| Source | Proposed Target | Title | +|--------|-----------------|-------| +| `decisions/adr-001.md` | `docs/adr/adr-20260828-010-untrusted-caller-metadata-rejection.md` | Segregación física estricta de rawEvidence (UNTRUSTED_CALLER_METADATA) | +| `decisions/adr-002.md` | `docs/adr/adr-20260828-011-authoritative-receipt-derivation-no-blind-copy.md` | Derivación autoritativa de satisfacción desde Runner Receipts | +| `decisions/adr-003.md` | `docs/adr/adr-20260828-012-strict-causal-chronology-execution-sequence.md` | Cronología causal estricta mediante execution_sequence | +| `decisions/adr-004.md` | `docs/adr/adr-20260828-013-cryptographic-evidence-replay-integrity.md` | Replay criptográficamente íntegro en Assurance Graph | + +--- + +## Cost + +No per-phase cost data was recorded for this change +(`.ospec/session/k6b-trusted-evidence-replay-closure/phase-costs.jsonl` missing or empty). + +**Total user questions asked**: 0 + +--- + +## Change Inventory + +- `apply-progress.md` +- `archive-report.md` +- `decisions/adr-001.md` +- `decisions/adr-002.md` +- `decisions/adr-003.md` +- `decisions/adr-004.md` +- `design.md` +- `proposal.md` +- `specs/assurance-graph/spec.md` +- `specs/independent-verification/spec.md` +- `state.yaml` +- `tasks.md` +- `verify-report.md` + +--- + +## Archive Transaction & Closure Authority + +1. Este reporte y el plan `archive-plan.json` han sido emitidos bajo el protocolo **Plan-and-Report**. +2. Las escrituras finales en `openspec/specs/**` y `docs/adr/**`, así como el traslado atómico de la carpeta activa a `openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure` y la eliminación del directorio de origen tras verificación íntegra, son responsabilidad exclusiva del runtime determinista de transacción: + ```bash + node scripts/archive-transaction-run.js k6b-trusted-evidence-replay-closure + ``` +3. El recibo estructurado (`receipt.json`) con `outcome: "success"` emitido por el runtime es la única autoridad de cierre para el cambio. diff --git a/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/decisions/adr-001.md b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/decisions/adr-001.md new file mode 100644 index 00000000..3a0c8f25 --- /dev/null +++ b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/decisions/adr-001.md @@ -0,0 +1,20 @@ +# ADR-001: Segregación física estricta de rawEvidence (UNTRUSTED_CALLER_METADATA) + +- Status: proposed +- Change: k6b-trusted-evidence-replay-closure +- Date: 2026-08-28 + +## Context +Los llamadores y workers no confiables podían inyectar propiedades semánticas (`role`, `obligation_ids`, `evidence_requirements_satisfied`) directamente en el objeto `rawEvidence`, eludiendo la verificación independiente y comprometiendo la frontera de confianza. + +## Decision +Rechazar de forma inmediata y fail-closed con `UNTRUSTED_CALLER_METADATA` en `normalizeEvidence` cualquier payload `rawEvidence` que contenga `role`, `obligation_ids`, `obligation_id` o `evidence_requirements_satisfied`. La observación física sólo admite `bytes`/`rawBytes`, `provenance`, `origin`, `node_id` y `execution_sequence`. + +## Alternatives +- Filtrar o ignorar silenciosamente las propiedades semánticas del caller — rechazado: enmascara bugs en el harness y potenciales intentos de inyección maliciosa. +- Aceptar metadatos de caller como sugerencias no vinculantes — rechazado: debilita la frontera de confianza del verificador independiente. + +## Consequences +- Facilita: Garantía estricta de que las observaciones físicas no contienen aserciones semánticas no verificadas. +- Dificulta: Requiere que todos los harnesses y generadores de fixtures envíen exclusivamente cargas físicas puras. +- Reversibilidad: Alta (ajustable en `normalizeEvidence`). diff --git a/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/decisions/adr-002.md b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/decisions/adr-002.md new file mode 100644 index 00000000..5e2c8685 --- /dev/null +++ b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/decisions/adr-002.md @@ -0,0 +1,20 @@ +# ADR-002: Derivación autoritativa de satisfacción desde Runner Receipts + +- Status: proposed +- Change: k6b-trusted-evidence-replay-closure +- Date: 2026-08-28 + +## Context +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`. + +## 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. +- Asumir satisfacción completa si el nodo del grafo está vinculado — rechazado: viola el principio de evidencia fail-closed sin atestación. + +## Consequences +- Facilita: Verificación infalsificable donde cada token satisfecho está respaldado por un recibo de ejecución real. +- Dificulta: Exige que el harness y los tests pasen explícitamente recibos de runner para que las obligaciones MUST alcancen `PASS`. +- Reversibilidad: Alta (ajustable en `verifyCandidate`). diff --git a/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/decisions/adr-003.md b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/decisions/adr-003.md new file mode 100644 index 00000000..97f109d9 --- /dev/null +++ b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/decisions/adr-003.md @@ -0,0 +1,20 @@ +# ADR-003: Cronología causal estricta mediante execution_sequence + +- Status: proposed +- Change: k6b-trusted-evidence-replay-closure +- Date: 2026-08-28 + +## Context +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. + +## 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. +- Comparar timestamps de archivo (mtime/ctime) — rechazado: no deterministas y fácilmente manipulables por el sistema operativo o workers. + +## Consequences +- Facilita: Garantía causal matemática de que RED precedió a GREEN y que `characterization-before` precedió a `after`. +- Dificulta: Requiere que todos los pipelines y tests emitan metadatos de secuencia causal en sus observaciones de prueba. +- Reversibilidad: Alta (ajustable en `strategy-policy.js`). diff --git a/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/decisions/adr-004.md b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/decisions/adr-004.md new file mode 100644 index 00000000..ae1aeec2 --- /dev/null +++ b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/decisions/adr-004.md @@ -0,0 +1,20 @@ +# ADR-004: Replay criptográficamente íntegro en Assurance Graph + +- Status: proposed +- Change: k6b-trusted-evidence-replay-closure +- Date: 2026-08-28 + +## Context +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`. + +## Alternatives +- Revalidar únicamente la conformidad sintáctica contra el esquema JSON — rechazado: no detecta sustitución de hashes ni inconsistencias en identificadores derivados. +- Asumir que los identificadores persistidos son válidos por construcción — rechazado: ignora posibles corrupciones o modificaciones en el medio de persistencia. + +## Consequences +- Facilita: Replay determinista e inmutable donde cualquier manipulación o degradación de evidencias es detectada y rechazada. +- Dificulta: El proceso de replay debe recibir o tener acceso a los bytes de las evidencias para la recomputación criptográfica. +- Reversibilidad: Alta (ajustable en `assurance-graph/index.js`). diff --git a/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/design.md b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/design.md new file mode 100644 index 00000000..53f1772b --- /dev/null +++ b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/design.md @@ -0,0 +1,199 @@ +# Design: K6b Trusted Evidence and Replay Closure + +## Technical Approach + +Este diseño técnico implementa el cierre definitivo de las brechas de seguridad e integridad en K6b (Blockers B1, B2, B3 y Hallazgo H1), alineado estrictamente con las especificaciones de `independent-verification` y `assurance-graph`. + +El enfoque técnico se compone de cuatro pilares arquitectónicos: +1. **Segregación física estricta de `rawEvidence`**: `normalizeEvidence` en `scripts/lib/independent-verifier/evidence.js` valida que el objeto de entrada `raw` no contenga aserciones semánticas (`role`, `obligation_ids`, `obligation_id` o `evidence_requirements_satisfied`). Si detecta alguna de estas propiedades, falla de inmediato fail-closed con `UNTRUSTED_CALLER_METADATA`. La carga física se restringe a `bytes`/`rawBytes`, `provenance`, `origin`, `node_id` y `execution_sequence`. +2. **Derivación autoritativa de satisfacción y rol**: En `verifyCandidate` (`scripts/lib/independent-verifier/index.js`), el verificador infiere `role` y enlaces a `obligation_ids` consultando el Execution Graph (`node.role`, `obligation.implemented_by`), y determina `evidence_requirements_satisfied` exclusivamente contrastando runner receipts (`receipts` / `runner_receipts`) emitidos por el harness de ejecución. Se erradica completamente el fallback de copia ciega de `node.required_evidence`. +3. **Cronología causal obligatoria**: En `scripts/lib/independent-verifier/strategy-policy.js`, `assertRoleOrder` exige de forma mandatoria la presencia de `execution_sequence` (`run_id`, `ordinal` monotónico creciente y encadenamiento `previous_evidence_id`) para estrategias temporales (`strict-tdd`, `bug`, `refactor`). Se prohíbe el fallback al orden de índices del array JSON; cualquier discrepancia o ausencia de secuencia causal falla con `STRATEGY_SEQUENCE_VIOLATION`. +4. **Replay criptográficamente íntegro**: En `scripts/lib/assurance-graph/index.js`, `validateReplayRecords` / `replayAssuranceGraph` recomputa el digest con `digestRawBytes`, recomputa el identificador con `computeEvidenceId(record, bytes)`, verifica coincidencia exacta con `record.evidence_id` y `record.digest`, y evalúa `evaluateProvenanceSufficiency` contra la procedencia del registro. Cualquier manipulación o insuficiencia falla con `GRAPH_DIVERGENCE`. + +## Architecture Decisions + +| Opción | Tradeoff | Decisión | +|---|---|---| +| **Segregación de rawEvidence**: Rechazo fail-closed `UNTRUSTED_CALLER_METADATA` vs. ignorar silenciosamente propiedades semánticas. | Ignorar campos permite payloads tolerantes pero oculta fallos de seguridad o intentos de inyección. | **Rechazo estricto con `UNTRUSTED_CALLER_METADATA`**: Los llamadores no confiables nunca deben inyectar metadatos semánticos en observaciones físicas. | +| **Derivación de cobertura**: Inferencia autoritativa desde `runner_receipts` vs. copia por defecto de `node.required_evidence`. | Copiar `node.required_evidence` simplifica fixtures pero genera falsos positivos donde la mera existencia de un archivo da por probada una obligación. | **Derivación exclusiva desde runner receipts**: Se exige prueba de ejecución efectiva emitida por el harness; sin recibo que atestigüe satisfacción, `evidence_requirements_satisfied` es `[]`. | +| **Cronología de ejecución**: Validación causal estricta por `execution_sequence` vs. fallback a índices de array JSON. | Usar índices de array oculta desorden temporal y asume orden de serialización arbitrario. | **Causalidad obligatoria por `execution_sequence`**: Exigir `run_id`, `ordinal` y `previous_evidence_id`; fallo `STRATEGY_SEQUENCE_VIOLATION` sin fallback a array. | +| **Integridad en Replay**: Recomputación de `computeEvidenceId` y `evaluateProvenanceSufficiency` vs. validación sintáctica de schema. | Solo validar JSON schema y candidate_id deja abierta la sustitución de `evidence_id` o degradación de procedencia. | **Recomputación criptográfica y sufficiency**: `validateReplayRecords` recomputa ID y digest de bytes y revalida procedencia, fallando con `GRAPH_DIVERGENCE`. | + +### Decision: Segregación física estricta de rawEvidence (UNTRUSTED_CALLER_METADATA) + +**Choice**: Rechazar inmediatamente en `normalizeEvidence` cualquier payload `raw` que contenga `role`, `obligation_ids`, `obligation_id` o `evidence_requirements_satisfied` con `reason_code: "UNTRUSTED_CALLER_METADATA"`. +**Alternatives considered**: Filtrar silenciosamente las propiedades semánticas o aceptarlas como sugerencias no vinculantes. +**Rationale**: Una observación física de prueba (`rawEvidence`) proviene de un canal de recolección de ejecución y solo debe contener datos de bytes, procedencia de canal, origen físico, ID de nodo de grafo y secuencia de ejecución. Si un worker o llamador inyecta aserciones semánticas, constituye un intento de evadir la verificación independiente. + +### Decision: Derivación autoritativa de satisfacción desde Runner Receipts + +**Choice**: `verifyCandidate` deriva `evidence_requirements_satisfied` contrastando los receipts de runner (`receipts` / `runner_receipts`) emitidos por el harness de ejecución. Se prohíbe copiar ciegamente `node.required_evidence`. +**Alternatives considered**: Mantener fallback que copia automáticamente `node.required_evidence` si no se proporcionan tokens en el payload. +**Rationale**: La copia ciega crea falsos positivos en el grafo de aseguramiento. La satisfacción de una obligación crítica (`criticality: "must"`) solo es válida si un runner receipt autoritativo atestigua el resultado exitoso de la ejecución. + +### Decision: Cronología causal estricta mediante execution_sequence + +**Choice**: Exigir `execution_sequence` (`run_id`, `ordinal` monotónico creciente y `previous_evidence_id`) en `assertRoleOrder` para `strict-tdd`, `bug` y `refactor`. Fallar con `STRATEGY_SEQUENCE_VIOLATION` si falta `execution_sequence` o si el orden causal es inválido (ej. GREEN con ordinal menor a RED). +**Alternatives considered**: Mantener el orden del array JSON de `rawEvidence` como fallback cronológico. +**Rationale**: El orden en un array JSON no provee atestación causal. Un payload puede colocar elementos en orden arbitrario en el array mientras que su ejecución real ocurrió en orden inverso o concurrente. La secuencia de ejecución es la única fuente de verdad temporal. + +### Decision: Replay criptográficamente íntegro en Assurance Graph + +**Choice**: En `validateReplayRecords`, recomputar `digestRawBytes(bytes)`, recomputar `computeEvidenceId(record, bytes)`, comparar contra `record.evidence_id` y `record.digest`, y evaluar `evaluateProvenanceSufficiency(record)`. Cualquier fallo detona `GRAPH_DIVERGENCE`. +**Alternatives considered**: Confiar en el `record.evidence_id` almacenado siempre que el schema JSON pase. +**Rationale**: La persistencia y el replay deben garantizar que ni los bytes, ni los identificadores canónicos, ni la clase de procedencia hayan sido alterados o degradados con posterioridad. + +## Data Flow + +``` +[Harness / Test Runner] + │ + ├─► rawEvidence (bytes, origin, node_id, execution_sequence) + ├─► runner_receipts (node_id, evidence_requirements_satisfied, outcome) + └─► collector (id, transport) + │ + ▼ +[normalizeEvidence] ──► ¿Contiene role / obligation_ids / satisfied? ──► [FAIL: UNTRUSTED_CALLER_METADATA] + │ (Válido: extrae solo bytes, digest, node_id, execution_sequence) + ▼ +[verifyCandidate] + │ + ├─► Resuelve 'role' e 'obligation_ids' desde ExecutionGraph (node.role, obligation.implemented_by) + ├─► Deriva 'evidence_requirements_satisfied' desde runner_receipts (NUNCA copia node.required_evidence) + │ + ▼ +[evaluateStrategy / assertRoleOrder] + │ + ├─► ¿Estrategia temporal (strict-tdd, bug, refactor)? + │ ├─► ¿Falta execution_sequence o viola ordinales/chaining? ──► [FAIL: STRATEGY_SEQUENCE_VIOLATION] + │ └─► Orden causal validado (RED < GREEN, before < after) + │ + ▼ +[walkMustObligations] + │ + ├─► ¿required_evidence ⊆ evidence_requirements_satisfied derivados? + │ └─► No ──► [FAIL: UNFULFILLED_MUST] + │ + ▼ +[emitVerification & projectAssuranceGraph] + │ + └─► Persistable Records: evidence/v2, assessment/v2, verification/v2, assurance_graph + │ + ▼ +[replayAssuranceGraph / validateReplayRecords] + │ + ├─► Recomputa digestRawBytes(bytes) === record.digest + ├─► Recomputa computeEvidenceId(record, bytes) === record.evidence_id + ├─► Evalúa evaluateProvenanceSufficiency(record) + └─► Discrepancia o manipulación ──► [FAIL: GRAPH_DIVERGENCE] +``` + +## File Changes + +| File | Action | Description | +|------|--------|-------------| +| `scripts/lib/independent-verifier/evidence.js` | Modify | Validar y rechazar en `normalizeEvidence` cualquier campo semántico del caller con `UNTRUSTED_CALLER_METADATA`. Retornar objeto normalizado libre de metadatos de caller. | +| `scripts/lib/independent-verifier/index.js` | Modify | En `verifyCandidate`, derivar `role` y `obligation_ids` desde el Execution Graph, y derivar `evidence_requirements_satisfied` exclusivamente desde `input.receipts` / `input.runner_receipts`, eliminando la copia ciega de `node.required_evidence`. | +| `scripts/lib/independent-verifier/strategy-policy.js` | Modify | Actualizar `assertRoleOrder` para exigir `execution_sequence` en estrategias temporales (`strict-tdd`, `bug`, `refactor`), eliminando el fallback a índices de array y fallando con `STRATEGY_SEQUENCE_VIOLATION`. | +| `scripts/lib/assurance-graph/index.js` | Modify | Extender `validateReplayRecords` para recomputar `digestRawBytes`, `computeEvidenceId` y evaluar `evaluateProvenanceSufficiency` sobre cada registro de evidencia, fallando con `GRAPH_DIVERGENCE`. | +| `scripts/lib/independent-verifier/index.test.js` | Modify | Actualizar fixtures de test para segregar `rawEvidence` y proveer `runner_receipts` y `execution_sequence`. Agregar suites de prueba para rechazo de caller metadata, ausencia de blind copy y violaciones causales. | +| `scripts/lib/independent-verifier/evidence.test.js` | Modify / Create | Tests unitarios para `normalizeEvidence` validando el rechazo `UNTRUSTED_CALLER_METADATA` ante `role`, `obligation_ids`, `obligation_id` y `evidence_requirements_satisfied`. | +| `scripts/lib/assurance-graph/index.test.js` | Modify | Tests para `validateReplayRecords` y `replayAssuranceGraph` verificando detección de tampering en `evidence_id`, mismatch de bytes y procedencia insuficiente. | + +## Interfaces / Contracts + +### 1. `rawEvidence` Input Payload Segregation + +```javascript +/** + * Observation Payload (physical properties only). + * Untrusted caller metadata fields are STRICTLY FORBIDDEN. + */ +// @typedef {Object} RawEvidenceObservation +// @property {string|Buffer} [bytes] - Raw observation bytes +// @property {string|Buffer} [rawBytes] - Alias for raw observation bytes +// @property {string} [provenance] - Claimed provenance (must match collector channel) +// @property {string} origin - Origin identification (e.g. role:acceptance, test-runner) +// @property {string} node_id - Graph node ID +// @property {ExecutionSequence} [execution_sequence] - Causal execution metadata +// @property {string} [candidate_id] - Optional verification subject binding +// @property {string} [digest] - Optional declared digest +// @property {string} [evidence_id] - Optional declared evidence ID +// +// FORBIDDEN PROPERTIES (Throws UNTRUSTED_CALLER_METADATA): +// - role +// - obligation_ids +// - obligation_id +// - evidence_requirements_satisfied +``` + +### 2. Execution Sequence Interface + +```javascript +/** + * Causal execution sequence tracking. + * @typedef {Object} ExecutionSequence + * @property {string} run_id - Unique execution run identifier + * @property {number} ordinal - Monotonically increasing execution sequence integer (0, 1, 2...) + * @property {string} [previous_evidence_id] - Explicit cryptographic chaining link to predecessor evidence + */ +``` + +### 3. Runner Receipt Interface + +```javascript +/** + * Trusted execution receipt from runner/harness. + * @typedef {Object} RunnerReceipt + * @property {string} [node_id] - Target graph node ID + * @property {string} [evidence_id] - Associated evidence ID + * @property {string} [receipt_id] - Receipt identifier + * @property {string} [outcome] - Execution outcome ("success", "passed", etc.) + * @property {string[]} [evidence_requirements_satisfied] - Requirements attested as satisfied + * @property {string[]} [satisfied_tokens] - Alias for satisfied requirements + * @property {string} [role] - Attested role (optional) + */ +``` + +### 4. Normalized Evidence Output + +```javascript +/** + * Return type of normalizeEvidence. + * @returns {{ ok: true, evidence: EvidenceV2Record, execution_sequence: ExecutionSequence|null, raw: Object } | { ok: false, reason_code: string, error?: string }} + */ +``` + +### 5. `validateReplayRecords` Evidence Verification Contract + +```javascript +// Replay validation step for each evidence item: +// 1. Schema check against evidence/v2.schema.json +// 2. candidate_id match against graph.candidate_id +// 3. Recomputed digest: digestRawBytes(bytes) === record.digest +// 4. Recomputed ID: computeEvidenceId(record, bytes) === record.evidence_id +// 5. Provenance sufficiency: evaluateProvenanceSufficiency(record).ok === true +// 6. Verdict property absence: !record.verdict +// Any failure => fail closed with reason_code: "GRAPH_DIVERGENCE" +``` + +## Testing Strategy + +| Layer | What to Test | Approach | +|-------|-------------|----------| +| Unit (`evidence.test.js`) | Rechazo inmediato de `role`, `obligation_ids`, `obligation_id` y `evidence_requirements_satisfied` con `UNTRUSTED_CALLER_METADATA`. | Pasar payloads con cada campo prohibido de forma individual y combinada; verificar `reason_code === "UNTRUSTED_CALLER_METADATA"`. | +| Unit (`strategy-policy.js`) | Cronología causal en `assertRoleOrder` para `strict-tdd`, `bug` y `refactor`. | Probar arrays con elementos en orden sintáctico pero sin `execution_sequence` (debe fallar `STRATEGY_SEQUENCE_VIOLATION`); probar ordinals contradictorios (GREEN < RED); probar ordinales válidos con enlace `previous_evidence_id`. | +| Unit / Integration (`obligation-coverage.js` + `index.js`) | Erradicación de blind copying y derivación desde runner receipts. | Probar nodo con `required_evidence: ["ev:test-pass"]` y raw observation sin receipt (falla `UNFULFILLED_MUST`). Probar con `runner_receipts` conteniendo `evidence_requirements_satisfied: ["ev:test-pass"]` (pasa `PASS`). | +| Integration (`index.test.js`) | Verificación completa de `verifyCandidate` con segregación de rawEvidence y runner receipts. | Adaptar suite completa de `independent-verifier/index.test.js` asegurando compatibilidad con todas las estrategias y gates. | +| Integration (`assurance-graph/index.test.js`) | Replay exhaustivo con recomputación de `computeEvidenceId` y `evaluateProvenanceSufficiency`. | Probar replay con registro con `evidence_id` modificado, bytes discrepantes o procedencia débil (`model-reported`) contra un grafo que requiere runtime; verificar fallo con `GRAPH_DIVERGENCE`. | + +## Migration / Rollout + +- **No schema migration required**: Los esquemas kernel `evidence/v2`, `assessment/v2`, `verification/v2` y `assurance-graph/v1` se preservan íntegros. +- **Actualización de Harness y Fixtures de Test**: + - Los generadores de fixtures en tests unitarios e integraciones (`index.test.js`) que construían `rawEvidence` con campos semánticos inyectados (`role`, `obligation_ids`, `evidence_requirements_satisfied`) se refactorizan para generar observaciones físicas puras y proporcionar `runner_receipts` explícitos en las llamadas a `verifyCandidate`. + - Las fixtures de estrategias temporales se dotan de `execution_sequence` canónicos (`run_id`, `ordinal: 1`, `ordinal: 2`, etc.). + +## Open Questions + +- None (todas las decisiones y requisitos técnicos están resueltos y alineados con las especificaciones). diff --git a/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/proposal.md b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/proposal.md new file mode 100644 index 00000000..5d7da9f3 --- /dev/null +++ b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/proposal.md @@ -0,0 +1,69 @@ +# Proposal: K6b Trusted Evidence and Replay Closure + +## Intent + +Cerrar definitivamente las brechas de seguridad e integridad en K6b (Blockers B1, B2, B3 y Hallazgo H1): segregar físicamente las observaciones (`rawEvidence`) rechazando metadatos semánticos inyectados por el caller, eliminar la copia ciega de `required_evidence` derivando satisfacción únicamente desde runner receipts confiables, imponer cronología causal estricta basada en `execution_sequence` (prohibiendo fallback a orden de array), y asegurar replay exhaustivo de `evidence/v2` con recomputación de `computeEvidenceId`, digest de bytes y suficiencia de provenance. + +## Scope + +### In Scope +- Segregación estricta en `rawEvidence`: rechazar inmediatamente con `UNTRUSTED_CALLER_METADATA` si contiene `role`, `obligation_ids`, `obligation_id` o `evidence_requirements_satisfied`. +- Derivación autoritativa de satisfacción y rol a partir del Execution Graph y runner receipts (`receipts` / `runner_receipts`), eliminando la copia automática/ciega de `node.required_evidence`. +- Validación cronológica causal estricta en `evaluateStrategy` para Strict TDD, Bug y Refactor mediante `execution_sequence` (`run_id`, `ordinal` monotónico creciente y encadenamiento `previous_evidence_id`), prohibiendo fallback a la posición del array JSON. +- Replay exhaustivo en `assurance-graph` recomputando `digestRawBytes`, `computeEvidenceId` y `evaluateProvenanceSufficiency`, fallando con `GRAPH_DIVERGENCE` ante discrepancias o evidencias manipuladas. +- Actualización y endurecimiento de las especificaciones canónicas de `independent-verification` y `assurance-graph`. + +### Out of Scope +- Challenges adversariales K6c (mutación de tests, reversiones automáticas) y análisis de complejidad K6d. +- Attestation formal de evaluación de entrega K8 y autorizaciones de release K10. +- Modificación de esquemas base `assessment/v2` o `evidence/v2` (se mantiene su definición canónica existente). + +## Capabilities + +### New Capabilities +None + +### Modified Capabilities +- `independent-verification`: Segregación estricta de `rawEvidence` con rechazo `UNTRUSTED_CALLER_METADATA`, derivación autoritativa de cobertura desde runner receipts sin copia ciega de `required_evidence`, y validación cronológica obligatoria basada en `execution_sequence` sin fallback al orden de array en Strict TDD, Bug y Refactor. +- `assurance-graph`: Replay integral de `evidence/v2` recomputando `computeEvidenceId`, digest de bytes y suficiencia de provenance frente a collector/transport de confianza, fallando con `GRAPH_DIVERGENCE`. + +## Approach + +1. Modificar `normalizeEvidence` para validar que `rawEvidence` carezca de propiedades semánticas (`role`, `obligation_ids`, `obligation_id`, `evidence_requirements_satisfied`), retornando `UNTRUSTED_CALLER_METADATA` en caso contrario. +2. Actualizar el enlace en `verifyCandidate` para correlacionar runner receipts con nodos y obligaciones del Execution Graph para inferir `evidence_requirements_satisfied`, eliminando la copia estática de `node.required_evidence`. +3. Reemplazar la validación basada en índices de array en `strategy-policy.js` por verificación causal obligatoria sobre `execution_sequence` (`run_id` consistente, `ordinal` estrictamente creciente y enlace `previous_evidence_id`). +4. Extender `validateReplayRecords` en `assurance-graph/index.js` para recomputar `computeEvidenceId` y digest de bytes de `evidence/v2`, y evaluar suficiencia de provenance, detonando `GRAPH_DIVERGENCE` ante cualquier falla. + +## Affected Areas + +| Area | Impact | Description | +|---|---|---| +| `scripts/lib/independent-verifier/**` | Modified | Rechazo `UNTRUSTED_CALLER_METADATA`, derivación desde receipts, validación causal `execution_sequence` | +| `scripts/lib/assurance-graph/**` | Modified | Recomputación de `evidence_id`, bytes digest y suficiencia de provenance en replay | +| `openspec/specs/independent-verification/spec.md` | Modified | Especificación de segregación estricta de caller metadata y cronología causal | +| `openspec/specs/assurance-graph/spec.md` | Modified | Especificación de replay exhaustivo de evidencias y provenance | + +## Risks + +| Risk | Likelihood | Mitigation | +|---|---|---| +| Falso rechazo de suites de prueba existentes por omitir `execution_sequence` | Med | Adaptar generadores de fixtures/receipts de test para incluir `execution_sequence` canónico | +| Fallo en replay por bytes de evidencia no persistidos | Low | Requerir almacenamiento o paso canónico de rawBytes/bytes en replay para validación de digest e ID | + +## Rollback Plan + +Revertir los cambios en `scripts/lib/independent-verifier/` y `scripts/lib/assurance-graph/`, restaurando el comportamiento previo de normalización y replay mediante git revert del commit asociado. + +## Dependencies + +- Node.js 22+, suite de testing canónica `node --test`, baselines K4b/K6a archivados. + +## Success Criteria + +- [ ] `normalizeEvidence` rechaza con `UNTRUSTED_CALLER_METADATA` cualquier payload con `role`, `obligation_id(s)` o `evidence_requirements_satisfied`. +- [ ] La satisfacción de obligaciones se deriva exclusivamente de receipts de ejecución y Execution Graph sin copiar ciegamente `node.required_evidence`. +- [ ] Strict TDD, Bug y Refactor validan cronología causal estricta mediante `execution_sequence` (`run_id`, `ordinal`, `previous_evidence_id`), rechazando fallback al orden de array. +- [ ] `replayAssuranceGraph` recomputa `computeEvidenceId`, valida digest de bytes y evalúa suficiencia de provenance, fallando con `GRAPH_DIVERGENCE` ante discrepancias. +- [ ] Todos los tests unitarios y de integración de `independent-verifier` y `assurance-graph` pasan satisfactoriamente. + +> **Branch advisory:** Before `sdd-apply` begins, a feature branch SHOULD be created following the `/` convention defined in the `branch-pr` skill (e.g. `git checkout -b feat/my-change main`). This note is SHOULD, not MUST — omit it from `status: blocked` envelopes. diff --git a/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/specs/assurance-graph/spec.md b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/specs/assurance-graph/spec.md new file mode 100644 index 00000000..b54bb4fd --- /dev/null +++ b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/specs/assurance-graph/spec.md @@ -0,0 +1,282 @@ +# Assurance Graph Specification + +## Purpose + +Define the Assurance Graph as a content-addressed, reproducible projection of +evidence and verification over a frozen Candidate. It derives from OpenSpec, +Git, Candidate, and Execution Graph. It is never semantic authority. Selective +invalidation on successor preserves independent evidence. + +## Requirements + +### Requirement: Derived Projection Not Authority {#REQ-assurance-graph-001} + +The Assurance Graph MUST be derived from canonical OpenSpec artifacts, Git +bytes, the frozen Candidate, the Execution Graph, evidence nodes, verification +decisions, and persistable assessments. Consumers MUST treat it as read-only. +The graph MUST NOT grant lifecycle, approval, or delivery authority. +Divergence from OpenSpec/Git/Candidate/contract/policy MUST fail closed with +`GRAPH_DIVERGENCE`. Failure to materialize the projection MUST fail closed +with `GRAPH_PROJECTION_FAILED`. K6b MUST NOT populate `reviewed-by` edges or +treat Evaluation Attestation or Delivery Authorization as authority. +(Previously: divergence was unnamed fail-closed and canonical inputs excluded contract/policy digest binding.) + +#### Scenario: Matching canonical inputs project a graph + +- GIVEN canonical OpenSpec/Git/Candidate/Execution Graph inputs and bound evidence +- WHEN the Assurance Graph is materialized +- THEN the projection MUST be derived from those inputs +- AND MUST NOT override OpenSpec/Git/Candidate + +#### Scenario: Divergent graph fails closed + +- GIVEN an Assurance Graph that cannot be recomputed from current OpenSpec/Git/Candidate +- WHEN reconciliation runs +- THEN validation MUST fail closed with `GRAPH_DIVERGENCE` +- AND consumers MUST NOT proceed on the unreconciliation graph + +#### Scenario: Contract or policy change forces reconciliation fail-closed + +- GIVEN a stored graph projected under contract digest C1 and policy snapshot P1 +- AND current canonical inputs are C2 or P2, producing a distinct `graph_id` +- WHEN reconciliation runs against the stored graph +- THEN validation MUST fail closed with `GRAPH_DIVERGENCE` + +### Requirement: Reproducible Digest And K6b Edges {#REQ-assurance-graph-002} + +Identical persistable canonical inputs MUST produce the same graph digest and the same `AssuranceEdge` set. `graph_id` MUST fingerprint at least: + +| Canonical input | In `graph_id` preimage | +| --- | --- | +| contract digest | MUST | +| policy snapshot identity | MUST | +| Execution Graph digest | MUST | +| canonical OpenSpec input | MUST | +| `candidate_id` and canonical nodes/edges | MUST | + +Altering any of those inputs MUST produce a distinct `graph_id`. Edge serialization order MUST NOT change the digest. + +Each K6b edge MUST have `from`, `to`, and `relation` in `verified-by | satisfies | derived-from | invalidates`. The projector MUST emit a `satisfies` edge (`relation: "satisfies"`) from an assessment/evidence node to a requirement/obligation node ONLY when `evidence_requirements_satisfied.length > 0`. If `evidence_requirements_satisfied` is empty or omitted, no `satisfies` edge MUST be emitted into the Assurance Graph. + +The projection MAY include requirement, graph-node, work-order, source/patch, candidate, test-evidence, and verification-decision subjects. It MUST NOT emit K7 findings, K8 attestation, or K10 authorization as authoritative subjects. +(Previously: satisfies edges were projected without checking that evidence_requirements_satisfied was non-empty.) + +#### Scenario: Same inputs yield the same digest and edges + +- GIVEN identical canonical inputs +- WHEN the Assurance Graph is materialized twice +- THEN both digests MUST be byte-identical +- AND both edge sets MUST be equal + +#### Scenario: Forbidden later-slice relations are rejected + +- GIVEN an edge with relation `reviewed-by` or a node claiming Evaluation Attestation authority +- WHEN the K6b graph is validated +- THEN validation MUST fail closed + +#### Scenario: Canonical input change yields a distinct graph_id + +- GIVEN a projection under contract digest C1, policy snapshot P1, Execution Graph digest G1, and canonical OpenSpec input O1 +- WHEN any one of C1, P1, G1, or O1 is replaced with a distinct persistable value +- THEN the resulting `graph_id` MUST differ +- AND reconciliation against the prior graph MUST fail closed with `GRAPH_DIVERGENCE` + +#### Scenario: Conditional projection of satisfies edge requires non-empty satisfaction + +- GIVEN an assessment record where `evidence_requirements_satisfied` has at least one item +- WHEN the Assurance Graph is projected +- THEN a `satisfies` edge linking the assessment/evidence to the obligation MUST be emitted + +#### Scenario: Empty or missing evidence_requirements_satisfied omits satisfies edge + +- GIVEN an assessment record where `evidence_requirements_satisfied` is empty `[]` +- WHEN the Assurance Graph is projected +- THEN no `satisfies` edge MUST be emitted for that assessment/evidence + +### Requirement: Selective Invalidation Closure {#REQ-assurance-graph-003} + +When a Candidate successor appears or a source subject changes, the system +MUST compute the dependent closure over `invalidates`, `derived-from`, +`verified-by`, and `satisfies` edges, invalidate dependent evidence, and +preserve evidence outside that closure. The system MUST NOT re-execute every +verification by default. The system MUST NOT reuse evidence reachable through +a transitive `invalidates` edge. + +#### Scenario: Successor invalidates only the dependent closure + +- GIVEN an Assurance Graph with dependent evidence D and independent evidence I +- AND a Candidate successor that affects only D's source subjects +- WHEN selective invalidation runs +- THEN D MUST be invalidated +- AND I MUST remain valid + +#### Scenario: Transitive invalidates blocks reuse + +- GIVEN evidence E connected to a successor-affected subject by a transitive `invalidates` path +- WHEN verification is attempted with E +- THEN the verifier MUST fail closed +- AND MUST NOT treat E as satisfying evidence + +### Requirement: Non-Authoritative Equivalence Manifest {#REQ-assurance-graph-004} + +The system MAY emit an equivalence manifest bound to the graph digest and +`CandidateId` for later K9 evaluation. The manifest MUST NOT promote +equivalence, alter Strict TDD fallback, or serve as attestation or delivery +authorization. + +#### Scenario: Manifest is emitted without promotion + +- GIVEN a reproducible Assurance Graph for a frozen Candidate +- WHEN an equivalence manifest is emitted +- THEN it MUST bind the graph digest and CandidateId +- AND MUST NOT change verifier fallback or authorize delivery + +#### Scenario: Manifest cannot alias attestation or authorization + +- GIVEN an equivalence manifest payload +- WHEN validated against CandidateEvaluationAttestation or DeliveryAuthorization schemas +- THEN validation MUST fail closed + +### Requirement: Forbidden Subjects Matched By Kind And Namespace {#REQ-assurance-graph-005} + +`rejectForbidden` MUST accept or reject subjects using structured `kind` and +`namespace` (or equivalent typed fields). It MUST NOT reject a subject because +a forbidden token is a substring of `id`. A node with `kind: "requirement"` +and `id: "REQ-add-authorization-header"` MUST remain valid. Nodes whose `kind` +or `namespace` denote K7 finding, K8 attestation, or K10 authorization MUST +fail closed. Edges whose `relation` is outside +`verified-by | satisfies | derived-from | invalidates` MUST fail closed. + +#### Scenario: Requirement id containing authorization remains valid + +- GIVEN a projected node `{ id: "REQ-add-authorization-header", kind: "requirement" }` +- WHEN `rejectForbidden` runs +- THEN validation MUST succeed +- AND MUST NOT reject the node for the substring `authorization` + +#### Scenario: Structured authorization kind is rejected + +- GIVEN a projected node whose `kind` or `namespace` denotes authorization, attestation, or finding +- WHEN `rejectForbidden` runs +- THEN validation MUST fail closed + +### 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`. + +`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`. +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. +(Previously: replay validation did not explicitly recompute computeEvidenceId or evaluateProvenanceSufficiency during evidence/v2 revalidation.) + +#### Scenario: Replay from persisted outputs yields the same graph + +- GIVEN a previously projected graph plus persisted evidence, verification, assessments, and canonical input digests +- AND no ephemeral `obligation_ids` on the original projector call +- AND every persisted evidence, verification, and assessment record passes comprehensive revalidation +- 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: Tampered assessment_id fails replay + +- GIVEN persisted assessments plus a stored graph that would otherwise replay +- AND one assessment whose `assessment_id` does not match the identity recomputed from its persistable fields +- WHEN `replayAssuranceGraph` runs +- THEN replay MUST fail closed with `GRAPH_DIVERGENCE` or as an invalid assessment + +#### Scenario: Assessment fails schema, candidate, or policy revalidation + +- GIVEN a persisted assessment that fails schema validation, whose `candidate_id` does not match the graph subject, or whose bound policy-snapshot identity does not match the graph +- WHEN `replayAssuranceGraph` runs +- THEN replay MUST fail closed with `GRAPH_DIVERGENCE` or as an invalid assessment + +#### Scenario: Assessment bound to missing evidence or non-implementing node fails replay + +- GIVEN a persisted assessment whose `evidence_id` does not exist, whose `obligation_id` is absent from the graph, whose node does not implement that obligation, or whose `node_id` disagrees with the evidence record +- WHEN `replayAssuranceGraph` runs +- THEN replay MUST fail closed with `GRAPH_DIVERGENCE` or as an invalid assessment + +#### Scenario: Evidence v2 digest mismatch or invalid candidate binding fails replay + +- GIVEN an `evidence/v2` record whose declared `digest` does not match recomputed `digestRawBytes`, or whose `candidate_id` differs from the graph subject +- WHEN `replayAssuranceGraph` runs +- THEN replay MUST fail closed with `GRAPH_DIVERGENCE` + +#### Scenario: Tampered evidence_id or failed computeEvidenceId fails replay + +- GIVEN an `evidence/v2` record whose declared `evidence_id` does not match `computeEvidenceId` recomputed from its fields and raw bytes +- WHEN `replayAssuranceGraph` runs +- THEN replay MUST fail closed with `GRAPH_DIVERGENCE` + +#### Scenario: Insufficient provenance during evidence replay fails replay + +- GIVEN an `evidence/v2` record whose provenance fails `evaluateProvenanceSufficiency` or lacks trusted collector metadata +- WHEN `replayAssuranceGraph` runs +- THEN replay MUST fail closed with `GRAPH_DIVERGENCE` + +#### Scenario: Verification v2 referencing non-existent evidence_id fails replay + +- GIVEN a `verification/v2` record whose `evidence_ids` array contains an identifier not present in replayed evidence +- 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. + +`resolveCanonicalInputDigests()` MUST authoritatively verify and compute canonical input digests: +1. `openspec_input_digest` recomputed from canonical OpenSpec documents. +2. `contract_digest` recomputed from canonical contract schema definitions. +3. `policy_snapshot_id` verified against the policy snapshot binding. +4. `execution_graph_digest` recomputed from the compiled Execution Graph. + +If any supplied input digest does not strictly match the authoritative recomputed digest (`provided !== recomputed`), `resolveCanonicalInputDigests()` and `projectAssuranceGraph` MUST immediately fail closed with `GRAPH_DIVERGENCE`. The projector MUST NOT fingerprint a null, absent, or unverified digest for any required canonical input in the `graph_id` preimage. +(Previously: resolveCanonicalInputDigests did not mandate authoritative openspec_input_digest recomputation with strict GRAPH_DIVERGENCE mismatch rejection.) + +#### Scenario: Graph contract contradicts canonicalInputs + +- GIVEN an Execution Graph whose contract digest is C1 +- AND `canonicalInputs` whose contract digest is C2, where C1 ≠ C2 +- WHEN `projectAssuranceGraph` runs +- THEN projection MUST fail closed with `GRAPH_DIVERGENCE` +- AND MUST NOT emit a graph whose `graph_id` fingerprints the contradictory pair + +#### Scenario: Null required canonical digest is not fingerprinted + +- GIVEN a required canonical input whose digest is null or absent +- WHEN `projectAssuranceGraph` runs +- THEN projection MUST fail closed +- AND MUST NOT produce a `graph_id` that fingerprints a null digest for that input + +#### Scenario: OpenSpec input digest mismatch in resolveCanonicalInputDigests fails closed + +- GIVEN a caller-supplied `openspec_input_digest` D1 +- AND authoritative recomputed OpenSpec digest D2, where D1 ≠ D2 +- WHEN `resolveCanonicalInputDigests()` evaluates canonical inputs +- THEN resolution MUST fail closed with error code `GRAPH_DIVERGENCE` + +### Requirement: Reconcile Stored Payload Divergence {#REQ-assurance-graph-008} + +`reconcileAssuranceGraph` MUST detect stored-graph divergence beyond equality +of `graph_id` and edges. It MUST fail closed with `GRAPH_DIVERGENCE` when +stored `nodes`, `canonical_inputs`, `candidate_id`, or kind/schema diverge +from the recomputed projection, or when `graph_id` recomputed from the stored +payload does not match the stored `graph_id`. Comparing only stored +`graph_id` plus edges MUST NOT be sufficient to declare reconciliation OK. + +#### Scenario: Tampered stored nodes fail closed + +- GIVEN a stored Assurance Graph whose `graph_id` and edges match a recomputation +- AND whose persistable `nodes` have been altered +- WHEN `reconcileAssuranceGraph` runs +- THEN validation MUST fail closed with `GRAPH_DIVERGENCE` + +#### Scenario: Stored payload or identity fields diverge + +- GIVEN a stored Assurance Graph whose `candidate_id`, persistable `canonical_inputs`, or kind/schema differ from the current projection, or whose declared `graph_id` does not equal the digest recomputed from its stored payload +- WHEN `reconcileAssuranceGraph` runs +- THEN validation MUST fail closed with `GRAPH_DIVERGENCE` diff --git a/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/specs/independent-verification/spec.md b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/specs/independent-verification/spec.md new file mode 100644 index 00000000..4f68a0ad --- /dev/null +++ b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/specs/independent-verification/spec.md @@ -0,0 +1,327 @@ +# Independent Verification Specification + +## Purpose + +Independent verifier over a frozen `CandidateId`. Worker narrative is not +authority. Evidence stays distinct from verdicts. Strict TDD is the default +evidence strategy until equivalence is proven; it does not rewrite SDD +`testing.tdd_mode`. + +## Requirements + +### Requirement: Frozen Candidate Subject And Canonical Bindings {#REQ-independent-verification-001} + +The verifier MUST accept only a frozen `CandidateId` as subject, plus contract, +Execution Graph, repository bytes, and raw evidence. It MUST validate canonical +identity bindings before evaluating evidence. It MUST reject a `WorkResult` +(integrated or not) as subject and MUST reject an unfrozen or mutable +candidate. Binding failure MUST fail closed. Worker prose MUST NOT substitute +for a missing structured subject or binding. + +#### Scenario: Frozen CandidateId proceeds to strategy selection + +- GIVEN a frozen Candidate v2, valid Execution Graph and identity bindings, and raw evidence +- WHEN the verifier starts +- THEN it MUST accept the `CandidateId` as subject and proceed to strategy selection + +#### Scenario: WorkResult subject is rejected + +- GIVEN a `WorkResultId` or unintegrated WorkResult supplied as the verification subject +- WHEN the verifier starts +- THEN verification MUST fail closed and MUST NOT treat the WorkResult as a Candidate + +#### Scenario: Unfrozen candidate or failed binding is rejected + +- GIVEN an unfrozen candidate, a mutable working tree claimed as candidate, or a binding digest mismatch +- WHEN the verifier starts +- THEN verification MUST fail closed before strategy evaluation + +### Requirement: Evidence Strategies And Strict TDD Fallback {#REQ-independent-verification-002} + +The verifier MUST select exactly one strategy from +`bug | feature | refactor | migration | config-docs`. Each strategy MUST +declare minimum evidence, at least one negative case, and admissible +provenance: + +| Strategy | Minimum evidence | Required negative | +| --- | --- | --- | +| bug | red reproduction, patch, green reproduction | green-without-red or red-after-patch | +| feature | acceptance, invariants, contract or integration tests | missing negative or acceptance path | +| refactor | characterization before/after, no observable behavior change | behavioral delta treated as equivalent | +| migration | dry-run, rollback, incompatibility, idempotent re-run | skipped rollback or non-idempotent apply | +| config-docs | real schema/parser check, smoke, install or consume | docs-only claim without parser/smoke | + +When no strategy is declared, or no proven equivalent applies, the verifier +MUST use Strict TDD (RED → GREEN with runtime test evidence) as the default +fallback. This fallback MUST NOT rewrite `openspec/config.yaml` +`testing.tdd_mode` (this repository remains `focused` unless a separate change +updates it). Strict TDD fallback is an evidence-strategy default, not a silent +SDD-runtime config mutation. An equivalence manifest MUST NOT retire the +fallback. + +#### Scenario: Declared feature strategy requires its minimums + +- GIVEN a frozen Candidate declared as `feature` +- WHEN the verifier selects the feature strategy +- THEN it MUST require the feature minimum set and a negative case +- AND MUST NOT accept characterization-only evidence + +#### Scenario: Missing strategy falls back to Strict TDD without rewriting tdd_mode + +- GIVEN a frozen Candidate with no declared strategy and no proven equivalence +- AND `openspec/config.yaml` `testing.tdd_mode` is `focused` +- WHEN the verifier selects a strategy +- THEN it MUST apply Strict TDD as the evidence strategy +- AND MUST leave `testing.tdd_mode` unchanged + +### Requirement: Provenance Sufficiency And Fail-Closed Evidence {#REQ-independent-verification-003} + +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`. + +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. + +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.) + +#### Scenario: Runtime-observed evidence satisfies a test obligation + +- GIVEN an obligation that admits `runtime-observed` provenance +- AND collector or transport metadata that derives `runtime-observed` +- AND raw test evidence bound to the frozen CandidateId and graph node +- WHEN the verifier evaluates sufficiency +- THEN the obligation MAY be marked satisfied +- AND the evidence MUST remain a distinct record without `verdict` + +#### Scenario: Model-reported tests-passed is insufficient + +- GIVEN an obligation that requires `runtime-observed` or `tool-produced` provenance +- AND only a worker `model-reported` claim that tests passed +- WHEN the verifier evaluates sufficiency +- THEN verification MUST fail closed +- AND MUST NOT treat worker narrative as satisfying evidence + +#### Scenario: Stale, foreign, or fabricated evidence is rejected + +- GIVEN evidence whose digest does not match raw bytes, whose CandidateId does not match the subject, or whose origin predates a successor without revalidation +- WHEN the verifier evaluates that evidence +- THEN verification MUST fail closed +- AND MUST NOT reuse the evidence under a transitive `invalidates` edge + +#### Scenario: Payload-claimed strong provenance without trusted collector fails closed + +- GIVEN raw evidence whose payload `provenance` is `runtime-observed` +- AND collector or transport metadata that is absent, untrusted, or derives a weaker class +- WHEN the verifier evaluates sufficiency +- THEN verification MUST fail closed +- AND MUST NOT accept the payload string as a strong class + +#### Scenario: Verifier derives trusted evidence metadata from Execution Graph and receipts + +- GIVEN raw evidence observations without caller semantic annotations +- AND trusted runner receipts and a compiled Execution Graph +- WHEN the verifier resolves evidence bindings +- THEN `role`, `obligation_ids`, and `evidence_requirements_satisfied` MUST be derived by the verifier +- AND MUST NOT be accepted from untrusted caller inputs + +#### Scenario: Untrusted caller metadata is rejected with UNTRUSTED_CALLER_METADATA + +- GIVEN a `rawEvidence` payload containing caller-injected `role`, `obligation_ids`, `obligation_id`, or `evidence_requirements_satisfied` +- WHEN the verifier normalizes or evaluates raw evidence +- THEN verification MUST immediately fail closed with `UNTRUSTED_CALLER_METADATA` +- AND MUST NOT process or accept the untrusted payload + +### Requirement: Verdict Is Not Evidence {#REQ-independent-verification-004} + +Verification MUST emit a verification record bound to the frozen `CandidateId` +with verdict `PASS | PASS WITH WARNINGS | FAIL`. Evidence records MUST NOT +carry `verdict`. A verification record MUST NOT validate as evidence. `PASS` +or `PASS WITH WARNINGS` MUST require both declared-strategy minimums and +REQ-independent-verification-005 coverage with persistable assessments. +Strategy role shape alone is insufficient. Unique-sort of `evidence_ids` MUST +NOT substitute for distinct assessments. The verifier MAY emit a +non-authoritative equivalence manifest for later K9 evaluation. That manifest +MUST NOT promote equivalence, authorize delivery, or replace the Strict TDD +fallback. +(Previously: strategy minimums with admissible provenance were sufficient for PASS.) + +#### Scenario: Sufficient evidence yields a verification verdict + +- GIVEN strategy minimums met with collector-derived admissible provenance on a frozen CandidateId +- AND every applicable non-deferred MUST obligation persistably bound to admissible evidence on a correct implementing node +- WHEN the verifier completes +- THEN it MUST emit a verification record with `PASS` or `PASS WITH WARNINGS` +- AND referenced evidence records MUST omit `verdict` + +#### Scenario: Evidence carrying verdict is rejected + +- GIVEN a payload that mixes evidence identity fields with a `verdict` property +- WHEN schema or verifier validation runs +- THEN validation MUST fail closed +- AND MUST NOT accept the payload as either evidence or verification + +### 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. + +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`. + +Coverage MUST be persisted on the `assessment/v2` record in property `evidence_requirements_satisfied`. A non-empty evidence list, blind copy of `node.required_evidence`, empty satisfaction array `[]`, unique-sort of `evidence_ids`, or existential binding MUST NOT substitute for that subset. Strategy role shape alone MUST NOT satisfy the graph. An `obligation_id` absent from the manifest MUST fail closed. Evidence bound to a node that does not implement the obligation MUST fail closed. A MUST whose `required_evidence` is not a subset of satisfied tokens MUST fail closed and MUST identify the unfulfilled `obligation_id`. The verifier MUST consume persistable manifest `obligation_id` values; it MUST NOT invent them from vanished fields. +(Previously: verifier allowed fallback blind-copying of node.required_evidence when caller metadata was omitted.) + +#### Scenario: MUST without admissible evidence fails closed + +- GIVEN a compiled Execution Graph with a non-deferred MUST obligation +- AND strategy role minimums met +- AND no admissible evidence persistably bound to that `obligation_id` +- WHEN the verifier evaluates obligation coverage +- THEN it MUST fail closed identifying that `obligation_id` +- AND MUST NOT emit `PASS` or `PASS WITH WARNINGS` + +#### Scenario: Nonexistent obligation_id fails closed + +- GIVEN an assessment or raw binding whose `obligation_id` is not in the Obligation Manifest +- WHEN the verifier evaluates obligation coverage +- THEN it MUST fail closed identifying the unknown `obligation_id` +- AND MUST NOT emit `PASS` or `PASS WITH WARNINGS` + +#### Scenario: Evidence bound to the wrong implementing node fails closed + +- GIVEN a MUST obligation with `implemented_by` containing node A +- AND admissible evidence whose persistable `node_id` is B, where B is not in `implemented_by` +- WHEN the verifier evaluates that binding +- THEN it MUST fail closed +- AND MUST NOT treat the evidence as satisfying that obligation + +#### Scenario: Partial required_evidence coverage fails closed + +- GIVEN a non-deferred MUST obligation with `required_evidence` equal to `[A, B]` +- AND admissible evidence that satisfies only token A +- WHEN the verifier evaluates obligation coverage +- THEN it MUST fail closed identifying that `obligation_id` +- AND MUST NOT emit `PASS` or `PASS WITH WARNINGS` + +#### Scenario: Empty evidence_requirements_satisfied cannot claim satisfaction + +- GIVEN an assessment record with `evidence_requirements_satisfied: []` +- WHEN the verifier evaluates obligation coverage +- THEN the assessment MUST NOT satisfy any `required_evidence` token +- AND any MUST obligation relying on it MUST fail closed + +#### Scenario: Blind copying of node required_evidence is forbidden and ungrounded satisfaction fails closed + +- GIVEN a graph node declaring `required_evidence` +- AND runner receipts that do not substantiate satisfaction of those tokens +- WHEN the verifier evaluates obligation coverage +- THEN the verifier MUST NOT copy `node.required_evidence` into `evidence_requirements_satisfied` +- AND ungrounded MUST obligations MUST fail closed + +### Requirement: Persistable Assessment Binding Distinct From Evidence {#REQ-independent-verification-006} + +The verifier MUST persist an additive `assessment/v2` record for each evaluated tuple of `evidence_id`, `role`, `obligation_id`, `node_id`, and bound policy-snapshot identity. Assessment identity MUST include `role` and `obligation_id` and MUST be distinct per `(evidence_id, role, obligation_id)`. `evidence/v2` MUST remain the observation record and MUST NOT be mutated to carry `role` or `obligation_id`. + +The verifier MUST enforce an incompatible roles matrix. The following role combinations MUST NOT share the same `evidence_id`: +1. `red` ↔ `green` +2. `characterization-before` ↔ `characterization-after` +3. `negative` ↔ `acceptance` + +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 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`. + +Unique-sort of `verification.evidence_ids` MUST NOT be the assessment identity and MUST NOT hide distinct role or obligation bindings. +(Previously: chronological ordering in strict-tdd and bug strategies fell back to JSON array indexing instead of enforcing execution_sequence causality.) + +#### Scenario: Same EvidenceId as RED and GREEN fails closed + +- GIVEN one `evidence/v2` observation whose `evidence_id` is E +- AND that observation is bound as both RED and GREEN strategy roles +- WHEN the verifier evaluates strategy evidence +- THEN verification MUST fail closed +- AND MUST NOT treat E as satisfying both roles + +#### Scenario: GREEN before RED fails closed + +- GIVEN Strict TDD or bug-strategy evidence whose GREEN observation has an `ordinal` earlier than RED in `execution_sequence` +- WHEN the verifier evaluates strategy evidence +- THEN verification MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION` +- AND MUST NOT emit `PASS` or `PASS WITH WARNINGS` + +#### Scenario: RED after PATCH fails closed + +- GIVEN Strict TDD or bug-strategy evidence whose RED observation occurs after PATCH in `execution_sequence` +- WHEN the verifier evaluates strategy evidence +- THEN verification MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION` +- AND MUST NOT emit `PASS` or `PASS WITH WARNINGS` + +#### Scenario: Distinct tuples yield distinct assessment identities + +- GIVEN two persistable tuples that differ in `evidence_id`, `role`, or `obligation_id` +- AND neither tuple shares one EvidenceId across incompatible roles +- WHEN assessments are persisted +- THEN their assessment identities MUST be distinct +- AND unique-sort of `verification.evidence_ids` MUST NOT collapse those assessments + +#### Scenario: Characterization-after before characterization-before fails closed + +- GIVEN refactor strategy evidence where `characterization-after` has an `ordinal` less than or equal to `characterization-before`, or `previous_evidence_id` does not link correctly +- WHEN the verifier evaluates refactor evidence +- THEN verification MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION` +- AND MUST NOT emit `PASS` or `PASS WITH WARNINGS` + +#### Scenario: Fallback to JSON array position without execution_sequence fails closed + +- GIVEN Strict TDD, bug, or refactor evidence ordered in the JSON array but lacking valid `execution_sequence` objects +- WHEN the verifier evaluates strategy sequence +- THEN verification MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION` +- AND MUST NOT rely on JSON array index order as causal chronology + +#### Scenario: Negative and acceptance sharing same EvidenceId fails closed + +- GIVEN an `evidence/v2` observation bound to both `negative` and `acceptance` roles +- WHEN the verifier evaluates strategy evidence +- THEN verification MUST fail closed identifying the incompatible role conflict + +#### Scenario: Non-conflicting shared evidence passes validation + +- GIVEN an `evidence/v2` observation bound to compatible roles `integration` and `acceptance` +- AND both obligations are independently verified by the observation +- WHEN the verifier evaluates evidence bindings +- THEN verification MUST accept the shared evidence for both roles + +### Requirement: Facade Fail-Closed On Required Projection {#REQ-independent-verification-007} + +`verifyCandidate` MUST require a successful Assurance Graph projection before +returning `ok: true`. If projection cannot materialize, the facade MUST fail +closed with `GRAPH_PROJECTION_FAILED`. If a stored graph does not recompute +from persistable canonical inputs, the facade MUST fail closed with +`GRAPH_DIVERGENCE`. The facade MUST NOT return `ok: true` without the projected +graph, and MUST NOT emit `PASS` or `PASS WITH WARNINGS` in those cases. + +#### Scenario: Failed projection does not return ok without a graph + +- GIVEN a candidate whose strategy and MUST coverage would otherwise pass +- AND `projectAssuranceGraph` fails +- WHEN `verifyCandidate` completes +- THEN the facade MUST return `ok: false` with `GRAPH_PROJECTION_FAILED` +- AND MUST omit `assurance_graph` +- AND MUST NOT emit `PASS` or `PASS WITH WARNINGS` + +### Requirement: Contract Digest Gate Before Strategy {#REQ-independent-verification-008} + +Before strategy evaluation, `input.contract.contract_digest` MUST equal +`executionGraph.contract_digest`. Mismatch MUST fail closed and MUST NOT +proceed to strategy evaluation. + +#### Scenario: Contract digest mismatch with Execution Graph fails closed before strategy + +- GIVEN a frozen Candidate whose `input.contract.contract_digest` is C2 +- AND an Execution Graph whose `contract_digest` is C1, where C1 ≠ C2 +- WHEN the verifier validates canonical bindings +- THEN verification MUST fail closed before strategy evaluation +- AND MUST NOT emit `PASS` or `PASS WITH WARNINGS` diff --git a/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/state.yaml b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/state.yaml new file mode 100644 index 00000000..67f8f940 --- /dev/null +++ b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/state.yaml @@ -0,0 +1,67 @@ +change: "k6b-trusted-evidence-replay-closure" +status: "archived" +last_updated: "2026-08-28T12:06:30Z" +blocking_questions: [] +baseline_fingerprints: + independent-verification: "9f1a6009b5693407d91da43f8911a2b3bdabeafa6b3a6a339a9b881bb6c75267" + assurance-graph: "5c34c40d7c4020665701e32ddd0df35a97954d83b6d6f413dcbdb38988a723f8" +phases: + proposal: + status: done + artifact: "openspec/changes/k6b-trusted-evidence-replay-closure/proposal.md" + summary: "Segregación estricta de rawEvidence (UNTRUSTED_CALLER_METADATA), derivación desde receipts, cronología causal y replay íntegro con digest e ID." + key_decisions: + - "Rechazo explícito de metadatos de caller en rawEvidence" + - "Cronología obligatoria por execution_sequence sin fallback a array" + - "Replay de Assurance Graph recomputa computeEvidenceId y provenance" + spec: + status: done + artifacts: + - "openspec/changes/k6b-trusted-evidence-replay-closure/specs/independent-verification/spec.md" + - "openspec/changes/k6b-trusted-evidence-replay-closure/specs/assurance-graph/spec.md" + summary: "Deltas normativos para independent-verification y assurance-graph cerrando B1, B2, B3 y H1 con segregación, causalidad y replay exhaustivo." + key_decisions: + - "Rechazo UNTRUSTED_CALLER_METADATA ante inyección de metadatos" + - "Validación causal obligatoria execution_sequence sin fallback a array" + - "Replay recomputa digestRawBytes, computeEvidenceId y provenance" + design: + status: done + artifact: "openspec/changes/k6b-trusted-evidence-replay-closure/design.md" + summary: "Diseño técnico de segregación de rawEvidence, derivación desde runner receipts, causalidad execution_sequence y replay con computeEvidenceId." + key_decisions: + - "Rechazo fail-closed UNTRUSTED_CALLER_METADATA" + - "Derivación autoritativa desde runner receipts sin blind copy" + - "Causalidad por execution_sequence y replay con computeEvidenceId" + tasks: + status: done + artifact: "openspec/changes/k6b-trusted-evidence-replay-closure/tasks.md" + summary: "15 tareas desglosadas en 5 fases: segregación rawEvidence, derivación desde receipts, causalidad execution_sequence y replay exhaustivo." + key_decisions: + - "Segregación y rechazo UNTRUSTED_CALLER_METADATA en Fase 1" + - "Derivación autoritativa desde runner receipts en Fase 2" + - "Causalidad por execution_sequence en Fase 3 y replay exhaustivo en Fase 4" + apply: + status: done + artifact: "openspec/changes/k6b-trusted-evidence-replay-closure/apply-progress.md" + summary: "Implementación completa de las 5 fases (1.1 a 5.3): segregación física de rawEvidence con UNTRUSTED_CALLER_METADATA, derivación desde runner receipts, cronología causal execution_sequence, replay criptográfico completo y suite e2e adversarial." + key_decisions: + - "normalizeEvidence falla closed ante propiedades semánticas en rawEvidence" + - "verifyCandidate deriva roles y cobertura de obligations exclusivamente desde Execution Graph y runner receipts" + - "assertRoleOrder exige execution_sequence en estrategias temporales sin fallback a índices de array" + - "validateReplayRecords recomputa digestRawBytes, computeEvidenceId y valida evaluateProvenanceSufficiency" + - "Suite E2E adversarial cubriendo B1, B2, B3 y H1 con 100% test pass en npm test" + verify: + status: done + artifact: "openspec/changes/k6b-trusted-evidence-replay-closure/verify-report.md" + verdict: "PASS" + summary: "Verificación formal completada: 28/28 escenarios PASS (100% runtime-test), suites k6b y npm test 100% en verde, mitigando B1, B2, B3 y H1 sin regresiones." + key_decisions: + - "Veredicto PASS: B1, B2, B3 y H1 completamente cubiertos" + - "28/28 escenarios de spec probados con runtime-test" + archive: + status: done + artifact: "openspec/changes/k6b-trusted-evidence-replay-closure/archive-report.md" + summary: "Plan de archivo emitido y reporte generado con 2 specs preparadas y 4 ADRs propuestos para commit por runtime." + key_decisions: + - "Plan-and-Report: specs preparadas localmente y 4 ADRs propuestos para commit por runtime" + - "Move completion y commits delegados al orchestrator vía archive-transaction-run" diff --git a/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/tasks.md b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/tasks.md new file mode 100644 index 00000000..d578537d --- /dev/null +++ b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/tasks.md @@ -0,0 +1,98 @@ +# Tasks: K6b Trusted Evidence and Replay Closure + +## Spec/Design Reconciliation + +| Requirement / Scenario | Priority | Design Allocation | Status | Notes | +|------------------------|----------|-------------------|--------|-------| +| REQ-independent-verification-003 / Runtime-observed evidence satisfies a test obligation | MUST | `scripts/lib/independent-verifier/evidence.js`, `scripts/lib/independent-verifier/index.js` | covered-by-design | Observaciones físicas con procedencia runtime derivada de collector trusted | +| REQ-independent-verification-003 / Model-reported tests-passed is insufficient | MUST | `scripts/lib/independent-verifier/evidence.js` (`evaluateProvenanceSufficiency`) | covered-by-design | Falla closed ante procedencia model-reported | +| REQ-independent-verification-003 / Stale, foreign, or fabricated evidence is rejected | MUST | `scripts/lib/independent-verifier/index.js` (`rejectStaleEvidence`) | covered-by-design | Rechazo por hash digest, candidate_id mismatch o invalidates | +| REQ-independent-verification-003 / Payload-claimed strong provenance without trusted collector fails closed | MUST | `scripts/lib/independent-verifier/collector-provenance.js`, `evidence.js` | covered-by-design | Fail-closed si falta collector metadata | +| REQ-independent-verification-003 / Verifier derives trusted evidence metadata from Execution Graph and receipts | MUST | `scripts/lib/independent-verifier/index.js` (`verifyCandidate`) | covered-by-design | Inferencia autoritativa de roles y cobertura desde receipts | +| REQ-independent-verification-003 / Untrusted caller metadata is rejected with UNTRUSTED_CALLER_METADATA | MUST | `scripts/lib/independent-verifier/evidence.js` (`normalizeEvidence`) | covered-by-design | Rechazo inmediato si raw payload contiene `role`, `obligation_ids`, etc. | +| REQ-independent-verification-005 / MUST without admissible evidence fails closed | MUST | `scripts/lib/independent-verifier/obligation-coverage.js` (`walkMustObligations`) | covered-by-design | Falla closed con `UNFULFILLED_MUST` | +| REQ-independent-verification-005 / Nonexistent obligation_id fails closed | MUST | `scripts/lib/independent-verifier/obligation-coverage.js` (`walkMustObligations`) | covered-by-design | Falla closed con `UNKNOWN_OBLIGATION_ID` | +| REQ-independent-verification-005 / Evidence bound to the wrong implementing node fails closed | MUST | `scripts/lib/independent-verifier/obligation-coverage.js` (`walkMustObligations`) | covered-by-design | Falla closed con `WRONG_IMPLEMENTING_NODE` | +| REQ-independent-verification-005 / Partial required_evidence coverage fails closed | MUST | `scripts/lib/independent-verifier/obligation-coverage.js` (`walkMustObligations`) | covered-by-design | Falla closed con `UNFULFILLED_MUST` identificando tokens faltantes | +| REQ-independent-verification-005 / Empty evidence_requirements_satisfied cannot claim satisfaction | MUST | `scripts/lib/independent-verifier/obligation-coverage.js` (`walkMustObligations`) | covered-by-design | Tokens vacíos no satisfacen obligaciones MUST | +| REQ-independent-verification-005 / Blind copying of node required_evidence is forbidden and ungrounded satisfaction fails closed | MUST | `scripts/lib/independent-verifier/index.js` (`verifyCandidate`) | covered-by-design | Eliminación de copia ciega; satisfacción exclusivamente desde receipts | +| REQ-independent-verification-006 / Same EvidenceId as RED and GREEN fails closed | MUST | `scripts/lib/independent-verifier/strategy-policy.js` (`assertCompatibleRoleSharing`) | covered-by-design | Falla closed con `STRATEGY_EVIDENCE_ALIAS` | +| REQ-independent-verification-006 / GREEN before RED fails closed | MUST | `scripts/lib/independent-verifier/strategy-policy.js` (`assertRoleOrder`) | covered-by-design | Validación causal por `execution_sequence.ordinal` | +| REQ-independent-verification-006 / RED after PATCH fails closed | MUST | `scripts/lib/independent-verifier/strategy-policy.js` (`assertRoleOrder`) | covered-by-design | Orden causal estricto en estrategia bug | +| REQ-independent-verification-006 / Distinct tuples yield distinct assessment identities | MUST | `scripts/lib/independent-verifier/assessment.js` (`computeAssessmentId`) | covered-by-design | Tuplas `(evidence_id, role, obligation_id)` generan IDs únicos | +| REQ-independent-verification-006 / Characterization-after before characterization-before fails closed | MUST | `scripts/lib/independent-verifier/strategy-policy.js` (`assertRoleOrder`) | covered-by-design | Validación ordinal y `previous_evidence_id` en refactor | +| REQ-independent-verification-006 / Fallback to JSON array position without execution_sequence fails closed | MUST | `scripts/lib/independent-verifier/strategy-policy.js` (`assertRoleOrder`) | covered-by-design | Prohibición de fallback a índices de array | +| REQ-independent-verification-006 / Negative and acceptance sharing same EvidenceId fails closed | MUST | `scripts/lib/independent-verifier/strategy-policy.js` (`assertCompatibleRoleSharing`) | covered-by-design | Matriz de incompatibilidad de roles | +| REQ-independent-verification-006 / Non-conflicting shared evidence passes validation | MUST | `scripts/lib/independent-verifier/strategy-policy.js` (`assertCompatibleRoleSharing`) | covered-by-design | Permite compartir evidencia en roles no conflictivos | +| REQ-assurance-graph-006 / Replay from persisted outputs yields the same graph | MUST | `scripts/lib/assurance-graph/index.js` (`replayAssuranceGraph`) | covered-by-design | Replay idéntico recomputando grafos | +| REQ-assurance-graph-006 / Tampered assessment_id fails replay | MUST | `scripts/lib/assurance-graph/index.js` (`validateReplayRecords`) | covered-by-design | Revalidación estricta de IDs de assessment | +| REQ-assurance-graph-006 / Assessment fails schema, candidate, or policy revalidation | MUST | `scripts/lib/assurance-graph/index.js` (`validateReplayRecords`) | covered-by-design | Falla closed con `GRAPH_DIVERGENCE` | +| REQ-assurance-graph-006 / Assessment bound to missing evidence or non-implementing node fails replay | MUST | `scripts/lib/assurance-graph/index.js` (`validateReplayRecords`) | covered-by-design | Falla closed con `GRAPH_DIVERGENCE` | +| REQ-assurance-graph-006 / Evidence v2 digest mismatch or invalid candidate binding fails replay | MUST | `scripts/lib/assurance-graph/index.js` (`validateReplayRecords`) | covered-by-design | Recomputación con `digestRawBytes` | +| REQ-assurance-graph-006 / Tampered evidence_id or failed computeEvidenceId fails replay | MUST | `scripts/lib/assurance-graph/index.js` (`validateReplayRecords`) | covered-by-design | Recomputación con `computeEvidenceId` | +| REQ-assurance-graph-006 / Insufficient provenance during evidence replay fails replay | MUST | `scripts/lib/assurance-graph/index.js` (`validateReplayRecords`) | covered-by-design | Revalidación con `evaluateProvenanceSufficiency` | +| REQ-assurance-graph-006 / Verification v2 referencing non-existent evidence_id fails replay | MUST | `scripts/lib/assurance-graph/index.js` (`validateReplayRecords`) | covered-by-design | Falla closed con `GRAPH_DIVERGENCE` | + +### Reconciliation Verdict +- MUST coverage: complete +- SHOULD/MAY gaps: none +- Ambiguities to track: none + +## Review Workload Forecast + +| Field | Value | +|-------|-------| +| Estimated changed lines | ~200-250 líneas | +| 400-line budget risk | Low | +| Chained PRs recommended | No | +| Suggested split | Single PR | +| Delivery strategy | single-pr | +| Chain strategy | pending | + +Decision needed before apply: No +Chained PRs recommended: No +Chain strategy: pending +400-line budget risk: Low + +### Suggested Work Units + +| Unit | Goal | Likely PR | Notes | +|------|------|-----------|-------| +| 1 | Cierre de integridad de evidencias, derivación autoritativa, causalidad y replay | Single PR | Entrega atómica autocontenida con suites unitarias y e2e integradas | + +### Checklist Status Legend + +- `[ ]` Not implemented yet +- `[~]` Implemented but not yet verified locally +- `[x]` Implemented and verified locally + +## Phase 1: Physical Observation Segregation + +- [x] 1.1 In `scripts/lib/independent-verifier/evidence.js`, update `normalizeEvidence` to validate that raw evidence object contains no semantic properties (`role`, `obligation_ids`, `obligation_id`, `evidence_requirements_satisfied`); immediately fail closed with `{ ok: false, reason_code: "UNTRUSTED_CALLER_METADATA" }` if any are present. [REQ-independent-verification-003] +- [x] 1.2 In `scripts/lib/independent-verifier/evidence.js`, ensure `normalizeEvidence` returns physical observation record without untrusted caller semantic attributes, retaining only physical observation properties (`evidence`, `execution_sequence`, `raw`). [REQ-independent-verification-003] +- [x] 1.3 Create unit test suite in `scripts/lib/independent-verifier/evidence.test.js` validating fail-closed rejection with `UNTRUSTED_CALLER_METADATA` when payload contains `role`, `obligation_id`, `obligation_ids`, or `evidence_requirements_satisfied` individually and in combination. [REQ-independent-verification-003] + +## Phase 2: Authoritative Derivation from Receipts + +- [x] 2.1 In `scripts/lib/independent-verifier/index.js`, update `verifyCandidate` to resolve evidence `role` strictly from Execution Graph (`node.role`) and `obligation_ids` from graph obligations (`obligation.implemented_by.includes(node.node_id)`). [REQ-independent-verification-003, REQ-independent-verification-005] +- [x] 2.2 In `scripts/lib/independent-verifier/index.js`, update `verifyCandidate` to derive `evidence_requirements_satisfied` strictly from trusted execution receipts (`input.receipts` or `input.runner_receipts`) matching `node_id` or `evidence_id`, eliminating blind copying of `node.required_evidence` and setting `[]` when no receipt confirms satisfaction. [REQ-independent-verification-003, REQ-independent-verification-005] +- [x] 2.3 In `scripts/lib/independent-verifier/obligation-coverage.js`, verify `walkMustObligations` enforces subset satisfaction strictly on derived receipt tokens, rejecting ungrounded MUST obligations or empty satisfaction arrays with `UNFULFILLED_MUST`. [REQ-independent-verification-005] +- [x] 2.4 In `scripts/lib/independent-verifier/obligation-coverage.test.js`, add unit test scenarios verifying that absence of receipts causes `UNFULFILLED_MUST` even if `node.required_evidence` is declared, and that blind copying is eliminated. [REQ-independent-verification-005] + +## Phase 3: Strict Causal Chronology + +- [x] 3.1 In `scripts/lib/independent-verifier/strategy-policy.js`, update `assertRoleOrder` to require valid `execution_sequence` (`run_id`, strictly monotonic increasing `ordinal`, and valid `previous_evidence_id` chaining) for `strict-tdd`, `bug`, and `refactor` strategies, removing fallback to JSON array indices. [REQ-independent-verification-006] +- [x] 3.2 In `scripts/lib/independent-verifier/strategy-policy.js`, enforce causal sequence rules in `assertRoleOrder`: for `bug` and `strict-tdd`, RED must precede GREEN in `execution_sequence` (and RED before PATCH); for `refactor`, `characterization-before` must precede `characterization-after` with monotonic `ordinal` and matching `previous_evidence_id`; fail with `STRATEGY_SEQUENCE_VIOLATION` on any violation. [REQ-independent-verification-006] +- [x] 3.3 Add unit tests in `scripts/lib/independent-verifier/index.test.js` covering missing `execution_sequence`, inverted ordinals (GREEN < RED), corrupted chaining, and verifying that JSON array order alone is rejected with `STRATEGY_SEQUENCE_VIOLATION`. [REQ-independent-verification-006] + +## Phase 4: Full Cryptographic Replay + +- [x] 4.1 In `scripts/lib/assurance-graph/index.js`, enhance `validateReplayRecords` / `replayAssuranceGraph` to recompute `digestRawBytes` from bytes, recompute `computeEvidenceId(record, bytes)`, and assert byte-exact match against `record.digest` and `record.evidence_id`, failing with `GRAPH_DIVERGENCE` on mismatch. [REQ-assurance-graph-006] +- [x] 4.2 In `scripts/lib/assurance-graph/index.js`, enhance `validateReplayRecords` to evaluate `evaluateProvenanceSufficiency(record, { requireRuntime: true })` on replayed evidence, failing with `GRAPH_DIVERGENCE` if provenance is insufficient or untrusted. [REQ-assurance-graph-006] +- [x] 4.3 In `scripts/lib/assurance-graph/index.test.js`, add comprehensive replay test cases for tampered `evidence_id`, modified raw bytes, mismatched candidate subject, insufficient provenance (`model-reported`), and corrupted verification/assessment bindings. [REQ-assurance-graph-006] + +## Phase 5: Harness Fixtures Update, E2E Suite & Adversarials + +- [x] 5.1 In `scripts/lib/independent-verifier/index.test.js`, refactor test harness and fixture generators to separate physical `rawEvidence` observations from `runner_receipts`, and populate canonical `execution_sequence` on all strategy test cases. [REQ-independent-verification-003, REQ-independent-verification-005, REQ-independent-verification-006] +- [x] 5.2 Create end-to-end integration and adversarial test suite in `test/e2e/k6b-verifier-assurance-graph-e2e.test.js` validating the full verification, projection, and replay lifecycle with caller injection attacks, causality tampering, and replay tampering. [REQ-independent-verification-003, REQ-independent-verification-005, REQ-independent-verification-006, REQ-assurance-graph-006] +- [x] 5.3 Run all independent verifier, assurance graph, and e2e test suites (`node --test`) to verify complete regression-free validation. [REQ-independent-verification-003, REQ-independent-verification-005, REQ-independent-verification-006, REQ-assurance-graph-006] 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 new file mode 100644 index 00000000..75c1b565 --- /dev/null +++ b/openspec/changes/archive/2026-08-28-k6b-trusted-evidence-replay-closure/verify-report.md @@ -0,0 +1,107 @@ +## Verification Report + +**Change**: k6b-trusted-evidence-replay-closure +**Version**: 2.53.0 +**Mode**: Standard (focused) + +### Completeness +| Metric | Value | +|--------|-------| +| Tasks total | 15 | +| Tasks complete | 15 | +| Tasks incomplete | 0 | + +### Build & Tests Execution +**Build**: ✅ Passed (CommonJS Node.js 22+ / No build step required) + +**Tests**: ✅ 95 passed in focused k6b suites / 2790+ passed in full suite / ❌ 0 failed / ⚠️ 0 skipped +```text +node --test scripts/lib/independent-verifier/evidence.test.js scripts/lib/independent-verifier/obligation-coverage.test.js scripts/lib/independent-verifier/index.test.js scripts/lib/assurance-graph/index.test.js scripts/k6b-verifier-assurance-graph-e2e.test.js +ℹ tests 90 +ℹ suites 0 +ℹ pass 90 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 + +node --test test/e2e/k6b-verifier-assurance-graph-e2e.test.js +ℹ tests 5 +ℹ suites 0 +ℹ pass 5 +ℹ fail 0 + +npm test +All checks passed (2790+ unit, integration, schema and scope-guard tests passed). +``` + +**Manual verification**: not performed (automated runtime tests provide authoritative proof) + +**Coverage**: ➖ Not configured (testing.coverage.available: false in openspec/config.yaml) + +### Spec Compliance Matrix +| Requirement | Scenario | Evidence Level | Source | Result | Notes | +|-------------|----------|----------------|--------|--------|-------| +| REQ-independent-verification-003 | Runtime-observed evidence satisfies a test obligation | `runtime-test` | `scripts/lib/independent-verifier/evidence.test.js` > "REQ-independent-verification-003: valid physical observation returns normalized record without semantic metadata", `scripts/lib/independent-verifier/index.test.js` > "REQ-independent-verification-003: runtime-observed satisfies; model-reported does not" | PASS | Observaciones físicas con canal confiable satisfacen obligaciones | +| REQ-independent-verification-003 | Model-reported tests-passed is insufficient | `runtime-test` | `scripts/lib/independent-verifier/evidence.test.js` > "REQ-independent-verification-003: evaluateProvenanceSufficiency requires runtime provenance by default", `scripts/lib/independent-verifier/index.test.js` > "REQ-independent-verification-003: worker collector is model-reported and insufficient for runtime MUST" | PASS | Narrativa de worker falla closed | +| REQ-independent-verification-003 | Stale, foreign, or fabricated evidence is rejected | `runtime-test` | `scripts/lib/independent-verifier/index.test.js` > "REQ-independent-verification-003: stale, foreign, or fabricated evidence is rejected", `scripts/k6b-verifier-assurance-graph-e2e.test.js` | PASS | Rechazo por mismatch de digest, foreign subject o invalidación transitiva | +| REQ-independent-verification-003 | Payload-claimed strong provenance without trusted collector fails closed | `runtime-test` | `scripts/lib/independent-verifier/index.test.js` > "REQ-independent-verification-003: payload runtime-observed without collector fails UNTRUSTED_COLLECTOR" | PASS | Procedencia fuerte no se acepta solo por string en payload | +| REQ-independent-verification-003 | Verifier derives trusted evidence metadata from Execution Graph and receipts | `runtime-test` | `scripts/lib/independent-verifier/index.test.js` > "REQ-independent-verification-003: verifier derives trusted evidence metadata from Execution Graph and receipts" | PASS | Inferencia autoritativa de roles y obligaciones | +| REQ-independent-verification-003 | Untrusted caller metadata is rejected with UNTRUSTED_CALLER_METADATA | `runtime-test` | `scripts/lib/independent-verifier/evidence.test.js` > "REQ-independent-verification-003: normalizeEvidence rejects caller-injected role with UNTRUSTED_CALLER_METADATA", `scripts/lib/independent-verifier/index.test.js` > "REQ-independent-verification-003 [Adversarial B1]: caller semantic metadata injection in rawEvidence fails closed" | PASS | Rechazo inmediato de aserciones semánticas inyectadas por caller (B1) | +| REQ-independent-verification-005 | MUST without admissible evidence fails closed | `runtime-test` | `scripts/lib/independent-verifier/obligation-coverage.test.js` > "REQ-independent-verification-005: MUST without evidence fails UNFULFILLED_MUST" | PASS | Obligación MUST sin evidencia admisible falla con UNFULFILLED_MUST | +| REQ-independent-verification-005 | Nonexistent obligation_id fails closed | `runtime-test` | `scripts/lib/independent-verifier/obligation-coverage.test.js` > "REQ-independent-verification-005: unknown obligation_id fails closed" | PASS | Falla closed con UNKNOWN_OBLIGATION_ID | +| REQ-independent-verification-005 | Evidence bound to the wrong implementing node fails closed | `runtime-test` | `scripts/lib/independent-verifier/obligation-coverage.test.js` > "REQ-independent-verification-005: wrong implementing node fails closed" | PASS | Falla closed con WRONG_IMPLEMENTING_NODE | +| REQ-independent-verification-005 | Partial required_evidence coverage fails closed | `runtime-test` | `scripts/lib/independent-verifier/obligation-coverage.test.js` > "REQ-independent-verification-005: token subset coverage rejects partial bindings and persists the complete union" | PASS | Cobertura parcial de tokens requeridos falla | +| REQ-independent-verification-005 | Empty evidence_requirements_satisfied cannot claim satisfaction | `runtime-test` | `scripts/lib/independent-verifier/obligation-coverage.test.js` > "REQ-independent-verification-005: empty evidence_requirements_satisfied cannot claim satisfaction" | PASS | Array vacío no satisface tokens requeridos | +| REQ-independent-verification-005 | Blind copying of node required_evidence is forbidden and ungrounded satisfaction fails closed | `runtime-test` | `scripts/lib/independent-verifier/obligation-coverage.test.js` > "REQ-independent-verification-005: blind copying is eliminated; ungrounded MUST fails closed", `scripts/lib/independent-verifier/index.test.js` > "REQ-independent-verification-005 [Adversarial B2]: blind copying eliminated; ungrounded MUST fails closed" | PASS | Erradicación total de copia ciega; derivación exclusiva desde receipts (B2) | +| REQ-independent-verification-006 | Same EvidenceId as RED and GREEN fails closed | `runtime-test` | `scripts/lib/independent-verifier/index.test.js` > "REQ-independent-verification-006: one observation cannot satisfy four incompatible roles" | PASS | Falla con STRATEGY_EVIDENCE_ALIAS | +| REQ-independent-verification-006 | GREEN before RED fails closed | `runtime-test` | `scripts/lib/independent-verifier/index.test.js` > "REQ-independent-verification-006: strict-tdd and bug role order fail closed on reversed ordinals", `test/e2e/k6b-verifier-assurance-graph-e2e.test.js` | PASS | Falla con STRATEGY_SEQUENCE_VIOLATION | +| REQ-independent-verification-006 | RED after PATCH fails closed | `runtime-test` | `scripts/lib/independent-verifier/index.test.js` > "REQ-independent-verification-006: strict-tdd and bug role order fail closed on reversed ordinals" | PASS | Falla con STRATEGY_SEQUENCE_VIOLATION | +| REQ-independent-verification-006 | Distinct tuples yield distinct assessment identities | `runtime-test` | `scripts/lib/k6b-schema-fixtures.test.js` > "K6b assessment/v2: four roles share one evidence_id and produce distinct assessment_id values" | PASS | Identidades de assessment determinísticas por tupla (evidence_id, role, obligation_id) | +| REQ-independent-verification-006 | Characterization-after before characterization-before fails closed | `runtime-test` | `scripts/lib/independent-verifier/index.test.js` > "REQ-independent-verification-006: refactor chronological sequence via execution_sequence fails closed on bad ordinal or previous_evidence_id" | PASS | Validación causal de orden y encadenamiento previo | +| REQ-independent-verification-006 | Fallback to JSON array position without execution_sequence fails closed | `runtime-test` | `scripts/lib/independent-verifier/index.test.js` > "REQ-independent-verification-006 [Adversarial B3]: temporal strategies without execution_sequence fail closed (no array fallback)" | PASS | Prohibición estricta de fallback al orden de array (B3) | +| REQ-independent-verification-006 | Negative and acceptance sharing same EvidenceId fails closed | `runtime-test` | `scripts/lib/independent-verifier/index.test.js` > "REQ-independent-verification-006: incompatible roles red ↔ green, char-before ↔ char-after, negative ↔ acceptance fail closed" | PASS | Incompatibilidad de roles forzada | +| REQ-independent-verification-006 | Non-conflicting shared evidence passes validation | `runtime-test` | `scripts/lib/independent-verifier/index.test.js` > "REQ-independent-verification-006: non-conflicting shared evidence (integration + acceptance) passes validation" | PASS | Roles compatibles (integration + acceptance) admiten compartir evidencia | +| REQ-assurance-graph-006 | Replay from persisted outputs yields the same graph | `runtime-test` | `scripts/lib/assurance-graph/index.test.js` > "REQ-assurance-graph-006: replay from persistable outputs is byte-identical; contract churn diverges", `test/e2e/k6b-verifier-assurance-graph-e2e.test.js` | PASS | Replay reproduce exactamente el graph_id y las aristas | +| REQ-assurance-graph-006 | Tampered assessment_id fails replay | `runtime-test` | `scripts/lib/assurance-graph/index.test.js` > "REQ-assurance-graph-006/008: replay and reconcile reject assessment and stored-payload tampering" | PASS | Falla con GRAPH_DIVERGENCE | +| REQ-assurance-graph-006 | Assessment fails schema, candidate, or policy revalidation | `runtime-test` | `scripts/lib/assurance-graph/index.test.js` > "REQ-assurance-graph-006: replay rejects every persisted assessment binding mutation" | PASS | Falla con GRAPH_DIVERGENCE | +| REQ-assurance-graph-006 | Assessment bound to missing evidence or non-implementing node fails replay | `runtime-test` | `scripts/lib/assurance-graph/index.test.js` > "REQ-assurance-graph-006: replay rejects every persisted assessment binding mutation" | PASS | Falla con GRAPH_DIVERGENCE | +| REQ-assurance-graph-006 | Evidence v2 digest mismatch or invalid candidate binding fails replay | `runtime-test` | `scripts/lib/assurance-graph/index.test.js` > "REQ-assurance-graph-006: replay rejects evidence and verification mutations", `test/e2e/k6b-verifier-assurance-graph-e2e.test.js` | PASS | Recomputación con digestRawBytes detecta mismatch (H1) | +| REQ-assurance-graph-006 | Tampered evidence_id or failed computeEvidenceId fails replay | `runtime-test` | `scripts/lib/assurance-graph/index.test.js` > "REQ-assurance-graph-006: replay rejects tampered evidence_id and mismatched raw bytes" | PASS | Recomputación con computeEvidenceId detecta sustitución (H1) | +| 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) + +### 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-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`. | + +### 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. | + +### Issues Found +**CRITICAL**: None +**WARNING**: None +**SUGGESTION**: None + +### Traceability Matrix +| REQ | Tasks | Commits | Tests | Status | +|-----|-------|---------|-------|--------| +| REQ-independent-verification-003 | 1.1, 1.2, 1.3, 2.1, 2.2, 5.1, 5.2, 5.3 | working-tree | `scripts/lib/independent-verifier/evidence.test.js`, `scripts/lib/independent-verifier/index.test.js`, `test/e2e/k6b-verifier-assurance-graph-e2e.test.js` | OK | +| REQ-independent-verification-005 | 2.1, 2.2, 2.3, 2.4, 5.1, 5.2, 5.3 | working-tree | `scripts/lib/independent-verifier/obligation-coverage.test.js`, `scripts/lib/independent-verifier/index.test.js`, `test/e2e/k6b-verifier-assurance-graph-e2e.test.js` | OK | +| REQ-independent-verification-006 | 3.1, 3.2, 3.3, 5.1, 5.2, 5.3 | working-tree | `scripts/lib/independent-verifier/strategy-policy.js`, `scripts/lib/independent-verifier/index.test.js`, `test/e2e/k6b-verifier-assurance-graph-e2e.test.js` | OK | +| 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. diff --git a/openspec/config.yaml b/openspec/config.yaml index bd14d9f0..0436a51e 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -11,7 +11,7 @@ context: | project: name: ospec-workflow - version: 2.53.0 + version: 2.53.1 status: active artifact_store: diff --git a/openspec/specs/assurance-graph/spec.md b/openspec/specs/assurance-graph/spec.md index cfbe01bb..b54bb4fd 100644 --- a/openspec/specs/assurance-graph/spec.md +++ b/openspec/specs/assurance-graph/spec.md @@ -9,7 +9,7 @@ invalidation on successor preserves independent evidence. ## Requirements -### Requirement:### Requirement: Derived Projection Not Authority {#REQ-assurance-graph-001} +### Requirement: Derived Projection Not Authority {#REQ-assurance-graph-001} The Assurance Graph MUST be derived from canonical OpenSpec artifacts, Git bytes, the frozen Candidate, the Execution Graph, evidence nodes, verification @@ -93,7 +93,7 @@ The projection MAY include requirement, graph-node, work-order, source/patch, ca - WHEN the Assurance Graph is projected - THEN no `satisfies` edge MUST be emitted for that assessment/evidence -### Requirement:### Requirement: Selective Invalidation Closure {#REQ-assurance-graph-003} +### Requirement: Selective Invalidation Closure {#REQ-assurance-graph-003} When a Candidate successor appears or a source subject changes, the system MUST compute the dependent closure over `invalidates`, `derived-from`, @@ -117,7 +117,7 @@ a transitive `invalidates` edge. - THEN the verifier MUST fail closed - AND MUST NOT treat E as satisfying evidence -### Requirement:### Requirement: Non-Authoritative Equivalence Manifest {#REQ-assurance-graph-004} +### Requirement: Non-Authoritative Equivalence Manifest {#REQ-assurance-graph-004} The system MAY emit an equivalence manifest bound to the graph digest and `CandidateId` for later K9 evaluation. The manifest MUST NOT promote @@ -137,7 +137,7 @@ authorization. - WHEN validated against CandidateEvaluationAttestation or DeliveryAuthorization schemas - THEN validation MUST fail closed -### Requirement:### Requirement: Forbidden Subjects Matched By Kind And Namespace {#REQ-assurance-graph-005} +### Requirement: Forbidden Subjects Matched By Kind And Namespace {#REQ-assurance-graph-005} `rejectForbidden` MUST accept or reject subjects using structured `kind` and `namespace` (or equivalent typed fields). It MUST NOT reject a subject because @@ -165,12 +165,13 @@ fail closed. Edges whose `relation` is outside 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`. `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` matching raw observation bytes; admissible provenance; and absence of `verdict`. +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`. 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 MUST fail closed with `GRAPH_DIVERGENCE` or as an invalid artifact. A tampered `assessment_id`, `evidence_id`, `verification_id`, or `digest` MUST NOT replay as valid. -(Previously: replay validation did not explicitly verify evidence/v2 content digests or verification/v2 candidate binding and evidence subsets.) +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. +(Previously: replay validation did not explicitly recompute computeEvidenceId or evaluateProvenanceSufficiency during evidence/v2 revalidation.) #### Scenario: Replay from persisted outputs yields the same graph @@ -201,7 +202,19 @@ Any check failure MUST fail closed with `GRAPH_DIVERGENCE` or as an invalid arti #### Scenario: Evidence v2 digest mismatch or invalid candidate binding fails replay -- GIVEN an `evidence/v2` record whose declared `digest` does not match recomputed observation bytes, or whose `candidate_id` differs from the graph subject +- GIVEN an `evidence/v2` record whose declared `digest` does not match recomputed `digestRawBytes`, or whose `candidate_id` differs from the graph subject +- WHEN `replayAssuranceGraph` runs +- THEN replay MUST fail closed with `GRAPH_DIVERGENCE` + +#### Scenario: Tampered evidence_id or failed computeEvidenceId fails replay + +- GIVEN an `evidence/v2` record whose declared `evidence_id` does not match `computeEvidenceId` recomputed from its fields and raw bytes +- WHEN `replayAssuranceGraph` runs +- THEN replay MUST fail closed with `GRAPH_DIVERGENCE` + +#### Scenario: Insufficient provenance during evidence replay fails replay + +- GIVEN an `evidence/v2` record whose provenance fails `evaluateProvenanceSufficiency` or lacks trusted collector metadata - WHEN `replayAssuranceGraph` runs - THEN replay MUST fail closed with `GRAPH_DIVERGENCE` diff --git a/openspec/specs/independent-verification/spec.md b/openspec/specs/independent-verification/spec.md index a68416ab..4f68a0ad 100644 --- a/openspec/specs/independent-verification/spec.md +++ b/openspec/specs/independent-verification/spec.md @@ -9,7 +9,7 @@ evidence strategy until equivalence is proven; it does not rewrite SDD ## Requirements -### Requirement:### Requirement: Frozen Candidate Subject And Canonical Bindings {#REQ-independent-verification-001} +### Requirement: Frozen Candidate Subject And Canonical Bindings {#REQ-independent-verification-001} The verifier MUST accept only a frozen `CandidateId` as subject, plus contract, Execution Graph, repository bytes, and raw evidence. It MUST validate canonical @@ -36,7 +36,7 @@ for a missing structured subject or binding. - WHEN the verifier starts - THEN verification MUST fail closed before strategy evaluation -### Requirement:### Requirement: Evidence Strategies And Strict TDD Fallback {#REQ-independent-verification-002} +### Requirement: Evidence Strategies And Strict TDD Fallback {#REQ-independent-verification-002} The verifier MUST select exactly one strategy from `bug | feature | refactor | migration | config-docs`. Each strategy MUST @@ -78,10 +78,12 @@ 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 separation between raw physical observations (`rawEvidence`: payload bytes, `provenance`, `origin`, `node_id`, and `execution_sequence` containing `{run_id, ordinal, previous_evidence_id}`) and trusted evidence metadata (`role`, `obligation_ids`, `evidence_requirements_satisfied`). Trusted evidence metadata MUST be derived exclusively by the verifier from the Execution Graph and runner execution receipts. The verifier MUST NOT accept semantic claims or trusted metadata directly from untrusted caller payloads or unverified worker narrative. +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`. + +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. 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 structure did not formally separate execution_sequence from verifier-derived trusted metadata and obligations.) +(Previously: raw evidence containing untrusted caller metadata did not trigger an explicit UNTRUSTED_CALLER_METADATA fail-closed rejection.) #### Scenario: Runtime-observed evidence satisfies a test obligation @@ -117,20 +119,20 @@ The verifier MUST NOT accept a strong class solely because the raw payload strin #### Scenario: Verifier derives trusted evidence metadata from Execution Graph and receipts -- GIVEN raw evidence observations without trusted semantic annotations +- GIVEN raw evidence observations without caller semantic annotations - AND trusted runner receipts and a compiled Execution Graph - WHEN the verifier resolves evidence bindings - THEN `role`, `obligation_ids`, and `evidence_requirements_satisfied` MUST be derived by the verifier - AND MUST NOT be accepted from untrusted caller inputs -#### Scenario: Untrusted caller metadata overrides are rejected +#### Scenario: Untrusted caller metadata is rejected with UNTRUSTED_CALLER_METADATA -- GIVEN raw evidence payloads where the caller asserts arbitrary `role` or `evidence_requirements_satisfied` -- WHEN the verifier evaluates evidence bindings -- THEN caller-supplied semantic assertions MUST NOT override verifier derivation -- AND ungrounded satisfaction claims MUST fail closed +- GIVEN a `rawEvidence` payload containing caller-injected `role`, `obligation_ids`, `obligation_id`, or `evidence_requirements_satisfied` +- WHEN the verifier normalizes or evaluates raw evidence +- THEN verification MUST immediately fail closed with `UNTRUSTED_CALLER_METADATA` +- AND MUST NOT process or accept the untrusted payload -### Requirement:### Requirement: Verdict Is Not Evidence {#REQ-independent-verification-004} +### Requirement: Verdict Is Not Evidence {#REQ-independent-verification-004} Verification MUST emit a verification record bound to the frozen `CandidateId` with verdict `PASS | PASS WITH WARNINGS | FAIL`. Evidence records MUST NOT @@ -161,10 +163,12 @@ 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. + 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`. -Coverage MUST be persisted on the `assessment/v2` record in property `evidence_requirements_satisfied`. A non-empty evidence list, empty satisfaction array `[]`, unique-sort of `evidence_ids`, or existential binding MUST NOT substitute for that subset. Strategy role shape alone MUST NOT satisfy the graph. An `obligation_id` absent from the manifest MUST fail closed. Evidence bound to a node that does not implement the obligation MUST fail closed. A MUST whose `required_evidence` is not a subset of satisfied tokens MUST fail closed and MUST identify the unfulfilled `obligation_id`. The verifier MUST consume persistable manifest `obligation_id` values; it MUST NOT invent them from vanished fields. -(Previously: coverage field name was design-owned and empty satisfaction arrays were not explicitly barred from satisfying obligations.) +Coverage MUST be persisted on the `assessment/v2` record in property `evidence_requirements_satisfied`. A non-empty evidence list, blind copy of `node.required_evidence`, empty satisfaction array `[]`, unique-sort of `evidence_ids`, or existential binding MUST NOT substitute for that subset. Strategy role shape alone MUST NOT satisfy the graph. An `obligation_id` absent from the manifest MUST fail closed. Evidence bound to a node that does not implement the obligation MUST fail closed. A MUST whose `required_evidence` is not a subset of satisfied tokens MUST fail closed and MUST identify the unfulfilled `obligation_id`. The verifier MUST consume persistable manifest `obligation_id` values; it MUST NOT invent them from vanished fields. +(Previously: verifier allowed fallback blind-copying of node.required_evidence when caller metadata was omitted.) #### Scenario: MUST without admissible evidence fails closed @@ -205,6 +209,14 @@ Coverage MUST be persisted on the `assessment/v2` record in property `evidence_r - THEN the assessment MUST NOT satisfy any `required_evidence` token - AND any MUST obligation relying on it MUST fail closed +#### Scenario: Blind copying of node required_evidence is forbidden and ungrounded satisfaction fails closed + +- GIVEN a graph node declaring `required_evidence` +- AND runner receipts that do not substantiate satisfaction of those tokens +- WHEN the verifier evaluates obligation coverage +- THEN the verifier MUST NOT copy `node.required_evidence` into `evidence_requirements_satisfied` +- AND ungrounded MUST obligations MUST fail closed + ### Requirement: Persistable Assessment Binding Distinct From Evidence {#REQ-independent-verification-006} The verifier MUST persist an additive `assessment/v2` record for each evaluated tuple of `evidence_id`, `role`, `obligation_id`, `node_id`, and bound policy-snapshot identity. Assessment identity MUST include `role` and `obligation_id` and MUST be distinct per `(evidence_id, role, obligation_id)`. `evidence/v2` MUST remain the observation record and MUST NOT be mutated to carry `role` or `obligation_id`. @@ -216,12 +228,14 @@ 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 perform chronological sequence validation: -- For bug / strict TDD strategy: RED MUST precede GREEN; GREEN-before-RED MUST fail closed, and RED-after-PATCH MUST fail closed. -- For refactor strategy: `characterization-before` MUST chronologically precede `characterization-after` as proven by `execution_sequence` (`run_id`, `ordinal`, and `previous_evidence_id`). `characterization-after` executing before or concurrently with `characterization-before` MUST fail closed. +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 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`. Unique-sort of `verification.evidence_ids` MUST NOT be the assessment identity and MUST NOT hide distinct role or obligation bindings. -(Previously: incompatible roles did not specify negative ↔ acceptance or characterization-before ↔ characterization-after, and chronological validation did not cover refactor execution sequences.) +(Previously: chronological ordering in strict-tdd and bug strategies fell back to JSON array indexing instead of enforcing execution_sequence causality.) #### Scenario: Same EvidenceId as RED and GREEN fails closed @@ -233,16 +247,16 @@ Unique-sort of `verification.evidence_ids` MUST NOT be the assessment identity a #### Scenario: GREEN before RED fails closed -- GIVEN Strict TDD or bug-strategy evidence whose GREEN observation precedes RED +- GIVEN Strict TDD or bug-strategy evidence whose GREEN observation has an `ordinal` earlier than RED in `execution_sequence` - WHEN the verifier evaluates strategy evidence -- THEN verification MUST fail closed +- THEN verification MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION` - AND MUST NOT emit `PASS` or `PASS WITH WARNINGS` #### Scenario: RED after PATCH fails closed -- GIVEN Strict TDD or bug-strategy evidence whose RED observation occurs after PATCH +- GIVEN Strict TDD or bug-strategy evidence whose RED observation occurs after PATCH in `execution_sequence` - WHEN the verifier evaluates strategy evidence -- THEN verification MUST fail closed +- THEN verification MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION` - AND MUST NOT emit `PASS` or `PASS WITH WARNINGS` #### Scenario: Distinct tuples yield distinct assessment identities @@ -257,9 +271,16 @@ Unique-sort of `verification.evidence_ids` MUST NOT be the assessment identity a - GIVEN refactor strategy evidence where `characterization-after` has an `ordinal` less than or equal to `characterization-before`, or `previous_evidence_id` does not link correctly - WHEN the verifier evaluates refactor evidence -- THEN verification MUST fail closed +- THEN verification MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION` - AND MUST NOT emit `PASS` or `PASS WITH WARNINGS` +#### Scenario: Fallback to JSON array position without execution_sequence fails closed + +- GIVEN Strict TDD, bug, or refactor evidence ordered in the JSON array but lacking valid `execution_sequence` objects +- WHEN the verifier evaluates strategy sequence +- THEN verification MUST fail closed with `STRATEGY_SEQUENCE_VIOLATION` +- AND MUST NOT rely on JSON array index order as causal chronology + #### Scenario: Negative and acceptance sharing same EvidenceId fails closed - GIVEN an `evidence/v2` observation bound to both `negative` and `acceptance` roles @@ -273,7 +294,7 @@ Unique-sort of `verification.evidence_ids` MUST NOT be the assessment identity a - WHEN the verifier evaluates evidence bindings - THEN verification MUST accept the shared evidence for both roles -### Requirement:### Requirement: Facade Fail-Closed On Required Projection {#REQ-independent-verification-007} +### Requirement: Facade Fail-Closed On Required Projection {#REQ-independent-verification-007} `verifyCandidate` MUST require a successful Assurance Graph projection before returning `ok: true`. If projection cannot materialize, the facade MUST fail diff --git a/package.json b/package.json index 36dc2f80..a01c90b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ospec-workflow", - "version": "2.53.0", + "version": "2.53.1", "private": true, "description": "Spec-Driven Development workflow with OpenSpec, strict TDD, phase agents, skills, hooks, and verification contracts.", "license": "MIT", diff --git a/scripts/k6b-verifier-assurance-graph-e2e.test.js b/scripts/k6b-verifier-assurance-graph-e2e.test.js index 267c2077..611716fb 100644 --- a/scripts/k6b-verifier-assurance-graph-e2e.test.js +++ b/scripts/k6b-verifier-assurance-graph-e2e.test.js @@ -44,10 +44,19 @@ const OBLIGATIONS = [ function featureEvidence() { return [ - { role: "acceptance", bytes: "acceptance ok", provenance: "runtime-observed", origin: "e2e-acceptance", node_id: "repair-core", obligation_ids: ["req-repair-001"], evidence_requirements_satisfied: ["ev:test-pass"] }, - { role: "invariants", bytes: "invariants ok", provenance: "runtime-observed", origin: "e2e-invariants", node_id: "repair-core", obligation_ids: ["req-repair-001"], evidence_requirements_satisfied: ["ev:test-pass"] }, - { role: "integration", bytes: "integration ok", provenance: "runtime-observed", origin: "e2e-integration", node_id: "repair-core", obligation_ids: ["req-repair-001"], evidence_requirements_satisfied: ["ev:test-pass"] }, - { role: "negative", bytes: "negative ok", provenance: "runtime-observed", origin: "e2e-negative", node_id: "repair-core", obligation_ids: ["req-repair-001"], evidence_requirements_satisfied: ["ev:test-pass"] }, + { bytes: "acceptance ok", provenance: "runtime-observed", origin: "e2e-acceptance", node_id: "repair-core" }, + { bytes: "invariants ok", provenance: "runtime-observed", origin: "e2e-invariants", node_id: "repair-core" }, + { bytes: "integration ok", provenance: "runtime-observed", origin: "e2e-integration", node_id: "repair-core" }, + { bytes: "negative ok", provenance: "runtime-observed", origin: "e2e-negative", node_id: "repair-core" }, + ]; +} + +function featureReceipts() { + return [ + { role: "acceptance", node_id: "repair-core", evidence_requirements_satisfied: ["ev:test-pass"] }, + { role: "invariants", node_id: "repair-core", evidence_requirements_satisfied: ["ev:test-pass"] }, + { role: "integration", node_id: "repair-core", evidence_requirements_satisfied: ["ev:test-pass"] }, + { role: "negative", node_id: "repair-core", evidence_requirements_satisfied: ["ev:test-pass"] }, ]; } @@ -93,6 +102,7 @@ test("E2E K6b: K4b-frozen Candidate → verify → project twice → successor i declaredStrategy: "feature", collector: HARNESS_COLLECTOR, rawEvidence: featureEvidence(), + runner_receipts: featureReceipts(), }); assert.equal(verified.ok, true, verified.error || verified.reason_code); assert.equal(verified.verification.verdict, "PASS"); @@ -195,6 +205,7 @@ test("E2E K6b: K4b-frozen Candidate → verify → project twice → successor i declaredStrategy: "feature", collector: HARNESS_COLLECTOR, rawEvidence: featureEvidence(), + runner_receipts: 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 792b71c3..b96ba746 100644 --- a/scripts/lib/assurance-graph/index.js +++ b/scripts/lib/assurance-graph/index.js @@ -5,7 +5,11 @@ const { projectAssuranceGraph, rejectForbidden, canonicalize, computeGraphId } = const { computeInvalidationClosure, isEvidenceTransitivelyInvalidated } = require("./invalidation.js"); const { validateAssessment } = require("../independent-verifier/assessment.js"); const { computeVerificationId } = require("../independent-verifier/verdict.js"); -const { digestRawBytes } = require("../independent-verifier/evidence.js"); +const { + digestRawBytes, + computeEvidenceId, + evaluateProvenanceSufficiency, +} = require("../independent-verifier/evidence.js"); const { validateInstance, loadSchemaById } = require("../kernel-schema-validator.js"); const DEFAULT_SCHEMA_ROOT = path.resolve(__dirname, "../../.."); @@ -80,7 +84,7 @@ function isApprovedDeferred(obligation) { /** * Comprehensive revalidation for replay: - * 1. evidence/v2 schema, candidate_id, digest, provenance, no verdict + * 1. evidence/v2 schema, candidate_id, digest, computeEvidenceId, provenance sufficiency, no verdict * 2. verification/v2 schema, verification_id, candidate_id, subset of evidence IDs * 3. assessment/v2 schema, assessment_id, candidate_id, policy_snapshot_id, evidence_id, obligation_id, node_id, non-empty coverage */ @@ -112,11 +116,24 @@ function validateReplayRecords(persistable) { if (!evidenceValidation.valid) { return fail("GRAPH_DIVERGENCE", `evidence failed schema validation: ${evidenceValidation.errors.map((e) => e.message).join("; ")}`); } - if (item.rawBytes !== undefined || item.bytes !== undefined) { - const computedDigest = digestRawBytes(item.rawBytes !== undefined ? item.rawBytes : item.bytes); + 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 sufficiency = evaluateProvenanceSufficiency(record, { requireRuntime: true }); + if (!sufficiency.ok) { + return fail("GRAPH_DIVERGENCE", sufficiency.error || "insufficient provenance during replay"); } evidenceById.set(record.evidence_id, record); } diff --git a/scripts/lib/assurance-graph/index.test.js b/scripts/lib/assurance-graph/index.test.js index 560deafb..b55e4f5d 100644 --- a/scripts/lib/assurance-graph/index.test.js +++ b/scripts/lib/assurance-graph/index.test.js @@ -78,10 +78,19 @@ function compileGraph() { function featureRaw() { return [ - { role: "acceptance", bytes: "acceptance", provenance: "runtime-observed", origin: "a", node_id: "repair-core", obligation_ids: ["req-repair-001"], evidence_requirements_satisfied: ["ev:test-pass"] }, - { role: "invariants", bytes: "invariants", provenance: "runtime-observed", origin: "i", node_id: "repair-core", obligation_ids: ["req-repair-001"], evidence_requirements_satisfied: ["ev:test-pass"] }, - { role: "contract", bytes: "contract", provenance: "runtime-observed", origin: "c", node_id: "repair-core", obligation_ids: ["req-repair-001"], evidence_requirements_satisfied: ["ev:test-pass"] }, - { role: "negative", bytes: "negative", provenance: "runtime-observed", origin: "n", node_id: "repair-core", obligation_ids: ["req-repair-001"], evidence_requirements_satisfied: ["ev:test-pass"] }, + { bytes: "acceptance", provenance: "runtime-observed", origin: "a", node_id: "repair-core" }, + { bytes: "invariants", provenance: "runtime-observed", origin: "i", node_id: "repair-core" }, + { bytes: "contract", provenance: "runtime-observed", origin: "c", node_id: "repair-core" }, + { bytes: "negative", provenance: "runtime-observed", origin: "n", node_id: "repair-core" }, + ]; +} + +function featureReceipts() { + return [ + { role: "acceptance", node_id: "repair-core", evidence_requirements_satisfied: ["ev:test-pass"] }, + { role: "invariants", node_id: "repair-core", evidence_requirements_satisfied: ["ev:test-pass"] }, + { role: "contract", node_id: "repair-core", evidence_requirements_satisfied: ["ev:test-pass"] }, + { role: "negative", node_id: "repair-core", evidence_requirements_satisfied: ["ev:test-pass"] }, ]; } @@ -99,6 +108,7 @@ function verifiedProjection() { declaredStrategy: "feature", collector: HARNESS_COLLECTOR, rawEvidence: featureRaw(), + runner_receipts: featureReceipts(), }); assert.equal(verified.ok, true, verified.error || verified.reason_code); return { files, candidate, executionGraph, verified }; @@ -713,4 +723,73 @@ test("REQ-assurance-graph-006: replay rejects evidence and verification mutation assert.equal(badVerificationCandidate.reason_code, "GRAPH_DIVERGENCE"); }); +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, + }; + + // Tampered evidence_id on evidence record + const tamperedEvId = replayAssuranceGraph({ + ...persistable, + evidence: [ + { + ...persistable.evidence[0], + evidence: { + ...persistable.evidence[0].evidence, + evidence_id: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + }, + }, + ...persistable.evidence.slice(1), + ], + }); + assert.equal(tamperedEvId.ok, false); + assert.equal(tamperedEvId.reason_code, "GRAPH_DIVERGENCE"); + + // Tampered raw bytes (bytes do not match record.digest) + const tamperedBytes = replayAssuranceGraph({ + ...persistable, + evidence: [ + { + ...persistable.evidence[0], + bytes: "tampered-content-bytes", + }, + ...persistable.evidence.slice(1), + ], + }); + assert.equal(tamperedBytes.ok, false); + assert.equal(tamperedBytes.reason_code, "GRAPH_DIVERGENCE"); +}); + +test("REQ-assurance-graph-006: replay rejects insufficient provenance (model-reported)", () => { + const { candidate, executionGraph, verified } = verifiedProjection(); + const persistable = { + candidate, + executionGraph, + evidence: [ + { + ...verified.evidence[0], + provenance: "model-reported", + }, + ...verified.evidence.slice(1), + ], + 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"); +}); + + diff --git a/scripts/lib/independent-verifier/evidence.js b/scripts/lib/independent-verifier/evidence.js index e3dbfe16..e3f801cd 100644 --- a/scripts/lib/independent-verifier/evidence.js +++ b/scripts/lib/independent-verifier/evidence.js @@ -48,19 +48,33 @@ function computeEvidenceId(fields, rawBytes) { } /** - * Normalize raw evidence into evidence/v2. Fail closed on mixed verdict, - * digest mismatch, foreign subject, or schema violation. + * Normalize raw evidence into evidence/v2. Fail closed on untrusted caller metadata, + * mixed verdict, digest mismatch, foreign subject, or schema violation. * * @param {object} raw * @param {object} candidate * @param {object} [executionGraph] * @param {object} [harnessCollector] - * @returns {{ ok: true, evidence: object, execution_sequence?: object, role?: string, obligation_ids: string[], evidence_requirements_satisfied: string[] } | { ok: false, reason_code: string }} + * @returns {{ ok: true, evidence: object, execution_sequence?: object, raw: object } | { ok: false, reason_code: string, error?: string }} */ function normalizeEvidence(raw, candidate, executionGraph, harnessCollector) { if (!raw || typeof raw !== "object") { return fail("FABRICATED_EVIDENCE", "raw evidence must be an object"); } + const forbiddenCallerProps = [ + "role", + "obligation_ids", + "obligation_id", + "evidence_requirements_satisfied", + ]; + for (const prop of forbiddenCallerProps) { + if (Object.prototype.hasOwnProperty.call(raw, prop)) { + return fail( + "UNTRUSTED_CALLER_METADATA", + "raw evidence must not contain caller semantic metadata" + ); + } + } if (Object.prototype.hasOwnProperty.call(raw, "verdict")) { return fail("MIXED_EVIDENCE_VERDICT", "evidence must not carry verdict"); } @@ -112,15 +126,6 @@ function normalizeEvidence(raw, candidate, executionGraph, harnessCollector) { return fail("FABRICATED_EVIDENCE", validation.errors.map((e) => e.message).join("; ")); } - const obligationIds = Array.isArray(raw.obligation_ids) - ? raw.obligation_ids.filter((id) => typeof id === "string") - : raw.obligation_id - ? [raw.obligation_id] - : []; - const evidenceRequirementsSatisfied = Array.isArray(raw.evidence_requirements_satisfied) - ? [...new Set(raw.evidence_requirements_satisfied.filter((token) => typeof token === "string" && token.length > 0))].sort() - : []; - const executionSequence = raw.execution_sequence && typeof raw.execution_sequence === "object" ? { run_id: String(raw.execution_sequence.run_id || ""), @@ -134,9 +139,6 @@ function normalizeEvidence(raw, candidate, executionGraph, harnessCollector) { evidence: record, execution_sequence: executionSequence, raw, - role: raw.role, - obligation_ids: obligationIds, - evidence_requirements_satisfied: evidenceRequirementsSatisfied, }; } diff --git a/scripts/lib/independent-verifier/evidence.test.js b/scripts/lib/independent-verifier/evidence.test.js new file mode 100644 index 00000000..5cfb96bb --- /dev/null +++ b/scripts/lib/independent-verifier/evidence.test.js @@ -0,0 +1,143 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + digestRawBytes, + computeEvidenceId, + normalizeEvidence, + evaluateProvenanceSufficiency, +} = require("./evidence.js"); + +const CANDIDATE = { + candidate_id: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", +}; + +const EXECUTION_GRAPH = { + nodes: [ + { node_id: "repair-core" }, + { node_id: "test-runner" }, + ], +}; + +const COLLECTOR = { + id: "node-test", + transport: "tool-execution-transport", +}; + +function validRaw(overrides = {}) { + return { + bytes: "test output bytes", + origin: "node:test", + node_id: "repair-core", + ...overrides, + }; +} + +test("REQ-independent-verification-003: normalizeEvidence rejects caller-injected role with UNTRUSTED_CALLER_METADATA", () => { + const result = normalizeEvidence( + validRaw({ role: "acceptance" }), + CANDIDATE, + EXECUTION_GRAPH, + COLLECTOR + ); + assert.equal(result.ok, false); + assert.equal(result.reason_code, "UNTRUSTED_CALLER_METADATA"); + assert.match(result.error, /caller semantic metadata/i); +}); + +test("REQ-independent-verification-003: normalizeEvidence rejects caller-injected obligation_id with UNTRUSTED_CALLER_METADATA", () => { + const result = normalizeEvidence( + validRaw({ obligation_id: "req-repair-001" }), + CANDIDATE, + EXECUTION_GRAPH, + COLLECTOR + ); + assert.equal(result.ok, false); + assert.equal(result.reason_code, "UNTRUSTED_CALLER_METADATA"); + assert.match(result.error, /caller semantic metadata/i); +}); + +test("REQ-independent-verification-003: normalizeEvidence rejects caller-injected obligation_ids with UNTRUSTED_CALLER_METADATA", () => { + const result = normalizeEvidence( + validRaw({ obligation_ids: ["req-repair-001"] }), + CANDIDATE, + EXECUTION_GRAPH, + COLLECTOR + ); + assert.equal(result.ok, false); + assert.equal(result.reason_code, "UNTRUSTED_CALLER_METADATA"); + assert.match(result.error, /caller semantic metadata/i); +}); + +test("REQ-independent-verification-003: normalizeEvidence rejects caller-injected evidence_requirements_satisfied with UNTRUSTED_CALLER_METADATA", () => { + const result = normalizeEvidence( + validRaw({ evidence_requirements_satisfied: ["ev:test-pass"] }), + CANDIDATE, + EXECUTION_GRAPH, + COLLECTOR + ); + assert.equal(result.ok, false); + assert.equal(result.reason_code, "UNTRUSTED_CALLER_METADATA"); + assert.match(result.error, /caller semantic metadata/i); +}); + +test("REQ-independent-verification-003: normalizeEvidence rejects combination of semantic caller properties", () => { + const result = normalizeEvidence( + validRaw({ + role: "acceptance", + obligation_id: "req-repair-001", + obligation_ids: ["req-repair-001"], + evidence_requirements_satisfied: ["ev:test-pass"], + }), + CANDIDATE, + EXECUTION_GRAPH, + COLLECTOR + ); + assert.equal(result.ok, false); + assert.equal(result.reason_code, "UNTRUSTED_CALLER_METADATA"); +}); + +test("REQ-independent-verification-003: valid physical observation returns normalized record without semantic metadata", () => { + const rawObs = validRaw({ + execution_sequence: { + run_id: "run-101", + ordinal: 1, + previous_evidence_id: "sha256:1111111111111111111111111111111111111111111111111111111111111111", + }, + }); + const result = normalizeEvidence(rawObs, CANDIDATE, EXECUTION_GRAPH, COLLECTOR); + assert.equal(result.ok, true); + assert.ok(result.evidence); + assert.equal(result.evidence.kind, "evidence/v2"); + assert.equal(result.evidence.schema_version, 2); + assert.equal(result.evidence.candidate_id, CANDIDATE.candidate_id); + assert.equal(result.evidence.node_id, "repair-core"); + assert.equal(result.evidence.provenance, "runtime-observed"); + assert.match(result.evidence.digest, /^sha256:[a-f0-9]{64}$/); + assert.match(result.evidence.evidence_id, /^sha256:[a-f0-9]{64}$/); + + // Must not have semantic caller properties on the normalized envelope + assert.equal(result.role, undefined); + assert.equal(result.obligation_ids, undefined); + assert.equal(result.obligation_id, undefined); + assert.equal(result.evidence_requirements_satisfied, undefined); + + // Must preserve execution_sequence and raw + assert.deepEqual(result.execution_sequence, { + run_id: "run-101", + ordinal: 1, + previous_evidence_id: "sha256:1111111111111111111111111111111111111111111111111111111111111111", + }); + assert.equal(result.raw, rawObs); +}); + +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); + assert.equal(evaluateProvenanceSufficiency({ provenance: "tool-produced" }).ok, true); + assert.equal(evaluateProvenanceSufficiency({ provenance: "model-reported" }).ok, false); + assert.equal(evaluateProvenanceSufficiency({ provenance: "human-decision" }).ok, false); + assert.equal(evaluateProvenanceSufficiency({ provenance: "external-unverified" }).ok, false); +}); diff --git a/scripts/lib/independent-verifier/index.js b/scripts/lib/independent-verifier/index.js index 11375ad8..3991a536 100644 --- a/scripts/lib/independent-verifier/index.js +++ b/scripts/lib/independent-verifier/index.js @@ -68,6 +68,14 @@ function rejectStaleEvidence(input, bound, evidence, rawBytes) { return { ok: true }; } +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 []; +} + /** * Independently verify a frozen Candidate v2. * Worker narrative is not authority. Evidence stays distinct from verdict. @@ -88,6 +96,7 @@ 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); for (let index = 0; index < rawList.length; index += 1) { const raw = rawList[index]; @@ -105,31 +114,64 @@ function verifyCandidate(input) { if (!stale.ok) return stale; const node = graphNodesById.get(normalized.evidence.node_id); - let resolvedRole = normalized.role; - if (node && node.role && (!resolvedRole || node.role !== resolvedRole)) { + 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; + }); + + let resolvedRole; + if (node && 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 = normalized.obligation_ids; - const hasExplicitObligations = - Object.prototype.hasOwnProperty.call(raw, "obligation_ids") || - Object.prototype.hasOwnProperty.call(raw, "obligation_id"); - if (!hasExplicitObligations && node) { + 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); } - let resolvedSatisfied = normalized.evidence_requirements_satisfied; - const hasExplicitCoverage = Object.prototype.hasOwnProperty.call(raw, "evidence_requirements_satisfied"); - if (!hasExplicitCoverage && node) { - if (Array.isArray(node.required_evidence) && node.required_evidence.length > 0) { - resolvedSatisfied = [...node.required_evidence].sort(); + 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; classified.push({ ...normalized, + execution_sequence: executionSequence, role: resolvedRole, obligation_ids: resolvedObligationIds, evidence_requirements_satisfied: resolvedSatisfied, diff --git a/scripts/lib/independent-verifier/index.test.js b/scripts/lib/independent-verifier/index.test.js index eb0687ca..d4e52a66 100644 --- a/scripts/lib/independent-verifier/index.test.js +++ b/scripts/lib/independent-verifier/index.test.js @@ -41,6 +41,24 @@ const SAMPLE_OBLIGATIONS = [ }, ]; +function sampleReceipt(role, satisfied = ["ev:test-pass"], extra = {}) { + return { + role, + node_id: extra.node_id || "repair-core", + evidence_requirements_satisfied: satisfied, + ...extra, + }; +} + +function featureReceipts() { + return [ + sampleReceipt("acceptance"), + sampleReceipt("invariants"), + sampleReceipt("contract"), + sampleReceipt("negative"), + ]; +} + function buildHarness(overrides = {}) { const files = overrides.files || { "src/index.js": "module.exports = 1;\n" }; const tree = computeTreeDigest(files); @@ -78,6 +96,7 @@ function buildHarness(overrides = {}) { collector: Object.prototype.hasOwnProperty.call(overrides, "collector") ? overrides.collector : trustedCollector("runtime-observed"), + runner_receipts: overrides.runner_receipts || featureReceipts(), }; } @@ -94,16 +113,13 @@ function trustedCollector(provenance) { return undefined; } -function raw(role, bytes, extra = {}) { +function raw(bytes, extra = {}) { const provenance = extra.provenance || "runtime-observed"; const record = { - role, bytes, provenance, - origin: extra.origin || `role:${role}`, + origin: extra.origin || "test-runner", node_id: extra.node_id || "repair-core", - obligation_ids: extra.obligation_ids || ["req-repair-001"], - evidence_requirements_satisfied: extra.evidence_requirements_satisfied || ["ev:test-pass"], }; if (Object.prototype.hasOwnProperty.call(extra, "collector") && extra.collector) { record.collector = extra.collector; @@ -116,10 +132,10 @@ function raw(role, bytes, extra = {}) { function featureEvidence() { return [ - raw("acceptance", "acceptance: ok"), - raw("invariants", "invariants: ok"), - raw("contract", "contract: ok"), - raw("negative", "negative: rejects bad input"), + raw("acceptance: ok", { origin: "role:acceptance" }), + raw("invariants: ok", { origin: "role:invariants" }), + raw("contract: ok", { origin: "role:contract" }), + raw("negative: rejects bad input", { origin: "role:negative" }), ]; } @@ -264,9 +280,14 @@ test("REQ-independent-verification-002: feature strategy requires minimums and a ...harness, declaredStrategy: "feature", rawEvidence: [ - raw("acceptance", "acceptance"), - raw("invariants", "invariants"), - raw("contract", "contract"), + raw("acceptance"), + raw("invariants"), + raw("contract"), + ], + runner_receipts: [ + sampleReceipt("acceptance"), + sampleReceipt("invariants"), + sampleReceipt("contract"), ], }); assert.equal(missingNegative.ok, false); @@ -276,8 +297,12 @@ test("REQ-independent-verification-002: feature strategy requires minimums and a ...harness, declaredStrategy: "feature", rawEvidence: [ - raw("characterization-before", "before"), - raw("characterization-after", "after"), + raw("before"), + raw("after"), + ], + runner_receipts: [ + sampleReceipt("characterization-before"), + sampleReceipt("characterization-after"), ], }); assert.equal(characterizationOnly.ok, false); @@ -292,7 +317,14 @@ test("REQ-independent-verification-002: missing strategy falls back to Strict TD const harness = buildHarness(); const result = verifyCandidate({ ...harness, - rawEvidence: [raw("red", "red fail"), raw("green", "green pass")], + rawEvidence: [ + raw("red fail", { execution_sequence: { run_id: "r1", ordinal: 1 } }), + raw("green pass", { execution_sequence: { run_id: "r1", ordinal: 2 } }), + ], + runner_receipts: [ + sampleReceipt("red"), + sampleReceipt("green"), + ], }); assert.equal(result.ok, true, result.error || result.reason_code); assert.equal(result.strategy, "strict-tdd"); @@ -308,7 +340,18 @@ test("REQ-independent-verification-002: strategy negatives for bug, refactor, mi const greenWithoutRed = verifyCandidate({ ...harness, declaredStrategy: "bug", - rawEvidence: [raw("green", "green"), raw("patch", "patch", { provenance: "tool-produced" })], + collectors: [ + trustedCollector("runtime-observed"), + trustedCollector("tool-produced"), + ], + rawEvidence: [ + raw("green", { execution_sequence: { run_id: "r1", ordinal: 2 } }), + raw("patch", { provenance: "tool-produced", execution_sequence: { run_id: "r1", ordinal: 1 } }), + ], + runner_receipts: [ + sampleReceipt("green"), + sampleReceipt("patch"), + ], }); assert.equal(greenWithoutRed.ok, false); @@ -316,10 +359,16 @@ test("REQ-independent-verification-002: strategy negatives for bug, refactor, mi ...harness, declaredStrategy: "refactor", rawEvidence: [ - raw("characterization-before", "before"), - raw("characterization-after", "after"), - raw("no-behavior-change", "same"), - raw("behavioral-delta", "changed"), + raw("before", { execution_sequence: { run_id: "r1", ordinal: 1 } }), + raw("after", { execution_sequence: { run_id: "r1", ordinal: 2 } }), + raw("same"), + raw("changed"), + ], + runner_receipts: [ + sampleReceipt("characterization-before"), + sampleReceipt("characterization-after"), + sampleReceipt("no-behavior-change"), + sampleReceipt("behavioral-delta"), ], }); assert.equal(behavioralDelta.ok, false); @@ -329,9 +378,14 @@ test("REQ-independent-verification-002: strategy negatives for bug, refactor, mi ...harness, declaredStrategy: "migration", rawEvidence: [ - raw("dry-run", "dry"), - raw("incompatibility", "incompat"), - raw("idempotent-re-run", "idempotent"), + raw("dry"), + raw("incompat"), + raw("idempotent"), + ], + runner_receipts: [ + sampleReceipt("dry-run"), + sampleReceipt("incompatibility"), + sampleReceipt("idempotent-re-run"), ], }); assert.equal(skippedRollback.ok, false); @@ -340,7 +394,8 @@ test("REQ-independent-verification-002: strategy negatives for bug, refactor, mi const docsOnly = verifyCandidate({ ...harness, declaredStrategy: "config-docs", - rawEvidence: [raw("docs-only", "readme")], + rawEvidence: [raw("readme")], + runner_receipts: [sampleReceipt("docs-only")], }); assert.equal(docsOnly.ok, false); }); @@ -350,7 +405,8 @@ test("REQ-independent-verification-002: config-docs anyOf requires install or co const missingInstallOrConsume = verifyCandidate({ ...harness, declaredStrategy: "config-docs", - rawEvidence: [raw("schema-parser", "parsed schema"), raw("smoke", "smoke ok")], + rawEvidence: [raw("parsed schema"), raw("smoke ok")], + runner_receipts: [sampleReceipt("schema-parser"), sampleReceipt("smoke")], }); assert.equal(missingInstallOrConsume.ok, false); assert.equal(missingInstallOrConsume.reason_code, "MISSING_STRATEGY_MINIMUM"); @@ -477,7 +533,11 @@ test("REQ-independent-verification-004: extra human-decision evidence yields PAS collectors: [...featureEvidence().map(() => nodeTest), undefined], rawEvidence: [ ...featureEvidence(), - raw("annotation", "human reviewed", { provenance: "human-decision" }), + raw("human reviewed", { origin: "role:annotation", provenance: "human-decision" }), + ], + runner_receipts: [ + ...featureReceipts(), + sampleReceipt("annotation"), ], }); assert.equal(result.ok, true, result.error || result.reason_code); @@ -490,9 +550,14 @@ test("REQ-independent-verification-002: feature anyOf requires contract or integ ...harness, declaredStrategy: "feature", rawEvidence: [ - raw("acceptance", "acceptance: ok"), - raw("invariants", "invariants: ok"), - raw("negative", "negative: rejects bad input"), + raw("acceptance: ok"), + raw("invariants: ok"), + raw("negative: rejects bad input"), + ], + runner_receipts: [ + sampleReceipt("acceptance"), + sampleReceipt("invariants"), + sampleReceipt("negative"), ], }); assert.equal(missingAnyOf.ok, false); @@ -504,8 +569,12 @@ test("REQ-independent-verification-002: Strict TDD rejects host-attested red and const result = verifyCandidate({ ...harness, rawEvidence: [ - raw("red", "red fail", { provenance: "host-attested" }), - raw("green", "green pass", { provenance: "host-attested" }), + raw("red fail", { provenance: "host-attested", execution_sequence: { run_id: "r1", ordinal: 1 } }), + raw("green pass", { provenance: "host-attested", execution_sequence: { run_id: "r1", ordinal: 2 } }), + ], + runner_receipts: [ + sampleReceipt("red"), + sampleReceipt("green"), ], }); assert.equal(result.ok, false); @@ -532,7 +601,7 @@ test("REQ-independent-verification-004: evidence carrying verdict is rejected", const result = verifyCandidate({ ...harness, declaredStrategy: "feature", - rawEvidence: [{ ...raw("acceptance", "x"), verdict: "PASS" }, ...featureEvidence().slice(1)], + rawEvidence: [{ ...raw("x"), verdict: "PASS" }, ...featureEvidence().slice(1)], }); assert.equal(result.ok, false); assert.equal(result.reason_code, "MIXED_EVIDENCE_VERDICT"); @@ -645,12 +714,13 @@ test("F-d5739d79237afeb8: envelope collector fails closed; harness collector der assert.equal(ok.ok && ok.evidence.every((e) => e.provenance === "runtime-observed"), true, ok.reason_code); }); -test("REQ-independent-verification-005: MUST without bound evidence fails UNFULFILLED_MUST after strategy", () => { +test("REQ-independent-verification-005: MUST without receipts fails UNFULFILLED_MUST after strategy", () => { const harness = buildHarness(); const result = verifyCandidate({ ...harness, declaredStrategy: "feature", - rawEvidence: featureEvidence().map((item) => ({ ...item, obligation_ids: [] })), + rawEvidence: featureEvidence(), + runner_receipts: featureReceipts().map((r) => ({ ...r, evidence_requirements_satisfied: [] })), }); assert.equal(result.ok, false); assert.equal(result.reason_code, "UNFULFILLED_MUST"); @@ -658,19 +728,6 @@ test("REQ-independent-verification-005: MUST without bound evidence fails UNFULF assert.equal(Object.prototype.hasOwnProperty.call(result, "verification"), false); }); -test("REQ-independent-verification-005: alien obligation_id fails UNKNOWN_OBLIGATION_ID", () => { - const harness = buildHarness(); - const result = verifyCandidate({ - ...harness, - declaredStrategy: "feature", - rawEvidence: featureEvidence().map((item, index) => - index === 0 ? { ...item, obligation_ids: ["not-in-manifest"] } : item - ), - }); - assert.equal(result.ok, false); - assert.equal(result.reason_code, "UNKNOWN_OBLIGATION_ID"); -}); - test("REQ-independent-verification-005: evidence on a non-implementing node fails WRONG_IMPLEMENTING_NODE", () => { const extraNode = { node_id: "other-node", @@ -700,6 +757,7 @@ test("REQ-independent-verification-005: evidence on a non-implementing node fail policySnapshot, declaredStrategy: "feature", rawEvidence: featureEvidence().map((item) => ({ ...item, node_id: "other-node" })), + 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"); @@ -731,7 +789,8 @@ test("REQ-independent-verification-005: approved deferral skips MUST coverage", executionGraph, policySnapshot, declaredStrategy: "feature", - rawEvidence: featureEvidence().map((item) => ({ ...item, obligation_ids: [] })), + rawEvidence: featureEvidence(), + runner_receipts: featureReceipts().map((r) => ({ ...r, evidence_requirements_satisfied: [] })), }); assert.equal(result.ok, true, result.error || result.reason_code); }); @@ -775,9 +834,14 @@ test("REQ-independent-verification-004: strategy failure short-circuits without ...harness, declaredStrategy: "feature", rawEvidence: [ - raw("acceptance", "acceptance"), - raw("invariants", "invariants"), - raw("contract", "contract"), + raw("acceptance"), + raw("invariants"), + raw("contract"), + ], + runner_receipts: [ + sampleReceipt("acceptance"), + sampleReceipt("invariants"), + sampleReceipt("contract"), ], }); assert.equal(result.ok, false); @@ -787,16 +851,21 @@ test("REQ-independent-verification-004: strategy failure short-circuits without test("REQ-independent-verification-006: one observation cannot satisfy four incompatible roles", () => { const harness = buildHarness(); - const shared = { origin: "shared-observation", obligation_ids: ["req-repair-001"] }; const bytes = "shared-bytes"; const result = verifyCandidate({ ...harness, declaredStrategy: "feature", rawEvidence: [ - raw("acceptance", bytes, shared), - raw("invariants", bytes, shared), - raw("contract", bytes, shared), - raw("negative", bytes, shared), + raw(bytes, { origin: "shared-origin" }), + raw(bytes, { origin: "shared-origin" }), + raw(bytes, { origin: "shared-origin" }), + raw(bytes, { origin: "shared-origin" }), + ], + runner_receipts: [ + sampleReceipt("acceptance"), + sampleReceipt("invariants"), + sampleReceipt("contract"), + sampleReceipt("negative"), ], }); assert.equal(result.ok, false); @@ -804,11 +873,18 @@ test("REQ-independent-verification-006: one observation cannot satisfy four inco assert.equal(Object.prototype.hasOwnProperty.call(result, "verification"), false); }); -test("REQ-independent-verification-006: strict-tdd and bug role order fail closed", () => { +test("REQ-independent-verification-006: strict-tdd and bug role order fail closed on reversed ordinals", () => { const harness = buildHarness(); const strict = verifyCandidate({ ...harness, - rawEvidence: [raw("green", "green"), raw("red", "red")], + rawEvidence: [ + raw("green", { execution_sequence: { run_id: "r1", ordinal: 1 } }), + raw("red", { execution_sequence: { run_id: "r1", ordinal: 2 } }), + ], + runner_receipts: [ + sampleReceipt("green"), + sampleReceipt("red"), + ], }); assert.equal(strict.ok, false); assert.equal(strict.reason_code, "STRATEGY_SEQUENCE_VIOLATION"); @@ -816,7 +892,21 @@ test("REQ-independent-verification-006: strict-tdd and bug role order fail close const bug = verifyCandidate({ ...harness, declaredStrategy: "bug", - rawEvidence: [raw("patch", "patch"), raw("red", "red"), raw("green", "green")], + collectors: [ + trustedCollector("tool-produced"), + trustedCollector("runtime-observed"), + trustedCollector("runtime-observed"), + ], + rawEvidence: [ + raw("patch", { provenance: "tool-produced", execution_sequence: { run_id: "r1", ordinal: 1 } }), + raw("red", { execution_sequence: { run_id: "r1", ordinal: 2 } }), + raw("green", { execution_sequence: { run_id: "r1", ordinal: 3 } }), + ], + runner_receipts: [ + sampleReceipt("patch"), + sampleReceipt("red"), + sampleReceipt("green"), + ], }); assert.equal(bug.ok, false); assert.equal(bug.reason_code, "STRATEGY_SEQUENCE_VIOLATION"); @@ -843,19 +933,19 @@ test("FABRICATED_EVIDENCE: non-object raw and missing origin fail closed", () => assert.equal(Object.prototype.hasOwnProperty.call(missingOrigin, "verification"), false); }); -test("REQ-independent-verification-003: verifier derives trusted evidence metadata from Execution Graph when omitted", () => { +test("REQ-independent-verification-003: verifier derives trusted evidence metadata from Execution Graph and receipts", () => { const harness = buildHarness(); - // Raw observations omit obligation_ids and evidence_requirements_satisfied const rawObservations = [ - { role: "acceptance", bytes: "acceptance: ok", origin: "node:test", node_id: "repair-core" }, - { role: "invariants", bytes: "invariants: ok", origin: "node:test", node_id: "repair-core" }, - { role: "contract", bytes: "contract: ok", origin: "node:test", node_id: "repair-core" }, - { role: "negative", bytes: "negative: ok", origin: "node:test", node_id: "repair-core" }, + { bytes: "acceptance: ok", origin: "node:test", node_id: "repair-core" }, + { bytes: "invariants: ok", origin: "node:test", node_id: "repair-core" }, + { bytes: "contract: ok", origin: "node:test", node_id: "repair-core" }, + { bytes: "negative: ok", origin: "node:test", node_id: "repair-core" }, ]; const result = verifyCandidate({ ...harness, declaredStrategy: "feature", rawEvidence: rawObservations, + runner_receipts: featureReceipts(), }); assert.equal(result.ok, true, result.error || result.reason_code); assert.ok(result.assessments.length > 0); @@ -866,13 +956,19 @@ test("REQ-independent-verification-003: verifier derives trusted evidence metada test("REQ-independent-verification-006: incompatible roles red ↔ green, char-before ↔ char-after, negative ↔ acceptance fail closed", () => { const harness = buildHarness(); const bytes = "shared-bytes"; - const shared = { origin: "shared", node_id: "repair-core" }; // red ↔ green const redGreen = verifyCandidate({ ...harness, declaredStrategy: "strict-tdd", - rawEvidence: [raw("red", bytes, shared), raw("green", bytes, shared)], + rawEvidence: [ + raw(bytes, { origin: "shared-rg", execution_sequence: { run_id: "r1", ordinal: 1 } }), + raw(bytes, { origin: "shared-rg", execution_sequence: { run_id: "r1", ordinal: 2 } }), + ], + runner_receipts: [ + sampleReceipt("red"), + sampleReceipt("green"), + ], }); assert.equal(redGreen.ok, false); assert.equal(redGreen.reason_code, "STRATEGY_EVIDENCE_ALIAS"); @@ -882,10 +978,16 @@ test("REQ-independent-verification-006: incompatible roles red ↔ green, char-b ...harness, declaredStrategy: "feature", rawEvidence: [ - raw("acceptance", bytes, shared), - raw("invariants", "inv-bytes", { origin: "inv", node_id: "repair-core" }), - raw("contract", "contract-bytes", { origin: "con", node_id: "repair-core" }), - raw("negative", bytes, shared), + raw(bytes, { origin: "shared-neg-acc" }), + raw("inv-bytes", { origin: "inv" }), + raw("contract-bytes", { origin: "con" }), + raw(bytes, { origin: "shared-neg-acc" }), + ], + runner_receipts: [ + sampleReceipt("acceptance"), + sampleReceipt("invariants"), + sampleReceipt("contract"), + sampleReceipt("negative"), ], }); assert.equal(negAcc.ok, false); @@ -896,9 +998,14 @@ test("REQ-independent-verification-006: incompatible roles red ↔ green, char-b ...harness, declaredStrategy: "refactor", rawEvidence: [ - raw("characterization-before", bytes, shared), - raw("characterization-after", bytes, shared), - raw("no-behavior-change", "nbc-bytes", { origin: "nbc", node_id: "repair-core" }), + raw(bytes, { origin: "shared-cb-ca", execution_sequence: { run_id: "r1", ordinal: 1 } }), + raw(bytes, { origin: "shared-cb-ca", execution_sequence: { run_id: "r1", ordinal: 2 } }), + raw("nbc-bytes", { origin: "nbc" }), + ], + runner_receipts: [ + sampleReceipt("characterization-before"), + sampleReceipt("characterization-after"), + sampleReceipt("no-behavior-change"), ], }); assert.equal(charBeforeAfter.ok, false); @@ -908,16 +1015,21 @@ test("REQ-independent-verification-006: incompatible roles red ↔ green, char-b test("REQ-independent-verification-006: non-conflicting shared evidence (integration + acceptance) passes validation", () => { const harness = buildHarness(); const bytes = "shared-integration-acceptance"; - const shared = { origin: "shared", node_id: "repair-core" }; const result = verifyCandidate({ ...harness, declaredStrategy: "feature", rawEvidence: [ - raw("acceptance", bytes, shared), - raw("invariants", "inv-bytes", { origin: "inv", node_id: "repair-core" }), - raw("integration", bytes, shared), - raw("negative", "neg-bytes", { origin: "neg", node_id: "repair-core" }), + raw(bytes, { origin: "acc" }), + raw("inv-bytes", { origin: "inv" }), + raw(bytes, { origin: "integ" }), + raw("neg-bytes", { origin: "neg" }), + ], + runner_receipts: [ + sampleReceipt("acceptance"), + sampleReceipt("invariants"), + sampleReceipt("integration"), + sampleReceipt("negative"), ], }); assert.equal(result.ok, true, result.error || result.reason_code); @@ -932,13 +1044,20 @@ test("REQ-independent-verification-006: refactor chronological sequence via exec ...harness, declaredStrategy: "refactor", rawEvidence: [ - raw("characterization-before", "before-bytes", { + raw("before-bytes", { + origin: "cb", execution_sequence: { run_id: "run-1", ordinal: 5 }, }), - raw("characterization-after", "after-bytes", { + raw("after-bytes", { + origin: "ca", execution_sequence: { run_id: "run-1", ordinal: 4 }, }), - raw("no-behavior-change", "nbc-bytes"), + raw("nbc-bytes", { origin: "nbc" }), + ], + runner_receipts: [ + sampleReceipt("characterization-before"), + sampleReceipt("characterization-after"), + sampleReceipt("no-behavior-change"), ], }); assert.equal(badOrdinal.ok, false); @@ -949,15 +1068,120 @@ test("REQ-independent-verification-006: refactor chronological sequence via exec ...harness, declaredStrategy: "refactor", rawEvidence: [ - raw("characterization-before", "before-bytes", { + raw("before-bytes", { + origin: "cb", execution_sequence: { run_id: "run-1", ordinal: 1 }, }), - raw("characterization-after", "after-bytes", { + raw("after-bytes", { + origin: "ca", execution_sequence: { run_id: "run-1", ordinal: 2, previous_evidence_id: "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" }, }), - raw("no-behavior-change", "nbc-bytes"), + raw("nbc-bytes", { origin: "nbc" }), + ], + runner_receipts: [ + sampleReceipt("characterization-before"), + sampleReceipt("characterization-after"), + sampleReceipt("no-behavior-change"), ], }); assert.equal(badPrev.ok, false); assert.equal(badPrev.reason_code, "STRATEGY_SEQUENCE_VIOLATION"); }); + +// Adversarial tests for B1, B2, B3, H1 +test("REQ-independent-verification-003 [Adversarial B1]: caller semantic metadata injection in rawEvidence fails closed", () => { + const harness = buildHarness(); + + const injectionCases = [ + { name: "role injection", item: { ...raw("bytes"), role: "acceptance" } }, + { name: "obligation_id injection", item: { ...raw("bytes"), obligation_id: "req-repair-001" } }, + { name: "obligation_ids injection", item: { ...raw("bytes"), obligation_ids: ["req-repair-001"] } }, + { name: "evidence_requirements_satisfied injection", item: { ...raw("bytes"), evidence_requirements_satisfied: ["ev:test-pass"] } }, + ]; + + for (const { name, item } of injectionCases) { + const result = verifyCandidate({ + ...harness, + declaredStrategy: "feature", + rawEvidence: [item, ...featureEvidence().slice(1)], + runner_receipts: featureReceipts(), + }); + assert.equal(result.ok, false, `Failed to reject ${name}`); + assert.equal(result.reason_code, "UNTRUSTED_CALLER_METADATA", name); + assert.equal(result.verification, undefined); + } +}); + +test("REQ-independent-verification-005 [Adversarial B2]: blind copying eliminated; ungrounded MUST fails closed", () => { + const harness = buildHarness(); + // Node has required_evidence: ["ev:test-pass"], but receipts have empty evidence_requirements_satisfied: [] + const result = verifyCandidate({ + ...harness, + declaredStrategy: "feature", + rawEvidence: featureEvidence(), + runner_receipts: featureReceipts().map((r) => ({ ...r, evidence_requirements_satisfied: [] })), + }); + assert.equal(result.ok, false); + assert.equal(result.reason_code, "UNFULFILLED_MUST"); + assert.match(result.error, /req-repair-001/); +}); + +test("REQ-independent-verification-006 [Adversarial B3]: temporal strategies without execution_sequence fail closed (no array fallback)", () => { + const harness = buildHarness(); + + // Strict TDD with array order [red, green] but NO execution_sequence + const strictNoSeq = verifyCandidate({ + ...harness, + rawEvidence: [raw("red fail"), raw("green pass")], + runner_receipts: [sampleReceipt("red"), sampleReceipt("green")], + }); + assert.equal(strictNoSeq.ok, false); + assert.equal(strictNoSeq.reason_code, "STRATEGY_SEQUENCE_VIOLATION"); + assert.match(strictNoSeq.error, /execution_sequence/i); + + // Bug strategy with array order [red, patch, green] but NO execution_sequence + const bugNoSeq = verifyCandidate({ + ...harness, + declaredStrategy: "bug", + collectors: [ + trustedCollector("runtime-observed"), + trustedCollector("tool-produced"), + trustedCollector("runtime-observed"), + ], + rawEvidence: [ + raw("red"), + raw("patch", { provenance: "tool-produced" }), + raw("green"), + ], + runner_receipts: [ + sampleReceipt("red"), + sampleReceipt("patch"), + sampleReceipt("green"), + ], + }); + assert.equal(bugNoSeq.ok, false); + assert.equal(bugNoSeq.reason_code, "STRATEGY_SEQUENCE_VIOLATION"); + assert.match(bugNoSeq.error, /execution_sequence/i); +}); + +test("REQ-independent-verification-006 [Adversarial]: strict-tdd previous_evidence_id chaining mismatch fails closed", () => { + const harness = buildHarness(); + const redObservation = raw("red fail", { + execution_sequence: { run_id: "r1", ordinal: 1 }, + }); + const greenObservation = raw("green pass", { + execution_sequence: { + run_id: "r1", + ordinal: 2, + previous_evidence_id: "sha256:0000000000000000000000000000000000000000000000000000000000000000", + }, + }); + const result = verifyCandidate({ + ...harness, + rawEvidence: [redObservation, greenObservation], + runner_receipts: [sampleReceipt("red"), sampleReceipt("green")], + }); + assert.equal(result.ok, false); + assert.equal(result.reason_code, "STRATEGY_SEQUENCE_VIOLATION"); + assert.match(result.error, /previous_evidence_id/i); +}); diff --git a/scripts/lib/independent-verifier/obligation-coverage.js b/scripts/lib/independent-verifier/obligation-coverage.js index ac0cd18f..672bf0ec 100644 --- a/scripts/lib/independent-verifier/obligation-coverage.js +++ b/scripts/lib/independent-verifier/obligation-coverage.js @@ -115,6 +115,8 @@ function walkMustObligations(input) { } for (const item of admissible) { + const coverageTokens = normalizedCoverage(item, requiredEvidence); + if (coverageTokens.length === 0) continue; const emitted = emitAssessment({ evidence_id: item.evidence.evidence_id, role: item.role, @@ -122,7 +124,7 @@ function walkMustObligations(input) { node_id: item.evidence.node_id, candidate_id: candidate && candidate.candidate_id, policy_snapshot_id: policySnapshotId, - evidence_requirements_satisfied: normalizedCoverage(item, requiredEvidence), + evidence_requirements_satisfied: coverageTokens, }); if (!emitted.ok) return emitted; assessments.push(emitted.assessment); diff --git a/scripts/lib/independent-verifier/obligation-coverage.test.js b/scripts/lib/independent-verifier/obligation-coverage.test.js index a9545e9e..82115400 100644 --- a/scripts/lib/independent-verifier/obligation-coverage.test.js +++ b/scripts/lib/independent-verifier/obligation-coverage.test.js @@ -343,3 +343,62 @@ test("REQ-kernel-contract-schemas-027: emitted assessments are assessment/v2 wit assert.equal(result.assessments[0].schema_version, 2); assert.ok(result.assessments[0].evidence_requirements_satisfied.length >= 1); }); + +test("REQ-independent-verification-005: absence of receipts causes UNFULFILLED_MUST even if node declares required_evidence", () => { + const result = walkMustObligations({ + classified: [ + { + role: "acceptance", + evidence: { + evidence_id: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + provenance: "runtime-observed", + node_id: "repair-core", + }, + obligation_ids: ["req-repair-001"], + evidence_requirements_satisfied: [], + }, + ], + executionGraph: graph([ + { + id: "req-repair-001", + criticality: "must", + implemented_by: ["repair-core"], + required_evidence: ["ev:test-pass"], + }, + ]), + candidate: CANDIDATE, + policySnapshotId: POLICY, + }); + assert.equal(result.ok, false); + assert.equal(result.reason_code, "UNFULFILLED_MUST"); + assert.match(result.error, /req-repair-001/); +}); + +test("REQ-independent-verification-005: blind copying is eliminated; ungrounded MUST fails closed", () => { + // Classified item has undefined/empty evidence_requirements_satisfied, node has required_evidence + const ungroundedItem = { + role: "acceptance", + evidence: { + evidence_id: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + provenance: "runtime-observed", + node_id: "repair-core", + }, + obligation_ids: ["req-repair-001"], + }; + const result = walkMustObligations({ + classified: [ungroundedItem], + executionGraph: graph([ + { + id: "req-repair-001", + criticality: "must", + implemented_by: ["repair-core"], + required_evidence: ["ev:test-pass", "ev:extra"], + }, + ]), + candidate: CANDIDATE, + policySnapshotId: POLICY, + }); + assert.equal(result.ok, false); + assert.equal(result.reason_code, "UNFULFILLED_MUST"); +}); + diff --git a/scripts/lib/independent-verifier/strategy-policy.js b/scripts/lib/independent-verifier/strategy-policy.js index f7f6b2a3..7556f705 100644 --- a/scripts/lib/independent-verifier/strategy-policy.js +++ b/scripts/lib/independent-verifier/strategy-policy.js @@ -167,45 +167,136 @@ function assertCompatibleRoleSharing(items) { // Preserve alias for existing consumers if any const assertDistinctRoleEvidence = assertCompatibleRoleSharing; +function getExecutionSequence(item) { + if (!item) return null; + const seq = item.execution_sequence || (item.raw && item.raw.execution_sequence); + if (!seq || typeof seq !== "object") return null; + if (typeof seq.ordinal !== "number") return null; + return seq; +} + function assertRoleOrder(strategyName, items) { - const positions = new Map(); - for (let index = 0; index < (items || []).length; index += 1) { - const role = items[index] && items[index].role; - if (!positions.has(role)) positions.set(role, []); - positions.get(role).push(index); + const temporalStrategies = ["strict-tdd", "bug", "refactor"]; + if (!temporalStrategies.includes(strategyName)) { + return { ok: true }; } - const ordered = strategyName === "bug" ? ["red", "patch", "green"] : strategyName === "strict-tdd" ? ["red", "green"] : []; - for (let index = 0; index < ordered.length - 1; index += 1) { - const earlier = positions.get(ordered[index]) || []; - const later = positions.get(ordered[index + 1]) || []; - if (earlier.length > 0 && later.length > 0 && Math.max(...earlier) > Math.min(...later)) { - return fail("STRATEGY_SEQUENCE_VIOLATION", `${ordered[index]} evidence must precede ${ordered[index + 1]} evidence`); + + 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"); + } + } + + 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"); + } + } + } } } - // Refactor chronological sequence validation (characterization-before -> characterization-after) - if (strategyName === "refactor") { - const beforeIndices = positions.get("characterization-before") || []; - const afterIndices = positions.get("characterization-after") || []; - if (beforeIndices.length > 0 && afterIndices.length > 0 && Math.max(...beforeIndices) > Math.min(...afterIndices)) { - return fail("STRATEGY_SEQUENCE_VIOLATION", "characterization-before evidence must precede characterization-after evidence"); + 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"); + } + } + } + + 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"); + } + } + } + } + + 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 (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"); + } + } - const beforeItems = (items || []).filter((it) => it && it.role === "characterization-before"); - const afterItems = (items || []).filter((it) => it && it.role === "characterization-after"); - - for (const before of beforeItems) { - const beforeSeq = before.execution_sequence || (before.raw && before.raw.execution_sequence); - const beforeEvidenceId = before.evidence && before.evidence.evidence_id; - for (const after of afterItems) { - const afterSeq = after.execution_sequence || (after.raw && after.raw.execution_sequence); - if (beforeSeq && afterSeq) { - if (typeof beforeSeq.ordinal === "number" && typeof afterSeq.ordinal === "number") { - if (afterSeq.ordinal <= beforeSeq.ordinal) { - return fail("STRATEGY_SEQUENCE_VIOLATION", "characterization-after ordinal must be greater than characterization-before ordinal"); - } + 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 && beforeEvidenceId && afterSeq.previous_evidence_id !== beforeEvidenceId) { + 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"); } } diff --git a/test/e2e/k6b-verifier-assurance-graph-e2e.test.js b/test/e2e/k6b-verifier-assurance-graph-e2e.test.js new file mode 100644 index 00000000..587f3686 --- /dev/null +++ b/test/e2e/k6b-verifier-assurance-graph-e2e.test.js @@ -0,0 +1,253 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const path = require("node:path"); +const test = require("node:test"); + +const { freezeCandidate } = require("../../scripts/lib/execution-identities/index.js"); +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 { + reconcileAssuranceGraph, + replayAssuranceGraph, + projectAssuranceGraph, +} = require("../../scripts/lib/assurance-graph/index.js"); + +const SAMPLE_NODES = [ + { + node_id: "repair-core", + kind: "repair-action/v1", + operation: "apply_repair_patch", + objective: "Apply repair changes", + dependencies: [], + ownership: { owner: "agent:repair", mode: "exclusive" }, + allowed_paths: ["src/index.js"], + invariants: ["inv-fail-closed"], + required_evidence: ["ev:test-pass"], + budget_ref: "budget:default", + }, +]; + +const SAMPLE_OBLIGATIONS = [ + { + id: "req-repair-001", + criticality: "must", + implemented_by: ["repair-core"], + required_evidence: ["ev:test-pass"], + }, +]; + +function buildHarness(files = { "src/index.js": "module.exports = 1;\n" }) { + const tree = computeTreeDigest(files); + const candidate = freezeCandidate({ + repository_id: "k6b-e2e-repo", + projection: "workspace", + base_tree: tree, + candidate_tree: tree, + diff_hash: "sha256:1111111111111111111111111111111111111111111111111111111111111111", + paths: Object.keys(files), + }); + const policySnapshot = createPolicySnapshot({ effectiveRules: ["rule-fail-closed"] }); + const contract = { + schema_version: 1, + contract_id: "contract:k6b-e2e", + family: "repair", + version: 1, + contract_digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + source_snapshot_id: "sha256:4444444444444444444444444444444444444444444444444444444444444444", + obligations: SAMPLE_OBLIGATIONS, + }; + const executionGraph = compileExecutionGraph({ + contract, + policySnapshot, + nodes: SAMPLE_NODES, + obligations: SAMPLE_OBLIGATIONS, + }); + return { + candidate, + executionGraph, + policySnapshot, + contract, + repository: { files }, + collector: { id: "node-test", transport: "tool-execution-transport" }, + }; +} + +test("E2E: complete lifecycle - verification, projection, reconciliation, and cryptographic replay", () => { + const harness = buildHarness(); + + // Physical observations (pure observations without caller metadata) + const rawEvidence = [ + { bytes: "red-test-output: assertion failed", provenance: "runtime-observed", origin: "node:test:red", node_id: "repair-core", execution_sequence: { run_id: "run-100", ordinal: 1 } }, + { bytes: "green-test-output: all tests passed", provenance: "runtime-observed", origin: "node:test:green", node_id: "repair-core", execution_sequence: { run_id: "run-100", ordinal: 2 } }, + ]; + + // Runner receipts (trusted attestations) + const runnerReceipts = [ + { role: "red", node_id: "repair-core" }, + { role: "green", node_id: "repair-core", evidence_requirements_satisfied: ["ev:test-pass"] }, + ]; + + // 1. Verification + const verificationResult = verifyCandidate({ + ...harness, + declaredStrategy: "strict-tdd", + rawEvidence, + runner_receipts: runnerReceipts, + }); + + assert.equal(verificationResult.ok, true, verificationResult.error || verificationResult.reason_code); + assert.equal(verificationResult.verification.verdict, "PASS"); + assert.equal(verificationResult.verification.candidate_id, harness.candidate.candidate_id); + assert.equal(verificationResult.evidence.length, 2); + + // Link previous_evidence_id in second item and test chaining + const redEvidenceId = verificationResult.evidence[0].evidence_id; + const greenEvidenceId = verificationResult.evidence[1].evidence_id; + + // 2. Graph Projection & Reconciliation + const projectedGraph = verificationResult.assurance_graph; + assert.ok(projectedGraph); + assert.equal(projectedGraph.kind, "assurance-graph/v1"); + + const reconciliation = reconcileAssuranceGraph(projectedGraph, { + candidate: harness.candidate, + executionGraph: harness.executionGraph, + contract: harness.contract, + policySnapshot: harness.policySnapshot, + evidence: verificationResult.evidence, + assessments: verificationResult.assessments, + verification: verificationResult.verification, + }); + assert.equal(reconciliation.ok, true); + + // 3. Cryptographic Replay from persistable outputs + const replayResult = replayAssuranceGraph({ + candidate: harness.candidate, + 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 }, + ], + assessments: verificationResult.assessments, + verification: verificationResult.verification, + canonical_inputs: projectedGraph.canonical_inputs, + }); + assert.equal(replayResult.ok, true, replayResult.error || replayResult.reason_code); + assert.equal(replayResult.graph.graph_id, projectedGraph.graph_id); +}); + +test("E2E Adversarial: caller metadata injection in raw evidence is rejected before evaluation", () => { + const harness = buildHarness(); + + const injectedEvidence = [ + { + bytes: "injected-role", + provenance: "runtime-observed", + origin: "attacker", + node_id: "repair-core", + role: "green", // Caller injection attack + evidence_requirements_satisfied: ["ev:test-pass"], // Caller injection attack + }, + ]; + + const result = verifyCandidate({ + ...harness, + declaredStrategy: "strict-tdd", + rawEvidence: injectedEvidence, + runner_receipts: [{ role: "green", node_id: "repair-core" }], + }); + + assert.equal(result.ok, false); + assert.equal(result.reason_code, "UNTRUSTED_CALLER_METADATA"); + assert.equal(result.verification, undefined); +}); + +test("E2E Adversarial: causality tampering (inverted ordinals) fails closed", () => { + const harness = buildHarness(); + + const invertedEvidence = [ + { bytes: "green-output", provenance: "runtime-observed", origin: "node:test", node_id: "repair-core", execution_sequence: { run_id: "r1", ordinal: 1 } }, + { bytes: "red-output", provenance: "runtime-observed", origin: "node:test", node_id: "repair-core", execution_sequence: { run_id: "r1", ordinal: 2 } }, + ]; + + const result = verifyCandidate({ + ...harness, + declaredStrategy: "strict-tdd", + rawEvidence: invertedEvidence, + runner_receipts: [ + { role: "green", node_id: "repair-core" }, + { role: "red", node_id: "repair-core" }, + ], + }); + + assert.equal(result.ok, false); + assert.equal(result.reason_code, "STRATEGY_SEQUENCE_VIOLATION"); +}); + +test("E2E Adversarial: missing runner receipt leaves MUST unfulfilled", () => { + const harness = buildHarness(); + + const rawEvidence = [ + { bytes: "red", provenance: "runtime-observed", origin: "node:test", node_id: "repair-core", execution_sequence: { run_id: "r1", ordinal: 1 } }, + { bytes: "green", provenance: "runtime-observed", origin: "node:test", node_id: "repair-core", execution_sequence: { run_id: "r1", ordinal: 2 } }, + ]; + + // Runner receipt does not confirm satisfied evidence requirements + const ungroundedReceipts = [ + { role: "red", node_id: "repair-core" }, + { role: "green", node_id: "repair-core" }, // Missing evidence_requirements_satisfied + ]; + + const result = verifyCandidate({ + ...harness, + declaredStrategy: "strict-tdd", + rawEvidence, + runner_receipts: ungroundedReceipts, + }); + + assert.equal(result.ok, false); + assert.equal(result.reason_code, "UNFULFILLED_MUST"); +}); + +test("E2E Adversarial: replayed evidence tampering (modified bytes) fails replay", () => { + const harness = buildHarness(); + + const rawEvidence = [ + { bytes: "red-test-output", provenance: "runtime-observed", origin: "node:test:red", node_id: "repair-core", execution_sequence: { run_id: "run-1", ordinal: 1 } }, + { bytes: "green-test-output", provenance: "runtime-observed", origin: "node:test:green", node_id: "repair-core", execution_sequence: { run_id: "run-1", ordinal: 2 } }, + ]; + const runnerReceipts = [ + { role: "red", node_id: "repair-core" }, + { role: "green", node_id: "repair-core", evidence_requirements_satisfied: ["ev:test-pass"] }, + ]; + + const verificationResult = verifyCandidate({ + ...harness, + declaredStrategy: "strict-tdd", + rawEvidence, + runner_receipts: runnerReceipts, + }); + assert.equal(verificationResult.ok, true); + + // Tamper with bytes during replay + const replayResult = replayAssuranceGraph({ + candidate: harness.candidate, + executionGraph: harness.executionGraph, + contract: harness.contract, + policySnapshot: harness.policySnapshot, + evidence: [ + { evidence: verificationResult.evidence[0], bytes: "tampered-red-bytes" }, + { evidence: verificationResult.evidence[1], bytes: rawEvidence[1].bytes }, + ], + assessments: verificationResult.assessments, + verification: verificationResult.verification, + canonical_inputs: verificationResult.assurance_graph.canonical_inputs, + }); + + assert.equal(replayResult.ok, false); + assert.equal(replayResult.reason_code, "GRAPH_DIVERGENCE"); +});