From bddde49d385a270b5529ca5fb10b56c25211680c Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 15:00:11 +0530 Subject: [PATCH 01/22] docs: draft Proof Statement v1 and add a DCO commit-msg hook The spec is a draft for ruling. Three decisions in section 1 block everything below them: the artifact shape, the predicateType URI namespace, and the representation_sha256 conflict. Each carries a recommendation so the tradeoffs are concrete rather than abstract. Nothing is implemented. Scope of the eventual change is deliberately narrow: it changes artifact shape and adds corroboration, and it changes no verification semantics. Worth recording why the shape work is not a foreign idea. v0.6.0 already implements artifact-type dispatch in crates/ethos-cli/src/grounding.rs, and the grounding validation report already requires a self-describing artifact_type field. Five other output artifacts have neither. This finishes what WP-0..WP-3 started. Also adds scripts/hooks/commit-msg. The v0.6.0 branch hit two distinct DCO failures in one afternoon: a commit with no sign-off, and commits whose sign-off sat outside the trailer block because a blank line separated it from Co-Authored-By. Git only parses trailers in the final paragraph, so the second form looks correct and fails. The hook uses git interpret-trailers --parse, the same mechanism as .github/scripts/check_dco.py, so local and CI agree. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- CONTRIBUTING.md | 13 ++ docs/proof-statement-v1.md | 267 +++++++++++++++++++++++++++++++++++++ scripts/hooks/commit-msg | 11 ++ 3 files changed, 291 insertions(+) create mode 100644 docs/proof-statement-v1.md create mode 100755 scripts/hooks/commit-msg diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b0d9042..aeec89b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,3 +88,16 @@ 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`. + +## 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/docs/proof-statement-v1.md b/docs/proof-statement-v1.md new file mode 100644 index 0000000..b1cc8f7 --- /dev/null +++ b/docs/proof-statement-v1.md @@ -0,0 +1,267 @@ +# Proof Statement v1 + +Status: **draft for ruling.** Nothing here is implemented. Three decisions in §1 are +open and block everything below them; the rest is written assuming the recommended +answer so the tradeoffs are concrete rather than abstract. + +Scope: this changes the *shape* of Ethos output artifacts and adds corroboration. It +changes no verification semantics. If a proposal alters what `grounded` means for any +existing claim, it does not belong in this document. + +--- + +## 1. Open decisions + +### 1.1 Artifact shape — recommend 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. + +Recommendation: adopt the in-toto Statement as the native artifact. + +```json +{ + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { "name": "invoice.pdf", "digest": { "sha256": "3fc9…" } } + ], + "predicateType": "https:///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. + +Verify the current `_type` revision against the in-toto spec before freezing it. + +### 1.2 URI namespace — open + +`predicateType` URIs are permanent. The only real question is the domain: an Ethos domain +or a DocuShell one. + +Recommendation: Ethos. The product argument is independence, and independence is +weakened if the evidence format is namespaced to the commercial product built on it. + +### 1.3 `representation_sha256` — recommend keep + +This is the standing recommendation in `docs/v0-6-0-release.md` §3.1 and it becomes +`subject[].digest`. Rule it first, because it propagates into a frozen format. + +### 1.4 Decided while drafting, override if wrong + +- **`subject` holds source documents only.** A verification verdict is about three + inputs: the document, the claims, and the config. in-toto's subject model is + artifact-centric, so claims and config bind in the predicate's attestation block (§4) + instead. Two reports over one document with different claims share a subject, which is + correct: both are statements *about* that document. +- **Representations stay bare.** `document.ethos.json` and `chunks.jsonl` are + representations, not assertions about anything. Statements are for verdicts. + +--- + +## 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. + +The contract doc carries a field table stating which layer every field lives in, and an +explicit warning that basing policy decisions on wrapper fields is unsafe. Signet's +`SECURITY.md` is the model here: it tabulates signed versus unsigned fields and spells +out the attack when a developer trusts an unsigned one. + +--- + +## 3. Predicate types + +| Predicate | Replaces | Status | +| --- | --- | --- | +| `grounding/v1` | `verification_report.json` | migrate | +| `grounding-validation/v1` | `ethos.grounding_validation.v1` | migrate, URI-ify | +| `evidence-anchor/v1` | `evidence_anchor_report.json` | migrate | +| `security/v1` | `security_report.json` | migrate | +| `crop/v1` | crop descriptors | migrate | +| `answer-release/v1` | app-answer-release decision | migrate | +| `corroboration/v1` | nothing | new (§5) | + +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. + +If the release starts dragging, `crop/v1` and `answer-release/v1` are the first to defer. +They have the fewest consumers. + +--- + +## 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. Multi-source and corroboration + +### 5.1 `sources[]` + +```json +"sources": [ + { "parser": { }, "capabilities": { }, "fingerprint": "sha256:…" } +], +"evidence_tier": "exact_span" +``` + +An array even when N=1. Widening a frozen schema from one to many costs a major version; +starting wide costs nothing today. + +`evidence_tier` generalizes AetherProof's `model_root_type`: put the *strength of what was +proven* into the artifact as one enum, so a consumer reads one field rather than +interpreting a capabilities matrix. Values: `exact_span`, `element_scoped`, `page_scoped`, +`capability_limited`. Derived deterministically from locator precedence. + +### 5.2 `corroboration/v1` + +The reason this release is worth a migration. + +Run N independently derived grounding sources over one subject. Compare bindings under +declared tolerance. Emit one state: + +| State | Meaning | +| --- | --- | +| `corroborated` | every source binds the claim, locators agree | +| `single_source` | N=1, stated plainly rather than implied | +| `divergent` | sources disagree — the most useful signal Ethos can produce | +| `capability_asymmetric` | only some sources could answer | + +Source independence is **declared, never assumed**. Two adapters both wrapping PDFium share +failure modes, and the predicate has to say so. + +This is a deterministic dent in the gap `docs/hallucination-threat-model.md` and DocuShell's +own MVP notes both name: a parser error that both drafts and verifies consistently is +otherwise invisible. It does not close that gap. It makes one class of it visible. + +Corroboration raises confidence. It does not prove fidelity. That sentence belongs in +`CLAIMS.md` on day one, before anyone reads `divergent` as a verdict. + +--- + +## 6. Proof tiers + +Publish these as a table. The ordering is counterintuitive and the top rung is the one +nobody else can occupy. + +| Tier | Claim | Key required | +| --- | --- | --- | +| **T0 Reproducible** | anyone re-runs and gets identical bytes | no | +| **T1 Attested** | the record names the verifier, config, and exact claims that produced it | no | +| **T2 Signed** | a named key asserts who ran it and when | yes | + +Ethos ships T0 and T1 in this release. T2 is deliberately out: the statement shape makes +signing a wrapper you add later without touching the artifact, which is the whole reason +for getting the shape right first. + +The message the tiers carry: everyone else starts at T2 and calls it proof. T2 says someone +claimed this. T0 says check it yourself. + +--- + +## 7. Out of scope + +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 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 + +``` +0. Rule §1.1, §1.2, §1.3 +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. sources[] + evidence_tier +5. corroboration/v1 +6. Remaining five predicates +7. 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/scripts/hooks/commit-msg b/scripts/hooks/commit-msg new file mode 100755 index 0000000..eeb6f82 --- /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 +} From c27a625d07d99deaec64e791fe764f0c7fa9f8ea Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 15:10:42 +0530 Subject: [PATCH 02/22] docs: rule the three Proof Statement v1 decisions 1.1 Artifact shape: in-toto Statement. An established shape with existing tooling and growing auditor familiarity, for no invented format. Not DSSE as the on-disk artifact, because it base64-encodes the payload and readable evidence is half of what Ethos offers. 1.2 Namespace: an Ethos-owned domain, not DocuShell. The product argument is independence, and a predicateType reading docushell.com erodes it every time someone opens an artifact. The specific domain is not yet registered; that now blocks emitting any artifact, since the string is permanent. 1.3 Source identity: representation_sha256 stays authoritative, matching the standing ruling in docs/v0-6-0-release.md section 8. On the Grounding JSON path a foreign parser produced the representation and Ethos never touched the source PDF, so naming the PDF as what was read would assert something Ethos cannot know. 1.4 Subject shape: both, representation first. This revises the earlier draft, which said subject holds source documents only. subject is an array, so the honest answer and the useful one are not in conflict: subject[0] is always the representation Ethos read, subject[1] is the source document only when the binding is real. Consumers must not assume subject[0] is the PDF, which is a documentation obligation recorded in section 1.4. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- docs/proof-statement-v1.md | 74 ++++++++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 23 deletions(-) diff --git a/docs/proof-statement-v1.md b/docs/proof-statement-v1.md index b1cc8f7..d03b214 100644 --- a/docs/proof-statement-v1.md +++ b/docs/proof-statement-v1.md @@ -1,8 +1,11 @@ # Proof Statement v1 -Status: **draft for ruling.** Nothing here is implemented. Three decisions in §1 are -open and block everything below them; the rest is written assuming the recommended -answer so the tradeoffs are concrete rather than abstract. +Status: **ruled, not implemented.** The three decisions in §1 are settled. Nothing here +is built yet. + +One action remains before the first artifact ships: **pick and register the Ethos +domain** (§1.2). Every `predicateType` string is permanent, so no artifact can be +emitted until the domain is chosen and controlled. Scope: this changes the *shape* of Ethos output artifacts and adds corroboration. It changes no verification semantics. If a proposal alters what `grounded` means for any @@ -10,9 +13,9 @@ existing claim, it does not belong in this document. --- -## 1. Open decisions +## 1. Decisions -### 1.1 Artifact shape — recommend in-toto Statement +### 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 @@ -24,7 +27,7 @@ binding, and say nothing about what produced them. 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. -Recommendation: adopt the in-toto Statement as the native artifact. +Ruled: the in-toto Statement is the native artifact. ```json { @@ -48,28 +51,53 @@ reading it is half the value, it is a regression. DSSE stays a signing wrapper f Verify the current `_type` revision against the in-toto spec before freezing it. -### 1.2 URI namespace — open +### 1.2 URI namespace — RULED: an Ethos-owned domain + +`predicateType` URIs are permanent and they announce who owns the format. The product +argument is independence: the checker is not the thing being checked, and it runs without +trusting DocuShell. A namespace reading `docushell.com` erodes that every time someone +opens an artifact. + +**Blocking action:** the specific domain is not yet chosen or registered. No artifact can +be emitted until it is, because the string cannot change afterward. Placeholder in this +document is `ethos.dev`; substitute the real one before any code lands. + +### 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. -`predicateType` URIs are permanent. The only real question is the domain: an Ethos domain -or a DocuShell one. +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…" } } +] +``` -Recommendation: Ethos. The product argument is independence, and independence is -weakened if the evidence format is namespaced to the commercial product built on it. +- `subject[0]` is always the representation Ethos read. Required. +- `subject[1]` is the source document, present **only** when the binding is real. Omitted + otherwise, never guessed. -### 1.3 `representation_sha256` — recommend keep +Consumers must not assume `subject[0]` is the PDF. That is a documentation obligation and +it goes in the contract doc and in `CLAIMS.md`. -This is the standing recommendation in `docs/v0-6-0-release.md` §3.1 and it becomes -`subject[].digest`. Rule it first, because it propagates into a frozen format. +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.4 Decided while drafting, override if wrong +### 1.5 Decided while drafting, override if wrong -- **`subject` holds source documents only.** A verification verdict is about three - inputs: the document, the claims, and the config. in-toto's subject model is - artifact-centric, so claims and config bind in the predicate's attestation block (§4) - instead. Two reports over one document with different claims share a subject, which is - correct: both are statements *about* that document. -- **Representations stay bare.** `document.ethos.json` and `chunks.jsonl` are - representations, not assertions about anything. Statements are for verdicts. +**Representations stay bare.** `document.ethos.json` and `chunks.jsonl` are representations, +not assertions about anything. Statements are for verdicts. --- @@ -232,7 +260,7 @@ first. ## 8. Order of work ``` -0. Rule §1.1, §1.2, §1.3 +0. Register the Ethos domain (§1.2) — blocks every predicateType string 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 From f486e273dafd5693b9b3d2236e3c40b562261970 Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 15:21:13 +0530 Subject: [PATCH 03/22] docs: lock the predicateType base URI and add the build plan Base URI is https://docushell.com/ethos/, shape //v for all seven types. Chosen over a dedicated Ethos domain because a purchase plus 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; a hostname affects none of them. The /ethos/ path segment scopes the namespace so a later move stays a rename with the old string kept as an alias. Records that v versions the predicate schema and never the product, so grounding/v1 stays v1 across Ethos 0.6, 0.7, and 1.0. Adds docs/proof-statement-v1-implementation-plan.md: seven work packages in dependency order, each with a file-by-file touch list and its own acceptance evidence. Touch points were verified against main rather than assumed. Two findings shaped the sequencing: - verification_report_json_bytes in cmd/verify.rs is the only *_json_bytes serialiser in the CLI. The other five artifacts serialise inline at their write_output call sites, so WP-6 needs a shared serialiser introduced first rather than a single edit. - The config hash mechanism the attestation block needs already exists as sha256_hex(c14n(config)) at cmd/verify.rs:128, so WP-3 adds no new machinery. The load-bearing rule is one statement builder in ethos-core with no command hand-rolling a statement. That is the same constraint invariant 3 already places on c14n, and it is the specific failure mode observed in Signet, which builds four different signable shapes across two files with no schema anywhere. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- .../proof-statement-v1-implementation-plan.md | 251 ++++++++++++++++++ docs/proof-statement-v1.md | 38 ++- 2 files changed, 276 insertions(+), 13 deletions(-) create mode 100644 docs/proof-statement-v1-implementation-plan.md diff --git a/docs/proof-statement-v1-implementation-plan.md b/docs/proof-statement-v1-implementation-plan.md new file mode 100644 index 0000000..c2732f1 --- /dev/null +++ b/docs/proof-statement-v1-implementation-plan.md @@ -0,0 +1,251 @@ +# Implementation Plan: Proof Statement v1 + +Status: **approved for build, not started.** 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. + +--- + +## 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-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 — `sources[]` and `evidence_tier` + +**Touch list** +- `verify_types.rs` — `sources: Vec` replacing the singular `grounding` + field; `evidence_tier: EvidenceTier` enum +- `crates/ethos-verify/src/lib.rs` — populate `sources` with one entry today; derive + `evidence_tier` from the existing locator precedence in `resolve_target` +- schema + goldens + +`EvidenceTier`: `exact_span | element_scoped | page_scoped | capability_limited`. + +Array even at N=1. Widening a frozen schema later costs a major version. + +**Acceptance:** tier derivation covered per locator kind; existing capability-downgrade +tests still pass unchanged. + +### WP-5 — `corroboration/v1` + +**Goal:** N independently derived sources over one subject, with disagreement surfaced. + +**Touch list** +- `crates/ethos-verify/src/lib.rs` — accept N `GroundingSource`s; deterministic comparison + of resolved bindings under declared tolerance +- `crates/ethos-cli/src/cmd/verify.rs` — repeatable `--grounding` argument +- `crates/ethos-core/src/verify_types.rs` — `CorroborationReport` +- `schemas/ethos-corroboration-report.schema.json` (new) +- fixtures: one corroborated case, one divergent case + +**States:** `corroborated`, `single_source`, `divergent`, `capability_asymmetric`. + +**Independence is declared, never inferred.** Two adapters both wrapping PDFium share +failure modes; the predicate records the declaration rather than assuming it. + +**Acceptance** +- divergent fixture produces `divergent`, not a silent pick-one +- comparison is order-independent: sources in either order give identical bytes +- `capability_asymmetric` when only one source can answer + +### WP-6 — 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-7 — Documentation + +- `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 + +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 seven predicate types emit statements through one builder +- Payload equivalence proven for all six migrated artifacts +- `corroboration/v1` produces `divergent` on the divergence fixture +- 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 index d03b214..6c4e0c6 100644 --- a/docs/proof-statement-v1.md +++ b/docs/proof-statement-v1.md @@ -3,9 +3,8 @@ Status: **ruled, not implemented.** The three decisions in §1 are settled. Nothing here is built yet. -One action remains before the first artifact ships: **pick and register the Ethos -domain** (§1.2). Every `predicateType` string is permanent, so no artifact can be -emitted until the domain is chosen and controlled. +Base URI is locked to `https://docushell.com/ethos/` (§1.2). Build sequencing and the +file-by-file touch list live in `docs/proof-statement-v1-implementation-plan.md`. Scope: this changes the *shape* of Ethos output artifacts and adds corroboration. It changes no verification semantics. If a proposal alters what `grounded` means for any @@ -35,7 +34,7 @@ Ruled: the in-toto Statement is the native artifact. "subject": [ { "name": "invoice.pdf", "digest": { "sha256": "3fc9…" } } ], - "predicateType": "https:///grounding/v1", + "predicateType": "https://docushell.com/ethos/grounding/v1", "predicate": { } } ``` @@ -51,16 +50,30 @@ reading it is half the value, it is a regression. DSSE stays a signing wrapper f Verify the current `_type` revision against the in-toto spec before freezing it. -### 1.2 URI namespace — RULED: an Ethos-owned domain +### 1.2 URI namespace — RULED: `https://docushell.com/ethos/` -`predicateType` URIs are permanent and they announce who owns the format. The product -argument is independence: the checker is not the thing being checked, and it runs without -trusting DocuShell. A namespace reading `docushell.com` erodes that every time someone -opens an artifact. +`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. -**Blocking action:** the specific domain is not yet chosen or registered. No artifact can -be emitted until it is, because the string cannot change afterward. Placeholder in this -document is `ethos.dev`; substitute the real one before any code lands. +**Base URI, locked:** `https://docushell.com/ethos/` + +``` +https://docushell.com/ethos/grounding/v1 +https://docushell.com/ethos/corroboration/v1 +https://docushell.com/ethos/security/v1 +``` + +Shape is `//v` for all seven, 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 @@ -260,7 +273,6 @@ first. ## 8. Order of work ``` -0. Register the Ethos domain (§1.2) — blocks every predicateType string 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 From 1c0af17748a4bd02090d2d51f3388b4e4646ac6d Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 15:35:56 +0530 Subject: [PATCH 04/22] docs: cut corroboration and multi-source from Proof Statement v1 Corroboration is dropped. It was designed from a threat model rather than from a user, and the case for it does not survive contact with the questions it raises: no external user has asked for it, two parsers sharing an upstream share failure modes so their agreement proves little, it doubles parse cost against a 20k docs/day design target, and nobody has measured the divergence rate on real documents. A near-zero rate makes it not worth building; a high rate makes it noise reviewers learn to ignore. Those need opposite decisions and the number does not exist. A compensating control already ships: DocuShell shows the reviewer a rendered crop of the actual page region, and a human reading real pixels catches parser errors directly. Section 7 records that revisiting requires a measured divergence number, not an argument. sources[] goes with it. It existed to carry multiple grounding sources for comparison. Keeping it would freeze an array at length one, never exercised, which is precisely the unvalidated shape that forces a v2 later. The singular grounding field is unchanged. evidence_tier survives. It is independent of corroboration and useful alone: one deterministic enum for how strong a match was, so a consumer reads one field instead of interpreting a capability matrix. Six work packages remain, six predicate types, no new capability. The release is now plumbing with a clear payoff: self-describing artifacts that name what produced them, which is what the 7-year retention and replay commitments in the DocuShell workbench architecture need in order to mean anything. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- .../proof-statement-v1-implementation-plan.md | 48 ++++-------- docs/proof-statement-v1.md | 76 +++++++------------ 2 files changed, 42 insertions(+), 82 deletions(-) diff --git a/docs/proof-statement-v1-implementation-plan.md b/docs/proof-statement-v1-implementation-plan.md index c2732f1..39474a1 100644 --- a/docs/proof-statement-v1-implementation-plan.md +++ b/docs/proof-statement-v1-implementation-plan.md @@ -143,45 +143,24 @@ repeat runs. yanked except for security. A report naming an unobtainable verifier version stops being replayable, and that damage is retroactive. -### WP-4 — `sources[]` and `evidence_tier` +### WP-4 — `evidence_tier` **Touch list** -- `verify_types.rs` — `sources: Vec` replacing the singular `grounding` - field; `evidence_tier: EvidenceTier` enum -- `crates/ethos-verify/src/lib.rs` — populate `sources` with one entry today; derive - `evidence_tier` from the existing locator precedence in `resolve_target` +- `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`. -Array even at N=1. Widening a frozen schema later costs a major version. +**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 — `corroboration/v1` - -**Goal:** N independently derived sources over one subject, with disagreement surfaced. - -**Touch list** -- `crates/ethos-verify/src/lib.rs` — accept N `GroundingSource`s; deterministic comparison - of resolved bindings under declared tolerance -- `crates/ethos-cli/src/cmd/verify.rs` — repeatable `--grounding` argument -- `crates/ethos-core/src/verify_types.rs` — `CorroborationReport` -- `schemas/ethos-corroboration-report.schema.json` (new) -- fixtures: one corroborated case, one divergent case - -**States:** `corroborated`, `single_source`, `divergent`, `capability_asymmetric`. - -**Independence is declared, never inferred.** Two adapters both wrapping PDFium share -failure modes; the predicate records the declaration rather than assuming it. - -**Acceptance** -- divergent fixture produces `divergent`, not a silent pick-one -- comparison is order-independent: sources in either order give identical bytes -- `capability_asymmetric` when only one source can answer - -### WP-6 — Remaining five predicates +### 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. @@ -196,7 +175,7 @@ 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-7 — Documentation +### WP-6 — Documentation - `docs/CLAIMS.md` (new) — proves / does-not-prove / regulatory mapping with a residual-gap column / a paste-ready questionnaire paragraph @@ -229,9 +208,9 @@ exists for this; use it rather than working around it. ## 4. Out of scope -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. +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 @@ -242,9 +221,8 @@ party depends on the format, or when an auditor asks for a portable bundle. ## 5. Definition of done -- All seven predicate types emit statements through one builder +- All six predicate types emit statements through one builder - Payload equivalence proven for all six migrated artifacts -- `corroboration/v1` produces `divergent` on the divergence fixture - Attestation present and non-optional in every predicate - 400+ tests pass; determinism workflow green; `make -n release-gates` expands - `CLAIMS.md` published diff --git a/docs/proof-statement-v1.md b/docs/proof-statement-v1.md index 6c4e0c6..9783b00 100644 --- a/docs/proof-statement-v1.md +++ b/docs/proof-statement-v1.md @@ -6,8 +6,8 @@ is built yet. Base URI is locked to `https://docushell.com/ethos/` (§1.2). Build sequencing and the file-by-file touch list live in `docs/proof-statement-v1-implementation-plan.md`. -Scope: this changes the *shape* of Ethos output artifacts and adds corroboration. It -changes no verification semantics. If a proposal alters what `grounded` means for any +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. --- @@ -60,11 +60,11 @@ as a system consumes statements from more than one producer. ``` https://docushell.com/ethos/grounding/v1 -https://docushell.com/ethos/corroboration/v1 +https://docushell.com/ethos/evidence-anchor/v1 https://docushell.com/ethos/security/v1 ``` -Shape is `//v` for all seven, with no exceptions. +Shape is `//v` for all six, 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 @@ -147,7 +147,6 @@ out the attack when a developer trusts an unsigned one. | `security/v1` | `security_report.json` | migrate | | `crop/v1` | crop descriptors | migrate | | `answer-release/v1` | app-answer-release decision | migrate | -| `corroboration/v1` | nothing | new (§5) | 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 @@ -191,48 +190,23 @@ stops being replayable. --- -## 5. Multi-source and corroboration - -### 5.1 `sources[]` +## 5. Evidence tier ```json -"sources": [ - { "parser": { }, "capabilities": { }, "fingerprint": "sha256:…" } -], "evidence_tier": "exact_span" ``` -An array even when N=1. Widening a frozen schema from one to many costs a major version; -starting wide costs nothing today. - -`evidence_tier` generalizes AetherProof's `model_root_type`: put the *strength of what was -proven* into the artifact as one enum, so a consumer reads one field rather than -interpreting a capabilities matrix. Values: `exact_span`, `element_scoped`, `page_scoped`, -`capability_limited`. Derived deterministically from locator precedence. - -### 5.2 `corroboration/v1` - -The reason this release is worth a migration. +One deterministic enum saying how strong the match was, derived from the existing locator +precedence. Values: `exact_span`, `element_scoped`, `page_scoped`, `capability_limited`. -Run N independently derived grounding sources over one subject. Compare bindings under -declared tolerance. Emit one state: +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. -| State | Meaning | -| --- | --- | -| `corroborated` | every source binds the claim, locators agree | -| `single_source` | N=1, stated plainly rather than implied | -| `divergent` | sources disagree — the most useful signal Ethos can produce | -| `capability_asymmetric` | only some sources could answer | - -Source independence is **declared, never assumed**. Two adapters both wrapping PDFium share -failure modes, and the predicate has to say so. - -This is a deterministic dent in the gap `docs/hallucination-threat-model.md` and DocuShell's -own MVP notes both name: a parser error that both drafts and verifies consistently is -otherwise invisible. It does not close that gap. It makes one class of it visible. - -Corroboration raises confidence. It does not prove fidelity. That sentence belongs in -`CLAIMS.md` on day one, before anyone reads `divergent` as a verdict. +**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. --- @@ -258,9 +232,18 @@ claimed this. T0 says check it yourself. ## 7. Out of scope -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. +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. + +**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 @@ -276,10 +259,9 @@ first. 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. sources[] + evidence_tier -5. corroboration/v1 -6. Remaining five predicates -7. CLAIMS.md + README reframe +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 From f81dccc5814456022c6f2e9ed818b6e828c1887e Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 15:44:49 +0530 Subject: [PATCH 05/22] ci: restore the RAG framework examples gate, drop its wiring assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate was left orphaned by the CI cleanup: not in ci.yml, not in release-gates, failing when run by hand. Two failures, and one of them was self-inflicted. test_make_and_ci_run_the_offline_guard asserted that ci.yml contains the literal string "pip install -r examples/citation-emission/requirements-frameworks.txt". Removing that CI step broke the guard that asserts the step exists. This is the same prose-assertion class as the nine milestone-D guards deleted in the cleanup for exactly this reason, and it was reintroduced by the same commit that applied the rule elsewhere. That test is replaced by test_make_target_never_leaks_secrets_or_publishes, which keeps the half that guards content — no API keys, no publish commands in the target — and drops the half that asserts CI wiring. The second failure was environmental: the byte-identical-twice test needs langchain and llama_index, installed by the pip step that was removed. That test is real and worth keeping. It runs each framework example twice and asserts identical output with no API keys, which demonstrates the determinism claim in the exact place a newcomer looks. The job is restored to CI with its pip install. These examples are the adoption path, and broken examples fail for strangers evaluating Ethos rather than for us, so rotting between publishes is the wrong failure mode to accept. All four tests pass locally with the frameworks installed. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- .github/scripts/test_rag_framework_examples.py | 13 ++++--------- .github/workflows/ci.yml | 13 +++++++++++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/scripts/test_rag_framework_examples.py b/.github/scripts/test_rag_framework_examples.py index 7b27e86..8750f8b 100644 --- a/.github/scripts/test_rag_framework_examples.py +++ b/.github/scripts/test_rag_framework_examples.py @@ -136,17 +136,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/workflows/ci.yml b/.github/workflows/ci.yml index 0e691ce..986e959 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,6 +132,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: From c06fa1a1e5701199d1b72ddc64b9d69a7a156ba8 Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 15:47:58 +0530 Subject: [PATCH 06/22] docs: rule that representations stay bare Promotes section 1.5 from a provisional call to a ruling. document.ethos.json and chunks.jsonl are not wrapped in statements. A statement means "X asserts P about Y", and 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 the word statement stops distinguishing anything. Three supporting reasons recorded: chunks.jsonl is streaming NDJSON that wrapping would bloat or break, both files are consumed by other tools including DocuShell's retrieval path, and neither needs the wrapper for provenance since both already carry a document fingerprint, profile hash, and config hash per SPEC.md. 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. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- docs/proof-statement-v1.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/proof-statement-v1.md b/docs/proof-statement-v1.md index 9783b00..dd82264 100644 --- a/docs/proof-statement-v1.md +++ b/docs/proof-statement-v1.md @@ -107,10 +107,28 @@ claims, config — and in-toto's subject model is artifact-centric, so the other 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 Decided while drafting, override if wrong +### 1.5 Representations — RULED: stay bare -**Representations stay bare.** `document.ethos.json` and `chunks.jsonl` are representations, -not assertions about anything. Statements are for verdicts. +`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. --- From 6287240537f79f65b3f5874b7ed67752eed435cd Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 18:06:17 +0530 Subject: [PATCH 07/22] docs: make v0.6.0 pickup-ready and add WP-0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A developer landing in this repo would have been pointed the wrong way. docs/v0-6-0-release.md section 9 listed attestation as a non-goal for v0.6.0, which is now WP-3, a core deliverable. Both v0.6.0 documents described only the Grounding JSON scope. Neither said where to start. Adds to the implementation plan a "Start here" section (reading order, prerequisites including the commit-msg hook, the build and test loop, rules for claiming work, and what counts as a finished task) and a task board of 32 numbered tasks with dependencies and acceptance criteria. Adds WP-0, which keeps the multi-format path open without adding multi-format support. Three tasks: a test locking the geometry-free text path, bbox becoming Option in the trait while the schema stays strict, and recording the five gates from section 10.1. Multi-format itself is out of scope with a written trigger, because no DocuShell workflow needs it — the workbench scope contract puts non-PDF formats out of Part I and names no Part II trigger. Docling supporting every format is a fact about Docling, not a requirement on Ethos. One finding shaped WP-0's scope: GroundingCell already carries row, col, row_span, and col_span. Row and 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. Supersession notes added to both v0.6.0 documents, with the superseded non-goals struck through rather than deleted so the scope change stays visible. Execution status gains a dated entry. CONTRIBUTING gains a section naming the two rules easiest to break by accident: goldens move alone, and format questions are not decided in a PR. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- CONTRIBUTING.md | 18 ++ docs/execution-status.md | 35 +++- .../proof-statement-v1-implementation-plan.md | 154 ++++++++++++++++++ docs/proof-statement-v1.md | 23 ++- docs/v0-6-0-release-prep.md | 7 + docs/v0-6-0-release.md | 26 ++- 6 files changed, 256 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aeec89b..f6507f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,6 +89,24 @@ Your merged PR ships in the next train; the CHANGELOG line you wrote becomes the - 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 diff --git a/docs/execution-status.md b/docs/execution-status.md index 961850c..8ba27da 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 index 39474a1..45719a9 100644 --- a/docs/proof-statement-v1-implementation-plan.md +++ b/docs/proof-statement-v1-implementation-plan.md @@ -9,6 +9,103 @@ 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. @@ -56,6 +153,63 @@ hash mechanism needed for the attestation block already exists and needs no new 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. diff --git a/docs/proof-statement-v1.md b/docs/proof-statement-v1.md index dd82264..5adce88 100644 --- a/docs/proof-statement-v1.md +++ b/docs/proof-statement-v1.md @@ -250,9 +250,26 @@ claimed this. T0 says check it yourself. ## 7. 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. +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. **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 diff --git a/docs/v0-6-0-release-prep.md b/docs/v0-6-0-release-prep.md index 9acc6f3..9462462 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 e4154dc..0f4fb80 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, From 95f16eec9ecd4f2eedf4ee6f59815b55e0e4687c Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 18:06:31 +0530 Subject: [PATCH 08/22] docs: drop the public-beta posture from the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the status badge, the beta status block, the "Current evaluation support" framing, and every "evaluation" qualifier. Users should be able to try Ethos without wording that asks them to wait. The change worth explaining is the "Blocked" column. Five rows read Blocked — Windows artifacts, hosted API, bundled PDFium, benchmark claims, production positioning. That is internal release vocabulary meaning "not yet approved for publication". To someone evaluating Ethos it reads as broken or gatekept. The replacement states what is not supported as a fact about capability: "No OCR. Fails with ocr_required rather than guessing." Nothing here claims production readiness. claims_gate.py bans exactly that family of overclaim — production-ready, release-ready, fastest, world-class, state-of-the-art — and it passes, so removing hesitation did not buy an overclaim in exchange. Adds a statement rather than only cutting: no speed, footprint, or parser-quality comparison is published because no benchmark has been run whose numbers we would defend. Saying why there are no numbers is stronger than staying quiet about it. public-boundary-claims.json is updated to match. Five pinned README strings carried the retired beta wording; six replacements carry the same boundaries in the new voice, including the honest limits. Eleven install and PDFium claims are unchanged. CHANGELOG records three boundary-exception entries, which is what that gate exists to force. The README also now points at the v0.6.0 format plan and task board instead of naming the Grounding JSON prep document as "the v0.6.0 plan". Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- CHANGELOG.md | 17 ++++++ README.md | 93 ++++++++++++++++---------------- docs/public-boundary-claims.json | 11 ++-- 3 files changed, 70 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e81f37..407ead0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased +- boundary-exception: rewrite `README.md` to drop the public-beta posture. The status badge, + the beta status block, the "Current evaluation support" framing, and the "Blocked" column + are all removed. "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 in the rewrite claims production + readiness, and `claims_gate.py` still passes, so no banned overclaim was introduced in + exchange. +- boundary-exception: update `docs/public-boundary-claims.json` to match. Five pinned README + strings carried the retired beta wording; they are replaced by six that carry the same + boundaries in the new voice, including the honest limits — Ethos does not decide whether an + answer is true, a missing capability yields an explicit limitation rather than a guess, and + no speed, footprint, or parser-quality comparison is published because no defensible + benchmark has been run. Eleven existing install and PDFium claims are unchanged. +- boundary-exception: scope `.github/workflows/ci.yml` to product correctness and + architectural invariants, parking publication gates behind `make release-gates`. See + `docs/ci-scope.md`. + - 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/README.md b/README.md index 9bf09e5..7afeaf7 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 @@ -32,7 +30,8 @@ capabilities were missing. It does not decide whether an answer is true, relevan - [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) +- [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 @@ -89,29 +88,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 +128,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 +202,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 +210,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 +227,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 +285,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 +390,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 +433,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 +460,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/docs/public-boundary-claims.json b/docs/public-boundary-claims.json index 764c3e9..3dbac8b 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." ] }, From 67dc8498aff809c8f6f10274f76eccc4fa2e52d8 Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 18:06:43 +0530 Subject: [PATCH 09/22] test: lock the geometry-free text anchoring path (WP-0 task 0.1) docs/v0-6-0-release.md section 10.1 established by source audit that an evidence ref of {element_id, expected_text} with no page locator and no bbox reaches AnchorStatus::Bound at AnchorLevel::Text with no capability limit. Nothing tested it. A refactor could have closed that path and no one would have found out until someone tried a flow document years later. Four tests, no production code touched. They assert the four steps rather than only the outcome: page_locator_required is false via the element_id disjunct, requires_bbox excludes AnchorLevel::Text, resolve_page returns PageCheck::NotChecked rather than NotFound, and the anchor is Bound with an empty capability-limit list. The end-to-end test asserts checks.bbox is NotChecked and not CapabilityLimited. The ref never asked for geometry, so nothing was downgraded and the caller is owed no warning. Emitting CapabilityLimited there would put a spurious limitation on every geometry-free verification, which is the kind of noise that teaches consumers to ignore capability limits. Verified by mutation rather than assumed. Changing the resolve_page fallthrough to NotFound fails two of the four; adding AnchorLevel::Text to requires_bbox fails two. Both mutations were reverted and the diff is additions only. This is WP-0 task 0.1. It keeps the multi-format door open; it does not open it. Multi-format support remains out of scope per docs/proof-statement-v1.md section 7. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- crates/ethos-verify/src/lib.rs | 94 ++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/crates/ethos-verify/src/lib.rs b/crates/ethos-verify/src/lib.rs index 8f2b80a..7448323 100644 --- a/crates/ethos-verify/src/lib.rs +++ b/crates/ethos-verify/src/lib.rs @@ -2088,6 +2088,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, @@ -3652,4 +3653,97 @@ 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); + } + + #[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 + ); + } } From fb1d46d865361be1462cc80e6d5f7e1704db9aa3 Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 18:21:15 +0530 Subject: [PATCH 10/22] feat(grounding): make bbox optional in the trait, schema unchanged (WP-0 task 0.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GroundingElement, GroundingSpan, GroundingTable, and GroundingCell now carry Option<[i64; 4]> instead of [i64; 4]. docs/v0-6-0-release.md section 10.1 framed the choice as an 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 would cost a second breaking release later. The schema does not move, which is the WP-0 constraint. ethos.grounding.v1 still requires bbox on element, span, table, and cell; media_type is still const application/pdf; the positive-area check still stands. The Rust type can now express absence, and the wire contract still refuses it. Verified: no schema or golden file changed. Read sites fail closed rather than treating absence as a wildcard. An element with no declared box contains nothing, so containment filters exclude it and a bbox query over a geometry-free source resolves to NotFound. The adjacency join declines when either element lacks geometry, matching the posture it already takes for CoordinateOrigin::Unknown. The arithmetic helpers — contains_bbox, bbox_area, union_bbox, element_bboxes_are_adjacent — keep taking [i64; 4]; the Option handling belongs at the call sites, not in the geometry. Two tests cover the new semantics, because behaviour that only a comment describes is behaviour that decays. Both were verified by mutation: turning is_some_and into map_or(true) inside resolve_bbox, so absence reads as a wildcard, fails absent_element_geometry_never_satisfies_a_bbox_query. The first attempt at that mutation hit the wrong call site and the test passed, which is the reason to run the mutation rather than assume the assertion is wired to anything. 406 tests pass, up from 404. fmt, clippy, verify dependency boundary, and the minimal grounding feature build are all green. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- .../grounding/opendataloader-json/src/lib.rs | 28 ++-- crates/ethos-core/src/grounding.rs | 30 +++- crates/ethos-core/src/grounding_json.rs | 8 +- crates/ethos-core/src/model.rs | 8 +- crates/ethos-verify/src/lib.rs | 146 ++++++++++++++---- 5 files changed, 162 insertions(+), 58 deletions(-) diff --git a/adapters/grounding/opendataloader-json/src/lib.rs b/adapters/grounding/opendataloader-json/src/lib.rs index c826a60..4634664 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-core/src/grounding.rs b/crates/ethos-core/src/grounding.rs index e073752..7df3cdc 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 ad584ce..31d59bc 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/model.rs b/crates/ethos-core/src/model.rs index ed8eed5..76c7c8d 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-verify/src/lib.rs b/crates/ethos-verify/src/lib.rs index 7448323..6a1f87d 100644 --- a/crates/ethos-verify/src/lib.rs +++ b/crates/ethos-verify/src/lib.rs @@ -622,9 +622,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 +668,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 @@ -1500,9 +1508,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)); @@ -1549,7 +1562,7 @@ 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(), from_table_cell: false, element_index, @@ -1561,7 +1574,7 @@ 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()), from_table_cell: false, element_index: None, @@ -1605,7 +1618,7 @@ 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()), from_table_cell: true, element_index: None, @@ -1682,7 +1695,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 +1716,7 @@ fn adjacent_text_pair_target( Some(FoundTarget { page: Some(first.page.clone()), - bbox: Some(union_bbox(first.bbox, second.bbox)), + bbox: Some(union_bbox(first_bbox, second_bbox)), text: Some(joined), from_table_cell: false, element_index: None, @@ -2156,7 +2175,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(), @@ -2165,7 +2184,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, }, @@ -2186,7 +2205,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), @@ -2197,14 +2216,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 { @@ -2212,7 +2231,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(), }, ], @@ -2328,7 +2347,7 @@ mod tests { GroundingElement { id: id.into(), page: page.into(), - bbox, + bbox: Some(bbox), kind: "text_block".into(), text: text.map(str::to_string), } @@ -3715,6 +3734,75 @@ mod tests { 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 From db7d1b98a1346b89fdd8831af89b8049ecb1d9da Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 18:22:55 +0530 Subject: [PATCH 11/22] docs: record the five multi-format gates where adapters look (WP-0 task 0.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The analysis lives in docs/v0-6-0-release.md section 10.1, which is a release record and will eventually be superseded. The durable engineering content moves into docs/bring-your-own-parser.md, where someone implementing a GroundingSource already reads to find out what is required of them. Two audiences, one section. Adapter authors learn that geometry is required, that a zero-area sentinel is rejected outright, and that Option<[i64; 4]> on the trait is not an invitation to send None — the wire schema still requires the field. Maintainers get the five gates with their locations, and the reason the constraint is narrower than it looks: the verifier already binds text with no geometry, and the tests that keep that true are named so nobody deletes them during a cleanup. Also records why the sequencing is DOCX, XLSX, PPTX rather than the intuitive reverse, including the XLSX measurement that rules out computing column geometry — a 12.5% swing between Calibri 11 and Verdana 11 on the same nominal column, driven by system font metrics that ADR-0003 does not cover. Completes WP-0. Multi-format support is still out of scope; the trigger to revisit is in docs/proof-statement-v1.md section 7. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- docs/bring-your-own-parser.md | 56 +++++++++++++++++++++++++++++++++++ docs/proof-statement-v1.md | 3 ++ 2 files changed, 59 insertions(+) diff --git a/docs/bring-your-own-parser.md b/docs/bring-your-own-parser.md index 6a5e4a8..dae9167 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/proof-statement-v1.md b/docs/proof-statement-v1.md index 5adce88..adff3af 100644 --- a/docs/proof-statement-v1.md +++ b/docs/proof-statement-v1.md @@ -271,6 +271,9 @@ 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 From db39e8b55f3b1ad6b7bdac585c503a1f3e8dce3c Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 18:27:15 +0530 Subject: [PATCH 12/22] feat(core): add the in-toto Statement builder (WP-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New crates/ethos-core/src/statement.rs. The single place a Statement is constructed, for the same reason ethos-core owns the one c14n implementation: a wire format assembled independently in several places drifts, and the drift stays invisible until two producers disagree. Signet is the cautionary case, building four different signable shapes across two files with no schema anywhere. The _type string was verified against the in-toto v1 specification before being frozen: https://in-toto.io/Statement/v1. Subject entries are ResourceDescriptors with a required digest map, which is why digest is a BTreeMap rather than a struct — key order is canonical without relying on serde field order, and the algorithm lives in the key, so the value is bare hex with no sha256: prefix. Statement::new encodes the section 1.4 subject ruling in the type signature rather than leaving it to convention. It takes the representation by value and the source as an Option, so subject[0] is always what Ethos actually read and an empty subject array — which in-toto forbids — is unrepresentable. On the Grounding JSON path a foreign parser produced the representation and Ethos never touched the PDF, so passing None is the honest answer and inventing a source subject is the failure this shape prevents. Gated behind the `full` feature rather than `verify-types` as the plan drafted, because statement_bytes routes through c14n, which needs serde_json. That makes invariant 4 safer rather than weaker: ethos-verify building with only the grounding feature cannot see this module at all. Verified — grounding-only, verify-types, and ethos-verify all still compile, and the dependency boundary check passes. Seven unit tests and one doctest. The load-bearing one is predicate_survives_wrapping_byte_for_byte, which asserts c14n of the unwrapped predicate equals c14n of the input. WP-2's payload-equivalence test depends on that property holding, so it is proven here before anything relies on it. wire_field_names_match_the_in_toto_spec guards the _type and predicateType spellings, since renaming either silently produces an artifact no in-toto tool recognizes. 414 tests pass, up from 406. No schema or golden file changed. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- crates/ethos-core/src/lib.rs | 2 + crates/ethos-core/src/statement.rs | 270 +++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 crates/ethos-core/src/statement.rs diff --git a/crates/ethos-core/src/lib.rs b/crates/ethos-core/src/lib.rs index a428ed9..58b9641 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/statement.rs b/crates/ethos-core/src/statement.rs new file mode 100644 index 0000000..0856461 --- /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); + } +} From 408cac4cbb7ac124f24ef08f648511558523ed2e Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 18:44:00 +0530 Subject: [PATCH 13/22] feat(cli): ethos verify emits an in-toto Statement (WP-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verification_report_json_bytes now wraps the report in a Statement before canonicalizing. Both emit paths — single report and batch NDJSON — flow through that one function, so this is a single behavioural edit. Two decisions WP-2 forced that the plan had not anticipated. subject[0] digests the input file's bytes, not the report's document_fingerprint. The first attempt used the fingerprint and it broke foreign_source_without_fingerprint_blocks_fingerprint_pinned_citations: a foreign source that declares no fingerprint is a supported, tested case, and failing closed there turned a working verification into internal_error. That is a semantics change, which ground rule 1 forbids. Hashing the input bytes is also simply more correct — 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, not derivable from the file. For the Grounding JSON path the two agree anyway, since representation_sha256 hashes exactly those bytes. subject[1] is deliberately absent. The only source binding available is GroundingJsonSource::source_sha256, documented as the producer-declared PDF hash, and section 5.1 already says a source-hash match proves only that the mapper declared the hash of the PDF you supplied. Recording a declaration in a field that in-toto matches by digest would invite a consumer to resolve it and conclude Ethos verified against bytes it never saw. Section 1.4's second subject stays unimplemented pending a ruling on whether an unauthenticated declaration belongs there at all. The goldens do not move, which is better than the plan's approach. verify_alpha_demo_report_predicates_match_goldens asserts the emitted predicate against the existing pre-0.6 goldens, so payload equivalence is proven permanently rather than at one moment: every byte the verifier produces is unchanged, only nested. Regenerating the goldens would have destroyed exactly the evidence needed to show that. It also means determinism CI never goes blind during this migration. A semantic change now fails against the goldens; a wrapper change fails in verify_emits_a_proof_statement, and keeping those separate is the point. Test migration is mechanical: a verify_report helper unwraps the predicate for assertions, and the ethos grounding check tests are deliberately untouched because that command is not wrapped until WP-5. 415 tests pass. No golden, schema, or example file changed. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- crates/ethos-cli/src/cmd/verify.rs | 66 +++++++++++-- crates/ethos-cli/tests/verify.rs | 152 +++++++++++++++++++++-------- 2 files changed, 171 insertions(+), 47 deletions(-) diff --git a/crates/ethos-cli/src/cmd/verify.rs b/crates/ethos-cli/src/cmd/verify.rs index 32ba535..755c02e 100644 --- a/crates/ethos-cli/src/cmd/verify.rs +++ b/crates/ethos-cli/src/cmd/verify.rs @@ -26,6 +26,7 @@ use ethos_core::grounding::{ GroundingSpan, GroundingTable, PageGeometry, ParserIdentity, }; use ethos_core::model::Document; +use ethos_core::statement::{predicate_type, statement_bytes, Statement, Subject}; use ethos_core::verify_types::{ CapabilityLimit, Check, CheckReason, CheckStatus, ClaimKind, EvidenceOptions, MatchMethod, ProofLimitation, ProofStatus, ProofSummary, VerificationConfig, VerificationReport, @@ -96,12 +97,24 @@ pub(crate) fn verify(args: VerifyArgs) -> Result<(), Failure> { 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) + 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 @@ -134,7 +147,7 @@ pub(crate) fn verify_batch(args: VerifyBatchArgs) -> Result<(), Failure> { 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'); @@ -217,9 +230,10 @@ fn write_report( 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,14 +244,50 @@ 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))?; +fn verification_report_json_bytes( + report: &VerificationReport, + input: &Path, +) -> Result, Failure> { + let statement = Statement::new( + representation_subject(input)?, + // subject[1] is deliberately absent. The only source binding available is the + // producer-declared PDF hash, and an in-toto subject is matched by digest — a + // consumer could resolve it and conclude Ethos verified against those bytes. + // Ethos never saw them. `docs/proof-statement-v1.md` §1.4 admits a source subject + // only when the binding is real; a declaration is not one. + None, + predicate_type("grounding", 1), + report, + ); + let mut bytes = statement_bytes(&statement).map_err(|e| EthosError::internal(e.message))?; bytes.push(b'\n'); Ok(bytes) } +/// `subject[0]`: the representation Ethos actually read. +/// +/// The digest is the SHA-256 of the input file's bytes, not the report's +/// `document_fingerprint`. in-toto matches subjects by digest, so the value has to be +/// something a consumer holding the same file can compute for themselves; the document +/// fingerprint is the canonical-graph identity and is not derivable from the file. For the +/// Grounding JSON path the two agree anyway, since `representation_sha256` hashes exactly +/// these bytes. +/// +/// Reading the file a second time is deliberate. Threading bytes through `load_source`, +/// `read_document`, and both emit paths costs more than one re-read of an input already +/// validated and size-capped. +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(Subject::sha256( + name, + ethos_core::c14n::sha256_hex_bytes(&bytes), + )) +} + fn verification_report_summary_bytes(report: &VerificationReport) -> Result, Failure> { let proof = report.proof_summary(); let mut out = String::new(); diff --git a/crates/ethos-cli/tests/verify.rs b/crates/ethos-cli/tests/verify.rs index aa89654..0f00edb 100644 --- a/crates/ethos-cli/tests/verify.rs +++ b/crates/ethos-cli/tests/verify.rs @@ -53,6 +53,13 @@ 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() +} + fn parse_crop_element_success(args: &[&str]) -> Value { let output = run_ethos(args); assert!( @@ -261,7 +268,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 +286,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 +306,71 @@ 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}"); } } +/// 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 +449,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 +640,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 +672,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 +702,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 +725,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 +752,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 +778,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 +889,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!([])); @@ -1203,7 +1273,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 +1383,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 +1424,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 +1452,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 +1556,7 @@ fn stale_fingerprint_is_report_level_failure() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -1811,7 +1881,7 @@ fn bare_array_citation_input_works() { } ]"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -1843,7 +1913,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 +2289,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 +2320,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 +2355,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 +2441,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 +2531,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 +2606,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 +2722,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 +2789,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 +2866,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 +2937,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 +2990,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 +3059,7 @@ fn config_excluded_value_claim_is_unsupported() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -3027,7 +3097,7 @@ fn page_only_presence_works() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -3060,7 +3130,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 +3161,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 +3229,7 @@ fn case_insensitive_config_allows_literal_case_difference() { ] }"#, ); - let report = parse_success(&[ + let report = verify_report(&[ "verify", doc.to_str().unwrap(), "--citations", @@ -3390,7 +3460,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"], @@ -3454,7 +3525,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); } @@ -3518,8 +3590,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); From c4722dbd427d9df8505f52bcbf981ebde16bda7f Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 20:12:43 +0530 Subject: [PATCH 14/22] docs: rule that subject[1] carries only a hash Ethos computed Records the ruling of 2026-08-09 and makes section 1.4's "only when the binding is real" concrete: real means Ethos read the bytes itself, not that a producer declared a hash. ethos verify emits no subject[1]. On the Grounding JSON path the only available source binding is GroundingJsonSource::source_sha256, documented as the producer-declared PDF hash, and section 5.1 already says 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 who sees its hash in subject[1] reasonably concludes Ethos checked their file. It did not; it read a JSON claiming to come from it. Editing that one line leaves the statement pointing at the right PDF and still reporting everything grounded. The gap is invisible at exactly the moment it matters. --crop-source-pdf is the one case where subject[1] would be honest, because Ethos loads and validates the real bytes there. If it is ever built, that is the only permitted source. Also records why subject[0] digests the input file bytes rather than document_fingerprint: consumers match subjects by digest, so the value must be computable from the file they hold. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- docs/proof-statement-v1.md | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/proof-statement-v1.md b/docs/proof-statement-v1.md index adff3af..1047c7c 100644 --- a/docs/proof-statement-v1.md +++ b/docs/proof-statement-v1.md @@ -95,9 +95,31 @@ representation and **Ethos never touched the source PDF**. An artifact claiming ] ``` -- `subject[0]` is always the representation Ethos read. Required. -- `subject[1]` is the source document, present **only** when the binding is real. Omitted - otherwise, never guessed. +- `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 is a documentation obligation and it goes in the contract doc and in `CLAIMS.md`. From 564156e7fdc21477763df1cac61ff27d21ac3987 Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 20:36:58 +0530 Subject: [PATCH 15/22] feat: attest the verifier, config, and claims in every report (WP-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every VerificationReport now carries a required attestation block naming what produced the verdict. This promotes Part B of the citation-emission plan from proposal to shipped, and makes it non-optional rather than hardening-gated. The block is smaller than the spec drafted, deliberately. Section 4 listed four fields; two of them would have been duplicates. The config hash is already top-level as verification_config_sha256 and the source fingerprint is already top-level as document_fingerprint, so nesting copies of both is bloat, not attestation. The `replay` field is also dropped: a constant string repeated in every artifact is documentation misfiled as data, and the recipe is already present as data — verifier version, config hash, claims hash, document fingerprint. Part B's own text invited dropping it. What ships is what was actually missing: the verifier crate name and version, and a hash binding the report to its exact claims input. claims_sha256 is passed into verify_claims rather than computed there, because ethos-verify builds against ethos-core with only the grounding and verify-types features under invariant 4 — it has no c14n and no serde_json. That is the same reason config_sha256 has always been a parameter, so the shape is symmetric rather than novel. The verifier identity does come from ethos-verify's own env! macros, so a library caller gets the same attestation as a CLI user. Fixtures move, and this time legitimately: the report gained a field. All eight goldens are purely additive. The three schema examples are +9/-1 each, the minus being one comma; an earlier regeneration pass rewrote them in c14n key order and expanded compact arrays, which was correct content but an unreviewable diff, so the block is text-inserted instead and the hand-maintained formatting survives. Two tests, one mutation-checked. report_attests_the_verifier_config_and_claims compares the attested version against the crate's own CARGO_PKG_VERSION rather than a literal, so a bump that fails to flow through is caught; setting the version to 0.5.0 by hand fails it. claims_hash_covers_the_claims_and_not_their_ packaging proves different claims produce different hashes. 417 tests pass. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- crates/ethos-cli/src/cmd/verify.rs | 51 ++- crates/ethos-cli/tests/verify.rs | 65 +++ crates/ethos-core/src/verify_types.rs | 50 +++ crates/ethos-verify/src/lib.rs | 44 +- .../goldens/native_grounded_report.json | 8 + .../goldens/native_non_v1_claims_report.json | 8 + .../goldens/native_split_quote_report.json | 8 + .../verify/goldens/native_stale_report.json | 8 + .../goldens/native_ungrounded_report.json | 8 + ...ndataloader_capability_limited_report.json | 8 + .../opendataloader_grounded_report.json | 8 + .../opendataloader_not_found_report.json | 8 + ...pected.ungrounded.verification_report.json | 69 ++- .../real/expected.verification_report.json | 115 ++++- schemas/ethos-verification-report.schema.json | 415 ++++++++++++++---- .../verification-report-negative.example.json | 10 +- .../examples/verification-report.example.json | 10 +- .../verification-report.hardened.example.json | 10 +- 18 files changed, 810 insertions(+), 93 deletions(-) diff --git a/crates/ethos-cli/src/cmd/verify.rs b/crates/ethos-cli/src/cmd/verify.rs index 755c02e..20d99c7 100644 --- a/crates/ethos-cli/src/cmd/verify.rs +++ b/crates/ethos-cli/src/cmd/verify.rs @@ -84,6 +84,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 @@ -92,7 +97,8 @@ 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())?; @@ -106,7 +112,8 @@ pub(crate) fn verify(args: VerifyArgs) -> Result<(), Failure> { ); } let source = load_source(&args.input, args.grounding.as_deref())?; - let report = ethos_verify::verify_claims(&source, citations, &config, config_sha256); + let report = + ethos_verify::verify_claims(&source, citations, &config, config_sha256, claims_sha256); write_report( args.out, @@ -141,7 +148,7 @@ 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; @@ -216,15 +223,29 @@ 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, @@ -971,7 +992,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"; @@ -1004,6 +1027,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), + }, } } @@ -1269,6 +1300,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/tests/verify.rs b/crates/ethos-cli/tests/verify.rs index 0f00edb..caab61e 100644 --- a/crates/ethos-cli/tests/verify.rs +++ b/crates/ethos-cli/tests/verify.rs @@ -326,6 +326,71 @@ fn verify_alpha_demo_report_predicates_match_goldens() { } } +/// 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] diff --git a/crates/ethos-core/src/verify_types.rs b/crates/ethos-core/src/verify_types.rs index 32fa07b..9e71a0f 100644 --- a/crates/ethos-core/src/verify_types.rs +++ b/crates/ethos-core/src/verify_types.rs @@ -382,6 +382,48 @@ 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, +} + +/// 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: @@ -1345,6 +1387,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 6a1f87d..1377dd3 100644 --- a/crates/ethos-verify/src/lib.rs +++ b/crates/ethos-verify/src/lib.rs @@ -47,10 +47,10 @@ 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, GroundingMeta, MatchMethod, ProvenanceStatus, TextNormalization, + VerificationConfig, VerificationReport, VerifierIdentity, HARDENED_VERIFICATION_SCHEMA_VERSION, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -940,6 +940,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); @@ -1033,6 +1034,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, } } @@ -2319,7 +2330,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( @@ -2327,7 +2344,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 { @@ -2371,7 +2394,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] @@ -2523,7 +2546,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!( @@ -2570,6 +2593,7 @@ mod tests { }), &config, "0".repeat(64), + "1".repeat(64), ); let echo = report.checks[0].context_echo.as_ref().unwrap(); @@ -3251,6 +3275,7 @@ mod tests { }), &cfg, "0".repeat(64), + "1".repeat(64), ); assert_eq!(report.checks[0].status, CheckStatus::NotFound); } @@ -3391,6 +3416,7 @@ mod tests { }), &cfg, "0".repeat(64), + "1".repeat(64), ); assert!(report.fingerprint_stale); @@ -3418,6 +3444,7 @@ mod tests { }), &cfg, "0".repeat(64), + "1".repeat(64), ); assert!(!report.fingerprint_stale); @@ -3604,6 +3631,7 @@ mod tests { }), &cfg, "0".repeat(64), + "1".repeat(64), ); assert!(!report.fingerprint_stale); diff --git a/examples/verify/goldens/native_grounded_report.json b/examples/verify/goldens/native_grounded_report.json index 430d3bb..c80f4a6 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": [ { diff --git a/examples/verify/goldens/native_non_v1_claims_report.json b/examples/verify/goldens/native_non_v1_claims_report.json index 8577e14..6ca5d86 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": [ { diff --git a/examples/verify/goldens/native_split_quote_report.json b/examples/verify/goldens/native_split_quote_report.json index 544eb5b..dde2d32 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": [ { diff --git a/examples/verify/goldens/native_stale_report.json b/examples/verify/goldens/native_stale_report.json index 196b766..54b6cfb 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 f07729f..38f8f91 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": [ { diff --git a/examples/verify/goldens/opendataloader_capability_limited_report.json b/examples/verify/goldens/opendataloader_capability_limited_report.json index 9f3bcb7..0213631 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 064bb2a..a8f7ef7 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", diff --git a/examples/verify/goldens/opendataloader_not_found_report.json b/examples/verify/goldens/opendataloader_not_found_report.json index 9aff6dc..e828dae 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 7147619..deaef97 100644 --- a/fixtures/foreign/opendataloader/real/expected.ungrounded.verification_report.json +++ b/fixtures/foreign/opendataloader/real/expected.ungrounded.verification_report.json @@ -1 +1,68 @@ -{"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." + }, + "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 76966d0..65eacaf 100644 --- a/fixtures/foreign/opendataloader/real/expected.verification_report.json +++ b/fixtures/foreign/opendataloader/real/expected.verification_report.json @@ -1 +1,114 @@ -{"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" + }, + "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" + ] +} diff --git a/schemas/ethos-verification-report.schema.json b/schemas/ethos-verification-report.schema.json index 3e79010..b5c3d73 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,152 @@ "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" + } + } } } }, "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 6214fb9..a6d5258 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 c5c08cb..d8b5e38 100644 --- a/schemas/examples/verification-report.example.json +++ b/schemas/examples/verification-report.example.json @@ -75,5 +75,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 f649b1b..af98898 100644 --- a/schemas/examples/verification-report.hardened.example.json +++ b/schemas/examples/verification-report.hardened.example.json @@ -87,5 +87,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" + } + } } From 77ae87b3536e24f23a7e6dbfb879c7154a5a6928 Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 21:00:44 +0530 Subject: [PATCH 16/22] feat: state how precisely each check bound its evidence (WP-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds EvidenceTier and an optional evidence_tier on every Check. Per check rather than per report, ruled 2026-08-09: a report legitimately mixes tiers — one quote bound to an element, one presence claim scoped to a page — and a single top-level value would have to aggregate, baking a judgment into the artifact that a consumer cannot undo. Worth recording honestly: this is a convenience projection, not a new fact. A consumer could derive it from match_method plus the claim's citation, both already on every check. The AetherProof idea it comes from does not fully transfer — model_root_type was load-bearing precisely because a receipt could not otherwise reveal whether the root was a weights hash or a typed name. It earns its place anyway, because an evidence product should not make every consumer write the same derivation and get it subtly wrong. Five variants, not the four the spec drafted. A table cell is a first-class v1 claim kind, and folding it into element_scoped would understate a precisely bound cell while a four-value enum would have forced that lie. The tier is set on FoundTarget where the target resolves, not re-derived from the citation afterwards, so it cannot drift from the locator precedence it describes. Two consequences follow: a quote grounded across two adjacent elements is element_scoped, because two elements joined is still element precision, and a check whose outcome was decided by a missing span or table capability, or an unknown coordinate origin, reports capability_limited rather than a precision it did not achieve. A check that resolved nothing carries no tier at all. Fixtures are purely additive, +13 -0. Goldens regenerate cleanly since they are already in c14n order; the hand-formatted schema examples take a line insertion after each match_method so their ordering and compact arrays survive. Two tests. each_check_states_how_precisely_it_bound_evidence asserts three tiers in one report, since a derivation that only ever sees one tier is untested; mislabelling the table cell as element_scoped fails it. unresolved_checks_state_ no_tier holds the fail-closed edge. 419 tests pass. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- crates/ethos-cli/src/cmd/verify.rs | 3 + crates/ethos-cli/tests/verify.rs | 57 +++++++++++++++++++ crates/ethos-core/src/verify_types.rs | 30 ++++++++++ crates/ethos-verify/src/lib.rs | 41 ++++++++++++- .../goldens/native_grounded_report.json | 3 + .../goldens/native_non_v1_claims_report.json | 1 + .../goldens/native_split_quote_report.json | 1 + .../goldens/native_ungrounded_report.json | 1 + .../opendataloader_grounded_report.json | 3 + ...pected.ungrounded.verification_report.json | 1 + .../real/expected.verification_report.json | 3 + schemas/ethos-verification-report.schema.json | 11 ++++ .../examples/verification-report.example.json | 2 + .../verification-report.hardened.example.json | 2 + 14 files changed, 157 insertions(+), 2 deletions(-) diff --git a/crates/ethos-cli/src/cmd/verify.rs b/crates/ethos-cli/src/cmd/verify.rs index 20d99c7..1b18faf 100644 --- a/crates/ethos-cli/src/cmd/verify.rs +++ b/crates/ethos-cli/src/cmd/verify.rs @@ -1056,6 +1056,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), @@ -1255,6 +1256,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()), @@ -1284,6 +1286,7 @@ mod tests { status: CheckStatus::Grounded, reason: None, match_method: MatchMethod::PresenceOnly, + evidence_tier: None, semantic_unverified: false, evidence: Some(Evidence { text: None, diff --git a/crates/ethos-cli/tests/verify.rs b/crates/ethos-cli/tests/verify.rs index caab61e..e9f7b0c 100644 --- a/crates/ethos-cli/tests/verify.rs +++ b/crates/ethos-cli/tests/verify.rs @@ -326,6 +326,63 @@ fn verify_alpha_demo_report_predicates_match_goldens() { } } +/// `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 diff --git a/crates/ethos-core/src/verify_types.rs b/crates/ethos-core/src/verify_types.rs index 9e71a0f..1e00457 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, @@ -387,6 +391,31 @@ pub struct VerificationReport { 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 { @@ -1349,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, diff --git a/crates/ethos-verify/src/lib.rs b/crates/ethos-verify/src/lib.rs index 1377dd3..e855fbc 100644 --- a/crates/ethos-verify/src/lib.rs +++ b/crates/ethos-verify/src/lib.rs @@ -49,8 +49,9 @@ use ethos_core::grounding::{ use ethos_core::verify_types::{ compute_all_evidence_grounded, Attestation, CapabilityLimit, Check, CheckProvenance, CheckReason, CheckStatus, Claim, ClaimKind, ContextBoundary, ContextEcho, Evidence, - EvidenceDispersion, GroundingMeta, MatchMethod, ProvenanceStatus, TextNormalization, - VerificationConfig, VerificationReport, VerifierIdentity, HARDENED_VERIFICATION_SCHEMA_VERSION, + EvidenceDispersion, EvidenceTier, GroundingMeta, MatchMethod, ProvenanceStatus, + TextNormalization, VerificationConfig, VerificationReport, VerifierIdentity, + HARDENED_VERIFICATION_SCHEMA_VERSION, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -1082,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, @@ -1099,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, @@ -1120,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, @@ -1136,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, @@ -1153,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, @@ -1169,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, @@ -1187,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, @@ -1202,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, @@ -1218,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, @@ -1239,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, @@ -1270,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, @@ -1278,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()) @@ -1361,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, @@ -1544,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, @@ -1575,6 +1607,7 @@ fn target_from_element(element: &GroundingElement, element_index: Option) page: Some(element.page.clone()), bbox: element.bbox, text: element.text.clone(), + tier: EvidenceTier::ElementScoped, from_table_cell: false, element_index, element_ids: vec![element.id.clone()], @@ -1587,6 +1620,7 @@ fn target_from_span(span: &GroundingSpan) -> FoundTarget { page: Some(span.page.clone()), 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(), @@ -1631,6 +1665,7 @@ fn target_from_cell(page: &str, cell: &GroundingCell) -> FoundTarget { page: Some(page.to_string()), bbox: cell.bbox, text: Some(cell.text.clone()), + tier: EvidenceTier::TableCell, from_table_cell: true, element_index: None, element_ids: Vec::new(), @@ -1727,6 +1762,8 @@ fn adjacent_text_pair_target( Some(FoundTarget { page: Some(first.page.clone()), + // 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, diff --git a/examples/verify/goldens/native_grounded_report.json b/examples/verify/goldens/native_grounded_report.json index c80f4a6..036f208 100644 --- a/examples/verify/goldens/native_grounded_report.json +++ b/examples/verify/goldens/native_grounded_report.json @@ -29,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, @@ -57,6 +58,7 @@ "page": "p0001", "text": "$12.4M" }, + "evidence_tier": "table_cell", "id": "v0002", "match_method": "table_cell_lookup", "semantic_unverified": false, @@ -79,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 6ca5d86..c63d74c 100644 --- a/examples/verify/goldens/native_non_v1_claims_report.json +++ b/examples/verify/goldens/native_non_v1_claims_report.json @@ -26,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 dde2d32..e92d29c 100644 --- a/examples/verify/goldens/native_split_quote_report.json +++ b/examples/verify/goldens/native_split_quote_report.json @@ -28,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_ungrounded_report.json b/examples/verify/goldens/native_ungrounded_report.json index 38f8f91..ecae9ee 100644 --- a/examples/verify/goldens/native_ungrounded_report.json +++ b/examples/verify/goldens/native_ungrounded_report.json @@ -29,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_grounded_report.json b/examples/verify/goldens/opendataloader_grounded_report.json index a8f7ef7..e0ebd1d 100644 --- a/examples/verify/goldens/opendataloader_grounded_report.json +++ b/examples/verify/goldens/opendataloader_grounded_report.json @@ -34,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, @@ -62,6 +63,7 @@ "page": "page-1", "text": "$12.4M" }, + "evidence_tier": "table_cell", "id": "v0002", "match_method": "table_cell_lookup", "semantic_unverified": false, @@ -85,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/fixtures/foreign/opendataloader/real/expected.ungrounded.verification_report.json b/fixtures/foreign/opendataloader/real/expected.ungrounded.verification_report.json index deaef97..1d4b706 100644 --- a/fixtures/foreign/opendataloader/real/expected.ungrounded.verification_report.json +++ b/fixtures/foreign/opendataloader/real/expected.ungrounded.verification_report.json @@ -34,6 +34,7 @@ "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", diff --git a/fixtures/foreign/opendataloader/real/expected.verification_report.json b/fixtures/foreign/opendataloader/real/expected.verification_report.json index 65eacaf..8935ebd 100644 --- a/fixtures/foreign/opendataloader/real/expected.verification_report.json +++ b/fixtures/foreign/opendataloader/real/expected.verification_report.json @@ -34,6 +34,7 @@ "page": "page-1", "text": "Lorem Ipsum" }, + "evidence_tier": "element_scoped", "id": "v0001", "match_method": "normalized_text_contains", "semantic_unverified": false, @@ -58,6 +59,7 @@ "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, @@ -81,6 +83,7 @@ "page": "page-1", "text": "Lorem Ipsum" }, + "evidence_tier": "element_scoped", "id": "v0003", "match_method": "presence_only", "semantic_unverified": false, diff --git a/schemas/ethos-verification-report.schema.json b/schemas/ethos-verification-report.schema.json index b5c3d73..a8c57d8 100644 --- a/schemas/ethos-verification-report.schema.json +++ b/schemas/ethos-verification-report.schema.json @@ -372,6 +372,17 @@ "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." } } } diff --git a/schemas/examples/verification-report.example.json b/schemas/examples/verification-report.example.json index d8b5e38..9aa4368 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", diff --git a/schemas/examples/verification-report.hardened.example.json b/schemas/examples/verification-report.hardened.example.json index af98898..162a43e 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" }, From 0e8407504895d351828bbaa4094e77685974465c Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 21:23:36 +0530 Subject: [PATCH 17/22] feat(cli): every verdict command emits a proof statement (WP-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces statement_json_bytes in main.rs as the single path from an Ethos verdict to bytes on disk, and moves all five emitters onto it: verify, grounding check, evidence anchor, security report, and crop_element. verify had its own copy from WP-2 and now shares this one, so the statement shape cannot drift between producers — the same reason ethos-core owns one c14n. WP-5 is four migrations, not five. answer-release is not emitted by the CLI at all: it is an app-layer envelope consumers build through derive_app_answer_release_decision. There is no producer to change, so there is nothing to wrap and no predicate type worth reserving on speculation. The grounding validation payload keeps its artifact_type field. Retiring it in favour of predicateType would break payload equivalence for no benefit today, and ADR-0016 freezes it as an input contract regardless. Redundant, and the redundancy is cheaper than the change. Not migrated, deliberately: ethos doc parse and ethos rag chunk. A document graph and a chunk stream are representations, not assertions about anything, and statements are for verdicts (section 1.5). A test asserts chunks.jsonl still has no _type. Zero fixture changes. Every one of the four migrations was a pure re-wrap, so no golden, schema, or example file moved — the same property WP-2 established, now demonstrated across the whole surface. every_verdict_command_emits_its_own_predicate_type covers all four commands in one test, because the failure mode is a single command quietly keeping its own serialisation, and that drift only surfaces when two producers disagree. Verified by mutation: restoring the old inline c14n path in security_report fails it. Also drops three now-unused EthosError imports the shared helper made redundant. 420 tests pass. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- crates/ethos-cli/src/cmd/crop.rs | 8 +- crates/ethos-cli/src/cmd/evidence.rs | 9 +- crates/ethos-cli/src/cmd/grounding.rs | 12 +-- crates/ethos-cli/src/cmd/security.rs | 13 ++- crates/ethos-cli/src/cmd/verify.rs | 40 +-------- crates/ethos-cli/src/main.rs | 43 ++++++++++ crates/ethos-cli/tests/evidence_anchor.rs | 3 +- crates/ethos-cli/tests/security_report.rs | 16 +++- crates/ethos-cli/tests/verify.rs | 100 ++++++++++++++++++++-- 9 files changed, 169 insertions(+), 75 deletions(-) diff --git a/crates/ethos-cli/src/cmd/crop.rs b/crates/ethos-cli/src/cmd/crop.rs index eb3ebb2..fec77e7 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 757d03b..ab0b0b7 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 d410b71..9feadb5 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 b8f10f9..eed6e97 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 1b18faf..6384532 100644 --- a/crates/ethos-cli/src/cmd/verify.rs +++ b/crates/ethos-cli/src/cmd/verify.rs @@ -26,7 +26,6 @@ use ethos_core::grounding::{ GroundingSpan, GroundingTable, PageGeometry, ParserIdentity, }; use ethos_core::model::Document; -use ethos_core::statement::{predicate_type, statement_bytes, Statement, Subject}; use ethos_core::verify_types::{ CapabilityLimit, Check, CheckReason, CheckStatus, ClaimKind, EvidenceOptions, MatchMethod, ProofLimitation, ProofStatus, ProofSummary, VerificationConfig, VerificationReport, @@ -269,44 +268,7 @@ fn verification_report_json_bytes( report: &VerificationReport, input: &Path, ) -> Result, Failure> { - let statement = Statement::new( - representation_subject(input)?, - // subject[1] is deliberately absent. The only source binding available is the - // producer-declared PDF hash, and an in-toto subject is matched by digest — a - // consumer could resolve it and conclude Ethos verified against those bytes. - // Ethos never saw them. `docs/proof-statement-v1.md` §1.4 admits a source subject - // only when the binding is real; a declaration is not one. - None, - predicate_type("grounding", 1), - report, - ); - let mut bytes = statement_bytes(&statement).map_err(|e| EthosError::internal(e.message))?; - bytes.push(b'\n'); - Ok(bytes) -} - -/// `subject[0]`: the representation Ethos actually read. -/// -/// The digest is the SHA-256 of the input file's bytes, not the report's -/// `document_fingerprint`. in-toto matches subjects by digest, so the value has to be -/// something a consumer holding the same file can compute for themselves; the document -/// fingerprint is the canonical-graph identity and is not derivable from the file. For the -/// Grounding JSON path the two agree anyway, since `representation_sha256` hashes exactly -/// these bytes. -/// -/// Reading the file a second time is deliberate. Threading bytes through `load_source`, -/// `read_document`, and both emit paths costs more than one re-read of an input already -/// validated and size-capped. -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(Subject::sha256( - name, - ethos_core::c14n::sha256_hex_bytes(&bytes), - )) + crate::statement_json_bytes(input, "grounding", report) } fn verification_report_summary_bytes(report: &VerificationReport) -> Result, Failure> { diff --git a/crates/ethos-cli/src/main.rs b/crates/ethos-cli/src/main.rs index f05799d..aa4a751 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 aa75951..d5324d4 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 a89b032..f799469 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 e9f7b0c..938e1b6 100644 --- a/crates/ethos-cli/tests/verify.rs +++ b/crates/ethos-cli/tests/verify.rs @@ -60,6 +60,7 @@ 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!( @@ -73,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 { @@ -326,6 +327,89 @@ fn verify_alpha_demo_report_predicates_match_goldens() { } } +/// 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 @@ -1083,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] @@ -3547,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"] @@ -3558,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"); @@ -3630,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"); @@ -3683,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"); } From 7f05f29c1387823318dbf0b13fcfa3b84d7b7ee2 Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 21:34:02 +0530 Subject: [PATCH 18/22] docs: publish CLAIMS.md and the migration guide (WP-6) Adds docs/CLAIMS.md in four parts: what a verdict proves with the mechanism and the code it lives in, what it does not prove with what would close each gap, a conservative regulatory mapping carrying a residual-gap column, and the paragraph to paste into a security questionnaire. The does-not-prove table is the point of the document. Eight rows, including the ones easiest to leave out: Ethos verifies a claim against the representation and not the representation against the document, so a parser error that both drafts and verifies consistently is invisible; the attestation names the crate version and not the binary, so a hostile operator can lie in it; subject[0] is what Ethos read, which on the Grounding JSON path is a parser's output and not the PDF; a paraphrase of a true fact is a mismatch; and no speed, footprint, or parser-quality property is claimed because no benchmark has been run whose numbers we would defend. CLAIMS.md is added to the claims-gate surfaces. A claims document that is not itself claim-gated is the obvious gap. The payload-versus-envelope field table goes into proof-statement-v1.md section 2 rather than a separate contract document, because that table was the only thing the separate document would have held. It names the attack it exists to prevent: subject[].name is a caller-chosen label, in-toto matches artifacts by digest, and a consumer keying a release decision on the name can be handed a file called loan-file.pdf that is not the loan file. README gains a "What comes out" section showing the real emitted shape, verified against actual CLI output rather than written from the spec, plus the upgrade line: the report you already parse is the predicate, and jq .predicate returns the previous shape byte for byte. Completes WP-6 and the release. 420 tests pass. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- .github/scripts/claims_gate.py | 1 + CHANGELOG.md | 20 +++++ README.md | 41 +++++++++- docs/CLAIMS.md | 82 +++++++++++++++++++ .../proof-statement-v1-implementation-plan.md | 7 +- docs/proof-statement-v1.md | 33 +++++++- 6 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 docs/CLAIMS.md diff --git a/.github/scripts/claims_gate.py b/.github/scripts/claims_gate.py index 6d73106..ae31160 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/CHANGELOG.md b/CHANGELOG.md index 407ead0..9c3af19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## Unreleased +- boundary-exception: **every Ethos verdict is now an in-toto Statement.** `verify`, + `grounding check`, `evidence anchor`, `security report`, and `crop_element` wrap their + output in `{_type, subject, predicateType, predicate}`, emitted through one serialiser so + the shape cannot drift between producers. The report you already parse is the + `predicate`: `jq .predicate` returns the pre-0.6 shape byte for byte. Representations + (`doc parse`, `rag chunk`) stay bare, because a document graph is not an assertion about + the document. Base predicate URI is `https://docushell.com/ethos//v1`, where + `v1` versions the predicate schema and never the product. +- Two fields added inside the report. `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. `evidence_tier` states per check how precisely evidence + was bound — `exact_span`, `table_cell`, `element_scoped`, `page_scoped`, or + `capability_limited` — instead of leaving consumers to derive it. +- `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. +- Adds `docs/CLAIMS.md`, now covered by the claims gate: what a verdict proves, what it does + not, and the paragraph to paste into a security questionnaire. + - boundary-exception: rewrite `README.md` to drop the public-beta posture. The status badge, the beta status block, the "Current evaluation support" framing, and the "Blocked" column are all removed. "Blocked" was internal release vocabulary meaning "not yet approved for diff --git a/README.md b/README.md index 7afeaf7..142ddc2 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ Apache-2.0. Runs locally. No account, no API key, no network. - [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) +- [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 @@ -58,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 diff --git a/docs/CLAIMS.md b/docs/CLAIMS.md new file mode 100644 index 0000000..4a84d81 --- /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/proof-statement-v1-implementation-plan.md b/docs/proof-statement-v1-implementation-plan.md index 45719a9..6ab8bd4 100644 --- a/docs/proof-statement-v1-implementation-plan.md +++ b/docs/proof-statement-v1-implementation-plan.md @@ -1,6 +1,6 @@ # Implementation Plan: Proof Statement v1 -Status: **approved for build, not started.** Companion to `docs/proof-statement-v1.md`, +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. @@ -331,6 +331,11 @@ 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 diff --git a/docs/proof-statement-v1.md b/docs/proof-statement-v1.md index 1047c7c..2524e75 100644 --- a/docs/proof-statement-v1.md +++ b/docs/proof-statement-v1.md @@ -170,10 +170,35 @@ Enforce it by type, the way `QuantizedGeom` enforces quantize-at-extraction. A p struct that cannot hold a `SystemTime` cannot break the goldens. This is what makes signing safe to add later without a second migration. -The contract doc carries a field table stating which layer every field lives in, and an -explicit warning that basing policy decisions on wrapper fields is unsafe. Signet's -`SECURITY.md` is the model here: it tabulates signed versus unsigned fields and spells -out the attack when a developer trusts an unsigned one. +### 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. --- From 1b9c123b73e952209100fd6ef0c86979229fd4cb Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 21:37:12 +0530 Subject: [PATCH 19/22] docs: reconcile the spec with what shipped and consolidate the changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec still read as a proposal after being fully implemented. Status was "ruled, not implemented", the predicate table listed answer-release as a pending migration when it has no producer, "all six" counted a predicate that does not exist, and a note told the reader to verify the in-toto _type revision before freezing a string that was frozen five commits ago. A reference document that describes a plan rather than the thing is worse than no document, because it reads as authoritative. Section 3 now records what exists: five predicates, each with the command that emits it, plus the two decisions a reader will otherwise re-litigate — why answer-release has no type, and why grounding-validation keeps its artifact_type field inside the predicate. Section 6 stops restating the proof tiers. They are defined once, for readers, in docs/CLAIMS.md section 5; two copies of a claim is how the two copies start disagreeing. What the format owes the tiers stays here: signatures attach to the named-but-empty wrapper layer, the predicate stays byte-identical when one is added, and DSSE is the signing envelope and deliberately not the artifact. The changelog told the v0.6.0 story across four separate entry blocks written at different times. Consolidated into one section that reads in the order a reader needs it — what changed, how to upgrade, what was added inside the report, and what the CI scoping did — with every boundary-exception marker preserved so the release-boundary gate still passes. 420 tests pass. No code changed. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- CHANGELOG.md | 79 ++++++++++++++++++++++---------------- docs/proof-statement-v1.md | 72 +++++++++++++++++++--------------- 2 files changed, 87 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c3af19..2a3f5ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,42 +2,55 @@ ## Unreleased -- boundary-exception: **every Ethos verdict is now an in-toto Statement.** `verify`, - `grounding check`, `evidence anchor`, `security report`, and `crop_element` wrap their - output in `{_type, subject, predicateType, predicate}`, emitted through one serialiser so - the shape cannot drift between producers. The report you already parse is the - `predicate`: `jq .predicate` returns the pre-0.6 shape byte for byte. Representations - (`doc parse`, `rag chunk`) stay bare, because a document graph is not an assertion about - the document. Base predicate URI is `https://docushell.com/ethos//v1`, where - `v1` versions the predicate schema and never the product. -- Two fields added inside the report. `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. `evidence_tier` states per check how precisely evidence - was bound — `exact_span`, `table_cell`, `element_scoped`, `page_scoped`, or - `capability_limited` — instead of leaving consumers to derive it. +### 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. -- Adds `docs/CLAIMS.md`, now covered by the claims gate: what a verdict proves, what it does - not, and the paragraph to paste into a security questionnaire. - -- boundary-exception: rewrite `README.md` to drop the public-beta posture. The status badge, - the beta status block, the "Current evaluation support" framing, and the "Blocked" column - are all removed. "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 in the rewrite claims production - readiness, and `claims_gate.py` still passes, so no banned overclaim was introduced in - exchange. -- boundary-exception: update `docs/public-boundary-claims.json` to match. Five pinned README - strings carried the retired beta wording; they are replaced by six that carry the same - boundaries in the new voice, including the honest limits — Ethos does not decide whether an - answer is true, a missing capability yields an explicit limitation rather than a guess, and - no speed, footprint, or parser-quality comparison is published because no defensible - benchmark has been run. Eleven existing install and PDFium claims are unchanged. -- boundary-exception: scope `.github/workflows/ci.yml` to product correctness and - architectural invariants, parking publication gates behind `make release-gates`. See - `docs/ci-scope.md`. + 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 diff --git a/docs/proof-statement-v1.md b/docs/proof-statement-v1.md index 2524e75..ae76c5b 100644 --- a/docs/proof-statement-v1.md +++ b/docs/proof-statement-v1.md @@ -1,10 +1,11 @@ # Proof Statement v1 -Status: **ruled, not implemented.** The three decisions in §1 are settled. Nothing here -is built yet. +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 locked to `https://docushell.com/ethos/` (§1.2). Build sequencing and the -file-by-file touch list live in `docs/proof-statement-v1-implementation-plan.md`. +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 @@ -48,7 +49,7 @@ that is fine because tooling reads it. For document evidence, where opening the 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. -Verify the current `_type` revision against the in-toto spec before freezing it. +The `_type` string was verified against the in-toto v1 specification before being frozen. ### 1.2 URI namespace — RULED: `https://docushell.com/ethos/` @@ -64,7 +65,7 @@ https://docushell.com/ethos/evidence-anchor/v1 https://docushell.com/ethos/security/v1 ``` -Shape is `//v` for all six, with no exceptions. +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 @@ -121,8 +122,8 @@ The one case where `subject[1]` would be honest is `--crop-source-pdf`, where Et 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 is a documentation obligation and -it goes in the contract doc and in `CLAIMS.md`. +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 @@ -204,14 +205,24 @@ arrives in a layer that already exists rather than reshaping the artifact. ## 3. Predicate types -| Predicate | Replaces | Status | +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` | `verification_report.json` | migrate | -| `grounding-validation/v1` | `ethos.grounding_validation.v1` | migrate, URI-ify | -| `evidence-anchor/v1` | `evidence_anchor_report.json` | migrate | -| `security/v1` | `security_report.json` | migrate | -| `crop/v1` | crop descriptors | migrate | -| `answer-release/v1` | app-answer-release decision | migrate | +| `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 @@ -219,8 +230,8 @@ the new `predicate` block is byte-identical to the old top-level report, which r migration to a provably pure re-wrapping and forces any semantic change into its own visible commit. -If the release starts dragging, `crop/v1` and `answer-release/v1` are the first to defer. -They have the fewest consumers. +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. --- @@ -275,23 +286,22 @@ forces a `v2` later. The singular `grounding` field stays as it is. --- -## 6. Proof tiers +## 6. Proof tiers, and what the format owes them -Publish these as a table. The ordering is counterintuitive and the top rung is the one -nobody else can occupy. - -| Tier | Claim | Key required | -| --- | --- | --- | -| **T0 Reproducible** | anyone re-runs and gets identical bytes | no | -| **T1 Attested** | the record names the verifier, config, and exact claims that produced it | no | -| **T2 Signed** | a named key asserts who ran it and when | yes | +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. -Ethos ships T0 and T1 in this release. T2 is deliberately out: the statement shape makes -signing a wrapper you add later without touching the artifact, which is the whole reason -for getting the shape right first. +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: -The message the tiers carry: everyone else starts at T2 and calls it proof. T2 says someone -claimed this. T0 says check it yourself. +- 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) --- From a1b10ac092bcc47c3f0c909a0fe28afc2c952c68 Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 21:38:50 +0530 Subject: [PATCH 20/22] fix: read the predicate in the RAG framework examples gate The gate parses a verification report at the top level, which moved under predicate when verify started emitting statements. Caught before pushing by running every CI job locally; the gate is not in the ordinary cargo test loop, which is exactly why WP-5 did not surface it. Worth recording what did not break: the example scripts themselves are unchanged. They key on the verifier's exit code, and the wrapper does not touch the exit-code contract. An integrator who copied those scripts needs no changes, which is the compatibility property that actually matters here. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- .github/scripts/test_rag_framework_examples.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/scripts/test_rag_framework_examples.py b/.github/scripts/test_rag_framework_examples.py index 8750f8b..97c498f 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"]]) From 18b70f1b3139029d8fa3693c65cfa615c3cadeea Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 21:45:04 +0530 Subject: [PATCH 21/22] fix: update the Python and npm consumer surfaces for the new report fields Three consumer surfaces read the verification report and none were covered by cargo test, so WP-3 through WP-5 left them broken. All three surfaced only when running every CI job locally before pushing. python/ethos_pdf/emit.py validates reports against strict field allowlists. attestation and evidence_tier are added as allowed rather than required, deliberately: the wrapper drives a caller-provided ethos binary that may predate 0.6, and requiring the fields would break those callers with a confusing error for no benefit. The npm package's generated type declarations were stale against the schema, and the TypeScript consumer fixture needed attestation now that the type requires it. The RAG framework examples gate parsed the report at the top level. Worth noting what did not break: the example scripts themselves are unchanged, because they key on the verifier's exit code and the wrapper does not touch the exit-code contract. An integrator who copied those scripts needs no changes. All seventeen CI checks pass locally: fmt, clippy, 420 tests, validator ceiling, fixtures, layout evaluator, Python surface, npm, three schema validators, RAG examples, verify portability, dependency boundary, minimal grounding build, and both dogfood directions. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- packages/npm/ethos-pdf/test/types-consumer.ts | 5 +++++ .../ethos-pdf/types/verification-report.d.ts | 21 +++++++++++++++++++ python/ethos_pdf/emit.py | 6 ++++-- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/npm/ethos-pdf/test/types-consumer.ts b/packages/npm/ethos-pdf/test/types-consumer.ts index 0967d5a..9cbab8a 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 3ee4e46..2be8e2c 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 50d6e8a..90a1239 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: From 23f209e291ed03f2fcf6bc2643e843cfe0b46618 Mon Sep 17 00:00:00 2001 From: docushell-dev Date: Sun, 9 Aug 2026 22:13:44 +0530 Subject: [PATCH 22/22] fix: update the four remaining report consumers outside cargo test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The determinism workflow was skipped on this PR because its cross-platform job gates on a contract-change label that did not exist in the repository. Creating the label and applying it fired the job, which failed on all three platforms at "verification report repeated-byte and golden equality" — not a platform difference, but a consumer nothing in cargo test reaches. examples/verify/check_verify_alpha.py compares CLI output against the goldens directly. It now unwraps the predicate, and a load_predicate helper carries the reason: goldens stay in the pre-0.6 report shape on purpose, because comparing the predicate against them is what proves the wrapper is a pure re-wrap rather than a reshaping. A sweep for every file reading all_evidence_grounded or checks found three more: check_rendered_crops.py, test_citation_emission_v1_contract.py, and test_trust_benchmark_corpus.py. All three now read the predicate. The citation-emission gate additionally asserts that ci.yml contains "make citation-emission-v1-contract", which the CI scoping removed. Restored to the test job rather than weakening the assertion: seven of its eight tests are real, it runs in under a second, and it guards the documented pointer-first integration path and its runnable examples. Verified: make verify-alpha, citation-emission-v1-contract, trust-benchmark-corpus, rag-framework-examples, layout-evaluator-alpha, validator-ceiling-check, cargo test, Python surface, npm, schema validation, fmt, and clippy all pass. Co-Authored-By: Claude Opus 5 Signed-off-by: docushell-dev --- .../test_citation_emission_v1_contract.py | 3 ++- .../scripts/test_trust_benchmark_corpus.py | 3 ++- .github/workflows/ci.yml | 3 +++ examples/verify/check_rendered_crops.py | 3 ++- examples/verify/check_verify_alpha.py | 20 ++++++++++++++++--- 5 files changed, 26 insertions(+), 6 deletions(-) diff --git a/.github/scripts/test_citation_emission_v1_contract.py b/.github/scripts/test_citation_emission_v1_contract.py index 72099c7..c454ccd 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_trust_benchmark_corpus.py b/.github/scripts/test_trust_benchmark_corpus.py index 3eaa2b1..c779d31 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 986e959..fab2d4b 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 diff --git a/examples/verify/check_rendered_crops.py b/examples/verify/check_rendered_crops.py index d482a6d..13c95e6 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 b6a4caa..d686fd2 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"])