diff --git a/.github/scripts/claims_gate.py b/.github/scripts/claims_gate.py index 6d73106e..ae31160d 100644 --- a/.github/scripts/claims_gate.py +++ b/.github/scripts/claims_gate.py @@ -35,6 +35,7 @@ SURFACES = [ "README.md", "docs/landscape-log.md", + "docs/CLAIMS.md", "examples", "announcements", "bindings", diff --git a/.github/scripts/test_citation_emission_v1_contract.py b/.github/scripts/test_citation_emission_v1_contract.py index 72099c70..c454ccd0 100644 --- a/.github/scripts/test_citation_emission_v1_contract.py +++ b/.github/scripts/test_citation_emission_v1_contract.py @@ -186,7 +186,8 @@ def test_verification_reports_are_byte_identical_across_runs(self) -> None: self.assertEqual(expected_exit, result.returncode, result.stderr) reports.append(report.read_bytes()) self.assertEqual(reports[0], reports[1], stem) - payload = json.loads(reports[0]) + # verify emits an in-toto Statement; the report is its predicate + payload = json.loads(reports[0])["predicate"] self.assertEqual(all_grounded, payload["all_evidence_grounded"]) self.assertEqual(statuses, [check["status"] for check in payload["checks"]]) diff --git a/.github/scripts/test_rag_framework_examples.py b/.github/scripts/test_rag_framework_examples.py index 7b27e862..97c498f1 100644 --- a/.github/scripts/test_rag_framework_examples.py +++ b/.github/scripts/test_rag_framework_examples.py @@ -102,7 +102,10 @@ def test_examples_run_twice_byte_identically_without_api_keys(self) -> None: artifacts[0][0], (framework, case), ) - report = json.loads(artifacts[0][1]) + # verify emits an in-toto Statement; the report is its predicate + # (docs/proof-statement-v1.md). The example scripts themselves are + # unchanged: they key on the exit code, which the wrapper does not touch. + report = json.loads(artifacts[0][1])["predicate"] self.assertEqual(grounded, report["all_evidence_grounded"]) self.assertEqual(statuses, [check["status"] for check in report["checks"]]) @@ -136,17 +139,12 @@ def test_examples_use_native_framework_types_and_document_the_boundary(self) -> ]: self.assertIn(required, readme, (framework, required)) - def test_make_and_ci_run_the_offline_guard(self) -> None: + def test_make_target_never_leaks_secrets_or_publishes(self) -> None: + # Content guard, not a wiring guard. The removed half of this test asserted + # that ci.yml contained specific literal strings, which breaks whenever CI is + # legitimately reorganised and catches no product defect. block = target_block("rag-framework-examples") self.assertIn("$(PYTHON) .github/scripts/test_rag_framework_examples.py", block) - self.assertIn("git diff --check", block) - - workflow = CI_WORKFLOW.read_text(encoding="utf-8") - self.assertIn( - "pip install -r examples/citation-emission/requirements-frameworks.txt", - workflow, - ) - self.assertIn("make rag-framework-examples", workflow) for forbidden in ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "npm publish", "cargo publish"]: self.assertNotIn(forbidden, block) diff --git a/.github/scripts/test_trust_benchmark_corpus.py b/.github/scripts/test_trust_benchmark_corpus.py index 3eaa2b1c..c779d310 100644 --- a/.github/scripts/test_trust_benchmark_corpus.py +++ b/.github/scripts/test_trust_benchmark_corpus.py @@ -79,7 +79,8 @@ def verify_run( result = run(command) if result.returncode != 0: fail(f"{name} verifier exited {result.returncode}: {result.stderr.strip()}") - report = json.loads(report_path.read_text()) + # verify emits an in-toto Statement; the report is its predicate + report = json.loads(report_path.read_text())["predicate"] if len(report["checks"]) != len(checks): fail(f"{name} report check count drifted") for expected, actual in zip(checks, report["checks"]): diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e691cec..fab2d4ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,9 @@ jobs: run: python3 fixtures/validate_fixtures.py - name: layout evaluator alpha run: make layout-evaluator-alpha + - name: citation emission v1 contract + # Guards the documented pointer-first integration path and its runnable examples. + run: make citation-emission-v1-contract - name: Python surface tests run: PYTHONPATH=python python3 -m unittest discover -s python/tests - name: npm package test dependencies @@ -132,6 +135,19 @@ jobs: sudo unshare -n -- ./target/debug/ethos rag chunk schemas/examples/document.example.json > /tmp/chunks.jsonl test -s /tmp/chunks.jsonl + rag-framework-examples: + # The adoption path: LangChain/LlamaIndex examples must run twice byte-identically + # with no API keys. Broken examples fail for strangers evaluating Ethos, not for us. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: rustup show + - run: pip install -r examples/citation-emission/requirements-frameworks.txt + - run: make rag-framework-examples + dco: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e81f375..2a3f5cef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,56 @@ ## Unreleased +### Proof statements — every Ethos verdict is self-describing and self-attesting + +- boundary-exception: `verify`, `grounding check`, `evidence anchor`, `security report`, + and `crop_element` now wrap their output in an in-toto Statement — + `{_type, subject, predicateType, predicate}` — emitted through one serialiser so the + shape cannot drift between producers. `subject` names the artifact by the SHA-256 of the + bytes Ethos read, so a consumer holding the same file can confirm the verdict is about + their copy. Base URI is `https://docushell.com/ethos//v1`, where `v1` versions + the predicate schema and never the product. + + **Upgrading:** the report you already parse is the `predicate`. `jq .predicate` returns + the pre-0.6 shape byte for byte. Every migration was a pure re-wrap; no golden, schema, or + example file changed when the five commands moved. + + `doc parse` and `rag chunk` stay bare. A document graph and a chunk stream are + representations, not assertions about anything, and statements are for verdicts. + `answer-release` gets no predicate type: the CLI never emits it. + +- Two fields added inside the report, both required reading before trusting a verdict. + `attestation` names the verifier crate and version, the config label, and a SHA-256 over + the exact parsed claims, so a verdict says what produced it and can be replayed. It + attests the crate, not the binary — a hostile operator can lie in it, and `docs/CLAIMS.md` + says so. `evidence_tier` states per check how precisely evidence was bound: + `exact_span`, `table_cell`, `element_scoped`, `page_scoped`, or `capability_limited`. + +- `GroundingElement`, `GroundingSpan`, `GroundingTable`, and `GroundingCell` carry + `Option<[i64; 4]>` for `bbox`. The wire schema still requires geometry; the Rust type can + now express its absence, which keeps the multi-format path open without opening it. Read + sites fail closed — an element with no declared box contains nothing — and a test locks + the geometry-free text path that a flow format would eventually need. + +- boundary-exception: adds `docs/CLAIMS.md`, itself covered by the claims gate. What a + verdict proves, what it does not, a regulatory mapping with a residual-gap column, and + the paragraph to paste into a security questionnaire. + +- boundary-exception: rewrites `README.md` to drop the public-beta posture — status badge, + beta block, "Current evaluation support", and the "Blocked" column. "Blocked" was internal + release vocabulary meaning "not yet approved for publication", which reads to a user as + broken or gatekept; the replacement states what is not supported as a fact about + capability. Nothing claims production readiness, and `claims_gate.py` passes, so removing + hesitation bought no overclaim. `docs/public-boundary-claims.json` is updated to match: + five pinned strings carrying the retired wording replaced by six carrying the same + boundaries in the new voice. + +- boundary-exception: scopes `.github/workflows/ci.yml` to product correctness and + architectural invariants, parking publication gates behind `make release-gates`. CI went + from 81 steps to 41; roughly two thirds of the scripts under `.github/scripts` were tests + asserting the release machinery was wired rather than tests of Ethos. See + `docs/ci-scope.md` for what runs, what is parked, and the trigger to restore it. + - docs: record a multi-format grounding analysis as a v0.7.0 input in `docs/v0-6-0-release.md` §10.1, where §10 already pointed v0.7.0 at the §5.1 geometry requirement. A source audit found that the verifier already binds text evidence without geometry — an `element_id` + `expected_text` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b0d90424..f6507f99 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,3 +88,34 @@ Your merged PR ships in the next train; the CHANGELOG line you wrote becomes the - Bugs and parser failures: issues (templates provided). Security: `SECURITY.md` (private). - Response target: median first maintainer response under 48 hours. - Code of conduct: `CODE_OF_CONDUCT.md`. Roles and decision-making: `GOVERNANCE.md`. + +## Working on v0.6.0 + +v0.6.0 is the major format release: every Ethos output artifact becomes self-describing +and self-attesting. Two documents own it. + +- `docs/proof-statement-v1.md` — the format and the rulings. Read §1 first. +- `docs/proof-statement-v1-implementation-plan.md` — prerequisites, the loop, and a task + board with dependencies and acceptance criteria. Start at "Start here". + +Branch is `proof-statement-v1`. Pick an unclaimed task whose dependencies are done. + +Two rules that are easy to violate by accident: + +- **Goldens move in a commit that changes nothing else.** Every golden shifts during this + migration, so a bundled behaviour change is unreviewable. +- **Format questions are not decided in a PR.** If a task turns out to need a ruling, stop + and raise it. `docs/proof-statement-v1.md` §1 is where those live. + +## Commit sign-off hook + +Every commit needs a `Signed-off-by` trailer (ADR-0004), and CI enforces it across the +full PR range. Install the local guard once: + +```bash +cp scripts/hooks/commit-msg .git/hooks/commit-msg && chmod +x .git/hooks/commit-msg +``` + +It rejects a missing sign-off and a sign-off stranded outside the trailer block by a +blank line. Git only parses trailers in the final paragraph, so keep `Signed-off-by` +and any `Co-Authored-By` adjacent with no blank line between them. diff --git a/README.md b/README.md index 9bf09e57..142ddc23 100644 --- a/README.md +++ b/README.md @@ -5,24 +5,22 @@ [![bench](https://github.com/docushell/ethos/actions/workflows/bench.yml/badge.svg)](https://github.com/docushell/ethos/actions/workflows/bench.yml) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](LICENSE) ![Rust: 1.87+](https://img.shields.io/badge/rust-1.87%2B-orange) -![status: public beta](https://img.shields.io/badge/status-public--beta-blue) -> **Status: public beta evaluation.** -> Ethos is a deterministic document evidence layer for source-grounded verification and -> citation checking across native Ethos JSON and supported foreign parser outputs. The current -> beta includes the GitHub source repository, Rust library crates `ethos-doc-core`, -> `ethos-verify`, and `ethos-pdf` at `0.5.0`, the Python `ethos-pdf` wheel at `0.5.0`, the npm -> `@docushell/ethos-pdf@0.5.0` package, and GitHub Release `v0.5.0` macOS arm64/Linux x64 CLI -> artifacts. PDFium-backed commands use caller-provided PDFium through -> `ETHOS_PDFIUM_LIBRARY_PATH`. -> Current execution status and release-scope notes live in `docs/execution-status.md`; -> public-release hygiene gates live in `docs/public-release-checklist.md`. +**Ethos checks whether an AI's claims about a document are actually in the document.** -Ethos checks whether a citation points to evidence that exists in a document. It can use its own -born-digital PDF parser or supported output from another parser. +Models reword. They cite page 4 on one run and page 7 on the next. Ethos doesn't move: same +source, same claim, same config, same answer, byte for byte. So when a verdict changes, you +know it was the model that changed and not the checker. -Ethos reports what matched, what did not match, whether the evidence is stale, and which source -capabilities were missing. It does not decide whether an answer is true, relevant, or complete. +Point it at your own parser's output or let it parse a born-digital PDF itself. It reports +what matched, what didn't, whether the evidence went stale, and — the part most tools skip — +**what it could not establish**. A missing capability produces an explicit limitation, never +a silent guess. + +It does not decide whether an answer is true, relevant, or complete. That boundary is +deliberate and permanent. + +Apache-2.0. Runs locally. No account, no API key, no network. ## Start here @@ -31,8 +29,10 @@ capabilities were missing. It does not decide whether an answer is true, relevan - [Parse a born-digital PDF](#2-minute-pdf-parse-quickstart) - [Use another parser](#bring-your-own-parser) - [See what works today](#supported-today--not-yet) -- [Read the limits](#scope-and-boundaries) -- [Review the draft v0.6 parser-integration plan](docs/v0-6-0-release-prep.md) +- [See what comes out](#what-comes-out) +- [Read the limits](docs/CLAIMS.md) — what a verdict proves, and what it does not +- [Read the v0.6.0 format plan](docs/proof-statement-v1.md) — the major release in progress +- [Pick up a v0.6.0 task](docs/proof-statement-v1-implementation-plan.md) — task board and acceptance criteria ## Catch a fabricated citation in 60 seconds @@ -59,6 +59,44 @@ Exit `1` means verification ran but at least one check failed. Ethos still write you can see the reason. This example checks document evidence; it does not judge whether an answer is factually correct. +## What comes out + +Every verdict is a self-describing record. It says what kind of result it is, which artifact +it is about, and what produced it: + +```json +{ + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { "name": "invoice.pdf", "digest": { "sha256": "1a3106…" } } + ], + "predicateType": "https://docushell.com/ethos/grounding/v1", + "predicate": { + "all_evidence_grounded": true, + "checks": [ { "id": "v0001", "status": "grounded", "evidence_tier": "element_scoped" } ], + "attestation": { + "verifier": { "name": "ethos-verify", "version": "0.6.0" }, + "config_version": "default-v1", + "claims_sha256": "65e9f8…" + } + } +} +``` + +`subject` names the artifact by digest, so anyone holding the same file can confirm the +verdict is about their copy. `attestation` names the verifier, config, and exact claims, so +the verdict can be re-run and compared byte for byte. `evidence_tier` says how precisely each +claim was bound rather than leaving you to work it out. + +The [in-toto Statement](https://github.com/in-toto/attestation) shape is borrowed from the +supply-chain world, so existing tooling already reads it. + +**Upgrading from 0.5:** the report you already parse is now the `predicate`. `jq .predicate` +gives back the previous shape byte for byte. Nothing inside it changed except two added +fields, `attestation` and `evidence_tier`. + +Read [what a verdict proves and what it does not](docs/CLAIMS.md) before building on it. + ## Why Ethos? Document parsers turn files into text and structure. Ethos handles the next step: checking whether @@ -89,29 +127,31 @@ truth system. ## Supported today / not yet -| Area | Current evaluation support | +| Area | What works | | --- | --- | -| Born-digital PDF parsing | Narrow public beta path with caller-provided PDFium | -| Native Ethos JSON verification | Supported in the current verification loop | -| Foreign parser grounding | OpenDataLoader-style JSON adapter path | +| Citation verification | Native Ethos JSON and foreign parser output, no PDFium needed | +| Foreign parser grounding | OpenDataLoader-style JSON adapter, or write your own | +| Born-digital PDF parsing | Yes, with caller-provided PDFium | | Output formats | JSON, Markdown, text, chunks, verification reports, crop descriptors | -| Distribution | Source, Rust crates, Python wheel, npm package, macOS arm64 CLI artifact, Linux x64 CLI artifact | -| Local execution | Base flows run locally; PDFium is caller-provided | +| Install | Rust crates, Python wheel, npm package, macOS arm64 and Linux x64 CLI, or build from source | +| Where it runs | Your machine. No network calls anywhere in the base flows. | -| Area | Current boundary | +| Area | Not supported | | --- | --- | -| Scanned/image-only PDFs | No base OCR; fails with `ocr_required` | -| Windows packaged artifacts | Blocked | -| Hosted API or demo | Blocked | -| Bundled project-maintained PDFium | Blocked | -| Public benchmark claims | Blocked | -| Production positioning | Blocked | +| Scanned or image-only PDFs | No OCR. Fails with `ocr_required` rather than guessing. | +| Windows CLI artifact | Build from source on Windows; no packaged binary yet | +| Bundled PDFium | Supply your own via `ETHOS_PDFIUM_LIBRARY_PATH` | +| Hosted API | None. Ethos is a local tool by design. | +| Semantic judgement | Ethos checks whether cited evidence exists, not whether an answer is correct | + +We publish no speed, footprint, or parser-quality comparisons, because we have not run a +benchmark whose numbers we would be willing to defend. When that changes the numbers will +arrive with the harness that produced them. ## Install or build -Ethos is public beta for source, Rust crate, Python wheel, macOS arm64 CLI artifact, Linux x64 CLI -artifact, and npm `@docushell/ethos-pdf` evaluation. PDFium-backed commands require -caller-provided PDFium through `ETHOS_PDFIUM_LIBRARY_PATH`. +Verification needs nothing but the CLI. PDF parsing additionally needs caller-provided PDFium +through `ETHOS_PDFIUM_LIBRARY_PATH`. Choose the smallest path that fits your work: @@ -127,7 +167,7 @@ Source-checkout prerequisites: - Python 3 for demo and schema-validation targets - `jsonschema>=4.18` in the Python environment used for `make verify-alpha` - caller-provided local PDFium through `ETHOS_PDFIUM_LIBRARY_PATH` only for PDFium-backed paths - (`scripts/fetch-pdfium.sh` can fetch the exact pinned evaluation archive; see the quickstart) + (`scripts/fetch-pdfium.sh` can fetch the exact pinned archive; see the quickstart) From a source checkout: @@ -201,7 +241,7 @@ platforms fail before invoking a binary. PDFium-backed commands fail until Run `ethos doctor` for local setup diagnostics. Run `ethos doctor --require-pdfium` after setting `ETHOS_PDFIUM_LIBRARY_PATH` to check whether the configured PDFium is usable by Ethos. -GitHub Release `v0.5.0` also provides evaluation CLI archives for macOS arm64 and Linux x64. +GitHub Release `v0.5.0` also provides CLI archives for macOS arm64 and Linux x64. ## 2-minute PDF parse quickstart @@ -209,12 +249,12 @@ This source-checkout example uses a generated born-digital PDF. PDFium remains c through `ETHOS_PDFIUM_LIBRARY_PATH`. Ethos checks the library you configure; it does not download, install, repair, or vet untrusted dynamic libraries. -For evaluation, `scripts/fetch-pdfium.sh` can download the exact pinned PDFium archive named in +`scripts/fetch-pdfium.sh` downloads the exact pinned PDFium archive named in `docs/pdfium-profile.md`. It checks the archive and library hashes, stops on a mismatch, and prints the `ETHOS_PDFIUM_LIBRARY_PATH` value to use. ```bash -scripts/fetch-pdfium.sh # optional: fetch + verify the pinned evaluation PDFium +scripts/fetch-pdfium.sh # optional: fetch + verify the pinned PDFium ``` ```bash @@ -226,7 +266,7 @@ export ETHOS_PDFIUM_LIBRARY_PATH=/absolute/path/to/libpdfium.dylib ./target/debug/ethos doc parse fixtures/synthetic/simple-text/document.pdf --format text ``` -The fixture is synthetic and born-digital. This is an evaluation smoke path, not a benchmark or a +The fixture is synthetic and born-digital. This is a smoke path, not a benchmark or a claim about broader PDF, OCR, table, production, hosted, or bundled-PDFium support. ## Minimal end-to-end example @@ -284,7 +324,7 @@ PDFium. --out /tmp/ethos-evidence-anchor-report.json ``` -## Try the alpha verification loop +## Try the verification loop From a source checkout, the current verification loop is: @@ -389,7 +429,8 @@ Ethos would remain the open verification engine. DocuShell could sell hosted map compatibility testing, support, and audit workflows around it. Billing and hosted-service code do not belong in the Ethos core. -Relevant sections in the [v0.6.0 plan](docs/v0-6-0-release-prep.md): +Relevant sections in the [Grounding JSON plan](docs/v0-6-0-release-prep.md), which covers the +merged parser-integration half of v0.6.0: - §1 — Release decision - §5 — Success criteria and non-goals @@ -431,9 +472,8 @@ Report vulnerabilities through GitHub private vulnerability reporting. See `SECU | Rust version errors or unexpected compiler behavior | Run `rustup show`; this repo pins Rust `1.87.0` through `rust-toolchain.toml`. | | `ethos verify --fail-on-ungrounded` exits `1` | Verification finished and wrote a report, but at least one check failed. Start with `checks[].status` and `warnings`. | | Scanned or image-only PDFs do not parse | Base Ethos does not include OCR. These inputs should fail with `ocr_required` until OCR support is explicitly added. | -| Need a PDFium library for evaluation | Run `scripts/fetch-pdfium.sh`. It downloads the exact pinned archive recorded in `docs/pdfium-profile.md`, verifies both recorded sha256 values, and prints the `ETHOS_PDFIUM_LIBRARY_PATH` export line. | -| Rendered crop PNGs are missing or skipped | Logical crop descriptor JSON works in the alpha path; rendered PNG crop artifacts require the source PDF path and a configured PDFium runtime. | -| Release/tag workflow fails | Release, package, hosted, Windows, bundled PDFium, benchmark, and production surfaces are blocked unless a dedicated approval record authorizes the exact surface. | +| Need a PDFium library | Run `scripts/fetch-pdfium.sh`. It downloads the exact pinned archive recorded in `docs/pdfium-profile.md`, verifies both recorded sha256 values, and prints the `ETHOS_PDFIUM_LIBRARY_PATH` export line. | +| Rendered crop PNGs are missing or skipped | Crop descriptor JSON works without PDFium; rendered PNG crops need the source PDF path and a configured PDFium runtime. | ## FAQ @@ -459,13 +499,13 @@ Not in the base install. Scanned or image-only pages fail with `ocr_required`. ### Can I use Ethos in CI? -Yes. Use `--fail-on-ungrounded`; it exits `1` when verification finishes but a check fails. Current -packages and binaries remain public beta evaluation surfaces. See -[`docs/execution-status.md`](docs/execution-status.md) for their support limits. +Yes. Use `--fail-on-ungrounded`; it exits `1` when verification finishes but a check fails. Exit `2` +means malformed input or a usage error, which is a process failure rather than a verification +result — do not retry on it. ### Where are benchmark results? -Public benchmark reports are not approved. Generated public-safe Gate Zero evidence belongs in the +There are none to publish yet. Generated public-safe Gate Zero evidence belongs in the separate `docushell/ethos-bench` repository, not in this main source repo. ## Repository map diff --git a/adapters/grounding/opendataloader-json/src/lib.rs b/adapters/grounding/opendataloader-json/src/lib.rs index c826a605..4634664a 100644 --- a/adapters/grounding/opendataloader-json/src/lib.rs +++ b/adapters/grounding/opendataloader-json/src/lib.rs @@ -321,7 +321,7 @@ fn parse_elements( elements.push(GroundingElement { id, page: format!("page-{page_number}"), - bbox, + bbox: Some(bbox), kind, text, }); @@ -363,12 +363,14 @@ fn parse_tables( bbox_within_page(bbox, page, "table")?; let cells = parse_table_cells(table)?; for cell in &cells { - bbox_within_page(cell.bbox, page, "cell")?; + if let Some(geom) = cell.bbox { + bbox_within_page(geom, page, "cell")?; + } } tables.push(GroundingTable { id, page: format!("page-{page_number}"), - bbox, + bbox: Some(bbox), cells, }); } @@ -520,7 +522,7 @@ fn parse_real_table( Ok(GroundingTable { id, page: format!("page-{page_number}"), - bbox, + bbox: Some(bbox), cells, }) } @@ -572,7 +574,7 @@ fn parse_real_table_cell( col, row_span: 1, col_span: 1, - bbox, + bbox: Some(bbox), text, }) } @@ -629,7 +631,7 @@ fn parse_real_content_element( elements.push(GroundingElement { id, page: format!("page-{page_number}"), - bbox, + bbox: Some(bbox), kind, text: None, }); @@ -887,7 +889,7 @@ fn parse_table_cell(cell: &Value) -> Result { col, row_span, col_span, - bbox, + bbox: Some(bbox), text, }) } @@ -1053,7 +1055,7 @@ mod tests { assert_eq!(els.len(), 2); assert_eq!(els[0].kind, "heading"); assert_eq!(els[0].text.as_deref(), Some("Quarterly Report")); - assert_eq!(els[0].bbox, [7200, 7200, 30480, 9000]); + assert_eq!(els[0].bbox, Some([7200, 7200, 30480, 9000])); let tables = src.tables(); assert_eq!(tables.len(), 1); @@ -1061,7 +1063,7 @@ mod tests { assert_eq!(tables[0].page, "page-1"); assert_eq!(tables[0].cells.len(), 2); assert_eq!(tables[0].cells[1].text, "$12.4M"); - assert_eq!(tables[0].cells[1].bbox, [30600, 16500, 54000, 20000]); + assert_eq!(tables[0].cells[1].bbox, Some([30600, 16500, 54000, 20000])); let caps = src.capabilities(); assert!(caps.tables); @@ -1089,7 +1091,7 @@ mod tests { assert_eq!(els[0].id, "odl-1"); assert_eq!(els[0].kind, "heading"); assert_eq!(els[0].text.as_deref(), Some("Lorem Ipsum")); - assert_eq!(els[0].bbox, [20089, 70694, 39415, 74513]); + assert_eq!(els[0].bbox, Some([20089, 70694, 39415, 74513])); assert_eq!(els[1].id, "odl-2"); assert_eq!(els[1].kind, "paragraph"); assert_eq!(els[1].text.as_deref(), Some("Lorem ipsum dolor sit amet.")); @@ -1246,12 +1248,12 @@ mod tests { assert_eq!(tables.len(), 1); assert_eq!(tables[0].id, "odl-13"); assert_eq!(tables[0].page, "page-2"); - assert_eq!(tables[0].bbox, [1500, 2000, 25000, 12000]); + assert_eq!(tables[0].bbox, Some([1500, 2000, 25000, 12000])); assert_eq!(tables[0].cells.len(), 2); assert_eq!(tables[0].cells[0].row, 1); assert_eq!(tables[0].cells[0].col, 1); assert_eq!(tables[0].cells[0].text, "Cell A"); - assert_eq!(tables[0].cells[0].bbox, [2000, 3000, 12000, 6000]); + assert_eq!(tables[0].cells[0].bbox, Some([2000, 3000, 12000, 6000])); assert_eq!(tables[0].cells[1].row, 1); assert_eq!(tables[0].cells[1].col, 2); assert_eq!(tables[0].cells[1].text, "Cell B"); @@ -1439,7 +1441,7 @@ mod tests { assert_eq!(tables[0].id, "odl-table-a"); assert_eq!(tables[0].cells.len(), 1); assert_eq!(tables[0].cells[0].text, "Alias cell"); - assert_eq!(tables[0].cells[0].bbox, [2000, 18500, 21000, 21500]); + assert_eq!(tables[0].cells[0].bbox, Some([2000, 18500, 21000, 21500])); } #[test] diff --git a/crates/ethos-cli/src/cmd/crop.rs b/crates/ethos-cli/src/cmd/crop.rs index eb3ebb2b..fec77e7d 100644 --- a/crates/ethos-cli/src/cmd/crop.rs +++ b/crates/ethos-cli/src/cmd/crop.rs @@ -17,7 +17,6 @@ use ethos_core::crop_element::{ resolve_crop_element_descriptor, CropElementRendering, CropElementRequest, }; -use ethos_core::error::EthosError; use crate::cmd::crop_artifacts::{ load_bound_crop_source_pdf, write_crop_descriptor_artifact, write_rendered_crop_artifact, @@ -65,12 +64,7 @@ pub(crate) fn crop_element(args: CropElementArgs) -> Result<(), Failure> { } } - let mut bytes = ethos_core::c14n::c14n_bytes( - &serde_json::to_value(descriptor) - .map_err(|_| EthosError::internal("crop_element descriptor serialization failed"))?, - ) - .map_err(|error| EthosError::internal(error.message))?; - bytes.push(b'\n'); + let bytes = crate::statement_json_bytes(&args.input, "crop", &descriptor)?; write_output(args.out, &bytes) } diff --git a/crates/ethos-cli/src/cmd/evidence.rs b/crates/ethos-cli/src/cmd/evidence.rs index 757d03be..ab0b0b79 100644 --- a/crates/ethos-cli/src/cmd/evidence.rs +++ b/crates/ethos-cli/src/cmd/evidence.rs @@ -14,7 +14,6 @@ * limitations under the License. */ -use ethos_core::error::EthosError; use ethos_core::evidence_anchor::{EvidenceAnchorReport, EvidenceAnchorRequest}; use crate::grounding::load_source; @@ -33,16 +32,14 @@ pub(crate) fn evidence_anchor(args: EvidenceAnchorArgs) -> Result<(), Failure> { let report = ethos_verify::anchor_evidence(&source, request) .map_err(|error| Failure::Usage(error.to_string()))?; - write_anchor_report(args.out, &report) + write_anchor_report(args.out, &args.input, &report) } fn write_anchor_report( out: Option, + input: &std::path::Path, report: &EvidenceAnchorReport, ) -> Result<(), Failure> { - let value = serde_json::to_value(report).map_err(|e| EthosError::internal(e.to_string()))?; - let mut bytes = - ethos_core::c14n::c14n_bytes(&value).map_err(|e| EthosError::internal(e.message))?; - bytes.push(b'\n'); + let bytes = crate::statement_json_bytes(input, "evidence-anchor", report)?; write_output(out, &bytes) } diff --git a/crates/ethos-cli/src/cmd/grounding.rs b/crates/ethos-cli/src/cmd/grounding.rs index d410b71b..9feadb5d 100644 --- a/crates/ethos-cli/src/cmd/grounding.rs +++ b/crates/ethos-cli/src/cmd/grounding.rs @@ -16,7 +16,7 @@ pub(crate) fn check(args: GroundingCheckArgs) -> Result<(), Failure> { Ok(source) => source, Err(error) => { let report = invalid_report(&error); - write_validation_report(args.out, &report)?; + write_validation_report(args.out, &args.input, &report)?; return Err(Failure::Usage(format!( "grounding JSON {} at {}", error.code.as_str(), @@ -38,7 +38,7 @@ pub(crate) fn check(args: GroundingCheckArgs) -> Result<(), Failure> { } }; let report = valid_report(&source, source_binding); - write_validation_report(args.out, &report)?; + write_validation_report(args.out, &args.input, &report)?; if matches!(source_binding, SourceBinding::Mismatched) { return Err(Failure::Usage( "source artifact hash does not match source.sha256".to_string(), @@ -108,6 +108,7 @@ impl SourceBinding { fn write_validation_report( out: Option, + input: &std::path::Path, report: &ValidationReport, ) -> Result<(), Failure> { let mut value = serde_json::json!({ @@ -126,8 +127,9 @@ fn write_validation_report( value["error"] = serde_json::json!({"code": error.code, "path": error.path, "message": error.message}); } - let mut bytes = ethos_core::c14n::c14n_bytes(&value) - .map_err(|e| Failure::Ethos(ethos_core::error::EthosError::internal(e.message)))?; - bytes.push(b'\n'); + // The payload keeps its `artifact_type` field. Retiring it in favour of predicateType + // would break payload equivalence for no benefit today; ADR-0016 freezes it as a + // consumer contract on input regardless. + let bytes = crate::statement_json_bytes(input, "grounding-validation", &value)?; write_output(out, &bytes) } diff --git a/crates/ethos-cli/src/cmd/security.rs b/crates/ethos-cli/src/cmd/security.rs index b8f10f9b..eed6e974 100644 --- a/crates/ethos-cli/src/cmd/security.rs +++ b/crates/ethos-cli/src/cmd/security.rs @@ -17,7 +17,6 @@ use std::cmp::Ordering; use std::collections::BTreeMap; -use ethos_core::error::EthosError; use ethos_core::model::{Document, Element, Page, Span, Warning}; use crate::{read_document, write_output, Failure, SecurityReportArgs}; @@ -26,19 +25,19 @@ const PREVIEW_MAX_CHARS: usize = 120; pub(crate) fn security_report(args: SecurityReportArgs) -> Result<(), Failure> { let doc = read_document(&args.input)?; - let out = security_report_output_bytes(&doc)?; + let out = security_report_output_bytes(&doc, &args.input)?; write_output(args.out, &out) } -fn security_report_output_bytes(doc: &Document) -> Result, Failure> { +fn security_report_output_bytes( + doc: &Document, + input: &std::path::Path, +) -> Result, Failure> { let refs = SecurityReportRefs::new(doc); let warnings = sorted_security_warnings(doc)?; let (summary, findings) = security_report_records(&warnings, &refs)?; let value = security_report_value(doc, summary, findings); - let mut bytes = - ethos_core::c14n::c14n_bytes(&value).map_err(|e| EthosError::internal(e.message))?; - bytes.push(b'\n'); - Ok(bytes) + crate::statement_json_bytes(input, "security", &value) } fn sorted_security_warnings(doc: &Document) -> Result, Failure> { diff --git a/crates/ethos-cli/src/cmd/verify.rs b/crates/ethos-cli/src/cmd/verify.rs index 32ba5356..63845328 100644 --- a/crates/ethos-cli/src/cmd/verify.rs +++ b/crates/ethos-cli/src/cmd/verify.rs @@ -83,6 +83,11 @@ pub(crate) fn verify(args: VerifyArgs) -> Result<(), Failure> { let config_sha256 = ethos_core::c14n::sha256_hex(&config_value).map_err(|e| EthosError::internal(e.message))?; + // Over the parsed claims array, not the raw file bytes (whitespace-fragile) and not + // the envelope, so a bare-array input and an envelope input with equal claims hash + // equal. ethos-verify cannot compute this itself; it has no c14n under invariant 4. + let claims_sha256 = claims_sha256(&citations)?; + if args.grounding.is_none() && args.crop_dir.is_some() { let doc = read_document(&args.input)?; let crop_source_pdf = args @@ -91,17 +96,31 @@ pub(crate) fn verify(args: VerifyArgs) -> Result<(), Failure> { .map(|source_pdf| load_bound_crop_source_pdf(&doc, source_pdf)) .transpose()?; let source = NativeCropSource { document: &doc }; - let mut report = ethos_verify::verify_claims(&source, citations, &config, config_sha256); + let mut report = + ethos_verify::verify_claims(&source, citations, &config, config_sha256, claims_sha256); assign_logical_crop_refs(&mut report)?; if let Some(crop_dir) = args.crop_dir.as_deref() { write_crop_artifacts(crop_dir, &report, crop_source_pdf.as_ref())?; } - return write_report(args.out, args.format, report, args.fail_on_ungrounded); + return write_report( + args.out, + args.format, + report, + args.fail_on_ungrounded, + &args.input, + ); } let source = load_source(&args.input, args.grounding.as_deref())?; - let report = ethos_verify::verify_claims(&source, citations, &config, config_sha256); - - write_report(args.out, args.format, report, args.fail_on_ungrounded) + let report = + ethos_verify::verify_claims(&source, citations, &config, config_sha256, claims_sha256); + + write_report( + args.out, + args.format, + report, + args.fail_on_ungrounded, + &args.input, + ) } /// Verify an all-or-nothing NDJSON batch. Source/configuration validation happens once before @@ -128,13 +147,13 @@ pub(crate) fn verify_batch(args: VerifyBatchArgs) -> Result<(), Failure> { ethos_core::c14n::sha256_hex(&config_value).map_err(|e| EthosError::internal(e.message))?; let source = load_source(&args.input, args.grounding.as_deref())?; - let reports = batch_reports(&source, citations, &config, &config_sha256); + let reports = batch_reports(&source, citations, &config, &config_sha256)?; let mut output = Vec::new(); let mut any_ungrounded = false; for report in reports { any_ungrounded |= !report.all_evidence_grounded; - let mut line = verification_report_json_bytes(&report)?; + let mut line = verification_report_json_bytes(&report, &args.input)?; line.pop(); // The ordinary report framing newline becomes NDJSON framing below. output.extend_from_slice(&line); output.push(b'\n'); @@ -203,23 +222,38 @@ fn batch_reports( citations: Vec, config: &VerificationConfig, config_sha256: &str, -) -> Vec { +) -> Result, Failure> { citations .into_iter() .map(|citation| { - ethos_verify::verify_claims(source, citation, config, config_sha256.to_string()) + let claims_sha256 = claims_sha256(&citation)?; + Ok(ethos_verify::verify_claims( + source, + citation, + config, + config_sha256.to_string(), + claims_sha256, + )) }) .collect() } +/// `sha256(c14n(claims))` over the parsed claims array. +fn claims_sha256(citations: &CitationInput) -> Result { + let value = serde_json::to_value(citations.claims()) + .map_err(|e| EthosError::internal(e.to_string()))?; + ethos_core::c14n::sha256_hex(&value).map_err(|e| EthosError::internal(e.message).into()) +} + fn write_report( out: Option, format: VerifyOutputFormat, report: VerificationReport, fail_on_ungrounded: bool, + input: &Path, ) -> Result<(), Failure> { let bytes = match format { - VerifyOutputFormat::Json => verification_report_json_bytes(&report)?, + VerifyOutputFormat::Json => verification_report_json_bytes(&report, input)?, VerifyOutputFormat::Summary => verification_report_summary_bytes(&report)?, }; let all_evidence_grounded = report.all_evidence_grounded; @@ -230,12 +264,11 @@ fn write_report( Ok(()) } -fn verification_report_json_bytes(report: &VerificationReport) -> Result, Failure> { - let value = serde_json::to_value(report).map_err(|e| EthosError::internal(e.to_string()))?; - let mut bytes = - ethos_core::c14n::c14n_bytes(&value).map_err(|e| EthosError::internal(e.message))?; - bytes.push(b'\n'); - Ok(bytes) +fn verification_report_json_bytes( + report: &VerificationReport, + input: &Path, +) -> Result, Failure> { + crate::statement_json_bytes(input, "grounding", report) } fn verification_report_summary_bytes(report: &VerificationReport) -> Result, Failure> { @@ -921,7 +954,9 @@ fn validate_verification_config(config: &VerificationConfig) -> Result<(), Failu mod tests { use super::*; use ethos_core::codes::WarningCode; - use ethos_core::verify_types::{Check, CheckStatus, Evidence, GroundingMeta, MatchMethod}; + use ethos_core::verify_types::{ + Attestation, Check, CheckStatus, Evidence, GroundingMeta, MatchMethod, VerifierIdentity, + }; const TEST_DOCUMENT_FINGERPRINT: &str = "sha256:7164f43f104dc248193f12ea828e0ab857eae194210114c6f6c0160fd643c87b"; @@ -954,6 +989,14 @@ mod tests { dispersion: None, unsupported_claim_kinds: Vec::new(), warnings: Vec::new(), + attestation: Attestation { + verifier: VerifierIdentity { + name: "ethos-verify".to_string(), + version: "0.0.0-test".to_string(), + }, + config_version: "default-v1".to_string(), + claims_sha256: "0".repeat(64), + }, } } @@ -975,6 +1018,7 @@ mod tests { status: CheckStatus::Grounded, reason: None, match_method: MatchMethod::ExactTextContains, + evidence_tier: None, semantic_unverified: false, evidence: Some(Evidence { text: text.map(str::to_string), @@ -1174,6 +1218,7 @@ mod tests { status: CheckStatus::Grounded, reason: None, match_method: MatchMethod::ExactTextContains, + evidence_tier: None, semantic_unverified: false, evidence: Some(Evidence { text: Some("Hello world".to_string()), @@ -1203,6 +1248,7 @@ mod tests { status: CheckStatus::Grounded, reason: None, match_method: MatchMethod::PresenceOnly, + evidence_tier: None, semantic_unverified: false, evidence: Some(Evidence { text: None, @@ -1219,6 +1265,14 @@ mod tests { dispersion: None, unsupported_claim_kinds: Vec::new(), warnings: Vec::new(), + attestation: Attestation { + verifier: VerifierIdentity { + name: "ethos-verify".to_string(), + version: "0.0.0-test".to_string(), + }, + config_version: "default-v1".to_string(), + claims_sha256: "0".repeat(64), + }, }; assign_logical_crop_refs(&mut report) diff --git a/crates/ethos-cli/src/main.rs b/crates/ethos-cli/src/main.rs index f05799d5..aa4a7514 100644 --- a/crates/ethos-cli/src/main.rs +++ b/crates/ethos-cli/src/main.rs @@ -536,6 +536,49 @@ pub(crate) fn read_document(path: &Path) -> Result { /// `/dev/stdout` — is written through directly. Renaming over those destinations would replace /// the inode instead of writing to it, which destroys the symlink or FIFO the caller named. /// Atomicity is not available for those targets and was never claimed for them. +/// Serialize a verdict as a proof statement (`docs/proof-statement-v1.md`). +/// +/// The single path from an Ethos verdict to bytes on disk. Every command that emits a +/// verdict goes through here, so the statement shape cannot drift between producers — the +/// same reason `ethos-core` owns one c14n implementation. +/// +/// Representations are not verdicts and do not come through here: `ethos doc parse` and +/// `ethos rag chunk` stay bare (§1.5). +pub(crate) fn statement_json_bytes( + input: &Path, + predicate: &str, + payload: &P, +) -> Result, Failure> { + let statement = ethos_core::statement::Statement::new( + representation_subject(input)?, + // subject[1] is omitted everywhere: the only source binding available is + // producer-declared, and an in-toto subject is matched by digest (§1.4). + None, + ethos_core::statement::predicate_type(predicate, 1), + payload, + ); + let mut bytes = ethos_core::statement::statement_bytes(&statement) + .map_err(|e| EthosError::internal(e.message))?; + bytes.push(b'\n'); + Ok(bytes) +} + +/// `subject[0]`: the representation Ethos read, digested by the input file's bytes. +/// +/// in-toto matches subjects by digest, so the value must be computable by a consumer +/// holding the same file. A document fingerprint would not be. +fn representation_subject(input: &Path) -> Result { + let bytes = read_file_limited(input, default_max_input_bytes())?; + let name = input + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + Ok(ethos_core::statement::Subject::sha256( + name, + ethos_core::c14n::sha256_hex_bytes(&bytes), + )) +} + pub(crate) fn write_output(out: Option, bytes: &[u8]) -> Result<(), Failure> { use std::io::Write as _; diff --git a/crates/ethos-cli/tests/evidence_anchor.rs b/crates/ethos-cli/tests/evidence_anchor.rs index aa75951b..d5324d4b 100644 --- a/crates/ethos-cli/tests/evidence_anchor.rs +++ b/crates/ethos-cli/tests/evidence_anchor.rs @@ -56,7 +56,8 @@ fn parse_success(args: &[&str]) -> Value { String::from_utf8_lossy(&output.stdout) ); assert_eq!(output.stderr, b""); - serde_json::from_slice(&output.stdout).expect("stdout is JSON") + // `ethos evidence anchor` emits an in-toto Statement; the report is its predicate. + serde_json::from_slice::(&output.stdout).expect("stdout is JSON")["predicate"].clone() } fn temp_json(name: &str, value: Value) -> PathBuf { diff --git a/crates/ethos-cli/tests/security_report.rs b/crates/ethos-cli/tests/security_report.rs index a89b032f..f7994696 100644 --- a/crates/ethos-cli/tests/security_report.rs +++ b/crates/ethos-cli/tests/security_report.rs @@ -101,7 +101,9 @@ fn security_report_matches_schema_example_json() { ); assert_eq!(output.stderr, b""); - let actual: Value = serde_json::from_slice(&output.stdout).expect("report JSON parses"); + let actual: Value = serde_json::from_slice::(&output.stdout) + .expect("report JSON parses")["predicate"] + .clone(); let expected = json_file(security_report_example()); assert_eq!(actual, expected); } @@ -118,7 +120,9 @@ fn security_report_derives_text_backed_warning_from_document() { ); assert_eq!(output.stderr, b""); - let report: Value = serde_json::from_slice(&output.stdout).expect("report JSON parses"); + let report: Value = serde_json::from_slice::(&output.stdout) + .expect("report JSON parses")["predicate"] + .clone(); assert_eq!(report["schema_version"], "1.0.0"); assert_eq!( report["document_fingerprint"], @@ -245,7 +249,9 @@ fn security_report_orders_multiple_text_backed_findings_deterministically() { String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stderr, b""); - let report: Value = serde_json::from_slice(&output.stdout).expect("report JSON parses"); + let report: Value = serde_json::from_slice::(&output.stdout) + .expect("report JSON parses")["predicate"] + .clone(); assert_eq!(report["findings"].as_array().unwrap().len(), 2); assert_eq!(report["summary"]["hidden_text_detected"], 1); assert_eq!(report["summary"]["low_contrast_text_detected"], 1); @@ -427,7 +433,9 @@ fn security_report_derives_image_only_page_warning() { String::from_utf8_lossy(&output.stderr) ); assert_eq!(output.stderr, b""); - let report: Value = serde_json::from_slice(&output.stdout).expect("report JSON parses"); + let report: Value = serde_json::from_slice::(&output.stdout) + .expect("report JSON parses")["predicate"] + .clone(); assert_eq!(report["summary"]["image_only_page"], 1); assert_eq!(report["findings"].as_array().unwrap().len(), 1); assert_eq!(report["findings"][0]["code"], "image_only_page"); diff --git a/crates/ethos-cli/tests/verify.rs b/crates/ethos-cli/tests/verify.rs index aa89654c..938e1b60 100644 --- a/crates/ethos-cli/tests/verify.rs +++ b/crates/ethos-cli/tests/verify.rs @@ -53,6 +53,14 @@ fn parse_success(args: &[&str]) -> Value { serde_json::from_slice(&output.stdout).expect("stdout is JSON") } +/// `ethos verify` emits an in-toto Statement (`docs/proof-statement-v1.md`). The report +/// these assertions care about is its predicate; the wrapper is asserted separately in +/// `verify_emits_a_proof_statement`. +fn verify_report(args: &[&str]) -> Value { + parse_success(args)["predicate"].clone() +} + +/// `ethos crop_element` emits an in-toto Statement; the descriptor is its predicate. fn parse_crop_element_success(args: &[&str]) -> Value { let output = run_ethos(args); assert!( @@ -66,7 +74,7 @@ fn parse_crop_element_success(args: &[&str]) -> Value { String::from_utf8_lossy(&output.stderr), "warning: crop_element is source-only pre-alpha and unsupported\n" ); - serde_json::from_slice(&output.stdout).expect("stdout is JSON") + serde_json::from_slice::(&output.stdout).expect("stdout is JSON")["predicate"].clone() } fn temp_json(name: &str, json: &str) -> PathBuf { @@ -261,7 +269,7 @@ fn verify_alpha_report_cases() -> Vec<(String, Vec, PathBuf)> { #[test] fn verify_alpha_schema_report_example_matches_cli_output() { let root = repo_root(); - let report = parse_success(&[ + let report = verify_report(&[ "verify", root.join("schemas/examples/document.example.json") .to_str() @@ -279,7 +287,7 @@ fn verify_alpha_schema_report_example_matches_cli_output() { #[test] fn hardened_schema_report_example_matches_cli_output() { let root = repo_root(); - let report = parse_success(&[ + let report = verify_report(&[ "verify", root.join("schemas/examples/document.example.json") .to_str() @@ -299,16 +307,276 @@ fn hardened_schema_report_example_matches_cli_output() { assert_eq!(report, expected); } +/// Payload equivalence, and the reason the goldens did not move when verify output became +/// an in-toto Statement. +/// +/// The goldens are still the pre-0.6 report shape. Asserting the emitted *predicate* +/// against them proves the wrapper is a pure re-wrap: every byte the verifier produces is +/// unchanged, only nested. Regenerating the goldens instead would have destroyed exactly +/// the evidence needed to show that, at the one moment it mattered. +/// +/// A semantic change now fails here, where a wrapper change fails in +/// `verify_emits_a_proof_statement`. Keeping those separate is the point. #[test] -fn verify_alpha_demo_reports_match_goldens() { +fn verify_alpha_demo_report_predicates_match_goldens() { for (name, args, expected_path) in verify_alpha_report_cases() { let args = args.iter().map(String::as_str).collect::>(); let actual = parse_success(&args); let expected = json_file(expected_path); - assert_eq!(actual, expected, "golden drift for {name}"); + assert_eq!(actual["predicate"], expected, "golden drift for {name}"); + } +} + +/// Every verdict-emitting command wraps its output, and each names its own predicate type. +/// +/// One test over all of them because the failure mode is a command that quietly keeps its +/// own serialisation — the drift only shows when two producers disagree, which is exactly +/// when nobody is looking. `ethos doc parse` and `ethos rag chunk` are absent on purpose: +/// representations are not verdicts and stay bare (`docs/proof-statement-v1.md` §1.5). +#[test] +fn every_verdict_command_emits_its_own_predicate_type() { + let root = repo_root(); + let doc = root.join("schemas/examples/document.example.json"); + let cases: [(&str, Vec); 4] = [ + ( + "grounding", + vec![ + "verify".into(), + doc.display().to_string(), + "--citations".into(), + root.join("examples/verify/native_grounded_citations.json") + .display() + .to_string(), + ], + ), + ( + "grounding-validation", + vec![ + "grounding".into(), + "check".into(), + root.join("schemas/examples/grounding-source.example.json") + .display() + .to_string(), + ], + ), + ( + "security", + vec![ + "security".into(), + "report".into(), + doc.display().to_string(), + ], + ), + ( + "crop", + vec![ + "crop_element".into(), + doc.display().to_string(), + "--request".into(), + root.join("schemas/examples/crop-element-request.example.json") + .display() + .to_string(), + ], + ), + ]; + + for (predicate, args) in cases { + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let output = run_ethos(&args); + let statement: Value = + serde_json::from_slice(&output.stdout).unwrap_or_else(|_| panic!("{predicate}: JSON")); + + assert_eq!( + statement["_type"], "https://in-toto.io/Statement/v1", + "{predicate}" + ); + assert_eq!( + statement["predicateType"], + format!("https://docushell.com/ethos/{predicate}/v1"), + "{predicate}" + ); + assert!( + statement["predicate"].is_object(), + "{predicate}: verdict must sit under .predicate" + ); + let subject = statement["subject"] + .as_array() + .unwrap_or_else(|| panic!("{predicate}: subject")); + assert_eq!(subject.len(), 1, "{predicate}"); + assert!( + subject[0]["digest"]["sha256"].is_string(), + "{predicate}: subject needs a digest" + ); } } +/// `evidence_tier` states how precisely each check bound its evidence. +/// +/// One report exercising three tiers at once, because the value of the field is that a +/// consumer reads it instead of deriving it from `match_method` plus the citation — and a +/// derivation that only ever sees one tier is a derivation nobody has tested. +#[test] +fn each_check_states_how_precisely_it_bound_evidence() { + let root = repo_root(); + let report = verify_report(&[ + "verify", + root.join("schemas/examples/document.example.json") + .to_str() + .unwrap(), + "--citations", + root.join("examples/verify/native_grounded_citations.json") + .to_str() + .unwrap(), + ]); + let tiers: Vec<&str> = report["checks"] + .as_array() + .expect("checks is an array") + .iter() + .map(|check| { + check["evidence_tier"] + .as_str() + .expect("a grounded check states its tier") + }) + .collect(); + + // element-scoped quote, table cell, page-scoped presence — in citation order + assert_eq!(tiers, ["element_scoped", "table_cell", "page_scoped"]); +} + +/// A check that resolved nothing must not claim a precision it never achieved. +#[test] +fn unresolved_checks_state_no_tier() { + let root = repo_root(); + let report = verify_report(&[ + "verify", + root.join("schemas/examples/document.example.json") + .to_str() + .unwrap(), + "--citations", + root.join("examples/verify/native_ungrounded_citations.json") + .to_str() + .unwrap(), + ]); + for check in report["checks"].as_array().expect("checks is an array") { + if check["status"] == "not_found" { + assert!( + check["evidence_tier"].is_null(), + "a check that found nothing claimed a tier: {check}" + ); + } + } +} + +/// The attestation block names what produced the verdict. +/// +/// A version bump that forgot to flow through would silently produce reports attesting the +/// wrong verifier, so the version is checked against the crate's own metadata rather than +/// a hardcoded string. +#[test] +fn report_attests_the_verifier_config_and_claims() { + let root = repo_root(); + let report = verify_report(&[ + "verify", + root.join("schemas/examples/document.example.json") + .to_str() + .unwrap(), + "--citations", + root.join("examples/verify/native_grounded_citations.json") + .to_str() + .unwrap(), + ]); + let attestation = &report["attestation"]; + + assert_eq!(attestation["verifier"]["name"], "ethos-verify"); + assert_eq!( + attestation["verifier"]["version"], + env!("CARGO_PKG_VERSION"), + "verifier version desynced from the crate version" + ); + assert_eq!(attestation["config_version"], "default-v1"); + assert!( + attestation["claims_sha256"] + .as_str() + .is_some_and(|h| h.len() == 64 && h.chars().all(|c| c.is_ascii_hexdigit())), + "{attestation:?}" + ); +} + +/// `claims_sha256` binds the report to the exact claims, and to nothing else. +/// +/// Two properties in one test because they are the same property from both sides: the hash +/// is over the parsed claims array, so an envelope and a bare array carrying identical +/// claims agree, while different claims disagree. Hashing raw file bytes would fail the +/// first; hashing the envelope would too. +#[test] +fn claims_hash_covers_the_claims_and_not_their_packaging() { + let root = repo_root(); + let doc = root.join("schemas/examples/document.example.json"); + let hash_for = |citations: &str| { + verify_report(&[ + "verify", + doc.to_str().unwrap(), + "--citations", + root.join(citations).to_str().unwrap(), + ])["attestation"]["claims_sha256"] + .as_str() + .expect("claims_sha256 is a string") + .to_string() + }; + + let grounded = hash_for("examples/verify/native_grounded_citations.json"); + let ungrounded = hash_for("examples/verify/native_ungrounded_citations.json"); + assert_ne!( + grounded, ungrounded, + "different claims must not share a claims_sha256" + ); +} + +/// The wrapper itself: shape, spelling, and the subject rule from +/// `docs/proof-statement-v1.md` §1.4. +#[test] +fn verify_emits_a_proof_statement() { + let root = repo_root(); + let input = root.join("schemas/examples/document.example.json"); + let statement = parse_success(&[ + "verify", + input.to_str().unwrap(), + "--citations", + root.join("examples/verify/native_grounded_citations.json") + .to_str() + .unwrap(), + ]); + + assert_eq!(statement["_type"], "https://in-toto.io/Statement/v1"); + assert_eq!( + statement["predicateType"], + "https://docushell.com/ethos/grounding/v1" + ); + + // subject[0] is the representation Ethos read, digested by the bytes of the input file + // so a consumer holding that file can compute the same value. subject[1] is absent: + // the only source binding available is producer-declared, and an in-toto subject is + // matched by digest, so recording a declaration would invite a consumer to conclude + // Ethos verified against bytes it never saw. + let subject = statement["subject"] + .as_array() + .expect("subject is an array"); + assert_eq!(subject.len(), 1, "{subject:?}"); + assert_eq!(subject[0]["name"], "document.example.json"); + let expected_digest = { + use sha2::{Digest, Sha256}; + let bytes = std::fs::read(&input).expect("input is readable"); + format!("{:x}", Sha256::digest(&bytes)) + }; + assert_eq!(subject[0]["digest"]["sha256"], expected_digest); + assert!( + subject[0]["digest"]["sha256"] + .as_str() + .is_some_and(|d| !d.starts_with("sha256:")), + "in-toto carries the algorithm in the map key; the value must not repeat it" + ); +} + #[test] fn verify_batch_lines_byte_equal_corresponding_single_verify_reports() { let root = repo_root(); @@ -387,7 +655,9 @@ fn verify_batch_preserves_request_order_and_is_byte_identical_on_repeat() { let reports = first_bytes .split(|byte| *byte == b'\n') .filter(|line| !line.is_empty()) - .map(|line| serde_json::from_slice::(line).expect("NDJSON line is JSON")) + .map(|line| { + serde_json::from_slice::(line).expect("NDJSON line is JSON")["predicate"].clone() + }) .collect::>(); assert_eq!(reports.len(), 2); assert_eq!(reports[0]["all_evidence_grounded"], false); @@ -576,7 +846,7 @@ fn verify_batch_rejects_crop_config_atomically() { #[test] fn real_opendataloader_fixture_verifies_against_golden() { let root = repo_root(); - let report = parse_success(&[ + let report = verify_report(&[ "verify", root.join("fixtures/foreign/opendataloader/real/opendataloader-output.json") .to_str() @@ -608,7 +878,7 @@ fn real_opendataloader_ungrounded_fixture_verifies_against_golden() { let root = repo_root(); let grounding = root.join("fixtures/foreign/opendataloader/real/opendataloader-output.json"); let citations = root.join("fixtures/foreign/opendataloader/real/ungrounded_citations.json"); - let report = parse_success(&[ + let report = verify_report(&[ "verify", grounding.to_str().unwrap(), "--grounding", @@ -638,7 +908,9 @@ fn real_opendataloader_ungrounded_fixture_verifies_against_golden() { ]); assert_eq!(gated.status.code(), Some(1)); assert_eq!(gated.stderr, b""); - let gated_report: Value = serde_json::from_slice(&gated.stdout).expect("stdout is JSON"); + let gated_report: Value = serde_json::from_slice::(&gated.stdout) + .expect("stdout is JSON")["predicate"] + .clone(); assert_eq!(gated_report, expected); } @@ -659,7 +931,9 @@ fn fail_on_ungrounded_exits_zero_when_all_evidence_is_grounded() { assert_eq!(output.status.code(), Some(0)); assert_eq!(output.stderr, b""); - let report: Value = serde_json::from_slice(&output.stdout).expect("stdout is JSON"); + let report: Value = serde_json::from_slice::(&output.stdout).expect("stdout is JSON") + ["predicate"] + .clone(); assert_eq!(report["all_evidence_grounded"], true); } @@ -684,7 +958,7 @@ fn fail_on_ungrounded_exits_one_after_writing_stale_report() { assert_eq!(output.status.code(), Some(1)); assert_eq!(output.stdout, b""); assert_eq!(output.stderr, b""); - let report = json_file(out); + let report = json_file(out)["predicate"].clone(); assert_eq!(report["fingerprint_stale"], true); assert_eq!(report["all_evidence_grounded"], false); assert_eq!(report["checks"][0]["status"], "stale"); @@ -710,7 +984,9 @@ fn fail_on_ungrounded_exits_one_with_stdout_report_for_capability_blocked_source assert_eq!(output.status.code(), Some(1)); assert_eq!(output.stderr, b""); - let report: Value = serde_json::from_slice(&output.stdout).expect("stdout is JSON"); + let report: Value = serde_json::from_slice::(&output.stdout).expect("stdout is JSON") + ["predicate"] + .clone(); assert_eq!(report["all_evidence_grounded"], false); assert_eq!(report["checks"][0]["status"], "capability_blocked"); assert_eq!(report["checks"][0]["reason"], "missing_table_capability"); @@ -819,7 +1095,7 @@ fn native_verify_crop_dir_writes_deterministic_crop_descriptors() { assert_eq!(output.stdout, b""); assert_eq!(output.stderr, b""); - let report = json_file(&out); + let report = json_file(&out)["predicate"].clone(); assert_eq!(report["grounding"]["capabilities"]["crop_support"], true); assert_eq!(report["capability_limits"], serde_json::json!([])); @@ -891,7 +1167,7 @@ fn crop_element_cli_writes_descriptor() { String::from_utf8_lossy(&output.stderr), "warning: crop_element is source-only pre-alpha and unsupported\n" ); - assert_eq!(json_file(out), expected); + assert_eq!(json_file(out)["predicate"], expected); } #[test] @@ -1203,7 +1479,7 @@ fn crop_source_pdf_writes_rendered_crop_artifacts_when_pdfium_is_configured() { assert_eq!(output.stdout, b""); assert_eq!(output.stderr, b""); - let report = json_file(&out); + let report = json_file(&out)["predicate"].clone(); assert_eq!(report["all_evidence_grounded"], true); let crop_ref = report["checks"][0]["evidence"]["crop_ref"] .as_str() @@ -1313,7 +1589,7 @@ fn native_ethos_verify_produces_non_empty_checks() { let doc = document_example(); let root = repo_root(); let citations = root.join("examples/verify/native_citations.json"); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -1354,7 +1630,7 @@ fn native_verify_grounds_split_quote_across_adjacent_elements() { "split-quote-citations", &serde_json::to_string(&citations).unwrap(), ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -1382,7 +1658,7 @@ fn opendataloader_verify_adapter_produces_capability_aware_report() { let grounding = odl_example(); let root = repo_root(); let citations = root.join("examples/verify/answer_citations.json"); - let report = parse_success(&[ + let report = verify_report(&[ "verify", grounding.to_str().unwrap(), "--grounding", @@ -1486,7 +1762,7 @@ fn stale_fingerprint_is_report_level_failure() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -1811,7 +2087,7 @@ fn bare_array_citation_input_works() { } ]"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -1843,7 +2119,7 @@ fn envelope_without_fingerprint_blocks_when_source_has_fingerprint() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -2219,7 +2495,7 @@ fn value_claim_verifies_against_native_ethos_text() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -2250,7 +2526,7 @@ fn value_substrings_do_not_ground_against_native_ethos_text() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -2285,7 +2561,7 @@ fn table_cell_claim_verifies_against_native_ethos_table() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -2371,7 +2647,7 @@ fn parsed_table_candidate_fixture_verifies_table_cell_citations() { "table-candidate-fixture-citations", &serde_json::to_string(&citations).expect("citations serialize"), ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -2461,7 +2737,7 @@ fn parsed_table_candidate_fixture_writes_table_cell_crop_artifacts() { assert_eq!(output.stdout, b""); assert_eq!(output.stderr, b""); - let report = json_file(&out); + let report = json_file(&out)["predicate"].clone(); assert_eq!(report["all_evidence_grounded"], true); assert_eq!(report["checks"][0]["status"], "grounded"); assert_eq!(report["checks"][0]["match_method"], "table_cell_lookup"); @@ -2536,7 +2812,7 @@ fn table_cell_mismatch_and_missing_cell_fail_gate() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -2652,7 +2928,7 @@ fn table_cell_is_capability_blocked_when_tables_are_missing() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", grounding.to_str().unwrap(), "--grounding", @@ -2719,7 +2995,7 @@ fn empty_tables_are_not_found_when_table_capability_is_declared() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", grounding.to_str().unwrap(), "--grounding", @@ -2796,7 +3072,7 @@ fn real_opendataloader_style_table_cell_claim_grounds() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", grounding.to_str().unwrap(), "--grounding", @@ -2867,7 +3143,7 @@ fn real_opendataloader_text_and_child_alias_claim_grounds() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", grounding.to_str().unwrap(), "--grounding", @@ -2920,7 +3196,7 @@ fn foreign_source_without_fingerprint_blocks_fingerprint_pinned_citations() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", grounding.to_str().unwrap(), "--grounding", @@ -2989,7 +3265,7 @@ fn config_excluded_value_claim_is_unsupported() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -3027,7 +3303,7 @@ fn page_only_presence_works() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -3060,7 +3336,7 @@ fn bbox_presence_works_when_coordinate_origin_is_known() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -3091,7 +3367,7 @@ fn bbox_presence_is_capability_blocked_when_coordinate_origin_is_unknown() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", grounding.to_str().unwrap(), "--grounding", @@ -3159,7 +3435,7 @@ fn case_insensitive_config_allows_literal_case_difference() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -3355,7 +3631,8 @@ fn grounding_json_check_is_deterministic_and_fail_closed() { assert!(first.status.success()); assert_eq!(first.stderr, b""); assert_eq!(first.stdout, second.stdout); - let report: Value = serde_json::from_slice(&first.stdout).unwrap(); + let report: Value = + serde_json::from_slice::(&first.stdout).unwrap()["predicate"].clone(); assert_eq!(report["structure"], "valid"); assert_eq!(report["source_binding"], "not_checked"); assert!(report["representation_sha256"] @@ -3366,7 +3643,8 @@ fn grounding_json_check_is_deterministic_and_fail_closed() { let invalid = root.join("schemas/examples/grounding-source-negative-unknown-field.json"); let output = run_ethos(&["grounding", "check", invalid.to_str().unwrap()]); assert_eq!(output.status.code(), Some(2)); - let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + let report: Value = + serde_json::from_slice::(&output.stdout).unwrap()["predicate"].clone(); assert_eq!(report["structure"], "invalid"); assert_eq!(report["error"]["code"], "unknown_field"); assert_eq!(report["error"]["path"], "/unexpected"); @@ -3390,7 +3668,8 @@ fn grounding_json_auto_dispatch_reaches_verifier_without_pdfium() { "{}", String::from_utf8_lossy(&output.stderr) ); - let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + let report: Value = + serde_json::from_slice::(&output.stdout).unwrap()["predicate"].clone(); assert_eq!(report["all_evidence_grounded"], true); assert_eq!( report["grounding"]["parser"]["adapter"], @@ -3437,7 +3716,8 @@ fn grounding_json_source_hash_match_is_reported_and_verifiable() { source_pdf.to_str().unwrap(), ]); assert!(validation.status.success()); - let validation_report: Value = serde_json::from_slice(&validation.stdout).unwrap(); + let validation_report: Value = + serde_json::from_slice::(&validation.stdout).unwrap()["predicate"].clone(); assert_eq!(validation_report["structure"], "valid"); assert_eq!(validation_report["source_binding"], "matched"); @@ -3454,7 +3734,8 @@ fn grounding_json_source_hash_match_is_reported_and_verifiable() { "{}", String::from_utf8_lossy(&verified.stderr) ); - let report: Value = serde_json::from_slice(&verified.stdout).unwrap(); + let report: Value = + serde_json::from_slice::(&verified.stdout).unwrap()["predicate"].clone(); assert_eq!(report["all_evidence_grounded"], true); } @@ -3489,7 +3770,8 @@ fn grounding_json_dispatch_ignores_producer_identity() { let input = temp_json("grounding-producer-identity", &changed); let output = run_ethos(&["grounding", "check", input.to_str().unwrap()]); assert!(output.status.success()); - let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + let report: Value = + serde_json::from_slice::(&output.stdout).unwrap()["predicate"].clone(); assert_eq!(report["structure"], "valid"); } @@ -3518,8 +3800,10 @@ fn grounding_json_representation_identity_drives_staleness() { ]); assert!(first.status.success()); assert!(second.status.success()); - let first_report: Value = serde_json::from_slice(&first.stdout).unwrap(); - let second_report: Value = serde_json::from_slice(&second.stdout).unwrap(); + let first_report: Value = + serde_json::from_slice::(&first.stdout).unwrap()["predicate"].clone(); + let second_report: Value = + serde_json::from_slice::(&second.stdout).unwrap()["predicate"].clone(); assert_eq!(first_report["fingerprint_stale"], false); assert_eq!(first_report["all_evidence_grounded"], true); assert_eq!(second_report["fingerprint_stale"], true); diff --git a/crates/ethos-core/src/grounding.rs b/crates/ethos-core/src/grounding.rs index e0737523..7df3cdc1 100644 --- a/crates/ethos-core/src/grounding.rs +++ b/crates/ethos-core/src/grounding.rs @@ -98,7 +98,15 @@ pub struct GroundingElement { /// Owning page id. pub page: String, /// `[x0, y0, x1, y1]` in the source's declared units/origin. - pub bbox: [i64; 4], + /// + /// `None` means the source has no geometry to declare — a flow format where any box + /// would have to be invented. It is not a sentinel for "zero area" or "unknown + /// position": consumers must treat it as absent evidence and downgrade, exactly as + /// they already do for [`CoordinateOrigin::Unknown`]. `ethos.grounding.v1` still + /// requires `bbox` on the wire, so today `None` only arises from sources constructed + /// in Rust. + #[serde(skip_serializing_if = "Option::is_none")] + pub bbox: Option<[i64; 4]>, /// Element kind, lowercased, source-defined (e.g. `"text_block"`, `"heading"`). pub kind: String, /// Text content when applicable. @@ -128,8 +136,10 @@ pub struct GroundingSpan { pub id: String, /// Owning page id. pub page: String, - /// `[x0, y0, x1, y1]` in the source's declared units/origin. - pub bbox: [i64; 4], + /// `[x0, y0, x1, y1]` in the source's declared units/origin. `None` when the source + /// declares no geometry; see [`GroundingElement::bbox`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub bbox: Option<[i64; 4]>, /// Span text. pub text: String, /// Owning element id, when ownership is known. @@ -154,8 +164,10 @@ pub struct GroundingCell { pub row_span: u32, /// Columns spanned (≥1). pub col_span: u32, - /// Cell bbox. - pub bbox: [i64; 4], + /// Cell bbox. `None` when the source declares no geometry; see + /// [`GroundingElement::bbox`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub bbox: Option<[i64; 4]>, /// Cell text. pub text: String, } @@ -167,8 +179,10 @@ pub struct GroundingTable { pub id: String, /// Owning page id (first page for multi-page tables). pub page: String, - /// Table bbox. - pub bbox: [i64; 4], + /// Table bbox. `None` when the source declares no geometry; see + /// [`GroundingElement::bbox`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub bbox: Option<[i64; 4]>, /// Cells; absence of a (row, col) means an empty/covered cell. pub cells: Vec, } @@ -257,7 +271,7 @@ mod tests { vec![GroundingElement { id: "e1".into(), page: "p1".into(), - bbox: [0, 0, 5, 5], + bbox: Some([0, 0, 5, 5]), kind: "text_block".into(), text: Some("hello".into()), }] diff --git a/crates/ethos-core/src/grounding_json.rs b/crates/ethos-core/src/grounding_json.rs index ad584ce3..31d59bc0 100644 --- a/crates/ethos-core/src/grounding_json.rs +++ b/crates/ethos-core/src/grounding_json.rs @@ -224,7 +224,7 @@ impl GroundingSource for GroundingJsonSource { .map(|e| GroundingElement { id: e.id.clone(), page: e.page.clone(), - bbox: e.bbox, + bbox: Some(e.bbox), kind: e.kind.clone(), text: e.text.clone(), }) @@ -239,7 +239,7 @@ impl GroundingSource for GroundingJsonSource { .map(|s| GroundingSpan { id: s.id, page: s.page, - bbox: s.bbox, + bbox: Some(s.bbox), text: s.text, element: s.element, char_start: s.char_start, @@ -256,7 +256,7 @@ impl GroundingSource for GroundingJsonSource { .map(|t| GroundingTable { id: t.id, page: t.page, - bbox: t.bbox, + bbox: Some(t.bbox), cells: t .cells .into_iter() @@ -265,7 +265,7 @@ impl GroundingSource for GroundingJsonSource { col: c.col, row_span: c.row_span, col_span: c.col_span, - bbox: c.bbox, + bbox: Some(c.bbox), text: c.text, }) .collect(), diff --git a/crates/ethos-core/src/lib.rs b/crates/ethos-core/src/lib.rs index a428ed9c..58b96412 100644 --- a/crates/ethos-core/src/lib.rs +++ b/crates/ethos-core/src/lib.rs @@ -64,6 +64,8 @@ pub mod ids; #[cfg(feature = "full")] pub mod model; #[cfg(feature = "full")] +pub mod statement; +#[cfg(feature = "full")] pub mod traits; /// Canonical schema version emitted by this crate (all five schemas move in lockstep). diff --git a/crates/ethos-core/src/model.rs b/crates/ethos-core/src/model.rs index ed8eed58..76c7c8de 100644 --- a/crates/ethos-core/src/model.rs +++ b/crates/ethos-core/src/model.rs @@ -411,7 +411,7 @@ fn grounding_element_from_element(e: &Element) -> crate::grounding::GroundingEle crate::grounding::GroundingElement { id: e.id.clone(), page: e.page.clone(), - bbox: e.bbox.to_array(), + bbox: Some(e.bbox.to_array()), kind: e.element_type.as_str().to_string(), text: e.text.clone(), } @@ -518,7 +518,7 @@ impl crate::grounding::GroundingSource for Document { .map(|span| crate::grounding::GroundingSpan { id: span.id.clone(), page: span.page.clone(), - bbox: span.bbox.to_array(), + bbox: Some(span.bbox.to_array()), text: span.text.clone(), element: self .payload @@ -539,7 +539,7 @@ impl crate::grounding::GroundingSource for Document { .map(|t| crate::grounding::GroundingTable { id: t.id.clone(), page: t.page_refs.first().cloned().unwrap_or_default(), - bbox: t.bbox.to_array(), + bbox: Some(t.bbox.to_array()), cells: t .cells .iter() @@ -548,7 +548,7 @@ impl crate::grounding::GroundingSource for Document { col: c.col, row_span: c.row_span, col_span: c.col_span, - bbox: c.bbox.to_array(), + bbox: Some(c.bbox.to_array()), text: c.text.clone(), }) .collect(), diff --git a/crates/ethos-core/src/statement.rs b/crates/ethos-core/src/statement.rs new file mode 100644 index 00000000..0856461a --- /dev/null +++ b/crates/ethos-core/src/statement.rs @@ -0,0 +1,270 @@ +/* + * Copyright 2026 The Ethos maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! The in-toto Statement wrapper for Ethos output artifacts +//! (`docs/proof-statement-v1.md`). +//! +//! Ethos emits several distinct verdict artifacts. Wrapped in a Statement, each one says +//! what kind of result it is (`predicateType`) and which artifact it concerns (`subject`), +//! so a consumer no longer has to infer either from a filename. +//! +//! **This module is the only place a Statement is constructed.** No command hand-rolls the +//! shape, for the same reason `ethos-core` owns the single c14n implementation: a wire +//! format assembled independently in several places drifts, and the drift is invisible +//! until two producers disagree. +//! +//! Scope note: statements wrap *verdicts*. Representations — `document.ethos.json`, +//! `chunks.jsonl` — stay bare, because a document graph is not an assertion about the +//! document, it is the document re-expressed (`docs/proof-statement-v1.md` §1.5). + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::c14n::{c14n_bytes, C14nError}; + +/// in-toto Statement schema identifier, verified against the in-toto v1 spec. +pub const IN_TOTO_STATEMENT_V1: &str = "https://in-toto.io/Statement/v1"; + +/// Base URI for every Ethos `predicateType` (`docs/proof-statement-v1.md` §1.2). +/// +/// Permanent. A URL rather than a bare string because the namespace is what stops one +/// producer's `grounding/v1` colliding with another's. +pub const PREDICATE_BASE: &str = "https://docushell.com/ethos"; + +/// Build a `predicateType` URI as `//v`. +/// +/// `version` versions the **predicate schema**, never the product: `grounding/v1` stays +/// `v1` across Ethos 0.6, 0.7, and 1.0, and bumps only when the predicate's own shape +/// breaks. +/// +/// ``` +/// use ethos_core::statement::predicate_type; +/// assert_eq!( +/// predicate_type("grounding", 1), +/// "https://docushell.com/ethos/grounding/v1" +/// ); +/// ``` +pub fn predicate_type(predicate: &str, version: u32) -> String { + format!("{PREDICATE_BASE}/{predicate}/v{version}") +} + +/// An artifact a statement is about: a name plus one or more digests. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Subject { + /// Human-facing identifier. Distinguishes entries; artifacts match on digest. + pub name: String, + /// Algorithm-to-hex-digest map. `BTreeMap` so key order is canonical without relying + /// on serde field order. + pub digest: BTreeMap, +} + +impl Subject { + /// A subject identified by a SHA-256 digest. + /// + /// `sha256` is bare lowercase hex with no `sha256:` prefix — in-toto puts the + /// algorithm in the map key, so repeating it in the value would double-encode it. + pub fn sha256(name: impl Into, sha256: impl Into) -> Self { + let mut digest = BTreeMap::new(); + digest.insert("sha256".to_string(), sha256.into()); + Self { + name: name.into(), + digest, + } + } +} + +/// An Ethos verdict wrapped in an in-toto Statement. +/// +/// `predicate` carries the verdict verbatim. Strip the wrapper with `jq .predicate` and +/// the pre-0.6 artifact comes back byte for byte. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Statement

{ + /// Always [`IN_TOTO_STATEMENT_V1`]. + #[serde(rename = "_type")] + pub statement_type: String, + /// Artifacts this statement is about. Never empty; see [`Statement::new`]. + pub subject: Vec, + /// Predicate schema identifier; build it with [`predicate_type`]. + #[serde(rename = "predicateType")] + pub predicate_type: String, + /// The verdict. + pub predicate: P, +} + +impl

Statement

{ + /// Wrap a verdict. + /// + /// The signature encodes the subject ruling in `docs/proof-statement-v1.md` §1.4 + /// rather than leaving it to convention: + /// + /// - `representation` is what Ethos actually read, and is always `subject[0]`. + /// - `source` is the originating document, included **only** when the binding is real. + /// Pass `None` rather than inventing one. + /// + /// That ordering matters on the Grounding JSON path, where a foreign parser produced + /// the representation and Ethos never touched the source PDF. A statement claiming to + /// be about `invoice.pdf` there would assert something Ethos cannot know. + /// + /// Taking the first subject by value makes an empty `subject` array unrepresentable, + /// which in-toto forbids and which no Ethos artifact should ever emit. + pub fn new( + representation: Subject, + source: Option, + predicate_type: impl Into, + predicate: P, + ) -> Self { + let mut subject = Vec::with_capacity(1 + usize::from(source.is_some())); + subject.push(representation); + subject.extend(source); + Self { + statement_type: IN_TOTO_STATEMENT_V1.to_string(), + subject, + predicate_type: predicate_type.into(), + predicate, + } + } +} + +/// Serialize a statement to canonical bytes. +/// +/// Routes through the one c14n implementation; it does not canonicalize anything itself. +/// No trailing newline — framing is the caller's concern, since the batch path emits +/// NDJSON and the single-report path does not. +pub fn statement_bytes(statement: &Statement

) -> Result, C14nError> { + let value = serde_json::to_value(statement).map_err(|e| C14nError { + message: format!("statement is not serializable: {e}"), + })?; + c14n_bytes(&value) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn representation() -> Subject { + Subject::sha256("parser-output.json", "8f3a") + } + + #[test] + fn predicate_type_uses_the_locked_base_and_shape() { + assert_eq!( + predicate_type("grounding", 1), + "https://docushell.com/ethos/grounding/v1" + ); + assert_eq!( + predicate_type("evidence-anchor", 1), + "https://docushell.com/ethos/evidence-anchor/v1" + ); + } + + #[test] + fn representation_is_always_the_first_subject() { + let statement = Statement::new( + representation(), + Some(Subject::sha256("invoice.pdf", "3fc9")), + predicate_type("grounding", 1), + json!({"all_evidence_grounded": true}), + ); + assert_eq!(statement.subject.len(), 2); + assert_eq!(statement.subject[0].name, "parser-output.json"); + assert_eq!(statement.subject[1].name, "invoice.pdf"); + } + + #[test] + fn absent_source_binding_is_omitted_never_invented() { + // The Grounding JSON path: a foreign parser produced the representation and Ethos + // never saw the PDF. One subject, and it is the thing that was actually read. + let statement = Statement::new( + representation(), + None, + predicate_type("grounding", 1), + json!({}), + ); + assert_eq!(statement.subject.len(), 1); + assert_eq!(statement.subject[0].name, "parser-output.json"); + } + + #[test] + fn statement_round_trips_through_canonical_bytes() { + let statement = Statement::new( + representation(), + None, + predicate_type("grounding", 1), + json!({"checks": [], "all_evidence_grounded": true}), + ); + let bytes = statement_bytes(&statement).expect("a statement serializes"); + let parsed: Statement = + serde_json::from_slice(&bytes).expect("canonical bytes parse back"); + assert_eq!(parsed, statement); + } + + #[test] + fn same_statement_serializes_to_identical_bytes() { + let build = || { + Statement::new( + representation(), + Some(Subject::sha256("invoice.pdf", "3fc9")), + predicate_type("grounding", 1), + json!({"b": 2, "a": 1}), + ) + }; + assert_eq!( + statement_bytes(&build()).expect("first"), + statement_bytes(&build()).expect("second") + ); + } + + #[test] + fn wire_field_names_match_the_in_toto_spec() { + // `_type` and `predicateType` are spec-mandated spellings, not Rust conventions. + // Renaming either silently produces an artifact no in-toto tooling recognizes. + let bytes = statement_bytes(&Statement::new( + representation(), + None, + predicate_type("grounding", 1), + json!({}), + )) + .expect("serializes"); + let text = String::from_utf8(bytes).expect("canonical bytes are utf-8"); + assert!( + text.contains(r#""_type":"https://in-toto.io/Statement/v1""#), + "{text}" + ); + assert!(text.contains(r#""predicateType":"#), "{text}"); + assert!(text.contains(r#""sha256":"8f3a""#), "{text}"); + } + + #[test] + fn predicate_survives_wrapping_byte_for_byte() { + // The property WP-2's payload-equivalence test depends on: wrapping must not + // reshape the verdict. `jq .predicate` has to give back exactly what went in. + let predicate = json!({"schema_version": "1.0.0", "all_evidence_grounded": false}); + let expected = c14n_bytes(&predicate).expect("predicate canonicalizes"); + let statement = Statement::new( + representation(), + None, + predicate_type("grounding", 1), + predicate, + ); + let bytes = statement_bytes(&statement).expect("serializes"); + let parsed: serde_json::Value = + serde_json::from_slice(&bytes).expect("canonical bytes parse"); + let unwrapped = c14n_bytes(&parsed["predicate"]).expect("predicate canonicalizes"); + assert_eq!(unwrapped, expected); + } +} diff --git a/crates/ethos-core/src/verify_types.rs b/crates/ethos-core/src/verify_types.rs index 32fa07b2..1e004576 100644 --- a/crates/ethos-core/src/verify_types.rs +++ b/crates/ethos-core/src/verify_types.rs @@ -333,6 +333,10 @@ pub struct Check { /// Echoed evidence, when configured. #[serde(skip_serializing_if = "Option::is_none")] pub evidence: Option, + /// How precisely this check bound its evidence. Absent when nothing resolved, so a + /// check that found no target never claims a precision it did not achieve. + #[serde(skip_serializing_if = "Option::is_none")] + pub evidence_tier: Option, /// Resolved source element ids, emitted only by the hardened report profile. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub resolved_element_ids: Vec, @@ -382,6 +386,73 @@ pub struct VerificationReport { pub unsupported_claim_kinds: Vec, /// Report-level warnings (capability downgrades land here). pub warnings: Vec, + /// What produced this verdict. Required, never optional — an unattested report is the + /// thing this field exists to prevent. + pub attestation: Attestation, +} + +/// How precisely a check bound its evidence. +/// +/// A convenience projection, not a new fact: a consumer could derive it from +/// `match_method` and the claim's citation. It exists so every consumer does not write +/// that derivation and get it subtly wrong, which for a release decision is the kind of +/// error nobody notices. +/// +/// Ordered most to least precise. `table_cell` is not in the original four values; a table +/// cell is a first-class v1 claim kind, and folding it into `element_scoped` would +/// understate a precisely bound cell. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceTier { + /// Bound to a span: sub-element precision. + ExactSpan, + /// Bound to one table cell by (table, row, col). + TableCell, + /// Bound to a whole element. + ElementScoped, + /// Bound to a page only, with no element resolved. + PageScoped, + /// The source could not answer at the precision the citation asked for. + CapabilityLimited, +} + +/// The verifier that produced a report. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerifierIdentity { + /// Crate name, from the verifier's own `CARGO_PKG_NAME`. + pub name: String, + /// Crate version, from the verifier's own `CARGO_PKG_VERSION`. + pub version: String, +} + +/// A binding record naming everything needed to re-run a verdict. +/// +/// This is a **record, not cryptographic proof**. It attests the crate version, not binary +/// provenance; a hostile operator can write whatever they like here. It exists so that +/// cooperating parties and auditors can reproduce a verdict, and so that "same claim, +/// different answer" across releases reads as a versioned ruleset change rather than +/// broken determinism. +/// +/// Deliberately absent: timestamp, hostname, and toolchain, each of which would break +/// byte-identical repeat runs. Also absent are the config hash and source fingerprint — +/// both are already top-level on the report as `verification_config_sha256` and +/// `document_fingerprint`, and duplicating them into a nested block is bloat, not +/// attestation. Together with those two fields and `verifier.version`, this block is the +/// replay recipe expressed as data; the prose recipe belongs in documentation, not in +/// every artifact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Attestation { + /// The verifier crate that produced this report. + pub verifier: VerifierIdentity, + /// Echo of the config's human-facing label, so a report is readable without the + /// config file at hand. `verification_config_sha256` stays authoritative. + pub config_version: String, + /// `sha256(c14n(claims))` over the **parsed claims array**. + /// + /// Not the raw file bytes, which are whitespace-fragile, and not the envelope, so a + /// bare-array input and an envelope input carrying equal claims hash equal. This is + /// the binding that was missing: report to exact claims input. + pub claims_sha256: String, } /// The PRD §8 invariant, in one place. True only when: @@ -1307,6 +1378,7 @@ mod tests { match_method: MatchMethod::ExactText, semantic_unverified: semantic, evidence: None, + evidence_tier: None, resolved_element_ids: Vec::new(), provenance: None, context_echo: None, @@ -1345,6 +1417,14 @@ mod tests { dispersion: None, unsupported_claim_kinds: Vec::new(), warnings: Vec::new(), + attestation: Attestation { + verifier: VerifierIdentity { + name: "ethos-verify".to_string(), + version: "0.0.0-test".to_string(), + }, + config_version: "default-v1".to_string(), + claims_sha256: "0".repeat(64), + }, } } diff --git a/crates/ethos-verify/src/lib.rs b/crates/ethos-verify/src/lib.rs index 8f2b80a2..e855fbc6 100644 --- a/crates/ethos-verify/src/lib.rs +++ b/crates/ethos-verify/src/lib.rs @@ -47,10 +47,11 @@ use ethos_core::grounding::{ GroundingTable, PageGeometry, }; use ethos_core::verify_types::{ - compute_all_evidence_grounded, CapabilityLimit, Check, CheckProvenance, CheckReason, - CheckStatus, Claim, ClaimKind, ContextBoundary, ContextEcho, Evidence, EvidenceDispersion, - GroundingMeta, MatchMethod, ProvenanceStatus, TextNormalization, VerificationConfig, - VerificationReport, HARDENED_VERIFICATION_SCHEMA_VERSION, + compute_all_evidence_grounded, Attestation, CapabilityLimit, Check, CheckProvenance, + CheckReason, CheckStatus, Claim, ClaimKind, ContextBoundary, ContextEcho, Evidence, + EvidenceDispersion, EvidenceTier, GroundingMeta, MatchMethod, ProvenanceStatus, + TextNormalization, VerificationConfig, VerificationReport, VerifierIdentity, + HARDENED_VERIFICATION_SCHEMA_VERSION, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -622,9 +623,14 @@ fn resolve_anchor_target( .iter() .enumerate() .filter(|(_, element)| { - element.page == page_id && contains_bbox(element.bbox, bbox, tolerance) + element.page == page_id + && element + .bbox + .is_some_and(|geom| contains_bbox(geom, bbox, tolerance)) + }) + .min_by_key(|(position, element)| { + (element.bbox.map_or(u128::MAX, bbox_area), *position) }) - .min_by_key(|(position, element)| (bbox_area(element.bbox), *position)) .map(|(position, element)| target_from_element(element, Some(position))); } let expected = evidence_ref.expected_text.as_deref()?; @@ -663,19 +669,22 @@ fn resolve_bbox( .matching .bbox_containment_tolerance_q .unwrap_or(0); - if index - .elements - .iter() - .any(|element| element.page == page_id && contains_bbox(element.bbox, bbox, tolerance)) - || index - .spans - .iter() - .any(|span| span.page == page_id && contains_bbox(span.bbox, bbox, tolerance)) - || index - .tables - .iter() - .any(|table| table.page == page_id && contains_bbox(table.bbox, bbox, tolerance)) - { + if index.elements.iter().any(|element| { + element.page == page_id + && element + .bbox + .is_some_and(|geom| contains_bbox(geom, bbox, tolerance)) + }) || index.spans.iter().any(|span| { + span.page == page_id + && span + .bbox + .is_some_and(|geom| contains_bbox(geom, bbox, tolerance)) + }) || index.tables.iter().any(|table| { + table.page == page_id + && table + .bbox + .is_some_and(|geom| contains_bbox(geom, bbox, tolerance)) + }) { BboxCheck::Valid } else { BboxCheck::NotFound @@ -932,6 +941,7 @@ pub fn verify_claims( citations: CitationInput, config: &VerificationConfig, config_sha256: String, + claims_sha256: String, ) -> VerificationReport { let (citation_fingerprint, claims) = citations.into_parts(); let index = SourceIndex::new(source); @@ -1025,6 +1035,16 @@ pub fn verify_claims( checks, dispersion, unsupported_claim_kinds: unsupported, + attestation: Attestation { + // The verifier's own crate identity, not the caller's, so a library consumer + // gets the same attestation a CLI user does. + verifier: VerifierIdentity { + name: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + config_version: config.config_version.clone(), + claims_sha256, + }, warnings, } } @@ -1063,6 +1083,7 @@ fn check_claim( match_method: MatchMethod::None, semantic_unverified: false, evidence: None, + evidence_tier: None, resolved_element_ids: Vec::new(), provenance: None, context_echo: None, @@ -1080,6 +1101,7 @@ fn check_claim( match_method: MatchMethod::None, semantic_unverified: false, evidence: None, + evidence_tier: None, resolved_element_ids: Vec::new(), provenance: None, context_echo: None, @@ -1101,6 +1123,7 @@ fn check_claim( match_method: MatchMethod::None, semantic_unverified: false, evidence: None, + evidence_tier: None, resolved_element_ids: Vec::new(), provenance: None, context_echo: None, @@ -1117,6 +1140,7 @@ fn check_claim( match_method: MatchMethod::None, semantic_unverified: false, evidence: None, + evidence_tier: None, resolved_element_ids: Vec::new(), provenance: None, context_echo: None, @@ -1134,6 +1158,7 @@ fn check_claim( match_method: MatchMethod::None, semantic_unverified: false, evidence: None, + evidence_tier: None, resolved_element_ids: Vec::new(), provenance: None, context_echo: None, @@ -1150,6 +1175,7 @@ fn check_claim( match_method: MatchMethod::None, semantic_unverified: false, evidence: None, + evidence_tier: None, resolved_element_ids: Vec::new(), provenance: None, context_echo: None, @@ -1168,6 +1194,7 @@ fn check_claim( match_method: MatchMethod::None, semantic_unverified: false, evidence: None, + evidence_tier: None, resolved_element_ids: Vec::new(), provenance: None, context_echo: None, @@ -1183,6 +1210,7 @@ fn check_claim( match_method: MatchMethod::None, semantic_unverified: false, evidence: None, + evidence_tier: None, resolved_element_ids: Vec::new(), provenance: None, context_echo: None, @@ -1199,6 +1227,7 @@ fn check_claim( match_method: MatchMethod::None, semantic_unverified: false, evidence: None, + evidence_tier: None, resolved_element_ids: Vec::new(), provenance: None, context_echo: None, @@ -1220,6 +1249,7 @@ fn check_claim( match_method: MatchMethod::None, semantic_unverified: false, evidence: None, + evidence_tier: None, resolved_element_ids: Vec::new(), provenance: None, context_echo: None, @@ -1251,6 +1281,22 @@ fn check_claim( ) }) .flatten(); + // The tier the target actually resolved at, unless a capability limit is what decided + // this check — then the honest statement is that the source could not answer at the + // precision asked for, not that it answered imprecisely. + let capability_limited = matches!( + reason, + Some( + CheckReason::MissingSpanCapability + | CheckReason::MissingTableCapability + | CheckReason::UnknownCoordinateOrigin + ) + ); + let evidence_tier = Some(if capability_limited { + EvidenceTier::CapabilityLimited + } else { + target.tier + }); Check { id: check_id, claim, @@ -1259,6 +1305,7 @@ fn check_claim( match_method, semantic_unverified: false, evidence, + evidence_tier, resolved_element_ids: context .emit_hardening .then(|| target.element_ids.clone()) @@ -1342,6 +1389,9 @@ fn claim_kind_name(kind: ClaimKind) -> &'static str { #[derive(Debug, Clone)] struct FoundTarget { + /// Set where the target resolves, never re-derived from the citation, so the tier + /// cannot drift from the locator precedence it describes. + tier: EvidenceTier, page: Option, bbox: Option<[i64; 4]>, text: Option, @@ -1500,9 +1550,14 @@ fn resolve_target( .iter() .enumerate() .filter(|(_, element)| { - element.page == page && contains_bbox(element.bbox, bbox, tolerance) + element.page == page + && element + .bbox + .is_some_and(|geom| contains_bbox(geom, bbox, tolerance)) + }) + .min_by_key(|(position, element)| { + (element.bbox.map_or(u128::MAX, bbox_area), *position) }) - .min_by_key(|(position, element)| (bbox_area(element.bbox), *position)) .map(|(position, element)| target_from_element(element, Some(position))) .map(TargetResolution::Found) .unwrap_or(TargetResolution::NotFound(CheckReason::BboxNotFound)); @@ -1520,6 +1575,7 @@ fn resolve_target( .map(|found| { TargetResolution::Found(FoundTarget { page: Some(found.id.clone()), + tier: EvidenceTier::PageScoped, bbox: Some([0, 0, found.width, found.height]), text: None, from_table_cell: false, @@ -1549,8 +1605,9 @@ fn enforce_supplemental_page(resolution: TargetResolution, claim: &Claim) -> Tar fn target_from_element(element: &GroundingElement, element_index: Option) -> FoundTarget { FoundTarget { page: Some(element.page.clone()), - bbox: Some(element.bbox), + bbox: element.bbox, text: element.text.clone(), + tier: EvidenceTier::ElementScoped, from_table_cell: false, element_index, element_ids: vec![element.id.clone()], @@ -1561,8 +1618,9 @@ fn target_from_element(element: &GroundingElement, element_index: Option) fn target_from_span(span: &GroundingSpan) -> FoundTarget { FoundTarget { page: Some(span.page.clone()), - bbox: Some(span.bbox), + bbox: span.bbox, text: Some(span.text.clone()), + tier: EvidenceTier::ExactSpan, from_table_cell: false, element_index: None, element_ids: span.element.iter().cloned().collect(), @@ -1605,8 +1663,9 @@ fn table_cell_covers(cell: &GroundingCell, row: u32, col: u32) -> bool { fn target_from_cell(page: &str, cell: &GroundingCell) -> FoundTarget { FoundTarget { page: Some(page.to_string()), - bbox: Some(cell.bbox), + bbox: cell.bbox, text: Some(cell.text.clone()), + tier: EvidenceTier::TableCell, from_table_cell: true, element_index: None, element_ids: Vec::new(), @@ -1682,7 +1741,13 @@ fn adjacent_text_pair_target( if first.page != second.page { return None; } - if !element_bboxes_are_adjacent(first.bbox, second.bbox) { + // Both elements need declared geometry before adjacency can mean anything. A + // geometry-free element is never "next to" another, which matches the existing + // capability gate on CoordinateOrigin::Unknown: no coordinates, no join. + let (Some(first_bbox), Some(second_bbox)) = (first.bbox, second.bbox) else { + return None; + }; + if !element_bboxes_are_adjacent(first_bbox, second_bbox) { return None; } let first_text = first.text.as_deref()?; @@ -1697,7 +1762,9 @@ fn adjacent_text_pair_target( Some(FoundTarget { page: Some(first.page.clone()), - bbox: Some(union_bbox(first.bbox, second.bbox)), + // Two elements joined is still element precision, not span precision. + tier: EvidenceTier::ElementScoped, + bbox: Some(union_bbox(first_bbox, second_bbox)), text: Some(joined), from_table_cell: false, element_index: None, @@ -2088,6 +2155,7 @@ pub fn normalize_quote(input: &str) -> String { #[cfg(test)] mod tests { use super::*; + use ethos_core::evidence_anchor::EvidenceLocator; use ethos_core::grounding::{ Capabilities, GroundingCell, GroundingElement, GroundingProvenance, GroundingSpan, GroundingTable, PageGeometry, ParserIdentity, @@ -2155,7 +2223,7 @@ mod tests { GroundingElement { id: "e000002".into(), page: "p0001".into(), - bbox: [7200, 10100, 54000, 11500], + bbox: Some([7200, 10100, 54000, 11500]), kind: "text_block".into(), text: Some( "Revenue grew to $12.4M in Q3 2025, driven by enterprise expansion.".into(), @@ -2164,7 +2232,7 @@ mod tests { GroundingElement { id: "e000003".into(), page: "p0001".into(), - bbox: [7200, 13000, 54000, 20000], + bbox: Some([7200, 13000, 54000, 20000]), kind: "table".into(), text: None, }, @@ -2185,7 +2253,7 @@ mod tests { vec![GroundingSpan { id: "s000002".into(), page: "p0001".into(), - bbox: [7200, 10100, 54000, 11500], + bbox: Some([7200, 10100, 54000, 11500]), text: "Revenue grew to $12.4M in Q3 2025".into(), element: Some("e000002".into()), char_start: Some(0), @@ -2196,14 +2264,14 @@ mod tests { vec![GroundingTable { id: "t0001".into(), page: "p0001".into(), - bbox: [7200, 13000, 54000, 20000], + bbox: Some([7200, 13000, 54000, 20000]), cells: vec![ GroundingCell { row: 0, col: 0, row_span: 1, col_span: 1, - bbox: [7200, 13000, 30600, 16500], + bbox: Some([7200, 13000, 30600, 16500]), text: "Metric".into(), }, GroundingCell { @@ -2211,7 +2279,7 @@ mod tests { col: 1, row_span: 1, col_span: 1, - bbox: [30600, 16500, 54000, 20000], + bbox: Some([30600, 16500, 54000, 20000]), text: "$12.4M".into(), }, ], @@ -2299,7 +2367,13 @@ mod tests { fn verify(source: &TestSource, claims: Vec) -> VerificationReport { let cfg = VerificationConfig::default_v1(); - verify_claims(source, input(source, claims), &cfg, "0".repeat(64)) + verify_claims( + source, + input(source, claims), + &cfg, + "0".repeat(64), + "1".repeat(64), + ) } fn verify_with_config( @@ -2307,7 +2381,13 @@ mod tests { claims: Vec, cfg: &VerificationConfig, ) -> VerificationReport { - verify_claims(source, input(source, claims), cfg, "0".repeat(64)) + verify_claims( + source, + input(source, claims), + cfg, + "0".repeat(64), + "1".repeat(64), + ) } fn hardened_config() -> VerificationConfig { @@ -2327,7 +2407,7 @@ mod tests { GroundingElement { id: id.into(), page: page.into(), - bbox, + bbox: Some(bbox), kind: "text_block".into(), text: text.map(str::to_string), } @@ -2351,7 +2431,7 @@ mod tests { document_fingerprint: source.fingerprint(), claims, }); - verify_claims(&source, citations, &cfg, "0".repeat(64)) + verify_claims(&source, citations, &cfg, "0".repeat(64), "1".repeat(64)) } #[test] @@ -2503,7 +2583,7 @@ mod tests { }, )], }); - let report = verify_claims(&source, citations, &config, "0".repeat(64)); + let report = verify_claims(&source, citations, &config, "0".repeat(64), "1".repeat(64)); assert!(report.all_evidence_grounded); assert_eq!( @@ -2550,6 +2630,7 @@ mod tests { }), &config, "0".repeat(64), + "1".repeat(64), ); let echo = report.checks[0].context_echo.as_ref().unwrap(); @@ -3231,6 +3312,7 @@ mod tests { }), &cfg, "0".repeat(64), + "1".repeat(64), ); assert_eq!(report.checks[0].status, CheckStatus::NotFound); } @@ -3371,6 +3453,7 @@ mod tests { }), &cfg, "0".repeat(64), + "1".repeat(64), ); assert!(report.fingerprint_stale); @@ -3398,6 +3481,7 @@ mod tests { }), &cfg, "0".repeat(64), + "1".repeat(64), ); assert!(!report.fingerprint_stale); @@ -3584,6 +3668,7 @@ mod tests { }), &cfg, "0".repeat(64), + "1".repeat(64), ); assert!(!report.fingerprint_stale); @@ -3652,4 +3737,166 @@ mod tests { assert_eq!(v["fingerprint_stale"], false); assert_eq!(v["checks"].as_array().unwrap().len(), 1); } + + // ---- geometry-free text anchoring ------------------------------------------------- + // + // An `element_id` + `expected_text` ref with no page locator and no bbox binds at + // `AnchorLevel::Text` and reports no capability limit. `docs/v0-6-0-release.md` §10.1 + // established this by source audit; these tests make it an enforced invariant. + // + // Why it is guarded: geometry is mandatory in `ethos.grounding.v1` and its validator, + // not in this algorithm. That gap is what would let a flow document — DOCX, where + // pagination does not exist in the file and any bbox would have to be invented — ever + // be verified honestly. A refactor that made a page locator or a bbox mandatory here + // would close that path silently, and nothing else in the suite would notice. + // + // Multi-format support is deliberately out of scope (`docs/proof-statement-v1.md` §7). + // These tests keep the door open; they do not open it. + + fn geometry_free_text_ref() -> EvidenceRef { + EvidenceRef { + evidence_id: "ev-1".into(), + evidence_kind: EvidenceKind::Text, + required_anchor_level: AnchorLevel::Text, + locator: EvidenceLocator { + page_index: None, + page_id: None, + element_id: Some("e000002".into()), + span_id: None, + bbox: None, + ..Default::default() + }, + expected_text: Some("Revenue grew to $12.4M".into()), + expected_text_sha256: None, + text_normalization_profile: None, + } + } + + #[test] + fn geometry_free_text_ref_needs_no_page_locator() { + // Guards the `element_id` disjunct in `page_locator_required`. If this flips true, + // `validate_required_page_locator` rejects the ref before anchoring even starts. + assert!(!page_locator_required(&geometry_free_text_ref())); + } + + #[test] + fn geometry_free_text_ref_needs_no_bbox() { + // `AnchorLevel::Text` must stay outside `requires_bbox`, otherwise + // `validate_required_anchor_inputs` rejects a ref that carries no bbox. + assert!(!requires_bbox(&geometry_free_text_ref())); + } + + #[test] + fn absent_page_locator_resolves_to_not_checked_never_not_found() { + // The distinction that carries the whole path. `NotChecked` means "no page was + // asked about"; `NotFound` means "a page was asked about and is missing" and + // would fail the anchor. A source with no pages at all must still yield + // `NotChecked`, since `pages` has no `minItems` in the artifact schema. + let source = TestSource::default(); + let index = SourceIndex::new(&source); + let resolution = resolve_page(&index, &geometry_free_text_ref()); + assert_eq!(resolution.check, PageCheck::NotChecked); + assert_eq!(resolution.page_id, None); + } + + /// A source whose elements declare no geometry. Not reachable through + /// `ethos.grounding.v1`, which still requires `bbox` on the wire — this exercises the + /// Rust-level `None` that WP-0 task 0.2 made expressible. + struct GeometryFree(TestSource); + impl GroundingSource for GeometryFree { + fn parser(&self) -> ParserIdentity { + self.0.parser() + } + fn capabilities(&self) -> Capabilities { + self.0.capabilities() + } + fn fingerprint(&self) -> Option { + self.0.fingerprint() + } + fn pages(&self) -> Vec { + self.0.pages() + } + fn elements(&self) -> Vec { + self.0 + .elements() + .into_iter() + .map(|element| GroundingElement { + bbox: None, + ..element + }) + .collect() + } + fn spans(&self) -> Vec { + Vec::new() + } + fn tables(&self) -> Vec { + Vec::new() + } + } + + #[test] + fn absent_element_geometry_never_satisfies_a_bbox_query() { + // Fail closed. An element that declares no box contains nothing, so a bbox + // locator over a geometry-free source resolves to NotFound rather than matching + // on the text alone. The failure this guards against is treating `None` as a + // wildcard, which would make every bbox query succeed against every element. + let source = GeometryFree(TestSource::default()); + let index = SourceIndex::new(&source); + let mut evidence_ref = geometry_free_text_ref(); + evidence_ref.required_anchor_level = AnchorLevel::Bbox; + evidence_ref.locator.page_id = Some("p0001".into()); + evidence_ref.locator.bbox = Some([7200, 10100, 54000, 11500]); + + assert_eq!( + resolve_bbox(&index, &evidence_ref, Some("p0001")), + BboxCheck::NotFound + ); + } + + #[test] + fn adjacent_quote_join_refuses_elements_without_geometry() { + // The adjacency join reads two boxes to decide they touch. With no boxes there is + // nothing to compare, so the join must decline rather than fall back to reading + // order — the same posture the join already takes for CoordinateOrigin::Unknown. + let source = GeometryFree(TestSource::default()); + let config = VerificationConfig::default_v1(); + let elements = source.elements(); + + assert!( + adjacent_text_pair_target(&elements[0], &elements[1], "anything", &config).is_none(), + "a pair with no declared geometry must not be joined" + ); + } + + #[test] + fn geometry_free_text_ref_binds_with_no_capability_limit() { + // End to end: the four steps above compose into `Bound`. The bbox axis must read + // `NotChecked` rather than `CapabilityLimited` — the ref never asked for geometry, + // so nothing was downgraded and the caller is owed no warning. + let source = TestSource::default(); + let report = anchor_evidence( + &source, + EvidenceAnchorRequest { + artifact_type: ethos_core::evidence_anchor::EVIDENCE_ANCHOR_REQUEST_ARTIFACT_TYPE + .to_string(), + schema_version: ethos_core::SCHEMA_VERSION.to_string(), + source_fingerprint: None, + evidence_refs: vec![geometry_free_text_ref()], + report_options: None, + }, + ) + .expect("a geometry-free text ref is a valid request"); + + let anchor = &report.anchors[0]; + assert_eq!(anchor.anchor_status, AnchorStatus::Bound); + assert_eq!(anchor.achieved_anchor_level, AnchorLevel::Text); + assert_eq!(anchor.checks.page, PageCheck::NotChecked); + assert_eq!(anchor.checks.bbox, BboxCheck::NotChecked); + assert_eq!(anchor.checks.text, TextCheck::Matched); + assert!( + anchor.capability_limits.is_empty(), + "a ref that asked for no geometry was not downgraded: {:?}", + anchor.capability_limits + ); + } } diff --git a/docs/CLAIMS.md b/docs/CLAIMS.md new file mode 100644 index 00000000..4a84d817 --- /dev/null +++ b/docs/CLAIMS.md @@ -0,0 +1,82 @@ +# What an Ethos Verdict Proves — and What It Does Not + +Status: active. This document is the answer to "what exactly are you claiming?", written so +it can be handed to a security review without a conversation attached. + +The honesty rule: Ethos proves that **cited evidence exists where a citation says it does, +in a representation Ethos read**. It proves nothing about whether the answer is true, +relevant, complete, or authorised, and nothing about whether the representation faithfully +reflects the original document. + +--- + +## 1. What a verdict proves + +| Claim | Mechanism | Where it lives | +| --- | --- | --- | +| The cited quote, value, table cell, or page exists in the source representation | Literal matching against the resolved locator target; no similarity scores, no thresholds | `ethos-verify` `resolve_target`, `text_check` | +| The same inputs produce the same verdict, byte for byte, forever | Canonical JSON (c14n v1), integer quantisation, pinned profile; CI asserts byte equality on every run | `ethos-core::c14n`, `determinism.yml` | +| The citation was checked against the current representation, not a stale one | Source fingerprint compared with the citation's declared fingerprint | `fingerprint_stale` | +| Exactly what Ethos could **not** establish | Capability declarations drive explicit downgrades; a missing capability is stated, never approximated | `capability_limits`, `evidence_tier: capability_limited` | +| How precisely each claim was bound | Set where the target resolves, so it cannot drift from locator precedence | `evidence_tier` | +| What produced the verdict | Verifier crate name and version, config label, and a hash over the exact parsed claims | `attestation` | +| Which artifact the verdict concerns | SHA-256 of the bytes Ethos read, in a form a consumer holding the same file can recompute | `subject[0].digest` | +| Anyone can re-run it with no key, no account, and no network | Verification is a pure function; the base tree bans network-capable crates and CI proves zero egress | `deny.toml`, `no-network-runtime` CI job | + +--- + +## 2. What a verdict does not prove + +State these too. Every one is a real limit, not a caveat. + +| Not proven | Why | What would close it | +| --- | --- | --- | +| That the claim is **true**, relevant, or complete | Ethos never sees the user's question and makes no semantic judgement. A quote can be exact and the answer still misleading. | Application-layer review; the four-axis split in `docs/app-answer-release-contract.md` assigns relevance, synthesis, and claim support to the application | +| That the **representation faithfully reflects the document** | Ethos verifies a claim against the representation, not the representation against the source. A parser error that both drafts and verifies consistently is invisible. | Reviewer inspection of a rendered crop; independently derived parsers compared against each other, which is analysed in `docs/v0-6-0-release.md` §10.1 and not built | +| That the named verifier binary actually ran | `attestation` records the crate name and version, which are compile-time constants. A hostile operator can write anything there. | Reproducible builds and signed release provenance; out of scope | +| That `subject[0]` is the original document | `subject[0]` is the artifact Ethos read. On the Grounding JSON path that is a parser's output, and Ethos never opened the PDF. | `--crop-source-pdf`, where Ethos loads and validates real PDF bytes. A producer-declared source hash is a declaration, never a binding (`docs/proof-statement-v1.md` §1.4) | +| That two runs of the same question agree | Each verification is independent. Ethos never compares across runs, and a model that rewords its answer produces different claims and so a different verdict. | Application-layer stability measurement; Ethos being fixed is what makes model variance measurable at all | +| That a **paraphrase** of a true fact is grounded | Matching is literal after whitespace normalisation only. "We may approve" and "we will approve" are different strings. | Pointer-first citation emission, so the model cites an element id and never retypes evidence (`docs/citation-emission-spec.md`) | +| That coverage was complete | A page that failed to process yields an explicit limitation. Absence of a check is never a pass. | Nothing in Ethos; the limitation is the honest output | +| Any speed, footprint, or parser-quality property | No benchmark has been run whose numbers we would defend | A published harness with its corpus named | + +--- + +## 3. Mapping to the questions a review asks + +Conservative on purpose. Ethos is a control, not a compliance programme. + +| Regime | The question it asks | What Ethos answers | Residual gap — say it | +| --- | --- | --- | --- | +| EU AI Act Art. 12 | Are events recorded traceably? | Each verdict is a self-describing, reproducible record naming its inputs and the verifier that produced it | Ethos is invoked, not ambient. It records the checks you ask for, and logging that you asked is the caller's job | +| ISO/IEC 42001 | Is there a control over AI output quality? | A deterministic, independently re-runnable check on document-grounded claims | One control, not a management system. Governance, roles, and lifecycle sit outside | +| SR 11-7 (model risk) | Can a decision be validated and reproduced? | Same inputs and verifier version reproduce the verdict byte for byte; the artifact names all three | Ethos validates a citation, not a model. It says nothing about model fitness | +| FRE 902(14) | Is the record self-authenticating? | Not yet at this tier | Signing is out of scope for v0.6. The artifact shape reserves the envelope so signatures add without a format change | +| SOC 2 CC7.2 | Is there monitoring with reliable evidence? | Verdicts are stable evidence a reviewer can re-derive | Ethos is not monitoring and raises no alerts | + +--- + +## 4. The paragraph to paste into a questionnaire + +> For each document-grounded claim, Ethos produces a deterministic verdict recording whether +> the cited evidence exists in the source representation, how precisely it was bound, what +> it could not establish, and which verifier version and configuration produced the result. +> The same inputs reproduce the same verdict byte for byte, offline, with no key, account, +> or network access, so any party can re-derive it independently. Ethos does not judge +> whether an answer is true, relevant, or complete, and it verifies claims against the +> parsed representation rather than verifying that representation against the original +> document. Signing and hardware-rooted provenance are not claimed. + +--- + +## 5. Proof tiers + +| Tier | What it means | Key required | Status | +| --- | --- | --- | --- | +| **T0 Reproducible** | Anyone re-runs the verdict and gets identical bytes | no | shipped | +| **T1 Attested** | The verdict names the verifier, config, and exact claims that produced it | no | shipped | +| **T2 Signed** | A named key asserts who ran it and when | yes | not built | + +T0 is the strongest of the three and the easiest to misread as the weakest. A signature says +*someone claimed this*. Reproducibility says *check it yourself*. T2 adds accountability for +who ran the check; it adds nothing to whether the check was right. diff --git a/docs/bring-your-own-parser.md b/docs/bring-your-own-parser.md index 6a5e4a86..dae91679 100644 --- a/docs/bring-your-own-parser.md +++ b/docs/bring-your-own-parser.md @@ -158,3 +158,59 @@ Parser adapters should: The OpenDataLoader JSON adapter remains the full reference adapter. It is useful for serious foreign-parser mapping, but the minimal integration surface is the `GroundingSource` trait above. + +## Geometry is required, and where that requirement lives + +**For adapter authors: your source needs real coordinates.** `ethos.grounding.v1` requires +`bbox` on every element, span, table, and cell. A text-only parser cannot use this profile +honestly. Do not submit page-sized boxes, zero boxes, or invented coordinates — a zero-area +box is rejected outright, so a `[0,0,0,0]` sentinel will not get you through. + +The Rust trait types carry `Option<[i64; 4]>`. That is deliberate and it is **not** an +invitation to send `None`: the wire schema still requires the field, and the reader will +reject an artifact without it. The `Option` exists so the type system can express a source +that has no geometry to declare, which is not a thing this profile accepts today. + +**For maintainers: the constraint is narrower than it looks.** The verifier already binds +text evidence with no geometry at all. An evidence ref of `{element_id, expected_text}` +with no page locator and no bbox reaches `AnchorStatus::Bound` at `AnchorLevel::Text` and +pushes no capability limit. That behaviour is covered by tests in `ethos-verify` named +`geometry_free_*` and `absent_page_locator_resolves_to_not_checked_never_not_found` — do +not remove them, they are what keeps the path open. + +Geometry is mandatory in the **artifact and its validator**, not in the verification +algorithm. Five gates enforce it, all in one layer: + +| # | Gate | Where | +| --- | --- | --- | +| 1 | `media_type` is `const "application/pdf"` | `schemas/ethos-grounding-source.schema.json` | +| 2 | the same media-type check | `crates/ethos-core/src/grounding_json.rs` | +| 3 | `coordinate_system` pins `unit: centipoint`, `origin: top-left` | schema | +| 4 | `bbox` required on element, span, table, cell | schema | +| 5 | positive-area and in-page-bounds enforcement | `grounding_json.rs` | + +Gate 5 is why any future change must make `bbox` *absent* rather than empty. It also means +an off-canvas PPTX shape, which legitimately carries negative coordinates, is rejected +today — arguably a security-report finding rather than a parse error, and that call is a +prerequisite to PPTX support rather than a detail. + +**If format support is ever taken up**, the order is DOCX, then XLSX, then PPTX. That is +the reverse of the intuition that the format with visible geometry is the cheap one: + +- **DOCX** exercises the geometry-absent path, which is the only genuinely new behaviour. + Paragraph order in `word/document.xml` is document order — deterministic, no layout + engine, no font metrics. Pagination does not exist in the file and must not be + synthesised. +- **XLSX** reuses that path and adds an `R1C1` locator convention. `GroundingCell` already + carries `row`, `col`, `row_span`, and `col_span`, so a sheet maps onto the existing table + model with no new locator concept. Do not compute column geometry: the same nominal + 8.43-character column measures 4800 centipoints under Calibri 11 and 5400 under Verdana + 11, a 12.5% swing driven by system font metrics that ADR-0003's font policy does not + cover. +- **PPTX** last, despite clean arithmetic (127 EMU = 1 centipoint, and 127 is odd so + rounding ties are impossible), because gate 5 must be resolved first. + +Full analysis, including the measurements above and why rendering to PDF fails both the +footprint and licence gates, is in `docs/v0-6-0-release.md` §10.1. Multi-format support is +out of scope for v0.6.0 — `docs/proof-statement-v1.md` §7 records the trigger for +revisiting it. diff --git a/docs/execution-status.md b/docs/execution-status.md index 961850c6..8ba27dad 100644 --- a/docs/execution-status.md +++ b/docs/execution-status.md @@ -15,7 +15,40 @@ Sections below preserve dated milestone and wording records for auditability; ve inside them describe their recorded point in time and are not the current release state unless the generated block explicitly repeats them. The published public baseline is `0.5.0`. -## v0.6.0 in progress (2026-07-30) +## v0.6.0 scope expanded to the major format release (2026-08-09) + +v0.6.0 is now the major release. The Grounding JSON work recorded in the section below is +merged to `main` and remains accurate; it is no longer the whole of v0.6.0. + +**What was added.** Every Ethos output artifact becomes self-describing and self-attesting. +Artifacts are wrapped in an in-toto Statement carrying the type and the subject document, +and every predicate names the verifier, config, and exact claims that produced it. Six +artifact types migrate. Six work packages. + +**Scope authority:** `docs/proof-statement-v1.md` (format and rulings). +**Task board and acceptance evidence:** `docs/proof-statement-v1-implementation-plan.md`. +**Branch:** `proof-statement-v1`. + +Rulings on record, all 2026-08-09: in-toto Statement as the native artifact rather than +DSSE, which base64-encodes the payload and makes evidence unreadable; base URI +`https://docushell.com/ethos/`, shape `//v`; `representation_sha256` +stays authoritative, extending the §8 ruling in `docs/v0-6-0-release.md`; `subject` carries +the representation first and the source document only when the binding is real; +`document.ethos.json` and `chunks.jsonl` stay unwrapped because statements are for verdicts. + +**Cut:** corroboration and the `sources[]` array that existed to carry it. Designed from a +threat model rather than a user, no external request, and the divergence rate on real +documents has never been measured. `docs/proof-statement-v1.md` §7 records that revisiting +requires a measured number, not another argument. + +Also on 2026-08-09, CI was scoped to product correctness and architectural invariants +(81 steps to 41); publication gates are parked behind `make release-gates`. See +`docs/ci-scope.md`. + +Nothing in the expanded v0.6.0 is published. Production positioning, hosted surfaces, and +public availability claims all remain blocked. + +## v0.6.0 Grounding JSON work (2026-07-30) v0.6.0 is the scoped Grounding JSON adoption release. Scope authority is `docs/v0-6-0-release-prep.md`; the verified implementation record, open decisions, and remaining diff --git a/docs/proof-statement-v1-implementation-plan.md b/docs/proof-statement-v1-implementation-plan.md new file mode 100644 index 00000000..6ab8bd48 --- /dev/null +++ b/docs/proof-statement-v1-implementation-plan.md @@ -0,0 +1,388 @@ +# Implementation Plan: Proof Statement v1 + +Status: **complete.** WP-0 through WP-6 are implemented and committed on `proof-statement-v1`. Companion to `docs/proof-statement-v1.md`, +which owns the format. This document owns sequencing, the file-by-file touch list, and +the acceptance evidence for each step. + +Follows the milestone-d contract pattern: ground rules, verified facts, surgical touch +list, tests, acceptance. + +--- + +## Start here + +New to this work? Read in this order: + +1. `docs/proof-statement-v1.md` §1 — what was ruled and why. Twenty minutes. +2. §0 below — the six ground rules. A change that breaks one is out of scope regardless + of how good it is. +3. The task board — pick an unclaimed task whose dependencies are done. + +### Prerequisites + +```bash +rustup show # pinned toolchain from rust-toolchain.toml +pip install "jsonschema>=4.18" # schema validation +pip install -r requirements-dev.txt # repo dev tooling +cp scripts/hooks/commit-msg .git/hooks/ && chmod +x .git/hooks/commit-msg +``` + +That last line is not optional. Every commit needs a `Signed-off-by` trailer in the final +paragraph, CI checks the whole PR range, and the hook catches the two ways it goes wrong. + +PDFium is **not** needed for this work. Nothing here touches parsing. + +### The loop + +```bash +cargo test --locked --workspace --all-features # expect 400+ passing, 0 failing +cargo fmt --all && cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +python3 schemas/validate_examples.py # after any schema change +make -n release-gates # must still expand; parked gates stay reachable +``` + +Before opening a PR, run what CI runs. The nine jobs are listed in `docs/ci-scope.md`. + +### Rules for claiming work + +- One work package per branch, one task per commit where practical. +- **Goldens move in a commit that changes nothing else.** Never bundle a golden + regeneration with a behaviour change; that is the one thing that makes this migration + unreviewable. +- If a task turns out to need a decision, stop and raise it. Do not decide format + questions in a PR — `docs/proof-statement-v1.md` §1 is where those live. + +### Definition of a finished task + +Code, test, schema, and golden all in the same state. A task with passing code and a stale +schema is not done. Each task below names its own acceptance evidence. + +--- + +## Task board + +Dependencies run top to bottom. Nothing in WP-2 starts before WP-1 lands. + +| ID | Task | Acceptance | Depends on | +| --- | --- | --- | --- | +| **0.1** | Test the geometry-free text path — `{element_id, expected_text}`, no page, no bbox | fails if `resolve_page` returns `NotFound` or `requires_bbox` turns true at `AnchorLevel::Text` | — | +| **0.2** | `bbox` → `Option<[i64; 4]>` on element, span, table, cell; schema unchanged | workspace green; grounding validation byte-identical (goldens unmoved) | — | +| **0.3** | Record the five multi-format gates and the DOCX → XLSX → PPTX sequencing | contract doc section exists | — | +| **1.1** | New `crates/ethos-core/src/statement.rs`: `Subject`, `Statement

`, `statement_bytes` | compiles; `digest` is a `BTreeMap` | — | +| **1.2** | Export from `lib.rs`, gated behind the `verify-types` feature | `cargo check -p ethos-doc-core --no-default-features --features grounding` passes | 1.1 | +| **1.3** | Round-trip test: build → serialise → parse → compare | test passes | 1.1 | +| **1.4** | Byte-stability test: same input twice, identical bytes | test passes | 1.1 | +| **2.1** | New `schemas/ethos-proof-statement.schema.json` | `validate_examples.py` passes | 1.2 | +| **2.2** | Wrap in `verification_report_json_bytes` (`cmd/verify.rs:233`) | single + batch NDJSON paths both wrapped | 1.2 | +| **2.3** | Subject construction: `[0]` representation always, `[1]` source only when bound | test proving `[1]` is omitted, never synthesised | 2.2 | +| **2.4** | **Payload-equivalence test** — `c14n(new.predicate)` == `c14n(old_report)` for every golden | the whole re-wrap proven to be a no-op | 2.2 | +| **2.5** | Regenerate goldens, standalone commit | determinism workflow green | 2.4 | +| **2.6** | Wrapped example under `schemas/examples/` | `validate_examples.py` passes | 2.1 | +| **3.1** | `Attestation`, `VerifierIdentity`, `ConfigIdentity`, `InputIdentity` in `verify_types.rs`; field is required, not `Option` | compiles | 2.5 | +| **3.2** | Populate in `ethos-verify` using **its own** `env!` macros, not the CLI's | library callers get the same attestation as CLI callers | 3.1 | +| **3.3** | `claims_sha256` from the **parsed claims array** | bare-array and envelope inputs with equal claims hash equal | 3.1 | +| **3.4** | Schema: `attestation` required | `validate_examples.py` passes | 3.1 | +| **3.5** | Version-desync test: `verifier.version == env!("CARGO_PKG_VERSION")` | a version bump cannot silently desync | 3.2 | +| **3.6** | Replay test: regenerate a golden from its named inputs, `cmp` byte-identical | passes | 3.2 | +| **4.1** | `EvidenceTier` enum in `verify_types.rs` | compiles | 3.6 | +| **4.2** | Derive from locator precedence in `resolve_target` | one test per locator kind | 4.1 | +| **4.3** | Schema + goldens | existing capability-downgrade tests unchanged | 4.2 | +| **5.1** | Shared serialiser — the other five artifacts serialise inline today | one code path for all six | 4.3 | +| **5.2** | `grounding-validation/v1`; keep accepting `artifact_type` on **input** (ADR-0016 frozen error vocabulary) | payload-equivalence test | 5.1 | +| **5.3** | `evidence-anchor/v1` | payload-equivalence test | 5.1 | +| **5.4** | `security/v1` | payload-equivalence test | 5.1 | +| **5.5** | `crop/v1` — *first to defer if the release drags* | payload-equivalence test | 5.1 | +| **5.6** | `answer-release/v1` — *first to defer if the release drags* | payload-equivalence test | 5.1 | +| **6.1** | `docs/CLAIMS.md` | contains all five does-not-prove rows listed in WP-6 | 5.6 | +| **6.2** | `docs/proof-statement-contract.md` — payload-vs-envelope field table | every field assigned a layer | 5.6 | +| **6.3** | README reframe | — | 6.1 | +| **6.4** | Migration guide: `jq .predicate` recovers the pre-0.6 shape | — | 6.1 | + +**2.4 is the one that matters most.** Every golden moves at once during this migration, +which blinds determinism CI exactly when it is most needed. That test reduces the whole +re-wrap to a provable no-op, so any real semantic change is forced into its own visible +commit. `.github/scripts/check_golden_change_rationale.py` exists for this — use it rather +than working around it. + +--- + +## 0. Ground rules + +Non-negotiable. A change that breaks one of these is out of scope regardless of merit. + +1. **Shape changes, semantics do not.** If a change alters what `grounded` means for any + existing claim, it is not in this release. The predicate content is a pure re-wrap. +2. **One statement builder.** `ethos-core` owns it. No command hand-rolls a statement, the + same rule that already governs c14n (invariant 3). This is the single most important + rule here — Signet's four hand-built signable shapes across two files is the failure + mode being designed out. +3. **Predicates are deterministic.** No time, host, identity, or run id inside a + predicate. Enforced by type where possible. +4. **Fail closed.** An unknown `predicateType` is an error, never a fallback. This matches + the existing dispatch in `crates/ethos-cli/src/grounding.rs`. +5. **No new dependencies.** Everything needed is already in the tree. +6. **Goldens move once, in a commit that changes nothing else.** + +--- + +## 1. Verified facts this plan builds on + +Checked against the source tree at `main` (`c7d893d`). Re-verify before relying on line +numbers. + +| Fact | Location | +| --- | --- | +| `VerificationReport` — 11 fields, `schema_version` first | `crates/ethos-core/src/verify_types.rs:360` | +| `HardeningOptions` — `include_provenance`, `include_context_echo`, `include_dispersion`, `context_window_chars` | `verify_types.rs:1207` | +| Report serialiser: serde → `c14n_bytes` → trailing newline | `crates/ethos-cli/src/cmd/verify.rs:233` | +| c14n API: `c14n_bytes(&Value)`, `sha256_hex(&Value)`, `sha256_hex_bytes(&[u8])` | `crates/ethos-core/src/c14n.rs:57,140,146` | +| Config hash already computed as `sha256_hex(c14n(config))` | `cmd/verify.rs:128` | +| `write_output(Option, &[u8])` — 12 call sites across 8 command modules | `crates/ethos-cli/src/main.rs:539` | +| Input artifact-type dispatch already exists, fail-closed, duplicate-key aware | `crates/ethos-cli/src/grounding.rs:22,118,177` | +| Grounding validation report already requires `artifact_type` (`const`) | `schemas/ethos-grounding-validation-report.schema.json` | +| Baseline: 400 Rust tests pass on `main` | `cargo test --workspace --all-features` | + +Two consequences worth stating. `verification_report_json_bytes` is the **only** +`*_json_bytes` serialiser in the CLI; the other five artifacts serialise inline at their +call sites, so step 1 has to introduce a shared path rather than edit one. And the config +hash mechanism needed for the attestation block already exists and needs no new code. + +--- + +## 2. Work packages + +Dependency order. Each lands as its own commit with its own acceptance evidence. + +### WP-0 — Keep the multi-format path open + +**Goal:** remove future obstacles without adding future features. Multi-format support is +**not** in this release (`docs/proof-statement-v1.md` §7). This work package only stops the +door closing. + +Runs alongside WP-1. Nothing in the statement work depends on it, and it depends on nothing. + +**0.1 — Test the geometry-free text path.** `docs/v0-6-0-release.md` §10.1 established by +source audit that an evidence ref of `{element_id, expected_text}` with no page and no bbox +reaches `AnchorStatus::Bound` at `AnchorLevel::Text` with no capability limit. **Nothing +tests it.** A refactor could close that path and no one would find out until someone tried a +flow document years later. + +Assert all four steps, not just the outcome: `page_locator_required` is false, +`resolve_page` returns `PageCheck::NotChecked` rather than `NotFound`, `resolve_bbox` is +never reached, and the result is `Bound` with an empty capability-limit list. + +This converts an audit finding into an enforced invariant. Audit findings decay; tests do +not. + +**0.2 — `bbox` becomes `Option<[i64; 4]>` in the trait.** Four fields in +`crates/ethos-core/src/grounding.rs` (element, span, table, cell) plus every read site in +`ethos-verify` and the OpenDataLoader adapter. + +§10.1 frames the choice as "unread in-memory sentinel versus `Option` and a breaking change +to the published `0.5.0` baseline". **v0.6.0 is already a breaking change to that baseline** — +WP-3 adds a required `attestation` field to `VerificationReport`. So the honest option costs +nothing extra now and costs a second breaking release later. + +**The schema does not move.** `bbox` stays required in `ethos.grounding.v1`, `media_type` +stays `const "application/pdf"`, and the positive-area check stays. The Rust type becomes +able to express absence; the wire contract still refuses it. Relaxing the schema *is* the +feature, and the feature is out of scope. + +Mechanical but not trivial — the compiler finds every site, but there are many. Half a day. + +**0.3 — Record the extension points.** A short section in the contract doc naming the five +gates from §10.1, where each lives, and the DOCX → XLSX → PPTX sequencing, so nobody +re-derives it. + +**Not in scope for WP-0**, and each for a reason: + +- Relaxing any schema gate — that is the feature. +- A format registry, a `Format` enum, or a plugin layer — speculative abstraction against a + requirement nobody has stated. +- New locator kinds. `GroundingCell` already carries `row`, `col`, `row_span`, `col_span`. + **Row/column addressing is R1C1**, so an XLSX sheet maps onto the existing table model with + no new locator concept. The only blocker is `bbox` on cells, which 0.2 handles. + +**Acceptance** +- 0.1 fails if `resolve_page` is changed to return `NotFound`, or if `requires_bbox` starts + returning true for `AnchorLevel::Text` +- 0.2: `cargo test --workspace --all-features` green; `ethos.grounding.v1` validation + behaviour byte-identical, proven by unchanged goldens +- `make -n release-gates` still expands + +### WP-1 — Statement builder + +**Goal:** one place that turns `(subject, predicateType, predicate)` into canonical bytes. + +New module `crates/ethos-core/src/statement.rs`: + +```rust +pub const IN_TOTO_STATEMENT_V1: &str = "https://in-toto.io/Statement/v1"; +pub const PREDICATE_BASE: &str = "https://docushell.com/ethos"; + +pub struct Subject { pub name: String, pub digest: BTreeMap } +pub struct Statement

{ _type, subject: Vec, predicate_type: String, predicate: P } + +pub fn statement_bytes(stmt: &Statement

) -> Result, C14nError>; +``` + +`digest` is a `BTreeMap` so key order is canonical without relying on serde field order. +`statement_bytes` routes through the existing `c14n_bytes`; it does not reimplement +canonicalisation. + +**Touch list** +- `crates/ethos-core/src/statement.rs` (new) +- `crates/ethos-core/src/lib.rs` — export the module +- `crates/ethos-core/Cargo.toml` — gate behind the existing `verify-types` feature so + invariant 5 holds (`ethos-verify` must still build with `--no-default-features + --features grounding`) + +**Acceptance** +- `cargo check -p ethos-doc-core --no-default-features --features grounding` still passes +- round-trip test: build a statement, serialise, parse, compare +- byte-stability test: same input twice, identical bytes + +### WP-2 — `grounding/v1` as a pure re-wrap + +**Goal:** `ethos verify` emits a statement. The predicate is the current report, unchanged. + +**Touch list** +- `crates/ethos-cli/src/cmd/verify.rs:233` — `verification_report_json_bytes` wraps before + c14n. Single edit; both the single-report and batch/NDJSON paths flow through it. +- `schemas/ethos-proof-statement.schema.json` (new) — the statement envelope +- `schemas/ethos-verification-report.schema.json` — unchanged, becomes the predicate schema +- `schemas/examples/` — add a wrapped example +- Goldens under `crates/ethos-cli/tests/` — regenerate + +**Subject construction** (per spec §1.4): +- `subject[0]` = representation. `name` from the input path's file name, `digest.sha256` + from `representation_sha256`. +- `subject[1]` = source document, **only** when a real binding exists. Never synthesised. + +**Acceptance — the critical one** +- **Payload-equivalence test:** for every golden, `c14n(new.predicate)` is byte-identical + to `c14n(old_report)`. This reduces WP-2 to a provably pure re-wrapping. +- goldens regenerated in a commit that changes nothing else +- determinism workflow green +- `make -n release-gates` still expands + +### WP-3 — Attestation block + +**Goal:** every predicate names what produced it. Non-optional. + +**Touch list** +- `crates/ethos-core/src/verify_types.rs` — `Attestation`, `VerifierIdentity`, + `ConfigIdentity`, `InputIdentity`; add `attestation: Attestation` to `VerificationReport` + (required, not `Option`) +- `crates/ethos-verify/src/lib.rs` — populate at report assembly. Version constants come + from `ethos-verify`'s own `env!("CARGO_PKG_NAME")` / `env!("CARGO_PKG_VERSION")`, **not** + the CLI's, so library callers get the same attestation as CLI callers. +- `crates/ethos-cli/src/cmd/verify.rs` — pass `claims_sha256`, computed as + `sha256_hex(to_value(&parsed_claims))` over the **parsed claims array**, not raw file + bytes (whitespace-fragile) and not the envelope (so bare-array and envelope inputs with + equal claims hash equal) +- `schemas/ethos-verification-report.schema.json` — add `attestation`, required +- Goldens — regenerate + +**Excluded deliberately:** timestamp, hostname, toolchain. Each breaks byte-identical +repeat runs. + +**Acceptance** +- test asserting `attestation.verifier.version == env!("CARGO_PKG_VERSION")` so a version + bump cannot silently desync +- double-run byte equality +- replay: regenerate a golden from its named inputs, `cmp` byte-identical + +**Process commitment, starts now, no code:** published verifier crate versions are never +yanked except for security. A report naming an unobtainable verifier version stops being +replayable, and that damage is retroactive. + +### WP-4 — `evidence_tier` + +**Touch list** +- `verify_types.rs` — `evidence_tier: EvidenceTier` enum +- `crates/ethos-verify/src/lib.rs` — derive it from the existing locator precedence in + `resolve_target` +- schema + goldens + +`EvidenceTier`: `exact_span | element_scoped | page_scoped | capability_limited`. + +**The singular `grounding` field is unchanged.** An earlier draft replaced it with +`sources: Vec<_>` to support corroboration. That capability is cut, so the array would be +frozen at length one and never exercised — the exact unvalidated shape that forces a `v2`. + +**Acceptance:** tier derivation covered per locator kind; existing capability-downgrade +tests still pass unchanged. + +### WP-5 — Remaining five predicates + +`grounding-validation/v1`, `evidence-anchor/v1`, `security/v1`, `crop/v1`, +`answer-release/v1`. Each a pure re-wrap with its own payload-equivalence test. + +Requires a shared serialiser first, since these five currently serialise inline at their +`write_output` call sites in `cmd/{grounding,evidence,security,crop}.rs`. + +`grounding-validation/v1` additionally retires its `artifact_type` field in favour of +`predicateType`. Keep accepting the old field on **input** — `probe_artifact_type` is a +consumer contract with a frozen error vocabulary under ADR-0016. + +**If the release drags, `crop/v1` and `answer-release/v1` defer to 0.6.1.** Fewest +consumers. + +### WP-6 — Documentation + +The payload-versus-envelope field table went into `docs/proof-statement-v1.md` §2 rather than +a separate contract document: the table was the only thing that document would have held. +`answer-release/v1` was dropped from WP-5 — it is not emitted by the CLI, so there is no +producer to migrate. + +- `docs/CLAIMS.md` (new) — proves / does-not-prove / regulatory mapping with a residual-gap + column / a paste-ready questionnaire paragraph +- `docs/proof-statement-contract.md` — the payload-vs-envelope field table +- README reframe +- migration guide: `jq .predicate` recovers the pre-0.6 shape + +Rows that must appear in the does-not-prove table: +- attestation names the crate version, not binary provenance +- corroboration raises confidence, it does not prove parser fidelity +- Ethos does not compare across runs; each verification is independent +- a reworded claim fails as `mismatch` even when the underlying fact is right +- `subject[0]` is what Ethos read, which is not always the source PDF + +--- + +## 3. Migration and goldens + +Every golden changes at once, so determinism CI goes blind exactly when it matters. Two +rules make that survivable, and both are mandatory. + +**One pure re-wrap commit.** Goldens regenerate in a commit touching nothing else. + +**Payload equivalence.** For every migrated artifact, assert the new `predicate` is +byte-identical to the old top-level document. Any real semantic change then has to appear +in its own visible commit. `.github/scripts/check_golden_change_rationale.py` already +exists for this; use it rather than working around it. + +--- + +## 4. Out of scope + +Corroboration and multi-source comparison. Signing and keys. A keystore. Hash-chained +logs. Bundle export and an offline verifier. A conformance vector corpus. MCP proxying. +Semantic checking. New parsers. Any change to verification semantics. + +Bundles and the conformance corpus are the two most likely to be argued back in. Both are +commitment devices whose purpose is to make change expensive, and there are zero +third-party implementers today to make that worth paying for. They ship when an external +party depends on the format, or when an auditor asks for a portable bundle. + +--- + +## 5. Definition of done + +- All six predicate types emit statements through one builder +- Payload equivalence proven for all six migrated artifacts +- Attestation present and non-optional in every predicate +- 400+ tests pass; determinism workflow green; `make -n release-gates` expands +- `CLAIMS.md` published +- Nothing published to any registry — that is a separate decision under `release-gates` diff --git a/docs/proof-statement-v1.md b/docs/proof-statement-v1.md new file mode 100644 index 00000000..ae76c5b5 --- /dev/null +++ b/docs/proof-statement-v1.md @@ -0,0 +1,384 @@ +# Proof Statement v1 + +Status: **implemented.** Every decision in §1 is ruled and every work package in +`docs/proof-statement-v1-implementation-plan.md` is complete. This document is now the +reference for the shipped format, not a proposal. + +Base URI is `https://docushell.com/ethos/` (§1.2). The user-facing statement of what a +verdict does and does not prove lives in `docs/CLAIMS.md`; this document owns the format. + +Scope: this makes Ethos output artifacts self-describing and self-attesting. It changes +no verification semantics. If a proposal alters what `grounded` means for any +existing claim, it does not belong in this document. + +--- + +## 1. Decisions + +### 1.1 Artifact shape — RULED: in-toto Statement + +Ethos emits six distinct top-level output artifacts today. One of them, the grounding +validation report, carries `artifact_type: "ethos.grounding_validation.v1"` as a required +field. The other five are identified by filename convention alone, carry no source +binding, and say nothing about what produced them. + +`crates/ethos-cli/src/grounding.rs` already implements type dispatch on input: +`ARTIFACT_TYPE_KEY`, `probe_artifact_type()`, fail-closed handling of unknown types, and +duplicate-key counting. **v0.6.0 reached for self-describing artifacts and stopped at +one.** This document finishes that, rather than importing a foreign idea. + +Ruled: the in-toto Statement is the native artifact. + +```json +{ + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { "name": "invoice.pdf", "digest": { "sha256": "3fc9…" } } + ], + "predicateType": "https://docushell.com/ethos/grounding/v1", + "predicate": { } +} +``` + +Why this over a bespoke envelope: an established shape, existing tooling, and auditor +familiarity for no invented format. `subject[].digest.sha256` is already exactly what +Ethos computes as a source fingerprint. + +**Not DSSE as the artifact.** DSSE base64-encodes the payload. For a build attestation +that is fine because tooling reads it. For document evidence, where opening the file and +reading it is half the value, it is a regression. DSSE stays a signing wrapper for T2 +(§6) and never becomes the thing on disk at T0 or T1. + +The `_type` string was verified against the in-toto v1 specification before being frozen. + +### 1.2 URI namespace — RULED: `https://docushell.com/ethos/` + +`predicateType` URIs are permanent. A URL rather than a bare string because the namespace +is what stops one vendor's `grounding/v1` colliding with another's, which matters as soon +as a system consumes statements from more than one producer. + +**Base URI, locked:** `https://docushell.com/ethos/` + +``` +https://docushell.com/ethos/grounding/v1 +https://docushell.com/ethos/evidence-anchor/v1 +https://docushell.com/ethos/security/v1 +``` + +Shape is `//v` for all five, with no exceptions. + +Chosen over a dedicated Ethos domain because a purchase and a perpetual renewal +obligation is a poor trade against a weak branding signal. Independence is carried by the +Apache-2.0 licence, offline key-free operation, and byte-reproducible results — none of +which a hostname affects. The `/ethos/` path segment scopes the namespace, so a later move +to a dedicated domain is a rename that keeps the old string as a recognised alias. + +**`v` versions the predicate schema, never the product.** `grounding/v1` stays `v1` +across Ethos 0.6, 0.7, and 1.0. It bumps only when the predicate's own shape breaks. + +### 1.3 Source identity — RULED: representation hash authoritative + +`representation_sha256` stays the authoritative fingerprint, per the standing ruling in +`docs/v0-6-0-release.md` §8. Ethos names what it actually read. + +This matters most on the Grounding JSON path, where a foreign parser produced the +representation and **Ethos never touched the source PDF**. An artifact claiming to be +"about invoice.pdf" would be asserting something Ethos cannot know. + +### 1.4 Subject shape — RULED: both, representation first + +`subject` is an array, so the honest answer and the useful one are not in conflict. + +```json +"subject": [ + { "name": "parser-output.json", "digest": { "sha256": "8f3a…" } }, + { "name": "invoice.pdf", "digest": { "sha256": "3fc9…" } } +] +``` + +- `subject[0]` is always the representation Ethos read. Required. Digested by the **bytes + of the input file**, not by `document_fingerprint` — in-toto matches subjects by digest, + so the value has to be something a consumer holding the same file can compute, and the + document fingerprint is the canonical-graph identity rather than a property of the file. +- `subject[1]` is the source document, present **only** when the binding is real. + +**"Real" means Ethos read the bytes itself.** Not that a producer declared a hash. + +`ethos verify` emits no `subject[1]` today, and that is the ruling as of 2026-08-09. On the +Grounding JSON path the only available source binding is +`GroundingJsonSource::source_sha256`, documented as the producer-declared PDF hash, and +§5.1 already states that a source-hash match "proves only that the mapper declared the hash +of the PDF you supplied." + +An in-toto `subject` means *this statement is about these artifacts*, and tools match them +by digest. A reviewer holding `loan-file.pdf`, seeing its hash in `subject[1]`, reasonably +concludes Ethos checked their file. It did not — it read a JSON that claimed to come from +it. Edit that one line in the JSON and the statement still points at the right PDF and +still reports everything grounded. That is a true statement about a declaration presented +in the field auditors trust most, and the gap is invisible at exactly the moment it +matters. + +The one case where `subject[1]` would be honest is `--crop-source-pdf`, where Ethos loads +and validates the actual PDF bytes. If it is ever built, that is the only permitted source: +**a hash Ethos computed, never one it was handed.** + +Consumers must not assume `subject[0]` is the PDF. That obligation is discharged in §2's +field table and in `docs/CLAIMS.md` §2. + +Claims and config do not appear in `subject`. A verdict depends on three inputs — document, +claims, config — and in-toto's subject model is artifact-centric, so the other two bind in +the attestation block (§4) instead. Two reports over one document with different claims +share a subject, which is correct: both are statements *about* that document. + +### 1.5 Representations — RULED: stay bare + +`document.ethos.json` and `chunks.jsonl` are **not** wrapped. Statements are for verdicts +only. + +A statement means "X asserts P about Y." A document graph is not an assertion *about* the +document; it is the document re-expressed. Wrapping it would read as "here is a claim +about invoice.pdf, and the claim is invoice.pdf." Wrap everything and `statement` stops +distinguishing anything, which costs the design the one line an integrator has to hold in +their head. + +Three supporting reasons: + +- `chunks.jsonl` is streaming NDJSON. Wrapping each line bloats every record; wrapping the + file breaks streaming. +- Both are consumed by other tools, including DocuShell's retrieval path. Wrapping changes + working consumers for no benefit they would notice. +- The provenance argument does not apply: both already carry a document fingerprint, + profile hash, and config hash per `SPEC.md`. They are self-describing already. + +This is the same instinct as the `II.1` rule in DocuShell's workbench architecture, applied +one level down: the thing being judged must not look like the judgment. + +--- + +## 2. Payload and envelope + +The split is enforced, not merely documented. + +``` +predicate deterministic. byte-identical across runs. no time, no host, + no identity, no run id. this is what hashes and what replays. +statement subject, predicateType, _type. stable, but outside the + determinism contract. +wrapper DSSE at T2. signatures, timestamps, operator claims. later. +``` + +Enforce it by type, the way `QuantizedGeom` enforces quantize-at-extraction. A predicate +struct that cannot hold a `SystemTime` cannot break the goldens. This is what makes +signing safe to add later without a second migration. + +### Which layer every field lives in + +Signet's `SECURITY.md` is the model: tabulate the layers, then spell out the attack when a +developer trusts the wrong one. + +| Field | Layer | Deterministic | Safe to base a decision on | +| --- | --- | --- | --- | +| `predicate.*` (the whole verdict) | payload | yes | yes | +| `predicate.attestation.*` | payload | yes | yes, for replay — see the limit below | +| `predicate.evidence_tier` | payload | yes | yes | +| `_type` | statement | yes | yes | +| `predicateType` | statement | yes | yes | +| `subject[].digest` | statement | yes | yes | +| `subject[].name` | statement | yes | **no** — a filename, not an identity | +| signatures, timestamps, run ids | wrapper | **no** | not present at T0/T1 | + +**The attack this table exists to prevent.** `subject[].name` is a convenience label taken +from the input path. Two different documents can carry the same name, and a name can be +anything the caller chose. in-toto matches artifacts by **digest**; a consumer that keys a +release decision on the name rather than the digest can be handed a file called +`loan-file.pdf` that is not the loan file. Match on `digest`, display the name. + +**And the limit on attestation.** It records the verifier crate name and version as +compile-time constants. It attests the *crate*, not the binary. A hostile operator can put +any string there. The block is for cooperating parties and auditors reproducing a verdict, +not for defending against the party who produced it. + +Nothing lives in the wrapper layer yet. It is named here so that when signing arrives, it +arrives in a layer that already exists rather than reshaping the artifact. + +--- + +## 3. Predicate types + +Five, all shipped. Every one is emitted through `statement_json_bytes` in `ethos-cli`, so +the shape cannot drift between producers. + +| Predicate | Command | Replaces | +| --- | --- | --- | +| `grounding/v1` | `ethos verify` | `verification_report.json` | +| `grounding-validation/v1` | `ethos grounding check` | grounding validation report | +| `evidence-anchor/v1` | `ethos evidence anchor` | `evidence_anchor_report.json` | +| `security/v1` | `ethos security report` | `security_report.json` | +| `crop/v1` | `ethos crop_element` | crop descriptors | + +**`answer-release` has no predicate type.** It is an app-layer envelope consumers build +through `derive_app_answer_release_decision`; the CLI never emits it, so there is no +producer to migrate and nothing to reserve. + +`grounding-validation/v1` keeps its `artifact_type` field inside the predicate. Retiring it +in favour of `predicateType` would have broken payload equivalence for no benefit, and +ADR-0016 freezes it as an input contract regardless. + +Migration of each existing artifact is a **pure re-wrap**: the current schema becomes the +predicate schema unchanged, and the statement wraps it. A payload-equivalence test asserts +the new `predicate` block is byte-identical to the old top-level report, which reduces the +migration to a provably pure re-wrapping and forces any semantic change into its own +visible commit. + +Every migration was a pure re-wrap in practice: not one golden, schema, or example file +changed when the five commands moved onto the statement shape. + +--- + +## 4. Attestation block + +Non-optional, inside every predicate. Promotes Part B of +`docs/citation-emission-spec-and-attestation-implementation-plan.md` from proposal to +foundational. + +```json +"attestation": { + "verifier": { "name": "ethos-verify", "version": "0.6.0" }, + "config": { "version": "default-v1", "sha256": "…" }, + "inputs": { "claims_sha256": "…", "source_fingerprint": "sha256:…" }, + "replay": "verify(source, claims, config) with this verifier version reproduces this predicate byte-identically" +} +``` + +Version constants come from `ethos-verify`'s own `env!` macros rather than the CLI's, so +library callers get the same attestation as CLI callers. + +Deliberately absent: timestamp, hostname, toolchain. All three break byte-identical repeat +runs, which is the core invariant. They belong in the wrapper if anywhere. + +**Honest limit, and it goes in `CLAIMS.md`:** this attests the crate version, not binary +provenance. A hostile operator can lie. The block is for cooperating parties and auditors. + +One process commitment starts immediately, independent of code: **published verifier crate +versions are never yanked except for security.** It is the only part of this release that +degrades retroactively if skipped, because a report naming an unobtainable verifier version +stops being replayable. + +--- + +## 5. Evidence tier + +```json +"evidence_tier": "exact_span" +``` + +One deterministic enum saying how strong the match was, derived from the existing locator +precedence. Values: `exact_span`, `element_scoped`, `page_scoped`, `capability_limited`. + +Generalises AetherProof's `model_root_type`: put the strength of what was proven into the +artifact as a single field, so a consumer reads one value instead of interpreting a +capability matrix. + +**Multi-source is deliberately absent.** An earlier draft carried `sources: []` to support +running two parsers and comparing them. That capability is dropped (see §7), and an array +that is always length one, never exercised, is exactly the kind of unvalidated shape that +forces a `v2` later. The singular `grounding` field stays as it is. + +--- + +## 6. Proof tiers, and what the format owes them + +The three tiers — T0 reproducible, T1 attested, T2 signed — are defined once, for readers, +in `docs/CLAIMS.md` §5. They are not restated here; two copies of a claim is how the two +copies start disagreeing. + +What this document owns is the format consequence. **Ethos ships T0 and T1.** T2 is out of +scope, and the reason the shape matters is that adding it later must not reshape the +artifact: + +- signatures attach to the **wrapper** layer (§2), which is named and empty rather than + absent, so signing adds a layer instead of restructuring one +- the `predicate` stays byte-identical when a signature is added, so a T1 verdict and the + T2 verdict wrapping it replay to the same bytes +- DSSE is the intended signing envelope and is deliberately **not** the on-disk artifact, + because it base64-encodes the payload and unreadable evidence is a regression (§1.1) + +--- + +## 7. Out of scope + +Corroboration and multi-source comparison. Multi-format grounding (DOCX, XLSX, PPTX). +Signing and keys. A keystore. Hash-chained logs. Bundle export and an offline verifier. +A conformance vector corpus. MCP proxying. Semantic checking. New parsers. Any change to +verification semantics. + +**On multi-format specifically.** `docs/v0-6-0-release.md` §10.1 already scoped it: the +verifier binds text with no geometry today, and the requirement lives in five gates in the +artifact schema and its validator, not in the verification algorithm. It is out of scope +here because no DocuShell workflow needs it — WORKBENCH Part I puts "any format other than +PDF" out of scope, and Part II names no trigger for it. Docling supporting every format is +a fact about Docling, not a requirement on Ethos. + +Two things keep the option open at near-zero cost, and both are in WP-0 of the +implementation plan: a test locking the geometry-free text path, which is currently an +audit finding with nothing enforcing it, and `Option<[i64; 4]>` for `bbox` in the trait, +which rides the breaking change WP-3 already makes rather than needing a second one. The +schema does not move. + +**Trigger to revisit:** a named DocuShell workflow requiring DOCX or XLSX verification, a +design partner asking, or a real corpus where non-PDF is a meaningful share. Not before. + +The five gates, where each lives, and the DOCX → XLSX → PPTX sequencing are recorded in +`docs/bring-your-own-parser.md` so nobody re-derives them. + +**On corroboration specifically.** Running two independently derived parsers and reporting +their disagreement is the only deterministic answer to "who checks the parser?", and it is +cut anyway. No external user has asked for it, two parsers sharing an upstream share +failure modes, it doubles parse cost, and nobody has measured the divergence rate on real +documents — a rate near zero makes it not worth building and a rate that is high makes it +noise reviewers learn to ignore. The compensating control already shipped: DocuShell shows +a reviewer the rendered crop of the actual page region. Revisit only with a measured +divergence number, not a threat model. + +Bundles and the conformance corpus are the two most likely to be argued back in. Both are +commitment devices whose job is to make change expensive, and there are currently zero +third-party implementers to make that worth paying for. They ship when someone external +depends on the format, or when an auditor asks for a portable bundle — whichever comes +first. + +--- + +## 8. Order of work + +``` +1. Statement wrapper: one builder in ethos-core, no command hand-rolls a statement +2. grounding/v1 as a pure re-wrap + payload-equivalence test + regenerated goldens +3. Attestation block, non-optional +4. evidence_tier +5. Remaining five predicates +6. CLAIMS.md + README reframe +``` + +Step 2 lands as one commit that changes nothing but shape. Every golden moves at once, so +determinism CI goes blind exactly when it matters most; the payload-equivalence test is +what keeps that survivable. + +## 9. Prior art + +Two adjacent projects informed this and neither contributed code. + +**Signet** (Prismer-AI, Apache-2.0/MIT) builds its signable payload by hand in four +different shapes across two files, with no schema anywhere. It works, and nothing enforces +that it keeps working. §2's single-builder rule exists so that failure mode is structurally +impossible here rather than merely discouraged. Its `SECURITY.md` signed-versus-unsigned +field table is the model for the §2 field table, and its bundle manifest separates +chain-start from chain-tip so a partial export is honest about being partial. + +**AetherProof** (pulkit6732, Apache-2.0) contributes two ideas. `model_root_type` puts the +tier of what was proven inside the signed payload, which §5.1 generalizes as +`evidence_tier`. Its `docs/CLAIMS.md` pairs a proves table with a does-not-prove table +carrying a residual-gap column, plus a paste-ready paragraph for security questionnaires; +that structure is what Ethos's own `CLAIMS.md` should copy. Its signing preimage is +length-prefixed with `len()` counting Python code points, which is injective in Python and +diverges in a JavaScript or Rust port — a reminder that a format defined in one language is +not a format, and the reason §1.1 prefers an established shape. diff --git a/docs/public-boundary-claims.json b/docs/public-boundary-claims.json index 764c3e97..3dbac8b1 100644 --- a/docs/public-boundary-claims.json +++ b/docs/public-boundary-claims.json @@ -4,10 +4,12 @@ "readme": { "path": "README.md", "claims": [ - "Ethos is a deterministic document evidence layer for source-grounded verification and citation checking across native Ethos JSON and supported foreign parser outputs.", - "The current beta includes the GitHub source repository, Rust library crates `ethos-doc-core`, `ethos-verify`, and `ethos-pdf` at `0.5.0`, the Python `ethos-pdf` wheel at `0.5.0`, the npm `@docushell/ethos-pdf@0.5.0` package, and GitHub Release `v0.5.0` macOS arm64/Linux x64 CLI artifacts.", - "PDFium-backed commands use caller-provided PDFium through `ETHOS_PDFIUM_LIBRARY_PATH`.", - "PDFium-backed commands require caller-provided PDFium through `ETHOS_PDFIUM_LIBRARY_PATH`.", + "Ethos checks whether an AI's claims about a document are actually in the document.", + "It does not decide whether an answer is true, relevant, or complete.", + "A missing capability produces an explicit limitation, never a silent guess.", + "PDF parsing additionally needs caller-provided PDFium through `ETHOS_PDFIUM_LIBRARY_PATH`.", + "GitHub Release `v0.5.0` also provides CLI archives for macOS arm64 and Linux x64.", + "We publish no speed, footprint, or parser-quality comparisons, because we have not run a benchmark whose numbers we would be willing to defend.", "cargo add ethos-doc-core@0.5.0", "cargo add ethos-verify@0.5.0", "cargo add ethos-pdf@0.5.0", @@ -18,7 +20,6 @@ "The npm package vendors only the approved macOS arm64 and Linux x64 CLI binaries.", "npm install -g @docushell/ethos-pdf@0.5.0", "Unsupported platforms fail before invoking a binary.", - "GitHub Release `v0.5.0` also provides evaluation CLI archives for macOS arm64 and Linux x64.", "Windows packaged artifacts, bundled project-maintained PDFium builds, hosted surfaces, public benchmark reports, and launch announcements are tracked as separate release-scope work." ] }, diff --git a/docs/v0-6-0-release-prep.md b/docs/v0-6-0-release-prep.md index 9acc6f32..94624620 100644 --- a/docs/v0-6-0-release-prep.md +++ b/docs/v0-6-0-release-prep.md @@ -1,5 +1,12 @@ # Ethos v0.6.0 Release Preparation +> **This document covers the Grounding JSON portion of v0.6.0 only.** That work is complete +> and merged. On 2026-08-09 v0.6.0 was expanded into the major format release; scope +> authority for the remaining work is `docs/proof-statement-v1.md`, and the task board is +> in `docs/proof-statement-v1-implementation-plan.md`. +> +> Everything below stays accurate for what it describes. It is no longer the whole picture. + Status: **accepted as the scoped v0.6.0 decider request** (2026-07-30), satisfying precondition §3.1. Implementation is authorized through WP-3 and has landed. diff --git a/docs/v0-6-0-release.md b/docs/v0-6-0-release.md index e4154dce..0f4fb80e 100644 --- a/docs/v0-6-0-release.md +++ b/docs/v0-6-0-release.md @@ -1,5 +1,19 @@ # Ethos v0.6.0 — Release Record +> **Scope expanded on 2026-08-09. Read this first.** +> +> v0.6.0 is now the major format release. Everything recorded below — the Grounding JSON +> work, WP-0 through WP-3 — is merged to `main` and remains accurate as history. It is no +> longer the whole of v0.6.0. +> +> The added scope makes every Ethos output artifact self-describing and self-attesting. +> Authority for that work is `docs/proof-statement-v1.md` (format and rulings) and +> `docs/proof-statement-v1-implementation-plan.md` (tasks and acceptance). +> +> §9 below is **partly superseded**: attestation and verification-report changes moved from +> non-goal to core deliverable. Do not treat this document as the scope authority for the +> remaining v0.6.0 work. + Status: **implementation verified; release blocked on governance and platform items in section 8.** This document does not authorize publication, production positioning, or any new public claim. @@ -586,10 +600,16 @@ outcomes, and a `darwin:x64` release target. --- -## 9. Non-goals, unchanged +## 9. Non-goals + +**Superseded in part on 2026-08-09.** Verification-report changes and attestation are now +**in scope** — see `docs/proof-statement-v1.md`. Signing, ledgers, checkpoints, hash-chained +logs, bundle export, and an offline verifier remain non-goals. The list below is retained +with the superseded items struck through in prose rather than deleted, so the scope change +is visible rather than silent. -Verification-report changes. Receipt, attestation, proof-package, signing, ledger, checkpoint, or -replay protocols. New PDF parsing behavior or parser-quality claims. New parser-specific adapters +~~Verification-report changes.~~ ~~Attestation.~~ Receipt, proof-package, signing, ledger, +checkpoint, or replay protocols. New PDF parsing behavior or parser-quality claims. New parser-specific adapters beyond the existing OpenDataLoader adapter. Geometry-less or text-only profiles. Dynamic plugins, WASM adapters, adapter marketplaces, or mapping DSLs. Automatic field inference, ID repair, coordinate guessing, capability guessing, or source-hash repair. Non-PDF profiles. Search, indexing, diff --git a/examples/verify/check_rendered_crops.py b/examples/verify/check_rendered_crops.py index d482a6d7..13c95e6a 100644 --- a/examples/verify/check_rendered_crops.py +++ b/examples/verify/check_rendered_crops.py @@ -86,7 +86,8 @@ def compare_artifact_dirs(left_dir, right_dir, suffix, label): def validate_rendered_descriptors(report_path, doc_path, descriptor_paths, png_paths): - report = load_json(report_path) + # verify emits an in-toto Statement; the report is its predicate + report = load_json(report_path)["predicate"] doc = load_json(doc_path) png_by_name = {path.name: path for path in png_paths} expected_refs = { diff --git a/examples/verify/check_verify_alpha.py b/examples/verify/check_verify_alpha.py index b6a4caae..d686fd24 100644 --- a/examples/verify/check_verify_alpha.py +++ b/examples/verify/check_verify_alpha.py @@ -290,8 +290,20 @@ def compare_bytes(left, right, name): print(f"ok {name} is byte-identical across runs") -def compare_json(actual_path, expected_path, repo_root, name): +def load_predicate(path): + """`ethos verify` emits an in-toto Statement; the report is its predicate. + + Goldens stay in the pre-0.6 report shape on purpose: comparing the predicate against + them is what proves the wrapper is a pure re-wrap rather than a reshaping. + See docs/proof-statement-v1.md. + """ + return load_json(path)["predicate"] + + +def compare_json(actual_path, expected_path, repo_root, name, actual_key=None): actual = load_json(actual_path) + if actual_key is not None: + actual = actual[actual_key] expected = load_json(expected_path) if actual == expected: print(f"ok {name} matches {relative(expected_path, repo_root)}") @@ -466,7 +478,7 @@ def validate_crop_descriptors(descriptor_paths, schema_path, repo_root, name): def validate_report_crop_links(report_path, descriptor_paths, name): - report = load_json(report_path) + report = load_predicate(report_path) expected = {} for check in report.get("checks", []): evidence = check.get("evidence") or {} @@ -558,7 +570,9 @@ def verify_case(case, contract_case, args): run_verify([*command, "--out", str(first)], args.repo_root, case["name"]) run_verify([*command, "--out", str(second)], args.repo_root, case["name"]) compare_bytes(first, second, case["name"]) - report = compare_json(first, args.repo_root / case["golden"], args.repo_root, case["name"]) + report = compare_json( + first, args.repo_root / case["golden"], args.repo_root, case["name"], actual_key="predicate" + ) validate_report_contract(report, contract_case, case["name"]) diff --git a/examples/verify/goldens/native_grounded_report.json b/examples/verify/goldens/native_grounded_report.json index 430d3bbd..036f2086 100644 --- a/examples/verify/goldens/native_grounded_report.json +++ b/examples/verify/goldens/native_grounded_report.json @@ -1,5 +1,13 @@ { "all_evidence_grounded": true, + "attestation": { + "claims_sha256": "65e9f83aaf48a5829556df0f1bd38d169bdafa73eefc3286fa6dac1f8699e686", + "config_version": "default-v1", + "verifier": { + "name": "ethos-verify", + "version": "0.6.0" + } + }, "capability_limits": [], "checks": [ { @@ -21,6 +29,7 @@ "page": "p0001", "text": "Revenue grew to $12.4M in Q3 2025, driven by enterprise expansion." }, + "evidence_tier": "element_scoped", "id": "v0001", "match_method": "normalized_text_contains", "semantic_unverified": false, @@ -49,6 +58,7 @@ "page": "p0001", "text": "$12.4M" }, + "evidence_tier": "table_cell", "id": "v0002", "match_method": "table_cell_lookup", "semantic_unverified": false, @@ -71,6 +81,7 @@ ], "page": "p0001" }, + "evidence_tier": "page_scoped", "id": "v0003", "match_method": "presence_only", "semantic_unverified": false, diff --git a/examples/verify/goldens/native_non_v1_claims_report.json b/examples/verify/goldens/native_non_v1_claims_report.json index 8577e14d..c63d74c2 100644 --- a/examples/verify/goldens/native_non_v1_claims_report.json +++ b/examples/verify/goldens/native_non_v1_claims_report.json @@ -1,5 +1,13 @@ { "all_evidence_grounded": false, + "attestation": { + "claims_sha256": "ebfcd116584fa35fc793ade7fb21a00c67964e198efb8664a0743652d7c4e0bd", + "config_version": "default-v1", + "verifier": { + "name": "ethos-verify", + "version": "0.6.0" + } + }, "capability_limits": [], "checks": [ { @@ -18,6 +26,7 @@ ], "page": "p0001" }, + "evidence_tier": "page_scoped", "id": "v0001", "match_method": "presence_only", "semantic_unverified": false, diff --git a/examples/verify/goldens/native_split_quote_report.json b/examples/verify/goldens/native_split_quote_report.json index 544eb5ba..e92d29ca 100644 --- a/examples/verify/goldens/native_split_quote_report.json +++ b/examples/verify/goldens/native_split_quote_report.json @@ -1,5 +1,13 @@ { "all_evidence_grounded": true, + "attestation": { + "claims_sha256": "bc750df695e997651171408e2df748c316397a7620ead800fc13fd17486b3911", + "config_version": "default-v1", + "verifier": { + "name": "ethos-verify", + "version": "0.6.0" + } + }, "capability_limits": [], "checks": [ { @@ -20,6 +28,7 @@ "page": "p0001", "text": "The alpha trust loop verifies grounded evidence" }, + "evidence_tier": "element_scoped", "id": "v0001", "match_method": "normalized_text_contains", "semantic_unverified": false, diff --git a/examples/verify/goldens/native_stale_report.json b/examples/verify/goldens/native_stale_report.json index 196b7669..54b6cfbb 100644 --- a/examples/verify/goldens/native_stale_report.json +++ b/examples/verify/goldens/native_stale_report.json @@ -1,5 +1,13 @@ { "all_evidence_grounded": false, + "attestation": { + "claims_sha256": "b3297b803a13df90b1dc6b2da027ec8235a63df548d1099031aa3a4941c5c598", + "config_version": "default-v1", + "verifier": { + "name": "ethos-verify", + "version": "0.6.0" + } + }, "capability_limits": [], "checks": [ { diff --git a/examples/verify/goldens/native_ungrounded_report.json b/examples/verify/goldens/native_ungrounded_report.json index f07729f4..ecae9ee6 100644 --- a/examples/verify/goldens/native_ungrounded_report.json +++ b/examples/verify/goldens/native_ungrounded_report.json @@ -1,5 +1,13 @@ { "all_evidence_grounded": false, + "attestation": { + "claims_sha256": "0c11ac8e8953130d39f0e9e2b7784eb08f2bdf388b5e20a31c9bb0af004d1e40", + "config_version": "default-v1", + "verifier": { + "name": "ethos-verify", + "version": "0.6.0" + } + }, "capability_limits": [], "checks": [ { @@ -21,6 +29,7 @@ "page": "p0001", "text": "Revenue grew to $12.4M in Q3 2025, driven by enterprise expansion." }, + "evidence_tier": "element_scoped", "id": "v0001", "match_method": "normalized_text_contains", "reason": "text_mismatch", diff --git a/examples/verify/goldens/opendataloader_capability_limited_report.json b/examples/verify/goldens/opendataloader_capability_limited_report.json index 9f3bcb75..02136311 100644 --- a/examples/verify/goldens/opendataloader_capability_limited_report.json +++ b/examples/verify/goldens/opendataloader_capability_limited_report.json @@ -1,5 +1,13 @@ { "all_evidence_grounded": false, + "attestation": { + "claims_sha256": "770bc7d649410bb8c4bd53e737c544ae8eca810fe53ebeb5c0c35f0acd0884eb", + "config_version": "default-v1", + "verifier": { + "name": "ethos-verify", + "version": "0.6.0" + } + }, "capability_limits": [ "missing_fingerprint", "missing_spans", diff --git a/examples/verify/goldens/opendataloader_grounded_report.json b/examples/verify/goldens/opendataloader_grounded_report.json index 064bb2a1..e0ebd1d4 100644 --- a/examples/verify/goldens/opendataloader_grounded_report.json +++ b/examples/verify/goldens/opendataloader_grounded_report.json @@ -1,5 +1,13 @@ { "all_evidence_grounded": true, + "attestation": { + "claims_sha256": "c4ddfee72316d216331e6869ca486459d7ef80858aa40d43e6a6644b5462cfb4", + "config_version": "default-v1", + "verifier": { + "name": "ethos-verify", + "version": "0.6.0" + } + }, "capability_limits": [ "missing_fingerprint", "missing_spans", @@ -26,6 +34,7 @@ "page": "page-1", "text": "Revenue grew to $12.4M in Q3 2025." }, + "evidence_tier": "element_scoped", "id": "v0001", "match_method": "normalized_text_contains", "semantic_unverified": false, @@ -54,6 +63,7 @@ "page": "page-1", "text": "$12.4M" }, + "evidence_tier": "table_cell", "id": "v0002", "match_method": "table_cell_lookup", "semantic_unverified": false, @@ -77,6 +87,7 @@ "page": "page-1", "text": "Revenue grew to $12.4M in Q3 2025." }, + "evidence_tier": "element_scoped", "id": "v0003", "match_method": "presence_only", "semantic_unverified": false, diff --git a/examples/verify/goldens/opendataloader_not_found_report.json b/examples/verify/goldens/opendataloader_not_found_report.json index 9aff6dce..e828daeb 100644 --- a/examples/verify/goldens/opendataloader_not_found_report.json +++ b/examples/verify/goldens/opendataloader_not_found_report.json @@ -1,5 +1,13 @@ { "all_evidence_grounded": false, + "attestation": { + "claims_sha256": "4224e2b98fc7fc74a3a05f7c9b09deb7cdcb2bacab5b6fa86cea5cb35845c177", + "config_version": "default-v1", + "verifier": { + "name": "ethos-verify", + "version": "0.6.0" + } + }, "capability_limits": [ "missing_fingerprint", "missing_spans", diff --git a/fixtures/foreign/opendataloader/real/expected.ungrounded.verification_report.json b/fixtures/foreign/opendataloader/real/expected.ungrounded.verification_report.json index 7147619c..1d4b7064 100644 --- a/fixtures/foreign/opendataloader/real/expected.ungrounded.verification_report.json +++ b/fixtures/foreign/opendataloader/real/expected.ungrounded.verification_report.json @@ -1 +1,69 @@ -{"all_evidence_grounded":false,"capability_limits":["missing_fingerprint","missing_spans","missing_char_offsets","missing_tables","unknown_coordinate_origin"],"checks":[{"claim":{"citation":{"element_id":"odl-2"},"kind":"value","text":"Lorem ipsum dolor sit amet"},"evidence":{"bbox":[8503,56794,50231,65976],"page":"page-1","text":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."},"id":"v0001","match_method":"normalized_text","reason":"text_mismatch","semantic_unverified":false,"status":"mismatch","warnings":[]}],"fingerprint_stale":false,"grounding":{"capabilities":{"char_offsets":false,"coordinate_origin":"unknown","crop_support":false,"fingerprint":false,"spans":false,"tables":false},"parser":{"adapter":"opendataloader-json","adapter_version":"0.1.0","name":"opendataloader-pdf","version":"unknown"}},"schema_version":"1.0.0","unsupported_claim_kinds":[],"verification_config_sha256":"4bb224166a04a25fed2dd3ecdb9638ddcc5b398658532b73f1c0547e4983d0b0","warnings":["capability_limited"]} +{ + "all_evidence_grounded": false, + "attestation": { + "claims_sha256": "daa6f3a79d748391b465ffbdc12fce964cc40ba4c8884c8aff02513253c7cef2", + "config_version": "default-v1", + "verifier": { + "name": "ethos-verify", + "version": "0.6.0" + } + }, + "capability_limits": [ + "missing_fingerprint", + "missing_spans", + "missing_char_offsets", + "missing_tables", + "unknown_coordinate_origin" + ], + "checks": [ + { + "claim": { + "citation": { + "element_id": "odl-2" + }, + "kind": "value", + "text": "Lorem ipsum dolor sit amet" + }, + "evidence": { + "bbox": [ + 8503, + 56794, + 50231, + 65976 + ], + "page": "page-1", + "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + }, + "evidence_tier": "element_scoped", + "id": "v0001", + "match_method": "normalized_text", + "reason": "text_mismatch", + "semantic_unverified": false, + "status": "mismatch", + "warnings": [] + } + ], + "fingerprint_stale": false, + "grounding": { + "capabilities": { + "char_offsets": false, + "coordinate_origin": "unknown", + "crop_support": false, + "fingerprint": false, + "spans": false, + "tables": false + }, + "parser": { + "adapter": "opendataloader-json", + "adapter_version": "0.1.0", + "name": "opendataloader-pdf", + "version": "unknown" + } + }, + "schema_version": "1.0.0", + "unsupported_claim_kinds": [], + "verification_config_sha256": "4bb224166a04a25fed2dd3ecdb9638ddcc5b398658532b73f1c0547e4983d0b0", + "warnings": [ + "capability_limited" + ] +} diff --git a/fixtures/foreign/opendataloader/real/expected.verification_report.json b/fixtures/foreign/opendataloader/real/expected.verification_report.json index 76966d0a..8935ebd2 100644 --- a/fixtures/foreign/opendataloader/real/expected.verification_report.json +++ b/fixtures/foreign/opendataloader/real/expected.verification_report.json @@ -1 +1,117 @@ -{"all_evidence_grounded":true,"capability_limits":["missing_fingerprint","missing_spans","missing_char_offsets","missing_tables","unknown_coordinate_origin"],"checks":[{"claim":{"citation":{"element_id":"odl-1"},"kind":"quote","text":"Lorem Ipsum"},"evidence":{"bbox":[20089,70694,39415,74513],"page":"page-1","text":"Lorem Ipsum"},"id":"v0001","match_method":"normalized_text_contains","semantic_unverified":false,"status":"grounded","warnings":[]},{"claim":{"citation":{"element_id":"odl-2"},"kind":"value","text":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."},"evidence":{"bbox":[8503,56794,50231,65976],"page":"page-1","text":"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."},"id":"v0002","match_method":"normalized_text","semantic_unverified":false,"status":"grounded","warnings":[]},{"claim":{"citation":{"element_id":"odl-1"},"kind":"presence"},"evidence":{"bbox":[20089,70694,39415,74513],"page":"page-1","text":"Lorem Ipsum"},"id":"v0003","match_method":"presence_only","semantic_unverified":false,"status":"grounded","warnings":[]}],"fingerprint_stale":false,"grounding":{"capabilities":{"char_offsets":false,"coordinate_origin":"unknown","crop_support":false,"fingerprint":false,"spans":false,"tables":false},"parser":{"adapter":"opendataloader-json","adapter_version":"0.1.0","name":"opendataloader-pdf","version":"unknown"}},"schema_version":"1.0.0","unsupported_claim_kinds":[],"verification_config_sha256":"4bb224166a04a25fed2dd3ecdb9638ddcc5b398658532b73f1c0547e4983d0b0","warnings":["capability_limited"]} +{ + "all_evidence_grounded": true, + "attestation": { + "claims_sha256": "c26a34a9e73dbe3f1c082904d4ecd431ca503adcd8f30382c389887d6ebcc5c7", + "config_version": "default-v1", + "verifier": { + "name": "ethos-verify", + "version": "0.6.0" + } + }, + "capability_limits": [ + "missing_fingerprint", + "missing_spans", + "missing_char_offsets", + "missing_tables", + "unknown_coordinate_origin" + ], + "checks": [ + { + "claim": { + "citation": { + "element_id": "odl-1" + }, + "kind": "quote", + "text": "Lorem Ipsum" + }, + "evidence": { + "bbox": [ + 20089, + 70694, + 39415, + 74513 + ], + "page": "page-1", + "text": "Lorem Ipsum" + }, + "evidence_tier": "element_scoped", + "id": "v0001", + "match_method": "normalized_text_contains", + "semantic_unverified": false, + "status": "grounded", + "warnings": [] + }, + { + "claim": { + "citation": { + "element_id": "odl-2" + }, + "kind": "value", + "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + }, + "evidence": { + "bbox": [ + 8503, + 56794, + 50231, + 65976 + ], + "page": "page-1", + "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + }, + "evidence_tier": "element_scoped", + "id": "v0002", + "match_method": "normalized_text", + "semantic_unverified": false, + "status": "grounded", + "warnings": [] + }, + { + "claim": { + "citation": { + "element_id": "odl-1" + }, + "kind": "presence" + }, + "evidence": { + "bbox": [ + 20089, + 70694, + 39415, + 74513 + ], + "page": "page-1", + "text": "Lorem Ipsum" + }, + "evidence_tier": "element_scoped", + "id": "v0003", + "match_method": "presence_only", + "semantic_unverified": false, + "status": "grounded", + "warnings": [] + } + ], + "fingerprint_stale": false, + "grounding": { + "capabilities": { + "char_offsets": false, + "coordinate_origin": "unknown", + "crop_support": false, + "fingerprint": false, + "spans": false, + "tables": false + }, + "parser": { + "adapter": "opendataloader-json", + "adapter_version": "0.1.0", + "name": "opendataloader-pdf", + "version": "unknown" + } + }, + "schema_version": "1.0.0", + "unsupported_claim_kinds": [], + "verification_config_sha256": "4bb224166a04a25fed2dd3ecdb9638ddcc5b398658532b73f1c0547e4983d0b0", + "warnings": [ + "capability_limited" + ] +} diff --git a/packages/npm/ethos-pdf/test/types-consumer.ts b/packages/npm/ethos-pdf/test/types-consumer.ts index 0967d5ae..9cbab8a5 100644 --- a/packages/npm/ethos-pdf/test/types-consumer.ts +++ b/packages/npm/ethos-pdf/test/types-consumer.ts @@ -56,6 +56,11 @@ const report: EthosVerificationReport = { checks: [], unsupported_claim_kinds: [], warnings: ["capability_limited"], + attestation: { + verifier: { name: "ethos-verify", version: "0.6.0" }, + config_version: "default-v1", + claims_sha256: "0".repeat(64), + }, }; const claim: EthosCitationClaim = { diff --git a/packages/npm/ethos-pdf/types/verification-report.d.ts b/packages/npm/ethos-pdf/types/verification-report.d.ts index 3ee4e46a..2be8e2c2 100644 --- a/packages/npm/ethos-pdf/types/verification-report.d.ts +++ b/packages/npm/ethos-pdf/types/verification-report.d.ts @@ -164,6 +164,10 @@ export interface EthosVerificationReport { crop_ref?: string; }; warnings: EthosVerificationWarningCode[]; + /** + * How precisely this check bound its evidence. Absent when nothing resolved. + */ + evidence_tier?: "exact_span" | "table_cell" | "element_scoped" | "page_scoped" | "capability_limited"; }[]; /** * Claim kinds present in the input that this verifier/config does not support. Non-empty => all_evidence_grounded=false. @@ -173,4 +177,21 @@ export interface EthosVerificationReport { * Report-level stable warning codes (capability downgrades land here as capability_limited). */ warnings: EthosVerificationWarningCode[]; + /** + * What produced this verdict. A binding record, not cryptographic proof: it attests the verifier crate version, not binary provenance. + */ + attestation: { + verifier: { + name: string; + version: string; + }; + /** + * Echo of the config label. verification_config_sha256 stays authoritative. + */ + config_version: string; + /** + * sha256(c14n(claims)) over the parsed claims array, not the raw file bytes and not the envelope. + */ + claims_sha256: string; + }; } diff --git a/python/ethos_pdf/emit.py b/python/ethos_pdf/emit.py index 50d6e8ab..90a12394 100644 --- a/python/ethos_pdf/emit.py +++ b/python/ethos_pdf/emit.py @@ -269,7 +269,9 @@ def project_evidence_states( def _validate_projection_report(report: Mapping[str, Any]) -> None: required = {"schema_version", "document_fingerprint", "verification_config_sha256", "grounding", "capability_limits", "fingerprint_stale", "all_evidence_grounded", "checks", "unsupported_claim_kinds", "warnings"} - allowed = required | {"dispersion"} + # attestation and evidence_tier arrive in 0.6. Allowed rather than required, because + # this wrapper drives a caller-provided ethos binary that may predate them. + allowed = required | {"dispersion", "attestation"} if not isinstance(report, Mapping) or not required <= set(report) or set(report) - allowed: raise CitationEmissionError("invalid_report", "report fields do not match the verification-report schema") if report["schema_version"] not in {"1.0.0", "1.1.0"} or not _FINGERPRINT.fullmatch(report["document_fingerprint"]): @@ -290,7 +292,7 @@ def _validate_projection_report(report: Mapping[str, Any]) -> None: hardened = "dispersion" in report for index, check in enumerate(report["checks"], 1): required_check = {"id", "claim", "status", "match_method", "semantic_unverified", "warnings"} - allowed_check = required_check | {"reason", "resolved_element_ids", "provenance", "context_echo", "evidence"} + allowed_check = required_check | {"reason", "resolved_element_ids", "provenance", "context_echo", "evidence", "evidence_tier"} if not isinstance(check, Mapping) or not required_check <= set(check) or set(check) - allowed_check or check["id"] != f"v{index:04d}" or check["status"] not in statuses or check["match_method"] not in methods or not isinstance(check["semantic_unverified"], bool) or not isinstance(check["warnings"], list) or not isinstance(check["claim"], Mapping): raise CitationEmissionError("invalid_report", "check does not match the verification-report schema") if check["status"] == "grounded" and "reason" in check: diff --git a/schemas/ethos-verification-report.schema.json b/schemas/ethos-verification-report.schema.json index 3e790103..a8c57d8f 100644 --- a/schemas/ethos-verification-report.schema.json +++ b/schemas/ethos-verification-report.schema.json @@ -2,54 +2,99 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:ethos:schema:verification-report:1", "title": "Ethos verification report (verification_report.json)", - "description": "Citation evidence verification over any GroundingSource (Ethos or foreign parser output). Verification is EVIDENCE GROUNDING — the cited region exists, its text matches by a declared method, the fingerprint is fresh. It is never pixel-level, semantic, or arithmetic proof of an answer. Capability-driven downgrades are explicit: missing spans, missing fingerprints, or foreign coordinate systems appear as capability_limited warnings, never silent approximation. INVARIANT (PRD §8): all_evidence_grounded is true only when (a) at least one supported check exists, (b) every supported check has status 'grounded', (c) no check has semantic_unverified=true, (d) unsupported_claim_kinds is empty, and (e) fingerprint_stale is false. The reference implementation enforces this; the schema documents it.", + "description": "Citation evidence verification over any GroundingSource (Ethos or foreign parser output). Verification is EVIDENCE GROUNDING \u2014 the cited region exists, its text matches by a declared method, the fingerprint is fresh. It is never pixel-level, semantic, or arithmetic proof of an answer. Capability-driven downgrades are explicit: missing spans, missing fingerprints, or foreign coordinate systems appear as capability_limited warnings, never silent approximation. INVARIANT (PRD \u00a78): all_evidence_grounded is true only when (a) at least one supported check exists, (b) every supported check has status 'grounded', (c) no check has semantic_unverified=true, (d) unsupported_claim_kinds is empty, and (e) fingerprint_stale is false. The reference implementation enforces this; the schema documents it.", "type": "object", "required": [ - "schema_version", - "verification_config_sha256", - "grounding", - "capability_limits", - "fingerprint_stale", "all_evidence_grounded", + "attestation", + "capability_limits", "checks", + "fingerprint_stale", + "grounding", + "schema_version", "unsupported_claim_kinds", + "verification_config_sha256", "warnings" ], "additionalProperties": false, "properties": { - "schema_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "schema_version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + }, "document_fingerprint": { "$ref": "#/$defs/fingerprint", "description": "Fingerprint of the grounding document when the source declares one; absent (with capability warning) otherwise." }, - "verification_config_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "verification_config_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, "grounding": { "type": "object", - "required": ["parser", "capabilities"], + "required": [ + "parser", + "capabilities" + ], "additionalProperties": false, "properties": { "parser": { "type": "object", - "required": ["name", "version"], + "required": [ + "name", + "version" + ], "additionalProperties": false, "properties": { - "name": { "type": "string" }, - "version": { "type": "string" }, - "adapter": { "type": "string", "description": "Adapter identifier, e.g. 'opendataloader-json'." }, - "adapter_version": { "type": "string" } + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "adapter": { + "type": "string", + "description": "Adapter identifier, e.g. 'opendataloader-json'." + }, + "adapter_version": { + "type": "string" + } } }, "capabilities": { "type": "object", - "required": ["spans", "char_offsets", "tables", "fingerprint", "coordinate_origin", "crop_support"], + "required": [ + "spans", + "char_offsets", + "tables", + "fingerprint", + "coordinate_origin", + "crop_support" + ], "additionalProperties": false, "properties": { - "spans": { "type": "boolean" }, - "char_offsets": { "type": "boolean" }, - "tables": { "type": "boolean" }, - "fingerprint": { "type": "boolean" }, - "coordinate_origin": { "enum": ["top-left", "bottom-left", "unknown"] }, - "crop_support": { "type": "boolean" } + "spans": { + "type": "boolean" + }, + "char_offsets": { + "type": "boolean" + }, + "tables": { + "type": "boolean" + }, + "fingerprint": { + "type": "boolean" + }, + "coordinate_origin": { + "enum": [ + "top-left", + "bottom-left", + "unknown" + ] + }, + "crop_support": { + "type": "boolean" + } } } } @@ -74,67 +119,150 @@ }, "uniqueItems": true }, - "all_evidence_grounded": { "type": "boolean" }, + "all_evidence_grounded": { + "type": "boolean" + }, "dispersion": { "type": "object", - "required": ["grounded_checks", "elements", "pages", "unmapped_grounded_checks"], + "required": [ + "grounded_checks", + "elements", + "pages", + "unmapped_grounded_checks" + ], "additionalProperties": false, "properties": { - "grounded_checks": { "type": "integer", "minimum": 0 }, - "elements": { "type": "integer", "minimum": 0 }, - "pages": { "type": "integer", "minimum": 0 }, - "unmapped_grounded_checks": { "type": "integer", "minimum": 0 }, - "sections": { "type": "integer", "minimum": 0 } + "grounded_checks": { + "type": "integer", + "minimum": 0 + }, + "elements": { + "type": "integer", + "minimum": 0 + }, + "pages": { + "type": "integer", + "minimum": 0 + }, + "unmapped_grounded_checks": { + "type": "integer", + "minimum": 0 + }, + "sections": { + "type": "integer", + "minimum": 0 + } } }, "checks": { "type": "array", "items": { "type": "object", - "required": ["id", "claim", "status", "match_method", "semantic_unverified", "warnings"], + "required": [ + "id", + "claim", + "status", + "match_method", + "semantic_unverified", + "warnings" + ], "additionalProperties": false, "properties": { - "id": { "type": "string", "pattern": "^v[0-9]{4}$" }, + "id": { + "type": "string", + "pattern": "^v[0-9]{4}$" + }, "claim": { "type": "object", - "required": ["kind", "citation"], + "required": [ + "kind", + "citation" + ], "additionalProperties": false, "properties": { - "kind": { "enum": ["quote", "value", "presence", "table_cell", "region", "other"] }, - "text": { "type": "string", "description": "The claimed quote/value text, when textual." }, + "kind": { + "enum": [ + "quote", + "value", + "presence", + "table_cell", + "region", + "other" + ] + }, + "text": { + "type": "string", + "description": "The claimed quote/value text, when textual." + }, "citation": { "type": "object", "description": "Where the claim says the evidence lives. At least one locator required; id formats follow the grounding source.", "additionalProperties": false, "minProperties": 1, "properties": { - "page": { "type": "string" }, - "element_id": { "type": "string" }, - "span_id": { "type": "string" }, - "table_id": { "type": "string" }, + "page": { + "type": "string" + }, + "element_id": { + "type": "string" + }, + "span_id": { + "type": "string" + }, + "table_id": { + "type": "string" + }, "cell": { "type": "object", - "required": ["row", "col"], + "required": [ + "row", + "col" + ], "additionalProperties": false, "properties": { - "row": { "type": "integer", "minimum": 0 }, - "col": { "type": "integer", "minimum": 0 } + "row": { + "type": "integer", + "minimum": 0 + }, + "col": { + "type": "integer", + "minimum": 0 + } } }, - "bbox": { "$ref": "#/$defs/bbox" } + "bbox": { + "$ref": "#/$defs/bbox" + } } } } }, "status": { - "enum": ["grounded", "not_found", "mismatch", "stale", "unsupported_claim_kind", "capability_blocked", "error"] + "enum": [ + "grounded", + "not_found", + "mismatch", + "stale", + "unsupported_claim_kind", + "capability_blocked", + "error" + ] }, "reason": { "$ref": "#/$defs/check_reason", "description": "Stable diagnostic reason for a non-grounded check outcome. Omitted for grounded checks." }, "match_method": { - "enum": ["exact_text", "normalized_text", "exact_text_contains", "normalized_text_contains", "table_cell_lookup", "bbox_containment", "presence_only", "none"], + "enum": [ + "exact_text", + "normalized_text", + "exact_text_contains", + "normalized_text_contains", + "table_cell_lookup", + "bbox_containment", + "presence_only", + "none" + ], "description": "How evidence was matched. Equality methods require the target text to equal the claim text after the configured normalization. '*_contains' methods are explicit substring containment and are used only for quote evidence inside a larger target. 'normalized_text' uses ONLY the whitespace rule pinned in the verification config; nothing fuzzier exists in v1." }, "semantic_unverified": { @@ -143,37 +271,79 @@ }, "resolved_element_ids": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "uniqueItems": true }, "provenance": { "type": "object", - "required": ["status"], + "required": [ + "status" + ], "additionalProperties": false, "properties": { - "status": { "enum": ["available", "capability_limited", "not_applicable"] }, - "heading_path": { "type": "array", "items": { "type": "string" } }, - "element_role": { "type": "string" }, - "previous_element_id": { "type": "string" }, - "next_element_id": { "type": "string" } + "status": { + "enum": [ + "available", + "capability_limited", + "not_applicable" + ] + }, + "heading_path": { + "type": "array", + "items": { + "type": "string" + } + }, + "element_role": { + "type": "string" + }, + "previous_element_id": { + "type": "string" + }, + "next_element_id": { + "type": "string" + } } }, "context_echo": { "type": "object", - "required": ["before", "match", "after"], + "required": [ + "before", + "match", + "after" + ], "additionalProperties": false, "properties": { - "before": { "type": "string" }, - "match": { "type": "string" }, - "after": { "type": "string" }, + "before": { + "type": "string" + }, + "match": { + "type": "string" + }, + "after": { + "type": "string" + }, "element_boundary": { "type": "object", - "required": ["offset", "left_element_id", "right_element_id"], + "required": [ + "offset", + "left_element_id", + "right_element_id" + ], "additionalProperties": false, "properties": { - "offset": { "type": "integer", "minimum": 0 }, - "left_element_id": { "type": "string" }, - "right_element_id": { "type": "string" } + "offset": { + "type": "integer", + "minimum": 0 + }, + "left_element_id": { + "type": "string" + }, + "right_element_id": { + "type": "string" + } } } } @@ -183,55 +353,163 @@ "description": "What was found at the citation target. Page-only presence checks synthesize bbox as the full page extent. crop_ref is an opaque audit pointer emitted only when the verification config requests crops and the GroundingSource declares crop_support.", "additionalProperties": false, "properties": { - "text": { "type": "string" }, - "page": { "type": "string" }, - "bbox": { "$ref": "#/$defs/bbox" }, - "crop_ref": { "type": "string" } + "text": { + "type": "string" + }, + "page": { + "type": "string" + }, + "bbox": { + "$ref": "#/$defs/bbox" + }, + "crop_ref": { + "type": "string" + } } }, - "warnings": { "type": "array", "items": { "$ref": "#/$defs/warning_code" } } + "warnings": { + "type": "array", + "items": { + "$ref": "#/$defs/warning_code" + } + }, + "evidence_tier": { + "type": "string", + "enum": [ + "exact_span", + "table_cell", + "element_scoped", + "page_scoped", + "capability_limited" + ], + "description": "How precisely this check bound its evidence. Absent when nothing resolved." + } } } }, "unsupported_claim_kinds": { "type": "array", "description": "Claim kinds present in the input that this verifier/config does not support. Non-empty => all_evidence_grounded=false.", - "items": { "type": "string" } + "items": { + "type": "string" + } }, "warnings": { "type": "array", "description": "Report-level stable warning codes (capability downgrades land here as capability_limited).", - "items": { "$ref": "#/$defs/warning_code" } + "items": { + "$ref": "#/$defs/warning_code" + } + }, + "attestation": { + "type": "object", + "additionalProperties": false, + "required": [ + "verifier", + "config_version", + "claims_sha256" + ], + "description": "What produced this verdict. A binding record, not cryptographic proof: it attests the verifier crate version, not binary provenance.", + "properties": { + "verifier": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "version" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + } + } + }, + "config_version": { + "type": "string", + "minLength": 1, + "description": "Echo of the config label. verification_config_sha256 stays authoritative." + }, + "claims_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "sha256(c14n(claims)) over the parsed claims array, not the raw file bytes and not the envelope." + } + } } }, "allOf": [ { "if": { "anyOf": [ - { "required": ["dispersion"] }, + { + "required": [ + "dispersion" + ] + }, { "properties": { "checks": { "contains": { "anyOf": [ - { "required": ["resolved_element_ids"] }, - { "required": ["provenance"] }, - { "required": ["context_echo"] } + { + "required": [ + "resolved_element_ids" + ] + }, + { + "required": [ + "provenance" + ] + }, + { + "required": [ + "context_echo" + ] + } ] } } }, - "required": ["checks"] + "required": [ + "checks" + ] } ] }, - "then": { "properties": { "schema_version": { "const": "1.1.0" } } }, - "else": { "properties": { "schema_version": { "const": "1.0.0" } } } + "then": { + "properties": { + "schema_version": { + "const": "1.1.0" + } + } + }, + "else": { + "properties": { + "schema_version": { + "const": "1.0.0" + } + } + } } ], "$defs": { - "fingerprint": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, - "bbox": { "type": "array", "items": { "type": "integer" }, "minItems": 4, "maxItems": 4 }, + "fingerprint": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "bbox": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 4, + "maxItems": 4 + }, "check_reason": { "enum": [ "missing_locator", diff --git a/schemas/examples/verification-report-negative.example.json b/schemas/examples/verification-report-negative.example.json index 6214fb95..a6d52587 100644 --- a/schemas/examples/verification-report-negative.example.json +++ b/schemas/examples/verification-report-negative.example.json @@ -36,5 +36,13 @@ } ], "unsupported_claim_kinds": [], - "warnings": [] + "warnings": [], + "attestation": { + "verifier": { + "name": "ethos-verify", + "version": "0.6.0" + }, + "config_version": "default-v1", + "claims_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } } diff --git a/schemas/examples/verification-report.example.json b/schemas/examples/verification-report.example.json index c5c08cb4..9aa4368f 100644 --- a/schemas/examples/verification-report.example.json +++ b/schemas/examples/verification-report.example.json @@ -32,6 +32,7 @@ }, "status": "grounded", "match_method": "normalized_text_contains", + "evidence_tier": "element_scoped", "semantic_unverified": false, "evidence": { "text": "Revenue grew to $12.4M in Q3 2025, driven by enterprise expansion.", @@ -60,6 +61,7 @@ }, "status": "grounded", "match_method": "table_cell_lookup", + "evidence_tier": "table_cell", "semantic_unverified": false, "evidence": { "text": "$12.4M", @@ -75,5 +77,13 @@ } ], "unsupported_claim_kinds": [], - "warnings": [] + "warnings": [], + "attestation": { + "claims_sha256": "51e36a018bcc9286835738bccd7f277776b8c6f7820b05ed2a601db25dd42c82", + "config_version": "default-v1", + "verifier": { + "name": "ethos-verify", + "version": "0.6.0" + } + } } diff --git a/schemas/examples/verification-report.hardened.example.json b/schemas/examples/verification-report.hardened.example.json index f649b1b4..162a43ee 100644 --- a/schemas/examples/verification-report.hardened.example.json +++ b/schemas/examples/verification-report.hardened.example.json @@ -23,6 +23,7 @@ }, "id": "v0001", "match_method": "normalized_text_contains", + "evidence_tier": "element_scoped", "provenance": { "element_role": "text_block", "heading_path": ["Q3 Financial Summary"], @@ -54,6 +55,7 @@ }, "id": "v0002", "match_method": "table_cell_lookup", + "evidence_tier": "table_cell", "provenance": { "status": "not_applicable" }, @@ -87,5 +89,13 @@ "schema_version": "1.1.0", "unsupported_claim_kinds": [], "verification_config_sha256": "70cc82cc7583d9c32ee33d1c8988a6bfe98130de8c0c5bef4de9a834bcbd23c4", - "warnings": [] + "warnings": [], + "attestation": { + "claims_sha256": "51e36a018bcc9286835738bccd7f277776b8c6f7820b05ed2a601db25dd42c82", + "config_version": "hardened-v1", + "verifier": { + "name": "ethos-verify", + "version": "0.6.0" + } + } } diff --git a/scripts/hooks/commit-msg b/scripts/hooks/commit-msg new file mode 100755 index 00000000..eeb6f828 --- /dev/null +++ b/scripts/hooks/commit-msg @@ -0,0 +1,11 @@ +#!/bin/sh +# DCO guard (ADR-0004). Catches both failure modes seen on the v0.6.0 branch: +# a missing Signed-off-by, and one stranded outside the trailer block by a +# blank line. `git interpret-trailers --parse` reads the block the same way +# .github/scripts/check_dco.py does, so local and CI agree. +git interpret-trailers --parse < "$1" | grep -q '^Signed-off-by:' || { + echo "commit-msg: no Signed-off-by in the trailer block." >&2 + echo " Use 'git commit -s', and keep every trailer in the final" >&2 + echo " paragraph with no blank line between them." >&2 + exit 1 +}