diff --git a/.github/workflows/standards-build.yml b/.github/workflows/standards-build.yml index 390522338..c1cfb9908 100644 --- a/.github/workflows/standards-build.yml +++ b/.github/workflows/standards-build.yml @@ -85,6 +85,12 @@ jobs: - { name: sandbox-rs, dir: sandbox/sandbox-rs, runner: linux, conformance: sandbox-conformance, feature_tests: "-p sandbox-tee --features amd-kds,intel-pcs" } - { name: smart-byte-rs, dir: smart-byte/smart-byte-rs, runner: linux } - { name: pool-rs, dir: pool/pool-rs, runner: linux } + # `clippy: true` adds the zero-warning gate below. It is opt-in per + # entry rather than repo-wide because several workspaces are not at + # zero yet, and a gate that fails on arrival teaches people to pass + # `--no-verify`. A workspace that reaches zero adds the flag and + # cannot regress; joulespec-rs starts there. + - { name: joulespec-rs, dir: joulespec/joulespec-rs, runner: linux, clippy: true } - { name: openpay, dir: openpay, runner: linux-arm64 } - { name: joule-code-rs, dir: joule-code/joule-code-rs, runner: linux-arm64, z3: true } - { name: map-rs, dir: map/map-rs, runner: linux-arm64, conformance: map-conformance } @@ -155,7 +161,7 @@ jobs: if: needs.plan.outputs.any == 'true' # Spread across the fleet's runners rather than pinning every job to `linux`. # For most of these the runner is a capacity choice, not a correctness one — - # and 17 jobs on a single runner queue behind whatever else is using it. + # and every job on one runner queues behind whatever else is using it. # `jouleclaw-rs` is the exception: see its matrix entry. runs-on: ${{ matrix.runner }} strategy: @@ -275,6 +281,19 @@ jobs: JOULE_FIX_EXPECT_Z3: ${{ matrix.z3 && '1' || '' }} run: cargo test --workspace + # The zero-warning gate, on the workspace's own runner. + # + # Deliberately not in the macOS lint step: that one exists for code only + # a macOS host can compile, and the matrix comments above are about not + # sending platform-neutral work to the contended Mac. A workspace that + # needs no Apple anything is linted where it is built. + - name: Clippy (all targets, warnings denied) + if: matrix.clippy + working-directory: ${{ matrix.dir }} + env: + RUSTFLAGS: ${{ matrix.rustflags }} + run: cargo clippy --workspace --all-targets -- -D warnings + # The NVML backends sit behind features no workspace member enables # (jcp-energy `nvml`, eoc-meter `cuda`, jouleclaw-energy `nvml`), so the # step above never compiles them, let alone runs their tests. Here they diff --git a/README.md b/README.md index 7e6a2d7fd..5c75061bf 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,49 @@ The **entitlement layer** in front of a contended pool: who may start, on whose - **Licence:** Apache-2.0 for reference code (see [`pool/LICENSE`](pool/LICENSE)); CC-BY-4.0 for specification text (see [`pool/LICENSE-SPEC`](pool/LICENSE-SPEC)). - **Status:** v0.1.0-draft.1. `SPEC.md` published; reference workspace `pool-rs/` — `pool-core` carries the entitlement tree with weighted sibling surplus, the normative §4 admission order (impossibility before pendency, guarantee, then borrowing), and the §5.2 preemption order. `cargo test --workspace` green. +### [joulespec/](joulespec/) — A carrier for intent + +The **specification cargo class**: what software owes, and whether anything +established it. Specification-driven practice converged on prose an agent +interprets and a human reads, and the difficulties catalogued wherever it is +examined — specifications drifting from the code they describe, generated +artifacts trusted past their evidence, no method for checking conformance, a +review burden heavy enough that practitioners report preferring to read the +code — are measurement problems rather than methodology ones. They persist +because the specification is a *document*: a clause cannot be addressed, so +nothing binds to it; conformance is a sentence in a review, so it cannot be +graded or capped; the cost of the ceremony is an anecdote, so the argument about +whether it earns its overhead has no units. `joulespec/1` leaves the prose +alone and changes what the specification *is*. A clause is addressed by its +**obligation** — the content address of its kind and text, with the label +deliberately excluded — so renaming a clause loses no evidence while rewording +one invalidates exactly that clause's evidence, which is the inversion that +lets drift go unobserved under label-bound citations. Evidence climbs a ladder +ordered by one question, *how hard would it be for this obligation to be +violated without this evidence noticing*: `E0:Cited` (testimony — where +prevailing practice lands) → `E1:Reviewed` → `E2:Exercised` (a green check, +which flatters: a test with no assertion passes) → `E3:Falsifiable` (the same +check on record **failing** against a named counter-artifact — the first rung +where green means something) → `E4:Proved`, reached only when the formalization +binding statement to clause was cross-checked by a key other than the prover's, +because a proof under a declared formalization is machine-checked evidence that +the something is this clause on the prover's word. A specification's grade is +its **weakest** owed obligation; non-goals are first-class, and evidence that +one was implemented is a flag that blocks conformance however good the rest is. +Revising a specification yields the exact partition of which evidence survives, +and a witness bound to an earlier artifact state is reported **stale** rather +than counted — so neither direction of drift needs anyone to remember to check. +Every witness and every unit of work carries its joules, so the receipt seals +the **specified share** (how much energy went into work citing no clause at +all) and **microjoules per falsifiable obligation** — the denominator +deliberately the rung whose evidence can fail, because a figure per *attempted* +obligation rewards ceremony. Receipts are derived from the evidence and never +from a verdict handed in, so every claim in one is reproducible by anyone +holding the same inputs. + +- **Licence:** Apache-2.0 for reference code (see [`joulespec/LICENSE`](joulespec/LICENSE)); CC-BY-4.0 for specification text (see [`joulespec/LICENSE-SPEC`](joulespec/LICENSE-SPEC)). +- **Status:** v0.1.0-draft.1. `SPEC.md` published; reference workspace `joulespec-rs/` — `joulespec-core` carries obligation-addressed clauses and signed revisions, the graded evidence ladder with independence computed from key disjointness, the deterministic evaluator with the weakest-obligation cap and its four flag conditions, the revision partition, and the `joulespec/1` JCR-1 receipt with its per-clause audit. `cargo test --workspace` green, `cargo clippy --workspace --all-targets` zero warnings and gated in CI. + ## How this is organised Each subdirectory is self-contained: it carries its own README, its own licence, and its own contribution guidance. Cross-protocol consistency lives at this level — in this README and in [`CHARTER.md`](CHARTER.md). @@ -152,7 +195,8 @@ open-standards/ ├── jcp/ — Joule Context Protocol: unified energy-native capability-secured agentic substrate (metered grants + signed receipts, decided outside the model) (Apache-2.0 code / CC-BY-4.0 spec, Rust ref impl) ├── joulecontract/ — contract cargo class: five-obligation energy-accounted interface contracts, bound + sealed receipts, adversarial-diff provenance (Apache-2.0 code / CC-BY-4.0 spec, Rust ref impl) ├── joule-os/ — the UDA reference runtime: single-level store + refine-family cascade + capability ledger, lowered per coordinate across native / PWA / bare-metal (Apache-2.0 code / CC-BY-4.0 spec, Rust ref impl) -└── mesh/ — inference serving + routing fabric: joule-ceiling × fidelity-floor requests routed cheapest-sufficient, metered, sealed as µJ-per-accepted-token receipts (Apache-2.0 code / CC-BY-4.0 spec, Rust ref impl)``` +├── mesh/ — inference serving + routing fabric: joule-ceiling × fidelity-floor requests routed cheapest-sufficient, metered, sealed as µJ-per-accepted-token receipts (Apache-2.0 code / CC-BY-4.0 spec, Rust ref impl) +└── joulespec/ — specification cargo class: obligation-addressed clauses, a graded evidence ladder from testimony to proof, deterministic drift, and the joules a specification cost to evidence (Apache-2.0 code / CC-BY-4.0 spec, Rust ref impl)``` The standards do not depend on one another. They share a steward and a doctrine — that the protocol is the public commitment and the operations are the offer. (Most also share a unit of accounting, joules; ARL is universal and tied to nothing, joules being one of its four measured axes rather than a shared substrate.) @@ -172,6 +216,8 @@ Contributions are welcome at the subdirectory level. Issues, discussions, and pu - **JouleContract:** the in-toto/DSSE `JouleContract` predicate emission at the recognized edge; a signed conformance vector pack (`conformance/v1/`); adversarial negatives from the published diff rounds (a frame amendment racing its breach, a quorum whose members share a parent entity, an evidence bundle with one swapped attestation); meter-attestation backends (RAPL / NVML / substrate telemetry) feeding the `Metered` binding; and a fourth adversarial diff round. - **Joule OS:** additional refine-family backends behind the cascade (real `LocalSearch` / `StateGraph` / `ConstrainedSearch` stages); per-platform measured-energy oracles feeding the work-class receipts; conformance negatives against the THEOREM I1–I20 invariants (a capability minted outside `Ledger::issue`, a single-level-store mutation that tears across an instruction boundary, an AOT/JIT divergence); and bare-metal surface drivers. +- **JouleSpec:** witness producers that emit `E3` evidence from suites already in use (a mutation runner recording the mutant each test was written against is the natural one), a bridge that turns the verification standard's discharged goals into `E4` witnesses with their formalizations carried through, adversarial negatives (a witness re-bound to a reworded obligation, a review countersigned by the citing key, a receipt sealed from a hand-built verdict), and measured-energy backends feeding the witness `HwShunt` path. + A `CONTRIBUTING.md` per standard captures the specifics where they diverge. ## Contact diff --git a/joulespec/LICENSE b/joulespec/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/joulespec/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/joulespec/LICENSE-SPEC b/joulespec/LICENSE-SPEC new file mode 100644 index 000000000..bd36f8a68 --- /dev/null +++ b/joulespec/LICENSE-SPEC @@ -0,0 +1,30 @@ +Creative Commons Attribution 4.0 International + +Copyright (c) 2026 Transaction Science, Inc. + +This specification text and accompanying documents are licensed under the +Creative Commons Attribution 4.0 International License (CC BY 4.0). + +You are free to: + - Share — copy and redistribute the material in any medium or format + - Adapt — remix, transform, and build upon the material for any purpose, + even commercially + +Under the following terms: + - Attribution — You must give appropriate credit, provide a link to the + license, and indicate if changes were made. You may do so in any + reasonable manner, but not in any way that suggests the licensor + endorses you or your use. + +No additional restrictions — You may not apply legal terms or technological +measures that legally restrict others from doing anything the license +permits. + +Full licence text: https://creativecommons.org/licenses/by/4.0/legalcode + +──────────────────────────────────────────────────────────────────────── + +Reference implementations and software accompanying this specification, if +and when published in this directory, may be released under a separate +licence (typically Apache-2.0). Refer to the licence file alongside each +artefact. diff --git a/joulespec/README.md b/joulespec/README.md new file mode 100644 index 000000000..a9724db50 --- /dev/null +++ b/joulespec/README.md @@ -0,0 +1,213 @@ +# JouleSpec — Energy-Accounted Specification Conformance + +**Version:** 0.1.0-draft.1 · **Status:** Draft · **Profile:** `joulespec/1` +**Specification text:** CC-BY-4.0 · **Reference code (`joulespec-rs/`):** Apache-2.0 + +JouleSpec is the open standard for **stating what software owes and measuring +whether anything established it**. A clause is addressed by its obligation, so +renaming is free and rewording costs exactly that clause's evidence. Evidence +climbs a ladder from testimony to proof. A revision yields the exact set of +obligations that just lost their evidence. And the receipt says what the whole +exercise cost in joules. + +JouleSpec is one of the [Transaction Science open standards](../README.md). + +## The problem is measurement, not methodology + +Specification-driven practice converged on a shared shape: write the intent +down, derive the plan and the implementation from it, review at each phase. The +artifact it produces is prose an agent interprets and a human reads, and the +difficulties that follow are catalogued consistently wherever the practice is +examined — specifications drifting from the code they describe, generated +artifacts trusted past their evidence, no method for checking that an +implementation conforms, and a review burden heavy enough that practitioners +report preferring to read the code. + +Each of those is a measurement problem wearing a methodology costume, and each +persists for the same reason: **the specification is a document.** A clause +cannot be addressed, so nothing can bind to it. Conformance is a sentence in a +review, so it cannot be graded or capped. The cost of the ceremony is an +anecdote, so the argument about whether it is worth the effort has no units and +cannot be settled. + +JouleSpec changes none of the methodology. It changes what the specification +*is*. + +## Renaming is free; rewording is not + +A clause carries a label and an **obligation**. The label — `C1`, `auth-2` — is +editorial. The obligation is the content address of the clause's kind and text, +with the label deliberately excluded (SPEC §1.2), and evidence binds to the +obligation. + +``` + C1 "The endpoint rejects unauthenticated requests." → b3:9f4c… + auth-2 "The endpoint rejects unauthenticated requests." → b3:9f4c… same obligation + C1 "The endpoint should reject unauthenticated requests." → b3:2e71… different obligation +``` + +Renumbering a whole specification moves nothing and loses no evidence. +Weakening one requirement by one word invalidates that clause's evidence and +nothing else's — automatically, with nobody having to notice. + +Binding evidence to a label gets both halves backwards: it survives the edit +that changed the requirement and breaks on the edit that renamed it. That +inversion is the mechanism by which drift goes unobserved. It is an addressing +problem, not a discipline problem, and an address is the whole fix. + +## A ladder ordered by one question + +How hard would it be for this obligation to be violated **without this evidence +noticing?** + +| | | | +|---|---|---| +| `E0` | `cited` | The implementer says it is done. Nothing noticed anything. | +| `E1` | `reviewed` | A second party read the artifact, once. | +| `E2` | `exercised` | A named check ran against the artifact and passed. | +| `E3` | `falsifiable` | The same check is on record *failing* against a named counter-artifact. | +| `E4` | `proved` | No input violates it, under a formalization someone independent checked. | + +**`E0` is where prevailing practice lands**, and it is testimony from the party +being evaluated. Much of it is true; it is still testimony, and reporting it as +conformance is the error this standard exists to stop. + +**`E2` flatters.** A green check establishes that something ran. A test with no +assertion passes, and so does one whose fixture never reaches the condition the +clause cares about. + +**`E3` is the first rung where green means something** — the check has +demonstrated it can fail, so its passing carries information. It is also the +cheapest strong rung: most projects can reach it with tests they already have, +by recording the mutant the test was written against. + +The ladder is ordered by **re-checkability, not diligence**. A review is bound +to the state a person looked at; a named check runs again against any later +state. That says nothing about how carefully anyone read the code — it says one +kind of evidence survives the next commit and the other does not. + +**Independence is computed, never declared.** A review signed by the key that +made the citation is the citation again, and grades `E0` (SPEC §2.5). + +## What an `E4` actually claims + +A proof discharges a formal statement, and the statement is not the clause. The +binding between them is a human act and the weakest link in any claim of proved +conformance — so a proof reaches `E4` only when that binding was cross-checked +by a key other than the prover's. Under a merely declared formalization it +grades `E2`: real mechanical evidence about *something*, where that the +something is this clause remains the prover's word (SPEC §2.6). + +The reference library never formalizes a clause on anyone's behalf. Demotion +rather than refusal is deliberate — the proof is not fake, and discarding it +would lose evidence that exists. + +## The weakest obligation sets the grade + +A specification's grade is the **minimum** over its requirements and +constraints, absent entirely when any of them has no surviving evidence (SPEC +§3.2). Averaging would let nine proved obligations conceal one nobody looked +at, which is the failure a grade exists to surface. + +**Non-goals are first-class and handled differently.** There is nothing to +evidence about behaviour that should not exist, so an unwitnessed non-goal is +correct and carries no grade. Evidence that one *was* implemented is a flag: it +does not lower the grade of the owed obligations — they really are evidenced — +and it does block conformance. The specification said not to build this and +something says it exists. + +**Nothing is dropped quietly.** A witness that does not verify, names another +specification, or names an obligation the specification does not state is a +reported finding, not a no-op. That last case is the normal aftermath of a +rewording and the clearest available signal that evidence and specification +have parted company. + +## Drift is a partition, not a warning + +Diffing two revisions by obligation address says exactly which evidence +survives (SPEC §4.1): obligations `kept`, `relabelled` (editorial — no evidence +lost), `reworded` (that clause's evidence invalidated, the new obligation +unevidenced), `added`, and `removed`. From those, the two sets a revision +review actually wants fall out: **what just lost its evidence**, and **what +starts unevidenced**. + +A freed label reused by a new clause is an addition, not a rewording — the old +obligation still stands elsewhere, so no evidence is invalidated, and +conflating the two would report a loss that did not happen. + +The other direction of drift is the artifact moving under evidence that stayed +still, and it needs no diff at all: a witness is bound to the artifact state it +observed, so evidence from an earlier state is reported stale rather than +counted (SPEC §3.1). Neither direction requires anyone to remember to check. + +## The ceremony has a price + +Every witness carries the energy it cost to produce, and every unit of work +carries the energy it cost to perform. From that the receipt seals two figures +nothing else in this space can state: + +- **The specified share** — how much of the work energy went into work citing + an obligation this specification actually states, in parts-per-million (SPEC + §5.2). This is the measured form of an observation the field makes + qualitatively: that agents produce features nobody requested. A run where a + quarter of the energy implemented no clause has a number saying so. +- **Microjoules per falsifiable obligation** — total attributed energy over the + count of owed obligations evidenced at `E3` or better (SPEC §5.3). The + denominator is deliberately the rung whose evidence can fail: a figure per + *attempted* obligation rewards ceremony, a figure per obligation something + would have caught rewards evidence. + +Whether specification-driven practice earns its overhead is currently settled +by anecdote in both directions. Two processes applied to the same +specification, reaching the same evidence, one spending an order of magnitude +more energy to get there — that is not a matter of taste. + +Where nothing reaches the bar the figure is **absent**, not zero. A cost per +unit of nothing is not a small number. + +## The receipt is derived, never signed on request + +A receipt is sealed from the specification, the artifact, the witnesses and the +work — never from a verdict handed in (SPEC §6.1). A verdict is an ordinary +data structure, and an interface that signed one would let a producer construct +the grade it wanted. Because the receipt is derived from the evidence, every +claim in it is reproducible by anyone holding the same inputs, and a producer +who wants a better grade has to go and get better evidence. + +Receipts seal as **`joulespec/1` JCR-1 envelopes** — canonical det-CBOR under +COSE_Sign1 with Ed25519, the wire format the rest of this family seals with, so +a receipt verifies under tooling that has never heard of JouleSpec. The core +admits no nulls and several figures are legitimately absent, so those fields +are **omitted** rather than zeroed: a reader who finds no grade knows none was +established, where a reader who found `E0` would believe one was. + +Each receipt carries a **per-clause audit** — label, kind, obligation, grade, +the witness that carried it, evidence energy, stale count — because which +obligation is the weak one is the actionable fact, and it must be answerable +from the receipt alone (SPEC §6.3). + +## What it does not do + +JouleSpec does not write specifications, run checks, formalize clauses, or +judge whether a clause states a good requirement. + +And it cannot tell whether a clause says what its author meant. An obligation +is addressed, signed and tracked, and may still be the wrong requirement. +Software can satisfy every clause of its specification and be unfit for +purpose, because the specification was incomplete — and an incomplete +specification is exactly the case where every clause is covered. A grade is a +property of evidence, not of software (SPEC §8). + +## Reference + +[`joulespec-rs/crates/joulespec-core`](joulespec-rs/crates/joulespec-core) — +obligation-addressed clauses and signed revisions (`spec`), the graded evidence +ladder with computed independence (`evidence`), the deterministic evaluator +with the weakest-obligation cap (`conform`), the revision partition (`drift`), +and the `joulespec/1` JCR-1 receipt derived from its inputs (`receipt`). + +Prior art inside this family: `joule-code-spec` first shipped the signed, +content-addressed clause and the citation binding a coding operation to it. +Its citations bind by clause **label** and its audit has one notion of coverage +— a clause is covered when something claims it. That is the `E0` rung here, and +label binding is the drift hole this standard closes. diff --git a/joulespec/SPEC.md b/joulespec/SPEC.md new file mode 100644 index 000000000..66e67572e --- /dev/null +++ b/joulespec/SPEC.md @@ -0,0 +1,480 @@ +# JouleSpec v0.1 — A Standard for Energy-Accounted Specification Conformance + +| | | +|---|---| +| **Standard** | joulespec | +| **Status** | v0.1.0 draft for review | +| **Profile** | `joulespec/1` | +| **Licence** | Specification text CC-BY-4.0 ([`LICENSE-SPEC`](LICENSE-SPEC)); reference code Apache-2.0 ([`LICENSE`](LICENSE)) | +| **Reference** | [`joulespec-rs/`](joulespec-rs/) | + +A vendor-neutral standard for **stating what software owes and measuring +whether anything established it**. JouleSpec specifies an obligation address +that survives renaming and breaks on rewording, a five-rung evidence ladder +from testimony to proof, a deterministic drift partition over revisions, and a +receipt that seals what a specification cost to evidence. + +JouleSpec is one of the [Transaction Science open standards](../README.md). +Transaction Science is the steward, not the proprietor. + +--- + +## 1. Scope and doctrine + +### 1.1 What JouleSpec decides + +JouleSpec decides four questions and no others: **what does this software +owe**, **what is known about each obligation and how well**, **which of that +knowledge survives a change**, and **what did establishing it cost**. + +It does not write specifications, does not run checks, does not formalize a +clause on anyone's behalf, and does not judge whether a clause states a good +requirement. Those belong to the author, the test runner, the prover, and the +reviewer respectively. JouleSpec is the part that holds their outputs together +and refuses to report them as interchangeable. + +### 1.2 The specification is prose; the obligation is an address + +A clause is a sentence a person wrote. That does not change under this +standard, and nothing here asks an author to write a requirement in a formal +notation. + +What changes is that a clause carries two identifiers, not one: + +- a **label** — `C1`, `auth-2` — which is editorial, exists so reports and + conversations have a short handle, and may be changed freely; +- an **obligation**, the content address of the clause's *kind and text*, with + the label deliberately excluded. + +Evidence binds to the obligation. The consequence is the mechanism this +standard turns on: + +> Renaming a clause moves no obligation and loses no evidence. Changing one +> word of a clause is a different obligation, and every piece of evidence bound +> to the old wording stops applying — with nobody having to notice. + +Binding evidence to a label inverts both halves: it survives the edit that +changed the requirement and breaks on the edit that renamed it. Prevailing +practice binds to labels, or to nothing at all, which is why specification +drift is discussed as a discipline problem. It is an addressing problem, and an +address is the whole fix. + +### 1.3 Conformance is a grade, not a claim + +The prevailing artifact of specification-driven practice is prose an agent +interprets and a human reads, and its verdict is a sentence in a review. A +sentence cannot be compared across changes, cannot be capped by its weakest +part, and cannot distinguish "an agent said it was done" from "no input +violates it". + +In `joulespec/1` every piece of evidence lands on a rung of an ordered ladder +(§2.5), and a specification's grade is the grade of its weakest owed obligation +(§3.2). An implementer's claim is a real rung — the bottom one — reported as +the testimony it is rather than as conformance. + +### 1.4 A figure's grade is part of the verdict + +Every energy figure carries how it was obtained: a counter read for this work, +a calibrated model, or a default guess. An aggregate is graded by its weakest +contributing figure, never by its best, and a receipt that summed one reading +and one guess reports a guess (§5.1). This is the same discipline the rest of +this family applies to every measured quantity it publishes. + +--- + +## 2. Objects + +### 2.1 The clause and its obligation + +A clause is `{label, kind, text}` where `kind` is one of: + +| Kind | Meaning | +|---|---| +| `requirement` | Behaviour the implementation must exhibit. | +| `constraint` | A bound it must stay inside — interface, dependency, style, energy. | +| `non-goal` | Behaviour deliberately out of scope. | + +Non-goals are first-class. Writing down what a system must *not* do is half the +value of a design conversation, and a non-goal is checkable in a way the field +usually discards: evidence that something implements it is a finding (§3.3). + +The **obligation** is the content address of the canonical document +`{kind, text}`. Text is compared exactly: an author who reflows a paragraph has +changed the obligation as far as this standard is concerned. That is the +conservative direction — evidence is dropped, never silently carried forward. + +Because `kind` is addressed, moving a clause from `requirement` to `non-goal` +is a change of obligation. Deciding not to build something is not an editorial +act. + +A conforming implementation MUST refuse a specification in which two clauses +state the same obligation. They would share an address, so evidence for one +would silently witness the other. The refusal MUST name both clauses: a report +naming only the second sends the author to the wrong place. + +### 2.2 The specification + +A specification is `{profile, alg, intent, clauses, parent?, author}`: +an intent in the author's words, ordered clauses with unique labels and +distinct obligations, an optional parent, and the author's key. Its address is +the content address of its canonical document; it is signed over +`spec_id ‖ parent`, so the signature binds a revision to its lineage. + +A specification that does not validate has no address. There is no such thing +as the address of a document that does not hold together. + +### 2.3 Revision + +A revision names its predecessor by address, so the history of what was +approved is tamper-evident, and a reviser signs with their own key. A revision +is not required to be an improvement and the standard takes no position on who +may make one; it records who did. + +### 2.4 The witness + +A witness is one signed record binding four things: + +| Field | Why it is bound | +|---|---| +| `spec` | Which specification this is about. | +| `obligation` | Which obligation — an address, never a label. | +| `artifact` | Which artifact state the evidence was observed against. | +| `evidence` | What was observed, with its full payload. | +| `energy` | What observing it cost, with provenance. | + +The signature covers `witness_id ‖ spec ‖ obligation ‖ artifact`, so none of +those can be swapped under a valid signature. + +The **artifact binding is what makes staleness computable.** Evidence gathered +against one state of the code says nothing about another, and a witness that +does not name the state it observed cannot be aged out. In practice this is a +tree digest, a build output address, or a commit — JouleSpec does not care +which, only that it is an address. + +### 2.5 The evidence ladder + +Every rung answers one question: **how hard would it be for this obligation to +be violated without this evidence noticing?** + +| Grade | Name | What it establishes | +|---|---|---| +| `E0` | `cited` | The implementer claims it is done. Nothing noticed anything. | +| `E1` | `reviewed` | A second party read the artifact against the clause, once. | +| `E2` | `exercised` | A named check ran against the artifact and passed. | +| `E3` | `falsifiable` | The same check is on record *failing* against a named counter-artifact. | +| `E4` | `proved` | No input violates it, under a formalization someone independent checked. | + +Three rungs need their reasons stated. + +**`E0` is where prevailing practice lands.** A clause an agent claims to have +implemented, with a human skim at the end, is testimony from the party being +evaluated. Much of it is true. It is still testimony, and reporting it as +conformance is the error this standard exists to stop. + +**`E2` flatters.** A green check establishes that something ran, not that the +obligation holds. A test with no assertion passes; so does one whose fixture +never reaches the condition the clause cares about. Nothing at `E2` +distinguishes those from a real check. + +**`E3` is the first rung where green means something.** A check recorded as +failing against a named counter-artifact — a mutant, a reverted fix, a +deliberately broken build — has demonstrated that it *can* fail, so its passing +carries information. This is the cheapest strong rung, and the one most +projects can reach with tests they already have. + +**The ladder is ordered by re-checkability, not by diligence.** A human review +is bound to the state a person looked at and cannot notice a violation +introduced afterwards; a named check can run again against any later state. +That ordering says nothing about how carefully anyone read the code — it says +that one kind of evidence survives the next commit and the other does not. + +**Independence is computed, never declared.** A review signed by the key that +made the citation is the citation again, and grades `E0`. A conforming +implementation MUST compute this from key disjointness and MUST NOT read it +from a field the producer set. + +### 2.6 Formalization, and what an `E4` claims + +A proof discharges a formal statement. The statement is not the clause; the +binding between them is a human act, and it is the weakest link in any claim of +proved conformance. A conforming implementation MUST NOT formalize a clause on +its own initiative: a formalization is supplied, and it carries a grounding. + +| Grounding | Reached grade | +|---|---| +| `cross-checked` by a key other than the witness author's | `E4` | +| `declared` | `E2` | +| `cross-checked` by the witness author's own key | `E2` | + +A proof under a formalization nobody independent read is machine-checked +evidence about *something*; that the something is this clause is the prover's +word. It is therefore real mechanical evidence — `E2` — and not proof of the +clause. Demoting rather than refusing is deliberate: the proof is not fake, and +discarding it would lose evidence that exists. + +This is the same boundary the family's verification standard draws from the +other side when it records that a proof is not a specification. + +### 2.7 Work + +Work is a unit of effort performed against a specification — a coding +operation, a build, a benchmark run — carrying its address, the obligations it +claims to advance, and its energy. + +Work and witnesses are accounted separately. Work is what *cost* energy; +a witness is what *observed* something. Conflating them hides the question this +standard exists to answer, which is whether the evidence was worth what it +cost. + +--- + +## 3. Evaluation + +### 3.1 What counts + +Evaluation takes a specification, one artifact state, the witnesses on record +and the work performed, and produces a verdict per clause plus a verdict for +the specification. + +A witness contributes to a grade only when **all** of the following hold. Each +other outcome is reported, and none is discarded: + +| Condition | Outcome when it fails | +|---|---| +| Its signature and structure verify | `unverifiable` flag, by input position | +| It names this specification | `foreign-spec` flag | +| It names an obligation this specification states | `unbound` flag | +| It was observed at this artifact state | recorded as **stale** on that clause | + +A witness naming an obligation the specification does not state is the normal +aftermath of a rewording, and it MUST NOT be dropped quietly — it is the +clearest available signal that evidence and specification have parted company. + +A clause's grade is the strongest grade among its contributing witnesses. Where +several witnesses tie, the one with the lowest address is named, so two +evaluations over the same inputs produce the same verdict. + +### 3.2 The cap is a minimum + +A specification's grade is the **minimum** over its requirements and +constraints, and is absent when any of them has no surviving evidence. + +Averaging would let nine proved obligations conceal one nobody looked at, which +is precisely the failure a grade exists to surface. This family applies the +same rule wherever a composite claim meets a weak component. + +### 3.3 Non-goals + +Non-goals are excluded from the cap: there is nothing to evidence about +behaviour that should not exist, so an unwitnessed non-goal is correct and +carries no grade. + +Evidence that a non-goal *was* implemented is a `non-goal-witnessed` flag. It +does not lower the grade of the owed obligations — they really are evidenced — +and it does block conformance (§3.4). The specification said not to build this +and something says it exists; no amount of evidence elsewhere settles that. + +### 3.4 Flags block conformance + +A flag is something wrong with the evidence set, as distinct from a weakly +evidenced obligation. An implementation reports conformance at a requested bar +only when every owed obligation reaches it **and** no flag is raised. + +### 3.5 Determinism + +Evaluation MUST be deterministic: the same specification, artifact, witnesses +and work in any order MUST produce an identical verdict. Collections in the +verdict are sorted, and ties are broken by address. + +--- + +## 4. Drift + +### 4.1 The partition + +Diffing two revisions by obligation address yields an exact partition: + +| Class | Meaning | Evidence | +|---|---|---| +| `kept` | Same obligation, same label | Survives | +| `relabelled` | Same obligation, new label | Survives | +| `reworded` | Same label, different obligation | Old evidence invalidated; new obligation unevidenced | +| `added` | Obligation only in the later revision | Unevidenced | +| `removed` | Obligation only in the earlier one | Orphaned | + +From that, two sets follow directly: the obligations whose evidence no longer +applies, and the obligations that start the revision unevidenced. A revision +review wants those sets, not the fact that the specification changed. + +### 4.2 Relabelling is free; rewording is not + +A revision that changes only labels and ordering is **editorial** and costs no +evidence. Renumbering a whole specification lands there. This matters because a +standard that penalised renaming would teach authors not to rename, and +specifications whose labels cannot be tidied acquire labels nobody can read. + +A revision that changes a clause's text costs exactly that clause's evidence, +and nothing else's. + +A freed label reused by a new clause is an **addition**, not a rewording: the +old obligation still stands elsewhere in the specification, so no evidence is +invalidated. An implementation MUST distinguish these, because conflating them +would report a loss of evidence that did not happen. + +### 4.3 Artifact drift + +Specification drift is one of two directions. The other is the artifact moving +under evidence that stayed still, and it is handled at evaluation: a witness +bound to an earlier artifact state is reported stale rather than counted +(§3.1). Neither direction requires anyone to remember to check. + +--- + +## 5. Cost + +### 5.1 Work energy and evidence energy + +A verdict accounts three figures in microjoules: energy spent on work citing at +least one of this specification's obligations, energy spent on work citing +none, and energy spent producing the surviving evidence. The provenance of the +total is the weakest provenance among every figure that fed it. + +### 5.2 The specified share + +The share of work energy that implements something the specification asked for +is reported in parts-per-million. + +This is the measured form of an observation the field makes qualitatively — +that agents produce features nobody requested. A run in which a quarter of the +energy went into work citing no clause has a number saying so, and that number +is in the receipt. + +Ratios are integers in parts-per-million because the canonical core admits no +floats (§6.2), and a ppm figure survives the round trip exactly where a rounded +percentage would not. + +### 5.3 Microjoules per falsifiable obligation + +The headline figure is total attributed energy divided by the number of owed +obligations evidenced at `E3` or better. + +The denominator is deliberately the rung whose evidence can fail. A figure per +*attempted* obligation rewards ceremony; a figure per obligation that something +would actually have caught rewards evidence. The family's other per-unit +figures make the same choice, counting accepted output and verified proofs +rather than attempts. + +Where nothing reaches the bar the figure is **absent**, not zero. A cost per +unit of nothing is not a small number. + +This figure is what makes the field's central argument decidable. Whether +specification-driven practice earns its overhead is currently settled by +anecdote in both directions. Two processes applied to the same specification, +reaching the same evidence, with one spending an order of magnitude more energy +to get there, is not a matter of taste. + +--- + +## 6. The receipt + +### 6.1 Sealed from the inputs, never from a verdict + +A conforming implementation MUST derive a receipt from the specification, the +artifact, the witnesses and the work — and MUST NOT seal a verdict handed to it. + +A verdict is an ordinary data structure. An interface that signed one would let +a producer construct the grade it wanted and obtain a receipt saying so. +Because the receipt is derived from the evidence, any claim in it is +reproducible by anyone holding the same inputs, and a producer who wants a +better grade has to go and get better evidence. + +### 6.2 Absent, not zero + +Receipts seal as **JCR-1** envelopes — canonical det-CBOR (RFC 8949 §4.2.1) +inside a COSE_Sign1 envelope (RFC 9052, EdDSA/Ed25519) — the signed-receipt +format shared across this family, verifiable by tooling that has never heard of +JouleSpec. + +That core admits no nulls, and several figures here are legitimately absent: a +specification with an unevidenced requirement has no grade; a run with no work +has no specified share. Such fields MUST be **omitted**, never sealed as zero. A +reader who finds no grade knows none was established; a reader who found `E0` +would believe one was. + +### 6.3 The per-clause audit + +A receipt carries one entry per clause — label, kind, obligation, grade where +established, the witness that carried it, evidence energy, and the count of +stale witnesses — alongside the specification-level summary. + +Which obligation is the weak one is the actionable fact. It must be answerable +from the receipt alone, rather than by re-running an evaluation whose inputs +nobody kept. + +--- + +## 7. Conformance + +An implementation conforms to `joulespec/1` when: + +1. **C1 — Addressing.** Obligation addresses are computed over `{kind, text}` + with the label excluded; relabelling preserves an address and rewording + changes it. +2. **C2 — Validation.** Specifications with a duplicate label, a duplicate + obligation, an empty intent, an empty clause, or no clauses are refused, and + a duplicate obligation names both clauses. +3. **C3 — Signatures.** Specifications and witnesses verify over the preimages + in §2.2 and §2.4, and altering any bound field invalidates the signature. +4. **C4 — The ladder.** Grades are assigned per §2.5 and §2.6, with + independence computed from key disjointness. +5. **C5 — Evaluation.** The weakest-owed-obligation cap, the non-goal rule, the + four flag conditions, staleness, and determinism under reordering all hold + per §3. +6. **C6 — Drift.** The §4.1 partition is computed by address, editorial + revisions invalidate nothing, and a reused label is distinguished from a + rewording. +7. **C7 — Cost.** Aggregates carry the weakest contributing provenance; ratios + are integer parts-per-million; absent figures are absent. +8. **C8 — Receipts.** Receipts are derived from inputs, seal as JCR-1, omit + absent fields, and carry the per-clause audit. + +The reference implementation is conformant by construction. A third-party +implementation certifies itself by reproducing these behaviours; the reference +test suite in [`joulespec-rs/`](joulespec-rs/) is the executable form of the +list above. + +--- + +## 8. The honest limit + +**JouleSpec cannot tell whether a clause says what its author meant.** An +obligation is addressed, signed and tracked, and it may still be the wrong +requirement, badly worded, or quietly contradicted by another clause. This +standard measures evidence against stated obligations; it does not validate +obligations against intent, and no address makes a bad requirement good. + +**A grade is a property of evidence, not of software.** `E4` across every clause +means no input violates the formalizations that were checked. Software can +satisfy every clause of its specification and be unfit for its purpose, because +the specification was incomplete — and an incomplete specification is exactly +the case where every clause is covered. + +**Falsifiability is demonstrated, not guaranteed.** An `E3` witness records that +a check failed against one named counter-artifact. That establishes the check +can fail; it does not establish that the check would catch every way the +obligation could be broken. + +**A specified share is not a quality measure.** Work citing no clause may be +necessary — a refactor, a build fix, a dependency bump. The figure says how +much energy went into work nobody asked for in this specification, which is a +question worth having an answer to, and not a verdict. + +**Energy figures are as good as their meters.** A figure graded as a default +guess is a guess no matter how precisely it is reported, which is why the grade +travels with the number and why an aggregate never claims better than its +weakest part. + +--- + +*JouleSpec is stewarded by [Transaction Science](https://transaction.science). +The protocol is owned by no one; the right to fork is public.* diff --git a/joulespec/joulespec-rs/.gitignore b/joulespec/joulespec-rs/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/joulespec/joulespec-rs/.gitignore @@ -0,0 +1 @@ +/target diff --git a/joulespec/joulespec-rs/Cargo.lock b/joulespec/joulespec-rs/Cargo.lock new file mode 100644 index 000000000..f53d0a80b --- /dev/null +++ b/joulespec/joulespec-rs/Cargo.lock @@ -0,0 +1,442 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "joulespec-core" +version = "0.1.0" +dependencies = [ + "blake3", + "ed25519-dalek", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/joulespec/joulespec-rs/Cargo.toml b/joulespec/joulespec-rs/Cargo.toml new file mode 100644 index 000000000..bc2162fa8 --- /dev/null +++ b/joulespec/joulespec-rs/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +resolver = "3" +members = ["crates/joulespec-core"] + +[workspace.package] +edition = "2024" +license = "Apache-2.0" +repository = "https://git.transaction.science/Transaction-Science/open-standards" + +[workspace.dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +blake3 = "1" +ed25519-dalek = { version = "2", features = ["rand_core"] } +thiserror = "2" diff --git a/joulespec/joulespec-rs/crates/joulespec-core/Cargo.toml b/joulespec/joulespec-rs/crates/joulespec-core/Cargo.toml new file mode 100644 index 000000000..95e565789 --- /dev/null +++ b/joulespec/joulespec-rs/crates/joulespec-core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "joulespec-core" +version = "0.1.0" +description = "JouleSpec — the reference conformance kernel for energy-accounted specifications (joulespec/1): obligation-addressed clauses, a graded evidence ladder from testimony to proof, deterministic drift, and the joules a specification cost to evidence." +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +blake3 = { workspace = true } +ed25519-dalek = { workspace = true } +thiserror = { workspace = true } + +[lints.rust] +missing_docs = "warn" diff --git a/joulespec/joulespec-rs/crates/joulespec-core/src/conform.rs b/joulespec/joulespec-rs/crates/joulespec-core/src/conform.rs new file mode 100644 index 000000000..7069c2df7 --- /dev/null +++ b/joulespec/joulespec-rs/crates/joulespec-core/src/conform.rs @@ -0,0 +1,666 @@ +//! Evaluation: the deterministic verdict over a specification, its evidence, +//! and the work done against it. +//! +//! ## What evaluation answers +//! +//! Given a specification, one artifact state, the witnesses on record, and the +//! work that was performed, evaluation answers four questions with no +//! judgement calls left in it: +//! +//! - **Per obligation** — what is the strongest evidence that survives at this +//! artifact state, and what did reaching it cost ([`ClauseVerdict`]). +//! - **For the specification** — the grade of its *weakest* owed obligation, +//! because a specification is no better evidenced than the requirement +//! nobody checked ([`Conformance::grade`]). +//! - **What is wrong with the evidence itself** — a non-goal somebody +//! implemented, a witness for an obligation this specification does not +//! state, a signature that does not verify ([`Flag`]). None of these are +//! dropped: a mis-bound witness is a finding, not a no-op. +//! - **What the energy bought** — how much went into work that cites an +//! obligation, and how much into work that cites none +//! ([`Conformance::specified_ppm`]). +//! +//! ## The cap, and why it is a minimum +//! +//! A specification's grade is the minimum over its requirements and +//! constraints. Averaging would let nine proved obligations hide one nobody +//! looked at, which is the failure the grade exists to surface. The family +//! applies the same rule wherever a composite claim meets a weak component — +//! a least-bound axis, a cross-axis gate, an aggregate graded by its worst +//! figure. +//! +//! Non-goals are excluded from the cap and handled as flags: there is nothing +//! to evidence about behaviour that should not exist, and evidence that it +//! *does* exist is a different kind of problem. +//! +//! ## Why the ratio is parts-per-million +//! +//! JCR-1's canonical core admits no floats, so every ratio this standard seals +//! is an integer. A fraction is reported in parts-per-million rather than as a +//! rounded percentage so it survives the round trip exactly. +//! +//! Normative: SPEC §3.1 (what counts), SPEC §3.2 (the cap), SPEC §3.3 +//! (non-goals), SPEC §3.4 (flags), SPEC §3.5 (determinism), SPEC §5.1 and +//! SPEC §5.2 (cost). + +use serde::{Deserialize, Serialize}; + +use crate::evidence::{Grade, SignedWitness}; +use crate::spec::{ClauseKind, Obligation, SignedSpec}; +use crate::{Address, Energy, Error, Provenance, worst_provenance}; + +/// One unit of work performed against a specification: a coding operation, a +/// build, a benchmark run. +/// +/// Work is what *cost* energy; a witness is what *observed* something. The two +/// are accounted separately because conflating them hides the question this +/// standard exists to answer — whether the evidence was worth what it cost. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Work { + /// The work unit's address — an operation id, a build digest. + pub id: Address, + /// The obligations this work claims to advance. Empty means the work + /// implements nothing the specification asked for. + pub cites: Vec, + /// What the work cost. + pub energy: Energy, +} + +impl Work { + /// A work unit. + pub fn new(id: Address, cites: Vec, energy: Energy) -> Self { + Self { id, cites, energy } + } +} + +/// Something wrong with the evidence set, as distinct from a weakly evidenced +/// obligation. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "flag")] +pub enum Flag { + /// A non-goal has evidence that something implements it. The + /// specification says not to build this; a witness says it exists. + NonGoalWitnessed { + /// The non-goal's label, for the report. + label: String, + /// Its obligation address. + obligation: Obligation, + }, + /// A witness names an obligation this specification does not state — + /// usually a clause that was reworded, sometimes one that was never + /// there. Never silently discarded. + Unbound { + /// The witness. + witness: Address, + /// The obligation it claims. + obligation: Obligation, + }, + /// A witness is about a different specification. + ForeignSpec { + /// The witness. + witness: Address, + /// The specification it actually names. + spec_id: Address, + }, + /// A witness failed structural validation or signature verification. Its + /// position in the input is reported because a witness that does not + /// verify has no trustworthy address. + Unverifiable { + /// Index in the witness slice as supplied. + index: u64, + }, +} + +/// The verdict for one clause. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClauseVerdict { + /// The clause's editorial label. + pub label: String, + /// Its obligation address. + pub obligation: Obligation, + /// Requirement, constraint, or non-goal. + pub kind: ClauseKind, + /// The strongest grade among witnesses that survive at this artifact + /// state. `None` when nothing witnesses it here. + pub grade: Option, + /// The witness carrying that grade — lowest address among ties, so the + /// verdict is reproducible. + pub best: Option
, + /// Witnesses for this obligation bound to some *other* artifact state. + /// Evidence that was true once and says nothing about now. + pub stale: Vec
, + /// What the surviving evidence for this obligation cost to produce. + pub evidence_uj: u64, + /// The weakest provenance among those figures. + pub provenance: Option, +} + +impl ClauseVerdict { + /// Whether this clause is owed and unmet at the requested bar. + #[must_use] + pub fn is_open(&self, min: Grade) -> bool { + self.kind.is_owed() && self.grade.is_none_or(|g| g < min) + } +} + +/// The deterministic evaluation of a specification at one artifact state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Conformance { + /// The specification evaluated. + pub spec_id: Address, + /// The artifact state it was evaluated against. + pub artifact: Address, + /// One verdict per clause, in clause order. + pub clauses: Vec, + /// The grade of the weakest owed obligation — the specification's grade. + /// `None` when any requirement or constraint has no surviving evidence. + pub grade: Option, + /// Problems with the evidence set itself, sorted. + pub flags: Vec, + /// Energy spent on work that cites at least one of this specification's + /// obligations. + pub specified_uj: u64, + /// Energy spent on work that cites none of them. + pub unspecified_uj: u64, + /// Energy spent producing the surviving evidence. + pub evidence_uj: u64, + /// The weakest provenance across every figure contributing above. + pub provenance: Option, +} + +impl Conformance { + /// Whether every owed obligation reaches `min` and the evidence set is + /// clean. + /// + /// Flags block conformance regardless of grade: a specification whose + /// non-goal somebody implemented has a problem no amount of evidence + /// elsewhere settles. + #[must_use] + pub fn is_conformant(&self, min: Grade) -> bool { + self.flags.is_empty() && self.grade.is_some_and(|g| g >= min) + } + + /// The owed obligations that do not reach `min`, in clause order. + #[must_use] + pub fn open(&self, min: Grade) -> Vec<&ClauseVerdict> { + self.clauses.iter().filter(|c| c.is_open(min)).collect() + } + + /// The share of work energy that implements something this specification + /// asked for, in parts-per-million. `None` when no work was supplied. + /// + /// This is the number that answers "how much of what the agent did was + /// asked for" — the measured form of the observation that agents produce + /// features nobody requested. + #[must_use] + pub fn specified_ppm(&self) -> Option { + let total = self.specified_uj.checked_add(self.unspecified_uj)?; + if total == 0 { + return None; + } + // u128 so a specified figure near u64::MAX cannot overflow the scale. + Some(((u128::from(self.specified_uj) * 1_000_000) / u128::from(total)) as u64) + } + + /// Total energy attributed to this specification: the work plus the + /// evidence. + #[must_use] + pub fn total_uj(&self) -> u64 { + self.specified_uj + .saturating_add(self.unspecified_uj) + .saturating_add(self.evidence_uj) + } + + /// Microjoules per owed obligation evidenced at `min` or better. + /// + /// The headline figure takes `min = `[`Grade::Falsifiable`] — the first + /// rung whose evidence can fail — so the denominator counts obligations + /// something would actually have caught, the way this family's other + /// per-unit figures count accepted tokens and verified proofs rather than + /// attempts. `None` when nothing reaches the bar: a cost per unit of + /// nothing is not a small number, it is not a number. + #[must_use] + pub fn uj_per_evidenced_clause(&self, min: Grade) -> Option { + let n = self + .clauses + .iter() + .filter(|c| c.kind.is_owed() && c.grade.is_some_and(|g| g >= min)) + .count(); + if n == 0 { + return None; + } + Some(self.total_uj() / n as u64) + } +} + +/// Evaluate a specification at one artifact state. +/// +/// Every witness is verified here; none is taken on trust and none is +/// discarded quietly. A witness counts toward a grade only when it verifies, +/// names *this* specification, names an obligation this specification states, +/// and was observed at *this* artifact state. Each of the other outcomes is +/// reported: a different artifact makes it stale, anything else makes it a +/// flag. +pub fn evaluate( + spec: &SignedSpec, + artifact: &Address, + witnesses: &[SignedWitness], + work: &[Work], +) -> Result { + let spec_id = spec.verify()?; + let obligations = spec.spec.obligations()?; + + let mut verdicts: Vec = obligations + .iter() + .map(|(label, kind, obligation)| ClauseVerdict { + label: label.clone(), + obligation: obligation.clone(), + kind: *kind, + grade: None, + best: None, + stale: Vec::new(), + evidence_uj: 0, + provenance: None, + }) + .collect(); + let index: std::collections::BTreeMap<&Obligation, usize> = + obligations.iter().enumerate().map(|(i, (_, _, o))| (o, i)).collect(); + + let mut flags = Vec::new(); + let mut evidence_provenances = Vec::new(); + + for (i, signed) in witnesses.iter().enumerate() { + let Ok(witness_id) = signed.verify() else { + flags.push(Flag::Unverifiable { index: i as u64 }); + continue; + }; + let w = &signed.witness; + if w.spec_id != spec_id { + flags.push(Flag::ForeignSpec { witness: witness_id, spec_id: w.spec_id.clone() }); + continue; + } + let Some(&slot) = index.get(&w.obligation) else { + flags.push(Flag::Unbound { + witness: witness_id, + obligation: w.obligation.clone(), + }); + continue; + }; + if &w.artifact != artifact { + verdicts[slot].stale.push(witness_id); + continue; + } + + let verdict = &mut verdicts[slot]; + if verdict.kind == ClauseKind::NonGoal { + // Evidence that a non-goal was implemented is not coverage, and + // the energy that produced it is not evidence energy either. + flags.push(Flag::NonGoalWitnessed { + label: verdict.label.clone(), + obligation: verdict.obligation.clone(), + }); + continue; + } + + let grade = w.grade(); + // Strongest grade wins; the lowest address breaks a tie so two runs + // over the same inputs name the same witness. + let replace = match (verdict.grade, &verdict.best) { + (None, _) => true, + (Some(current), Some(best)) => { + grade > current || (grade == current && &witness_id < best) + } + (Some(current), None) => grade > current, + }; + if replace { + verdict.grade = Some(grade); + verdict.best = Some(witness_id); + } + verdict.evidence_uj = verdict.evidence_uj.saturating_add(w.energy.uj); + verdict.provenance = Some(match verdict.provenance { + Some(p) => p.min(w.energy.provenance), + None => w.energy.provenance, + }); + evidence_provenances.push(w.energy.provenance); + } + + for verdict in &mut verdicts { + verdict.stale.sort(); + } + flags.sort(); + + let grade = verdicts + .iter() + .filter(|c| c.kind.is_owed()) + .map(|c| c.grade) + .min() + .unwrap_or(None); + + let mut specified_uj = 0u64; + let mut unspecified_uj = 0u64; + let mut work_provenances = Vec::new(); + for unit in work { + let specified = unit.cites.iter().any(|o| index.contains_key(o)); + if specified { + specified_uj = specified_uj.saturating_add(unit.energy.uj); + } else { + unspecified_uj = unspecified_uj.saturating_add(unit.energy.uj); + } + work_provenances.push(unit.energy.provenance); + } + + let evidence_uj = verdicts.iter().map(|c| c.evidence_uj).fold(0u64, u64::saturating_add); + let provenance = + worst_provenance(evidence_provenances.into_iter().chain(work_provenances)); + + Ok(Conformance { + spec_id, + artifact: artifact.clone(), + clauses: verdicts, + grade, + flags, + specified_uj, + unspecified_uj, + evidence_uj, + provenance, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::evidence::{Evidence, Witness}; + use crate::spec::fixtures::{key, spec}; + use ed25519_dalek::SigningKey; + + fn artifact() -> Address { + Address::of_bytes(b"artifact-v1") + } + + fn witness_for( + spec: &SignedSpec, + label: &str, + artifact: &Address, + evidence: Evidence, + uj: u64, + author: &SigningKey, + ) -> SignedWitness { + let obligation = spec + .spec + .clauses + .iter() + .find(|c| c.label == label) + .expect("label exists") + .obligation() + .unwrap(); + Witness::new( + spec.spec.spec_id().unwrap(), + obligation, + artifact.clone(), + evidence, + Energy::new(uj, Provenance::HwShunt), + author.verifying_key().to_bytes(), + ) + .sign(author) + .unwrap() + } + + fn falsifiable(check: &str) -> Evidence { + Evidence::Falsifiable { + check: check.into(), + counter: Address::of_bytes(b"mutant"), + } + } + + #[test] + fn a_specification_is_graded_by_its_weakest_owed_obligation() { + let k = key(1); + let s = spec(&k); + let a = artifact(); + let ws = vec![ + witness_for(&s, "C1", &a, falsifiable("rejects_anon"), 900, &k), + witness_for(&s, "C2", &a, Evidence::Cited, 10, &k), + ]; + let c = evaluate(&s, &a, &ws, &[]).unwrap(); + + assert_eq!(c.grade, Some(Grade::Cited), "one cited clause caps the whole specification"); + assert_eq!(c.clauses[0].grade, Some(Grade::Falsifiable)); + assert_eq!(c.clauses[1].grade, Some(Grade::Cited)); + assert!(!c.is_conformant(Grade::Exercised)); + assert!(c.is_conformant(Grade::Cited)); + assert_eq!(c.open(Grade::Exercised).len(), 1); + assert_eq!(c.open(Grade::Exercised)[0].label, "C2"); + } + + #[test] + fn an_unwitnessed_requirement_leaves_the_specification_ungraded() { + let k = key(1); + let s = spec(&k); + let a = artifact(); + let ws = vec![witness_for(&s, "C1", &a, falsifiable("rejects_anon"), 900, &k)]; + let c = evaluate(&s, &a, &ws, &[]).unwrap(); + + assert_eq!(c.grade, None, "C2 has no evidence at all"); + assert!(!c.is_conformant(Grade::Cited)); + assert_eq!(c.open(Grade::Cited).len(), 1); + } + + #[test] + fn a_non_goal_never_counts_as_coverage_and_never_blocks_the_grade() { + let k = key(1); + let s = spec(&k); + let a = artifact(); + // C3 is the non-goal. Nothing witnesses it, and that is correct. + let ws = vec![ + witness_for(&s, "C1", &a, falsifiable("rejects_anon"), 900, &k), + witness_for(&s, "C2", &a, falsifiable("decided_first"), 800, &k), + ]; + let c = evaluate(&s, &a, &ws, &[]).unwrap(); + assert_eq!(c.grade, Some(Grade::Falsifiable)); + assert!(c.flags.is_empty()); + assert!(c.is_conformant(Grade::Falsifiable)); + } + + #[test] + fn evidence_that_a_non_goal_was_built_is_a_flag_that_blocks_conformance() { + let k = key(1); + let s = spec(&k); + let a = artifact(); + let ws = vec![ + witness_for(&s, "C1", &a, falsifiable("rejects_anon"), 900, &k), + witness_for(&s, "C2", &a, falsifiable("decided_first"), 800, &k), + witness_for(&s, "C3", &a, Evidence::Exercised { check: "retry_backoff".into() }, 50, &k), + ]; + let c = evaluate(&s, &a, &ws, &[]).unwrap(); + + assert_eq!(c.grade, Some(Grade::Falsifiable), "the owed clauses are still well evidenced"); + assert!( + matches!(c.flags.as_slice(), [Flag::NonGoalWitnessed { label, .. }] if label == "C3") + ); + assert!( + !c.is_conformant(Grade::Falsifiable), + "a flag blocks conformance however good the rest of the evidence is" + ); + } + + #[test] + fn evidence_from_another_artifact_state_is_stale_not_counted() { + let k = key(1); + let s = spec(&k); + let old = Address::of_bytes(b"artifact-v0"); + let new = artifact(); + let ws = vec![ + witness_for(&s, "C1", &old, falsifiable("rejects_anon"), 900, &k), + witness_for(&s, "C2", &new, falsifiable("decided_first"), 800, &k), + ]; + let c = evaluate(&s, &new, &ws, &[]).unwrap(); + + assert_eq!(c.clauses[0].grade, None); + assert_eq!(c.clauses[0].stale.len(), 1, "reported, not dropped"); + assert_eq!(c.clauses[0].evidence_uj, 0, "stale evidence costs nothing here"); + assert_eq!(c.grade, None); + } + + #[test] + fn a_mis_bound_witness_is_a_finding_not_a_no_op() { + let k = key(1); + let s = spec(&k); + let a = artifact(); + let stray = Witness::new( + s.spec.spec_id().unwrap(), + crate::spec::Clause::requirement("C9", "A clause this specification never stated.") + .obligation() + .unwrap(), + a.clone(), + Evidence::Cited, + Energy::estimated(5), + k.verifying_key().to_bytes(), + ) + .sign(&k) + .unwrap(); + let c = evaluate(&s, &a, &[stray], &[]).unwrap(); + assert!(matches!(c.flags.as_slice(), [Flag::Unbound { .. }])); + } + + #[test] + fn a_witness_for_another_specification_is_flagged() { + let k = key(1); + let s = spec(&k); + let a = artifact(); + let mut foreign = witness_for(&s, "C1", &a, Evidence::Cited, 5, &k); + foreign.witness.spec_id = Address::of_bytes(b"some-other-spec"); + let foreign = foreign.witness.clone().sign(&k).unwrap(); + let c = evaluate(&s, &a, &[foreign], &[]).unwrap(); + assert!(matches!(c.flags.as_slice(), [Flag::ForeignSpec { .. }])); + } + + #[test] + fn an_unverifiable_witness_is_reported_by_position() { + let k = key(1); + let s = spec(&k); + let a = artifact(); + let mut tampered = witness_for(&s, "C1", &a, Evidence::Cited, 5, &k); + tampered.sig = vec![0u8; 64]; + let c = evaluate(&s, &a, &[tampered], &[]).unwrap(); + assert_eq!(c.flags, vec![Flag::Unverifiable { index: 0 }]); + } + + #[test] + fn the_strongest_surviving_witness_sets_the_grade() { + let k = key(1); + let s = spec(&k); + let a = artifact(); + let ws = vec![ + witness_for(&s, "C1", &a, Evidence::Cited, 5, &k), + witness_for(&s, "C1", &a, falsifiable("rejects_anon"), 900, &k), + witness_for(&s, "C2", &a, falsifiable("decided_first"), 800, &k), + ]; + let c = evaluate(&s, &a, &ws, &[]).unwrap(); + assert_eq!(c.clauses[0].grade, Some(Grade::Falsifiable)); + assert_eq!(c.clauses[0].evidence_uj, 905, "every surviving witness cost something"); + } + + #[test] + fn work_citing_nothing_is_counted_against_the_specified_share() { + let k = key(1); + let s = spec(&k); + let a = artifact(); + let c1 = s.spec.clauses[0].obligation().unwrap(); + let work = vec![ + Work::new(Address::of_bytes(b"op1"), vec![c1], Energy::new(750_000, Provenance::HwShunt)), + Work::new(Address::of_bytes(b"op2"), vec![], Energy::new(250_000, Provenance::HwShunt)), + ]; + let c = evaluate(&s, &a, &[], &work).unwrap(); + + assert_eq!(c.specified_uj, 750_000); + assert_eq!(c.unspecified_uj, 250_000); + assert_eq!(c.specified_ppm(), Some(750_000), "three quarters of the energy was asked for"); + } + + #[test] + fn work_citing_an_obligation_this_specification_lacks_is_unspecified() { + let k = key(1); + let s = spec(&k); + let a = artifact(); + let elsewhere = crate::spec::Clause::requirement("X", "Something else.") + .obligation() + .unwrap(); + let work = vec![Work::new( + Address::of_bytes(b"op1"), + vec![elsewhere], + Energy::estimated(1_000), + )]; + let c = evaluate(&s, &a, &[], &work).unwrap(); + assert_eq!(c.specified_uj, 0); + assert_eq!(c.unspecified_uj, 1_000); + assert_eq!(c.specified_ppm(), Some(0)); + } + + #[test] + fn a_ratio_with_no_work_is_absent_rather_than_zero() { + let k = key(1); + let s = spec(&k); + let c = evaluate(&s, &artifact(), &[], &[]).unwrap(); + assert_eq!(c.specified_ppm(), None, "no work is not zero specified work"); + assert_eq!(c.uj_per_evidenced_clause(Grade::Falsifiable), None); + } + + #[test] + fn the_wedge_figure_counts_only_obligations_whose_evidence_can_fail() { + let k = key(1); + let s = spec(&k); + let a = artifact(); + let ws = vec![ + witness_for(&s, "C1", &a, falsifiable("rejects_anon"), 1_000, &k), + witness_for(&s, "C2", &a, Evidence::Exercised { check: "smoke".into() }, 1_000, &k), + ]; + let work = + vec![Work::new(Address::of_bytes(b"op"), vec![s.spec.clauses[0].obligation().unwrap()], + Energy::new(98_000, Provenance::HwShunt))]; + let c = evaluate(&s, &a, &ws, &work).unwrap(); + + assert_eq!(c.total_uj(), 100_000); + assert_eq!( + c.uj_per_evidenced_clause(Grade::Falsifiable), + Some(100_000), + "one obligation reaches the bar, so the whole cost is attributed to it" + ); + assert_eq!( + c.uj_per_evidenced_clause(Grade::Exercised), + Some(50_000), + "at a lower bar two obligations qualify" + ); + } + + #[test] + fn an_aggregate_is_graded_by_the_weakest_figure_that_fed_it() { + let k = key(1); + let s = spec(&k); + let a = artifact(); + let measured = witness_for(&s, "C1", &a, falsifiable("rejects_anon"), 900, &k); + let mut guessed = witness_for(&s, "C2", &a, falsifiable("decided_first"), 800, &k); + guessed.witness.energy = Energy::estimated(800); + let guessed = guessed.witness.clone().sign(&k).unwrap(); + + let c = evaluate(&s, &a, &[measured, guessed], &[]).unwrap(); + assert_eq!(c.provenance, Some(Provenance::Estimator)); + assert_eq!(c.clauses[0].provenance, Some(Provenance::HwShunt)); + assert_eq!(c.clauses[1].provenance, Some(Provenance::Estimator)); + } + + #[test] + fn evaluation_is_deterministic_under_witness_reordering() { + let k = key(1); + let s = spec(&k); + let a = artifact(); + let mut ws = vec![ + witness_for(&s, "C1", &a, falsifiable("rejects_anon"), 900, &k), + witness_for(&s, "C1", &a, falsifiable("rejects_anon_too"), 900, &k), + witness_for(&s, "C2", &a, Evidence::Cited, 1, &k), + ]; + let first = evaluate(&s, &a, &ws, &[]).unwrap(); + ws.reverse(); + let second = evaluate(&s, &a, &ws, &[]).unwrap(); + assert_eq!(first, second, "ties break on address, not on input order"); + } +} diff --git a/joulespec/joulespec-rs/crates/joulespec-core/src/drift.rs b/joulespec/joulespec-rs/crates/joulespec-core/src/drift.rs new file mode 100644 index 000000000..7f33a49d2 --- /dev/null +++ b/joulespec/joulespec-rs/crates/joulespec-core/src/drift.rs @@ -0,0 +1,333 @@ +//! Drift: which evidence survives a revision, computed rather than warned +//! about. +//! +//! Specification drift is the first gap the field's own literature names, and +//! it is usually described as a discipline problem — someone forgot to update +//! the spec, someone edited the spec and forgot the code. It is really an +//! addressing problem. When evidence binds to a clause *label*, an editor can +//! change what a requirement says and every citation still points at it; the +//! evidence now attests to a requirement that no longer exists, and nothing in +//! the system can tell. +//! +//! Because evidence here binds to the obligation address ([`crate::spec`]), +//! revising a specification yields an exact partition: +//! +//! - **kept** — the obligation is in both revisions. Its evidence still +//! applies. +//! - **relabelled** — the same obligation under a new label. Editorial; no +//! evidence is lost. Renumbering a whole specification lands here. +//! - **reworded** — the same label, a different obligation. The evidence for +//! what it used to say no longer applies to what it now says, and the new +//! obligation starts unevidenced. This is the case that hides under +//! label-bound evidence. +//! - **added** / **removed** — clauses that appeared or disappeared outright. +//! +//! Drift against the *artifact* is the other half, and it is handled at +//! evaluation: a witness is bound to the artifact state it observed, so +//! evidence from an earlier state is reported stale rather than counted +//! ([`crate::conform`]). +//! +//! Normative: SPEC §4.1 (the partition), SPEC §4.2 (relabelling versus +//! rewording), SPEC §4.3 (artifact drift). + +use serde::{Deserialize, Serialize}; + +use crate::spec::{Obligation, SignedSpec}; +use crate::{Address, Error}; + +/// A clause whose label survived a revision while its obligation changed. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct Reword { + /// The label both revisions use. + pub label: String, + /// What it used to oblige. + pub was: Obligation, + /// What it obliges now. + pub now: Obligation, +} + +/// An obligation that moved from one label to another without changing. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct Relabel { + /// The label in the earlier revision. + pub was: String, + /// The label in the later one. + pub now: String, + /// The obligation, unchanged. + pub obligation: Obligation, +} + +/// The exact difference between two revisions of a specification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Drift { + /// The earlier revision. + pub from: Address, + /// The later revision. + pub to: Address, + /// Whether `to` names `from` as its parent. A diff between two + /// unrelated specifications is still computable and still useful; it is + /// just not a revision. + pub chained: bool, + /// Obligations present in both revisions under the same label. + pub kept: Vec, + /// Obligations carried over under a different label. + pub relabelled: Vec, + /// Labels carried over with a changed obligation. + pub reworded: Vec, + /// Obligations that appear only in the later revision. + pub added: Vec<(String, Obligation)>, + /// Obligations that appear only in the earlier one. + pub removed: Vec<(String, Obligation)>, +} + +impl Drift { + /// The obligations whose evidence no longer applies: everything removed, + /// plus the old side of every rewording. + /// + /// This is the number a revision review wants — not "the spec changed" + /// but "these four obligations just lost their evidence". + #[must_use] + pub fn invalidated(&self) -> Vec { + let mut out: Vec = self + .removed + .iter() + .map(|(_, o)| o.clone()) + .chain(self.reworded.iter().map(|r| r.was.clone())) + .collect(); + out.sort(); + out + } + + /// The obligations that start the revision unevidenced: everything added, + /// plus the new side of every rewording. + #[must_use] + pub fn unevidenced(&self) -> Vec { + let mut out: Vec = self + .added + .iter() + .map(|(_, o)| o.clone()) + .chain(self.reworded.iter().map(|r| r.now.clone())) + .collect(); + out.sort(); + out + } + + /// Whether this revision changed no obligation — labels and ordering only. + /// An editorial revision costs nothing in evidence, and saying so is the + /// point: a specification should be renumberable without penalty. + #[must_use] + pub fn is_editorial(&self) -> bool { + self.added.is_empty() && self.removed.is_empty() && self.reworded.is_empty() + } +} + +/// Diff two revisions by obligation address. +pub fn diff(from: &SignedSpec, to: &SignedSpec) -> Result { + let from_id = from.verify()?; + let to_id = to.verify()?; + + // label -> obligation, and obligation -> label, on both sides. Both + // directions are needed: one finds rewording, the other finds relabelling. + let mut from_by_label = std::collections::BTreeMap::new(); + let mut from_by_obligation = std::collections::BTreeMap::new(); + for (label, _, obligation) in from.spec.obligations()? { + from_by_label.insert(label.clone(), obligation.clone()); + from_by_obligation.insert(obligation, label); + } + let mut to_by_label = std::collections::BTreeMap::new(); + let mut to_by_obligation = std::collections::BTreeMap::new(); + for (label, _, obligation) in to.spec.obligations()? { + to_by_label.insert(label.clone(), obligation.clone()); + to_by_obligation.insert(obligation, label); + } + + let mut kept = Vec::new(); + let mut relabelled = Vec::new(); + let mut reworded = Vec::new(); + let mut added = Vec::new(); + let mut removed = Vec::new(); + + for (obligation, to_label) in &to_by_obligation { + match from_by_obligation.get(obligation) { + Some(from_label) if from_label == to_label => kept.push(obligation.clone()), + Some(from_label) => relabelled.push(Relabel { + was: from_label.clone(), + now: to_label.clone(), + obligation: obligation.clone(), + }), + None => { + // New obligation. If its label existed before, the clause was + // reworded rather than added. + match from_by_label.get(to_label) { + Some(was) if !to_by_obligation.contains_key(was) => reworded.push(Reword { + label: to_label.clone(), + was: was.clone(), + now: obligation.clone(), + }), + // The label existed, but its old obligation still lives + // under some other label — so this is a genuinely new + // clause that reused a freed label, not a rewording. + _ => added.push((to_label.clone(), obligation.clone())), + } + } + } + } + + let reworded_old: std::collections::BTreeSet<&Obligation> = + reworded.iter().map(|r| &r.was).collect(); + for (obligation, from_label) in &from_by_obligation { + if !to_by_obligation.contains_key(obligation) && !reworded_old.contains(obligation) { + removed.push((from_label.clone(), obligation.clone())); + } + } + + kept.sort(); + relabelled.sort(); + reworded.sort(); + added.sort(); + removed.sort(); + + Ok(Drift { + chained: to.spec.parent.as_ref() == Some(&from_id), + from: from_id, + to: to_id, + kept, + relabelled, + reworded, + added, + removed, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::spec::{Clause, Spec, fixtures::key}; + + /// Revise `spec` by applying `edit` to its clauses, signed by the same + /// author. + fn revise( + base: &SignedSpec, + edit: impl FnOnce(&mut Vec), + ) -> SignedSpec { + let author = key(1); + let mut next = base.spec.revise(author.verifying_key().to_bytes()).unwrap(); + edit(&mut next.clauses); + next.sign(&author).unwrap() + } + + fn base() -> SignedSpec { + crate::spec::fixtures::spec(&key(1)) + } + + #[test] + fn renumbering_a_specification_costs_no_evidence() { + let from = base(); + let to = revise(&from, |clauses| { + for (i, clause) in clauses.iter_mut().enumerate() { + clause.label = format!("R{}", i + 1); + } + }); + let d = diff(&from, &to).unwrap(); + + assert!(d.is_editorial(), "{d:?}"); + assert_eq!(d.relabelled.len(), 3); + assert!(d.kept.is_empty(), "every clause moved label"); + assert!(d.invalidated().is_empty(), "renaming must never cost evidence"); + assert!(d.chained); + } + + #[test] + fn rewording_one_clause_invalidates_exactly_that_clauses_evidence() { + let from = base(); + let to = revise(&from, |clauses| { + clauses[0].text = "The endpoint should reject unauthenticated requests.".into(); + }); + let d = diff(&from, &to).unwrap(); + + assert!(!d.is_editorial()); + assert_eq!(d.reworded.len(), 1); + assert_eq!(d.reworded[0].label, "C1"); + assert_eq!(d.kept.len(), 2, "the other two obligations are untouched"); + assert_eq!(d.invalidated().len(), 1, "one obligation lost its evidence"); + assert_eq!(d.unevidenced().len(), 1); + assert!(d.added.is_empty(), "a reworded clause is not an added one"); + assert!(d.removed.is_empty(), "nor a removed one"); + } + + #[test] + fn a_weakened_requirement_is_not_mistaken_for_the_one_it_replaced() { + // The case the standard exists for: same label, same intent, one word + // changed. Under label-bound evidence this revision is invisible. + let from = base(); + let to = revise(&from, |clauses| { + clauses[0].text = "The endpoint rejects most unauthenticated requests.".into(); + }); + let d = diff(&from, &to).unwrap(); + assert_eq!(d.reworded.len(), 1); + assert_ne!(d.reworded[0].was, d.reworded[0].now); + } + + #[test] + fn adding_and_removing_clauses_is_reported_as_such() { + let from = base(); + let to = revise(&from, |clauses| { + clauses.remove(1); + clauses.push(Clause::requirement("C4", "Every decision seals a receipt.")); + }); + let d = diff(&from, &to).unwrap(); + + assert_eq!(d.added.len(), 1); + assert_eq!(d.added[0].0, "C4"); + assert_eq!(d.removed.len(), 1); + assert_eq!(d.removed[0].0, "C2"); + assert_eq!(d.invalidated().len(), 1); + } + + #[test] + fn reusing_a_freed_label_is_an_addition_not_a_rewording() { + // C2's obligation survives under a new label, and the freed "C2" is + // given to a brand-new clause. Nothing was reworded: no evidence is + // invalidated, because every old obligation still stands. + let from = base(); + let to = revise(&from, |clauses| { + clauses[1].label = "decide-first".into(); + clauses.push(Clause::requirement("C2", "Every decision seals a receipt.")); + }); + let d = diff(&from, &to).unwrap(); + + assert!(d.reworded.is_empty(), "{d:?}"); + assert_eq!(d.relabelled.len(), 1); + assert_eq!(d.added.len(), 1); + assert_eq!(d.added[0].0, "C2"); + assert!(d.invalidated().is_empty(), "no obligation left the specification"); + } + + #[test] + fn an_unchanged_revision_keeps_everything() { + let from = base(); + let to = revise(&from, |_| {}); + let d = diff(&from, &to).unwrap(); + assert!(d.is_editorial()); + assert_eq!(d.kept.len(), 3); + assert!(d.relabelled.is_empty()); + } + + #[test] + fn a_diff_between_unrelated_specifications_is_computable_but_unchained() { + let author = key(1); + let from = base(); + let unrelated = Spec::new( + "Something else entirely.", + vec![Clause::requirement("A1", "A different obligation.")], + author.verifying_key().to_bytes(), + ) + .sign(&author) + .unwrap(); + let d = diff(&from, &unrelated).unwrap(); + + assert!(!d.chained, "no parent link, and the diff says so"); + assert_eq!(d.added.len(), 1); + assert_eq!(d.removed.len(), 3); + } +} diff --git a/joulespec/joulespec-rs/crates/joulespec-core/src/evidence.rs b/joulespec/joulespec-rs/crates/joulespec-core/src/evidence.rs new file mode 100644 index 000000000..c5c37509b --- /dev/null +++ b/joulespec/joulespec-rs/crates/joulespec-core/src/evidence.rs @@ -0,0 +1,569 @@ +//! The evidence ladder: what is known about an obligation, and how well. +//! +//! ## The question the ladder asks +//! +//! Every rung is ordered by one question: **how hard would it be for this +//! obligation to be violated without this evidence noticing?** +//! +//! | Grade | Name | Noticing | +//! |---|---|---| +//! | `E0` | [`Grade::Cited`] | Not at all — the implementer said it was done. | +//! | `E1` | [`Grade::Reviewed`] | A second party read the artifact once. | +//! | `E2` | [`Grade::Exercised`] | A named check ran against the artifact and passed. | +//! | `E3` | [`Grade::Falsifiable`] | The same check is on record failing when the obligation is broken. | +//! | `E4` | [`Grade::Proved`] | No input violates it, under a formalization someone independent checked. | +//! +//! Three of those rungs are worth their own paragraph. +//! +//! **`E0` is where the state of the art lands.** A specification clause an +//! agent claims to have implemented, with a human skim at the end, is +//! testimony. It is not worthless — it is the most common evidence in software +//! and much of it is true — but it is a statement by the party being +//! evaluated, and reporting it as "conformant" is the error this standard +//! exists to stop. +//! +//! **`E2` is the rung that flatters.** A green check is evidence that +//! something ran, not that the obligation holds: a test with no assertion +//! passes, and so does a test whose fixture avoids the condition the clause +//! cares about. Nothing at `E2` distinguishes those from a real check. +//! +//! **`E3` is the first rung where green means something.** A check recorded as +//! *failing* against a named counter-artifact — a mutant, a reverted fix, a +//! deliberately broken build — has demonstrated that it can fail, so its +//! passing on the real artifact carries information. This is the cheapest +//! strong rung and the one most projects can reach today. +//! +//! ## Why a machine check outranks a human review +//! +//! The ladder is ordered by *re-checkability*, not by diligence. A review is +//! bound to the artifact state a person looked at and cannot notice a +//! violation introduced afterwards; a named check can be run again against any +//! later state. That ordering says nothing about how carefully anyone read the +//! code — it says that one of the two kinds of evidence survives the next +//! commit and the other does not. +//! +//! ## Independence is computed, never declared +//! +//! A review signed by the same key that made the citation is the citation +//! again, so it grades `E0`. A formalization "cross-checked" by its own author +//! is a declared formalization, so its proof grades `E2`. Neither is refused +//! and neither is accepted at face value: independence is key disjointness, +//! and it is computed here rather than read off a flag the producer set. +//! +//! Normative: SPEC §2.4 (the witness), SPEC §2.5 (the ladder), SPEC §2.6 +//! (formalization). + +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::spec::Obligation; +use crate::{ALG_ED25519, Address, Energy, Error}; + +/// The profile a witness declares. +pub const WITNESS_PROFILE: &str = "joulespec-witness/1"; + +/// How well an obligation is known. Ordered: `Cited < Reviewed < Exercised < +/// Falsifiable < Proved`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Grade { + /// `E0` — the implementer claims the obligation is met. + Cited, + /// `E1` — a party other than the claimant read the artifact. + Reviewed, + /// `E2` — a named check ran against the artifact and passed. + Exercised, + /// `E3` — the same check is on record failing against a counter-artifact, + /// so its passing carries information. + Falsifiable, + /// `E4` — discharged as a proof obligation under an independently + /// cross-checked formalization. + Proved, +} + +impl Grade { + /// The short wire form (`"E0"` … `"E4"`). + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::Cited => "E0", + Self::Reviewed => "E1", + Self::Exercised => "E2", + Self::Falsifiable => "E3", + Self::Proved => "E4", + } + } + + /// Whether evidence at this grade can fail when the obligation is broken. + /// Everything below [`Grade::Falsifiable`] is compatible with a check that + /// cannot fail, or with no check at all. + #[must_use] + pub const fn is_falsifiable(self) -> bool { + matches!(self, Self::Falsifiable | Self::Proved) + } +} + +/// How a clause's text was turned into a formal statement. +/// +/// This crate never formalizes a clause. A formalization is supplied by +/// whoever made the proof attempt, and the binding between the clause's prose +/// and the statement that was proved is the weakest link in any `E4` claim — +/// so the grade depends on whether anyone else checked it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Formalization { + /// The formal statement that was discharged. + pub statement: String, + /// Who, if anyone, independently confirmed the statement says what the + /// clause says. + pub grounding: Grounding, +} + +/// Whether a formalization was checked by a second party. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Grounding { + /// Asserted by the party that produced the proof. The proof is real; that + /// it proves *this clause* is that party's word. + Declared, + /// Confirmed against the clause text by the named key. + CrossChecked { + /// The confirming party's Ed25519 verifying key. + by: [u8; 32], + }, +} + +/// What is known about an obligation, and by what means. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "kind")] +pub enum Evidence { + /// The implementer claims this artifact meets the obligation. + Cited, + /// A second party read the artifact against the clause. + Reviewed { + /// The reviewing party's Ed25519 verifying key. + reviewer: [u8; 32], + }, + /// A named check ran against the artifact and passed. + Exercised { + /// How the check is invoked — a test name, a lint, a conformance + /// vector id. Recorded so a reader can run it again. + check: String, + }, + /// A named check passed against the artifact and is on record failing + /// against a counter-artifact. + Falsifiable { + /// The check, as in [`Evidence::Exercised`]. + check: String, + /// The artifact state against which the same check failed — a mutant, + /// a reverted fix, a deliberately broken build. + counter: Address, + }, + /// A proof obligation was discharged, bound to the clause by a + /// formalization. + Proved { + /// The discharged goal — a `proof/1` address where that standard + /// produced it. + goal: Address, + /// The tier that closed it, as the prover reported it. + tier: String, + /// The binding from clause prose to formal statement. + formalization: Formalization, + }, +} + +impl Evidence { + /// The grade this evidence reaches, given the key that signed for it. + /// + /// Independence is computed here: a review or a cross-check by the + /// witness's own author establishes nothing beyond the citation, and + /// demotes rather than refuses. + #[must_use] + pub fn grade(&self, author: &[u8; 32]) -> Grade { + match self { + Self::Cited => Grade::Cited, + Self::Reviewed { reviewer } => { + if reviewer == author { Grade::Cited } else { Grade::Reviewed } + } + Self::Exercised { .. } => Grade::Exercised, + Self::Falsifiable { .. } => Grade::Falsifiable, + Self::Proved { formalization, .. } => match &formalization.grounding { + Grounding::CrossChecked { by } if by != author => Grade::Proved, + // A proof under a formalization nobody independent read is a + // machine-checked statement of an unverified reading of the + // clause: real, mechanical evidence about *something*. + _ => Grade::Exercised, + }, + } + } + + /// Structural validation of the payload. + pub fn validate(&self) -> Result<(), Error> { + match self { + Self::Cited | Self::Reviewed { .. } => Ok(()), + Self::Exercised { check } | Self::Falsifiable { check, .. } => { + if check.trim().is_empty() { + return Err(Error::IncompleteEvidence("a check must be named")); + } + Ok(()) + } + Self::Proved { tier, formalization, .. } => { + if tier.trim().is_empty() { + return Err(Error::IncompleteEvidence("a proof must name its tier")); + } + if formalization.statement.trim().is_empty() { + return Err(Error::IncompleteEvidence( + "a proof must carry the statement it discharged", + )); + } + Ok(()) + } + } + } + + /// The canonical document this evidence contributes to a witness's + /// address. Every payload field is present, so no part of the evidence can + /// be altered under a valid signature. + #[must_use] + pub fn canonical(&self) -> Value { + match self { + Self::Cited => json!({ "kind": self.label() }), + Self::Reviewed { reviewer } => { + json!({ "kind": self.label(), "reviewer": crate::hex32(reviewer) }) + } + Self::Exercised { check } => json!({ "kind": self.label(), "check": check }), + Self::Falsifiable { check, counter } => json!({ + "kind": self.label(), + "check": check, + "counter": counter.as_str(), + }), + Self::Proved { goal, tier, formalization } => { + let mut grounding = serde_json::Map::new(); + match &formalization.grounding { + Grounding::Declared => { + grounding.insert("kind".into(), json!("declared")); + } + Grounding::CrossChecked { by } => { + grounding.insert("kind".into(), json!("cross-checked")); + grounding.insert("by".into(), json!(crate::hex32(by))); + } + } + json!({ + "kind": self.label(), + "goal": goal.as_str(), + "tier": tier, + "statement": formalization.statement, + "grounding": Value::Object(grounding), + }) + } + } + } + + /// The wire label of the evidence kind. + #[must_use] + pub const fn label(&self) -> &'static str { + match self { + Self::Cited => "cited", + Self::Reviewed { .. } => "reviewed", + Self::Exercised { .. } => "exercised", + Self::Falsifiable { .. } => "falsifiable", + Self::Proved { .. } => "proved", + } + } +} + +/// One signed record of evidence about one obligation at one artifact state. +/// +/// A witness binds four things together: *which* obligation (by address, not +/// label), *which* artifact state it was observed against, *what* was +/// observed, and *what it cost* to observe. The artifact binding is what makes +/// staleness computable — evidence gathered against one state says nothing +/// about another. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Witness { + /// The profile (`joulespec-witness/1`). + pub profile: String, + /// Signature algorithm id (`0x01` = Ed25519). + pub alg: u8, + /// The specification this witness is about. + pub spec_id: Address, + /// The obligation witnessed — an address, so a relabelled clause keeps + /// its evidence and a reworded one does not. + pub obligation: Obligation, + /// The artifact state the evidence was observed against: a tree digest, a + /// build output address, a commit. + pub artifact: Address, + /// What was observed. + pub evidence: Evidence, + /// What producing this evidence cost, with its provenance. + pub energy: Energy, + /// The witnessing party's Ed25519 verifying key. + pub author: [u8; 32], +} + +impl Witness { + /// A witness at an artifact state. + pub fn new( + spec_id: Address, + obligation: Obligation, + artifact: Address, + evidence: Evidence, + energy: Energy, + author: [u8; 32], + ) -> Self { + Self { + profile: WITNESS_PROFILE.to_string(), + alg: ALG_ED25519, + spec_id, + obligation, + artifact, + evidence, + energy, + author, + } + } + + /// Structural validation. + pub fn validate(&self) -> Result<(), Error> { + if self.profile != WITNESS_PROFILE { + return Err(Error::BadProfile(self.profile.clone())); + } + if self.alg != ALG_ED25519 { + return Err(Error::BadAlg(self.alg)); + } + self.evidence.validate() + } + + /// The canonical document the address and the signature commit to. + /// Written out for the same reasons as [`crate::spec::Spec::canonical`]. + #[must_use] + pub fn canonical(&self) -> Value { + json!({ + "profile": self.profile, + "alg": self.alg, + "spec": self.spec_id.as_str(), + "obligation": self.obligation.as_str(), + "artifact": self.artifact.as_str(), + "evidence": self.evidence.canonical(), + "energy": json!({ + "uj": self.energy.uj, + "provenance": self.energy.provenance.label(), + }), + "author": crate::hex32(&self.author), + }) + } + + /// The witness's address. + pub fn witness_id(&self) -> Result { + self.validate()?; + Address::of(&self.canonical()) + } + + /// The grade this witness reaches. + pub fn grade(&self) -> Grade { + self.evidence.grade(&self.author) + } + + /// Preimage: `witness_id ‖ spec_id ‖ obligation ‖ artifact`. Signing binds + /// the evidence to the obligation and the state it was observed against, + /// so neither can be swapped under a valid signature. + fn preimage(&self) -> Result, Error> { + let id = self.witness_id()?; + let mut pre = id.as_str().as_bytes().to_vec(); + pre.extend_from_slice(self.spec_id.as_str().as_bytes()); + pre.extend_from_slice(self.obligation.as_str().as_bytes()); + pre.extend_from_slice(self.artifact.as_str().as_bytes()); + Ok(pre) + } + + /// Sign with the witnessing party's key. + pub fn sign(self, key: &SigningKey) -> Result { + let sig = key.sign(&self.preimage()?); + Ok(SignedWitness { witness: self, sig: sig.to_bytes().to_vec() }) + } +} + +/// A witness plus its signature. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedWitness { + /// The witness. + pub witness: Witness, + /// Ed25519 signature over `witness_id ‖ spec_id ‖ obligation ‖ artifact`. + pub sig: Vec, +} + +impl SignedWitness { + /// Verify structure and signature, returning the witness address. + pub fn verify(&self) -> Result { + let pre = self.witness.preimage()?; + let key = VerifyingKey::from_bytes(&self.witness.author).map_err(|_| Error::BadSignature)?; + let sig: [u8; 64] = self.sig.as_slice().try_into().map_err(|_| Error::BadSignature)?; + key.verify(&pre, &Signature::from_bytes(&sig)).map_err(|_| Error::BadSignature)?; + self.witness.witness_id() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Provenance; + use crate::spec::{Clause, fixtures::key}; + + fn obligation() -> Obligation { + Clause::requirement("C1", "The endpoint rejects unauthenticated requests.") + .obligation() + .unwrap() + } + + fn witness(evidence: Evidence, author: &SigningKey) -> SignedWitness { + Witness::new( + Address::of_bytes(b"spec"), + obligation(), + Address::of_bytes(b"artifact-v1"), + evidence, + Energy::new(1_200, Provenance::HwShunt), + author.verifying_key().to_bytes(), + ) + .sign(author) + .unwrap() + } + + #[test] + fn the_ladder_is_ordered_from_testimony_to_proof() { + assert!(Grade::Cited < Grade::Reviewed); + assert!(Grade::Reviewed < Grade::Exercised); + assert!(Grade::Exercised < Grade::Falsifiable); + assert!(Grade::Falsifiable < Grade::Proved); + assert!(!Grade::Exercised.is_falsifiable(), "a green check may be vacuous"); + assert!(Grade::Falsifiable.is_falsifiable()); + } + + #[test] + fn a_citation_is_testimony() { + let k = key(1); + let w = witness(Evidence::Cited, &k); + assert_eq!(w.witness.grade(), Grade::Cited); + w.verify().expect("verifies"); + } + + #[test] + fn a_review_by_someone_else_outranks_a_citation() { + let author = key(1); + let reviewer = key(2); + let w = witness(Evidence::Reviewed { reviewer: reviewer.verifying_key().to_bytes() }, &author); + assert_eq!(w.witness.grade(), Grade::Reviewed); + } + + #[test] + fn a_self_review_is_the_citation_again() { + let author = key(1); + let w = witness(Evidence::Reviewed { reviewer: author.verifying_key().to_bytes() }, &author); + assert_eq!( + w.witness.grade(), + Grade::Cited, + "independence is key disjointness, computed here rather than claimed" + ); + } + + #[test] + fn a_check_that_can_fail_outranks_one_that_merely_passed() { + let k = key(1); + let passed = witness(Evidence::Exercised { check: "rejects_anon".into() }, &k); + let can_fail = witness( + Evidence::Falsifiable { + check: "rejects_anon".into(), + counter: Address::of_bytes(b"mutant-auth-removed"), + }, + &k, + ); + assert_eq!(passed.witness.grade(), Grade::Exercised); + assert_eq!(can_fail.witness.grade(), Grade::Falsifiable); + } + + #[test] + fn a_proof_reaches_e4_only_when_its_formalization_was_cross_checked() { + let author = key(1); + let other = key(2); + let proved = |grounding| { + witness( + Evidence::Proved { + goal: Address::of_bytes(b"goal"), + tier: "T1".into(), + formalization: Formalization { + statement: "forall r. !authenticated(r) -> rejected(r)".into(), + grounding, + }, + }, + &author, + ) + }; + + assert_eq!( + proved(Grounding::CrossChecked { by: other.verifying_key().to_bytes() }) + .witness + .grade(), + Grade::Proved + ); + assert_eq!( + proved(Grounding::Declared).witness.grade(), + Grade::Exercised, + "the proof is real; that it proves THIS clause is the prover's word" + ); + assert_eq!( + proved(Grounding::CrossChecked { by: author.verifying_key().to_bytes() }) + .witness + .grade(), + Grade::Exercised, + "cross-checking your own formalization is declaring it" + ); + } + + #[test] + fn swapping_the_obligation_under_a_signature_is_caught() { + let k = key(1); + let mut w = witness(Evidence::Cited, &k); + w.witness.obligation = Clause::requirement("C1", "Something else entirely.") + .obligation() + .unwrap(); + assert!(matches!(w.verify(), Err(Error::BadSignature))); + } + + #[test] + fn swapping_the_artifact_under_a_signature_is_caught() { + let k = key(1); + let mut w = witness(Evidence::Cited, &k); + w.witness.artifact = Address::of_bytes(b"artifact-v2"); + assert!(matches!(w.verify(), Err(Error::BadSignature))); + } + + #[test] + fn evidence_without_its_payload_is_refused() { + let k = key(1); + let w = Witness::new( + Address::of_bytes(b"spec"), + obligation(), + Address::of_bytes(b"a"), + Evidence::Exercised { check: " ".into() }, + Energy::estimated(1), + k.verifying_key().to_bytes(), + ); + assert!(matches!(w.validate(), Err(Error::IncompleteEvidence(_)))); + + let no_statement = Witness::new( + Address::of_bytes(b"spec"), + obligation(), + Address::of_bytes(b"a"), + Evidence::Proved { + goal: Address::of_bytes(b"g"), + tier: "T1".into(), + formalization: Formalization { + statement: String::new(), + grounding: Grounding::Declared, + }, + }, + Energy::estimated(1), + k.verifying_key().to_bytes(), + ); + assert!(matches!(no_statement.validate(), Err(Error::IncompleteEvidence(_)))); + } +} diff --git a/joulespec/joulespec-rs/crates/joulespec-core/src/jcr1.rs b/joulespec/joulespec-rs/crates/joulespec-core/src/jcr1.rs new file mode 100644 index 000000000..6f5556c55 --- /dev/null +++ b/joulespec/joulespec-rs/crates/joulespec-core/src/jcr1.rs @@ -0,0 +1,510 @@ +//! JCR-1 wire primitives — canonical det-CBOR + COSE_Sign1. +//! +//! JouleSpec receipts seal with the **JouleClaw Receipt Specification (JCR-1)** +//! envelope, the signed-receipt wire format shared across the Transaction +//! Science family: canonical det-CBOR (RFC 8949 §4.2.1, length-first key order, +//! no floats/bools/nulls) inside a COSE_Sign1 envelope (RFC 9052, EdDSA/Ed25519, +//! `alg = -8`). JCR-1 is a published spec implementable with standard COSE/CBOR +//! tooling and no JouleSpec dependency; these primitives are that reference +//! algorithm, vendored so the JouleSpec reference library stays self-contained — the +//! same arrangement `mesh-receipt` documents for the same reason. They are +//! byte-compatible with JCR-1's cross-language (Python/Go) verifiers and with +//! the other family standards' receipts, which is the point of vendoring rather +//! than re-deriving: a JouleSpec receipt is verifiable by a tool that has never +//! heard of JouleSpec. + +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use serde_json::Value; + +/// Errors from JCR-1 canonical encoding or envelope handling. +#[derive(Debug, thiserror::Error)] +pub enum Jcr1Error { + /// A type not permitted in the canonical signed core (float, bool, null, + /// non-uint number). + #[error("disallowed type in canonical core: {0}")] + DisallowedType(&'static str), + /// The COSE_Sign1 envelope was malformed. + #[error("malformed COSE_Sign1 envelope: {0}")] + Envelope(String), + /// An unsupported COSE algorithm (only EdDSA `-8` is accepted). + #[error("unsupported COSE alg: {0} (only EdDSA -8)")] + UnsupportedAlg(i64), + /// The signature did not verify. + #[error("COSE_Sign1 signature did not verify")] + BadSignature, + /// The envelope's `kid` did not match the expected steward. + #[error("kid mismatch: expected {expected}, got {got}")] + KidMismatch { + /// The steward the verifier required. + expected: String, + /// The `kid` the envelope actually carried. + got: String, + }, + /// The canonical core did not decode, or decoded to something that does + /// not re-encode to the bytes it came from. + #[error("malformed canonical core: {0}")] + MalformedCore(&'static str), +} + +/// COSE algorithm tag for EdDSA (RFC 8152). The only `alg` JCR-1 v1 accepts. +pub const COSE_ALG_EDDSA: i64 = -8; + +fn cbor_uint(out: &mut Vec, major: u8, n: u64) { + let mt = major << 5; + if n < 24 { + out.push(mt | n as u8); + } else if n < 1 << 8 { + out.push(mt | 24); + out.push(n as u8); + } else if n < 1 << 16 { + out.push(mt | 25); + out.extend_from_slice(&(n as u16).to_be_bytes()); + } else if n < 1 << 32 { + out.push(mt | 26); + out.extend_from_slice(&(n as u32).to_be_bytes()); + } else { + out.push(mt | 27); + out.extend_from_slice(&n.to_be_bytes()); + } +} + +fn cbor_text(out: &mut Vec, s: &str) { + cbor_uint(out, 3, s.len() as u64); + out.extend_from_slice(s.as_bytes()); +} + +fn cbor_bytes(out: &mut Vec, b: &[u8]) { + cbor_uint(out, 2, b.len() as u64); + out.extend_from_slice(b); +} + +fn cbor_value(out: &mut Vec, v: &Value) -> Result<(), Jcr1Error> { + match v { + Value::String(s) => { + cbor_text(out, s); + Ok(()) + } + Value::Number(n) => { + let u = n.as_u64().ok_or(Jcr1Error::DisallowedType("non-uint number"))?; + cbor_uint(out, 0, u); + Ok(()) + } + Value::Array(a) => { + cbor_uint(out, 4, a.len() as u64); + for e in a { + cbor_value(out, e)?; + } + Ok(()) + } + Value::Object(m) => { + // length-first, then bytewise on the key (RFC 8949 §4.2.1). + let mut keys: Vec<&String> = m.keys().collect(); + keys.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.as_str().cmp(b.as_str()))); + cbor_uint(out, 5, keys.len() as u64); + for k in keys { + cbor_text(out, k); + cbor_value(out, &m[k])?; + } + Ok(()) + } + Value::Bool(_) => Err(Jcr1Error::DisallowedType("bool")), + Value::Null => Err(Jcr1Error::DisallowedType("null")), + } +} + +/// Encode a JSON value as canonical det-CBOR (RFC 8949 §4.2.1): length-first key +/// ordering, shortest integer form, no floats/bools/nulls. +pub fn canonical_cbor(value: &Value) -> Result, Jcr1Error> { + let mut out = Vec::new(); + cbor_value(&mut out, value)?; + Ok(out) +} + +/// The canonical signed-core bytes for a JCR-1 wire document: drop null-valued +/// fields, then canonical-CBOR encode. This is exactly what the envelope signs +/// over. +pub fn signed_core_bytes(wire: &Value) -> Result, Jcr1Error> { + let obj = wire + .as_object() + .ok_or(Jcr1Error::DisallowedType("non-object root"))?; + let mut core = serde_json::Map::new(); + for (k, v) in obj { + if v.is_null() { + continue; + } + core.insert(k.clone(), v.clone()); + } + canonical_cbor(&Value::Object(core)) +} + +// COSE_Sign1 (RFC 9052), EdDSA / Ed25519 only. + +fn build_protected_header(steward: &str) -> Vec { + // map(2) { 1: -8 (EdDSA), 4: bstr(steward) } + let mut p = vec![0xa2, 0x01, 0x27, 0x04]; + cbor_bytes(&mut p, steward.as_bytes()); + p +} + +fn build_sig_structure(protected: &[u8], payload: &[u8]) -> Vec { + // ["Signature1", protected, external_aad (empty), payload] + let mut s = vec![0x84]; + cbor_text(&mut s, "Signature1"); + cbor_bytes(&mut s, protected); + cbor_bytes(&mut s, &[]); + cbor_bytes(&mut s, payload); + s +} + +/// Build a tagged COSE_Sign1 envelope (CBOR tag 18) over the canonical signed +/// core. `steward` becomes the `kid` a verifier resolves. +pub fn cose_sign1_envelope(core: &[u8], signing_key: &SigningKey, steward: &str) -> Vec { + let protected = build_protected_header(steward); + let sig_struct = build_sig_structure(&protected, core); + let signature = signing_key.sign(&sig_struct).to_bytes(); + let mut envelope = vec![0xd2, 0x84]; // tag(18), array(4) + cbor_bytes(&mut envelope, &protected); + envelope.push(0xa0); // unprotected = {} + cbor_bytes(&mut envelope, core); + cbor_bytes(&mut envelope, &signature); + envelope +} + +struct ProtectedHeader { + alg: i64, + kid: Vec, +} + +/// Verify a tagged COSE_Sign1 envelope against a public key, returning the inner +/// payload (the signed-core bytes) on success. Rejects unknown `alg`, a `kid` +/// other than `expected_steward`, and signature failure. +pub fn verify_cose_sign1( + envelope: &[u8], + expected_steward: &str, + pubkey: &[u8; 32], +) -> Result, Jcr1Error> { + let (protected, payload, signature) = parse_cose_sign1(envelope)?; + let header = parse_protected_header(&protected)?; + if header.alg != COSE_ALG_EDDSA { + return Err(Jcr1Error::UnsupportedAlg(header.alg)); + } + let kid = std::str::from_utf8(&header.kid) + .map_err(|_| Jcr1Error::Envelope("kid is not UTF-8".into()))?; + if kid != expected_steward { + return Err(Jcr1Error::KidMismatch { + expected: expected_steward.to_string(), + got: kid.to_string(), + }); + } + let sig_struct = build_sig_structure(&protected, &payload); + let vk = VerifyingKey::from_bytes(pubkey) + .map_err(|e| Jcr1Error::Envelope(format!("bad pubkey: {e}")))?; + let sig = Signature::from_slice(&signature) + .map_err(|e| Jcr1Error::Envelope(format!("bad signature shape: {e}")))?; + vk.verify(&sig_struct, &sig).map_err(|_| Jcr1Error::BadSignature)?; + Ok(payload) +} + +// Minimal CBOR reader for the fixed COSE_Sign1 shape. + +fn read_len(b: &[u8], pos: &mut usize, major: u8) -> Result { + let head = *b.get(*pos).ok_or(Jcr1Error::Envelope("truncated".into()))?; + *pos += 1; + if head >> 5 != major { + return Err(Jcr1Error::Envelope(format!( + "expected major {major}, got {}", + head >> 5 + ))); + } + let info = head & 0x1f; + let n = match info { + 0..=23 => info as u64, + 24 => { + let v = *b.get(*pos).ok_or(Jcr1Error::Envelope("truncated".into()))? as u64; + *pos += 1; + v + } + 25 => { + let s = b.get(*pos..*pos + 2).ok_or(Jcr1Error::Envelope("truncated".into()))?; + *pos += 2; + u16::from_be_bytes([s[0], s[1]]) as u64 + } + 26 => { + let s = b.get(*pos..*pos + 4).ok_or(Jcr1Error::Envelope("truncated".into()))?; + *pos += 4; + u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as u64 + } + 27 => { + let s = b.get(*pos..*pos + 8).ok_or(Jcr1Error::Envelope("truncated".into()))?; + *pos += 8; + u64::from_be_bytes(s.try_into().expect("8 bytes")) + } + _ => return Err(Jcr1Error::Envelope("indefinite length".into())), + }; + Ok(n) +} + +fn read_bstr(b: &[u8], pos: &mut usize) -> Result, Jcr1Error> { + let len = read_len(b, pos, 2)? as usize; + let s = b + .get(*pos..*pos + len) + .ok_or(Jcr1Error::Envelope("truncated bstr".into()))?; + *pos += len; + Ok(s.to_vec()) +} + +/// (protected header bytes, payload bytes, signature bytes) of a COSE_Sign1. +type CoseSign1Parts = (Vec, Vec, Vec); + +fn parse_cose_sign1(envelope: &[u8]) -> Result { + let mut pos = 0; + // tag(18) = 0xd2 + if envelope.first() != Some(&0xd2) { + return Err(Jcr1Error::Envelope("missing COSE_Sign1 tag 18".into())); + } + pos += 1; + // array(4) = 0x84 + if envelope.get(pos) != Some(&0x84) { + return Err(Jcr1Error::Envelope("expected array(4)".into())); + } + pos += 1; + let protected = read_bstr(envelope, &mut pos)?; + // unprotected map — expect empty map 0xa0 + if envelope.get(pos) != Some(&0xa0) { + return Err(Jcr1Error::Envelope("expected empty unprotected map".into())); + } + pos += 1; + let payload = read_bstr(envelope, &mut pos)?; + let signature = read_bstr(envelope, &mut pos)?; + Ok((protected, payload, signature)) +} + +fn parse_protected_header(protected: &[u8]) -> Result { + let mut pos = 0; + let n = read_len(protected, &mut pos, 5)?; // map + let mut alg = None; + let mut kid = None; + for _ in 0..n { + let key = read_len(protected, &mut pos, 0)?; // small uint key + match key { + 1 => { + // alg: negative int (major 1) → value = -1 - n + let head = *protected + .get(pos) + .ok_or(Jcr1Error::Envelope("truncated alg".into()))?; + if head >> 5 != 1 { + return Err(Jcr1Error::Envelope("alg not negative int".into())); + } + let v = read_len(protected, &mut pos, 1)?; + alg = Some(-1 - v as i64); + } + 4 => kid = Some(read_bstr(protected, &mut pos)?), + _ => return Err(Jcr1Error::Envelope("unexpected protected header key".into())), + } + } + Ok(ProtectedHeader { + alg: alg.ok_or(Jcr1Error::Envelope("missing alg".into()))?, + kid: kid.ok_or(Jcr1Error::Envelope("missing kid".into()))?, + }) +} + +// ─────────────────────── canonical decoding ─────────────────────── +// +// The encoder above admits exactly four types in a signed core: unsigned +// integers, text strings, arrays and maps (byte strings appear only in the +// COSE envelope around it). The decoder therefore accepts exactly those and +// refuses the rest, rather than accepting a wider grammar and hoping the +// encoder never meets it. +// +// Canonicity is then checked by construction: the decoded value is re-encoded +// and compared with the input. A core that decodes but does not re-encode to +// its own bytes is not canonical, and since the signature covers those exact +// bytes, accepting it would let two different documents share one signature. + +fn read_head(b: &[u8], i: &mut usize) -> Result<(u8, u64), Jcr1Error> { + let ib = *b.get(*i).ok_or(Jcr1Error::MalformedCore("truncated head"))?; + *i += 1; + let major = ib >> 5; + let ai = ib & 0x1f; + let take = |i: &mut usize, n: usize| -> Result { + let end = i.checked_add(n).ok_or(Jcr1Error::MalformedCore("length overflow"))?; + let slice = b.get(*i..end).ok_or(Jcr1Error::MalformedCore("truncated argument"))?; + *i = end; + Ok(slice.iter().fold(0u64, |acc, byte| (acc << 8) | u64::from(*byte))) + }; + let val = match ai { + 0..=23 => u64::from(ai), + 24 => take(i, 1)?, + 25 => take(i, 2)?, + 26 => take(i, 4)?, + 27 => take(i, 8)?, + _ => return Err(Jcr1Error::MalformedCore("indefinite or reserved length")), + }; + Ok((major, val)) +} + +fn read_value(b: &[u8], i: &mut usize) -> Result { + let (major, val) = read_head(b, i)?; + match major { + 0 => Ok(Value::from(val)), + 3 => { + let n = usize::try_from(val).map_err(|_| Jcr1Error::MalformedCore("huge string"))?; + let end = i.checked_add(n).ok_or(Jcr1Error::MalformedCore("length overflow"))?; + let raw = b.get(*i..end).ok_or(Jcr1Error::MalformedCore("truncated string"))?; + *i = end; + let s = core::str::from_utf8(raw) + .map_err(|_| Jcr1Error::MalformedCore("text is not UTF-8"))?; + Ok(Value::from(s)) + } + 4 => { + let n = usize::try_from(val).map_err(|_| Jcr1Error::MalformedCore("huge array"))?; + let mut out = Vec::with_capacity(n.min(1024)); + for _ in 0..n { + out.push(read_value(b, i)?); + } + Ok(Value::Array(out)) + } + 5 => { + let n = usize::try_from(val).map_err(|_| Jcr1Error::MalformedCore("huge map"))?; + let mut m = serde_json::Map::new(); + for _ in 0..n { + let Value::String(k) = read_value(b, i)? else { + return Err(Jcr1Error::MalformedCore("non-text map key")); + }; + if m.insert(k, read_value(b, i)?).is_some() { + return Err(Jcr1Error::MalformedCore("duplicate map key")); + } + } + Ok(Value::Object(m)) + } + _ => Err(Jcr1Error::MalformedCore("type not permitted in a signed core")), + } +} + +/// Decode a canonical signed core back to JSON. +/// +/// The inverse of [`canonical_cbor`] over the grammar a signed core may use. +/// The result is checked by re-encoding: a core that decodes but does not +/// re-encode to the bytes it came from is refused, because the signature +/// covers those exact bytes and a second encoding of the same value would +/// otherwise ride the same signature. +/// +/// # Errors +/// [`Jcr1Error::MalformedCore`] if the bytes are truncated, use a type the +/// core grammar excludes, carry trailing data, or are not canonical. +pub fn from_canonical_cbor(bytes: &[u8]) -> Result { + let mut i = 0usize; + let v = read_value(bytes, &mut i)?; + if i != bytes.len() { + return Err(Jcr1Error::MalformedCore("trailing bytes after the core")); + } + if canonical_cbor(&v)? != bytes { + return Err(Jcr1Error::MalformedCore("core is not canonical")); + } + Ok(v) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // ── the decoder (written here, not vendored, so tested here) ── + + #[test] + fn the_decoder_round_trips_a_core() { + let v = json!({"a": 1, "bb": "x", "ccc": [1, 2], "d": {"e": 9}, "": "empty key"}); + let bytes = canonical_cbor(&v).expect("encodes"); + assert_eq!(from_canonical_cbor(&bytes).expect("decodes"), v); + } + + /// A value that decodes but was not written canonically is refused: the + /// signature covers exact bytes, so a second encoding of the same value + /// must not ride the same signature. + #[test] + fn a_non_shortest_integer_is_not_canonical() { + // 5 the long way: major 0, additional-info 24, then the byte. + assert!(from_canonical_cbor(&[0x18, 0x05]).is_err()); + // The same value, shortest form. + assert_eq!(from_canonical_cbor(&[0x05]).expect("decodes"), json!(5)); + } + + /// Length-first, then bytewise (RFC 8949 §4.2.1). `a` must precede `bb`. + #[test] + fn keys_out_of_canonical_order_are_refused() { + let good = canonical_cbor(&json!({"a": 2, "bb": 1})).expect("encodes"); + assert!(from_canonical_cbor(&good).is_ok()); + // The same map written "bb" first. + let bad = [0xa2, 0x62, b'b', b'b', 0x01, 0x61, b'a', 0x02]; + assert!(from_canonical_cbor(&bad).is_err()); + } + + #[test] + fn trailing_bytes_are_refused() { + let mut b = canonical_cbor(&json!(1)).expect("encodes"); + b.push(0x01); + assert!( + matches!(from_canonical_cbor(&b), Err(Jcr1Error::MalformedCore(m)) if m.contains("trailing")), + "refused for the trailing bytes specifically, not merely refused" + ); + } + + #[test] + fn a_type_outside_the_core_grammar_is_refused() { + assert!(from_canonical_cbor(&[0xf5]).is_err(), "true (major 7)"); + assert!(from_canonical_cbor(&[0xf6]).is_err(), "null (major 7)"); + assert!(from_canonical_cbor(&[0x20]).is_err(), "-1 (major 1)"); + assert!(from_canonical_cbor(&[0x41, 0x00]).is_err(), "a byte string (major 2)"); + } + + #[test] + fn a_truncated_core_is_refused_rather_than_guessed() { + let full = canonical_cbor(&json!({"a": "xyz"})).expect("encodes"); + for n in 1..full.len() { + assert!(from_canonical_cbor(&full[..n]).is_err(), "prefix of {n} bytes"); + } + assert!(from_canonical_cbor(&full).is_ok()); + } + + #[test] + fn a_duplicate_map_key_is_refused() { + // {"a": 1, "a": 2} — well-formed CBOR, not a well-formed core. + let bad = [0xa2, 0x61, b'a', 0x01, 0x61, b'a', 0x02]; + assert!( + matches!(from_canonical_cbor(&bad), Err(Jcr1Error::MalformedCore(m)) if m.contains("duplicate")), + "refused as a duplicate key specifically, not merely refused" + ); + } + + /// Every signed core this crate produces decodes back to what it encoded. + #[test] + fn a_signed_core_decodes_to_the_document_it_sealed() { + let wire = json!({"joulespec_receipt": "joulespec/1", "seq": 0, "prev": "00", "steward": "did:x"}); + let core = signed_core_bytes(&wire).expect("canonical"); + assert_eq!(from_canonical_cbor(&core).expect("decodes"), wire); + } + + #[test] + fn envelope_roundtrip() { + let sk = SigningKey::from_bytes(&[7u8; 32]); + let steward = "did:example:endpoint1"; + let core = canonical_cbor(&json!({"a": 1, "bb": "x"})).unwrap(); + let env = cose_sign1_envelope(&core, &sk, steward); + let payload = verify_cose_sign1(&env, steward, &sk.verifying_key().to_bytes()).unwrap(); + assert_eq!(payload, core); + } + + #[test] + fn envelope_rejects_wrong_steward() { + let sk = SigningKey::from_bytes(&[7u8; 32]); + let core = canonical_cbor(&json!({"a": 1})).unwrap(); + let env = cose_sign1_envelope(&core, &sk, "did:example:a"); + assert!(verify_cose_sign1(&env, "did:example:b", &sk.verifying_key().to_bytes()).is_err()); + } + + #[test] + fn bool_is_rejected_in_core() { + // The signed core forbids bools — fidelity_met et al. must be uints. + assert!(canonical_cbor(&json!({"x": true})).is_err()); + } +} diff --git a/joulespec/joulespec-rs/crates/joulespec-core/src/lib.rs b/joulespec/joulespec-rs/crates/joulespec-core/src/lib.rs new file mode 100644 index 000000000..ae8df80f7 --- /dev/null +++ b/joulespec/joulespec-rs/crates/joulespec-core/src/lib.rs @@ -0,0 +1,302 @@ +//! JouleSpec — the reference conformance kernel for energy-accounted +//! specifications (`joulespec/1`). +//! +//! ## What the field built, and what it left out +//! +//! Spec-driven development converged on a shared shape: write the intent +//! down, derive the plan and the implementation from it, review at each +//! phase. The artifact every framework in the field produces is **prose an +//! agent interprets and a human reads**, and the field's own literature names +//! the consequences — specifications drifting from the code they describe, +//! generated artifacts trusted beyond their evidence, no method for checking +//! that an implementation conforms, and a review burden heavy enough that +//! practitioners report preferring to read the code. +//! +//! Each of those is a measurement problem wearing a methodology costume. They +//! persist because the specification is a *document*: a clause cannot be +//! addressed, so nothing can bind to it; conformance is a sentence in a +//! review, so it cannot be graded; and the cost of the ceremony is an +//! anecdote, so the argument about whether it is worth the effort has no +//! units. +//! +//! ## What this crate makes true +//! +//! `joulespec/1` keeps the specification exactly as prose — a clause is still +//! a sentence a person wrote — and makes four things computable about it: +//! +//! 1. **A clause is addressed by its obligation, not its label.** The address +//! of a clause covers its kind and its text and *not* its label +//! ([`spec::Clause::obligation`]), so renaming `C1` to `auth-2` keeps every +//! piece of evidence bound to it, while changing one word of the +//! requirement invalidates that evidence. This is the distinction that lets +//! drift be computed instead of warned about — evidence bound to a label +//! survives a silent rewording, which is precisely how drift hides. +//! +//! 2. **Conformance is a grade, not a claim.** Evidence climbs a five-rung +//! ladder ([`evidence::Grade`]) ordered by one question: *how hard would it +//! be for this obligation to be violated without this evidence noticing?* +//! An implementer's citation — what the state of the art produces — is the +//! bottom rung, `E0`, and is reported as the testimony it is. +//! +//! 3. **Drift is a diff over addresses.** Revising a specification yields an +//! exact answer to "which evidence still applies" ([`drift::diff`]): +//! relabelling costs nothing, rewording costs that clause's evidence, and +//! the surviving set is computed rather than asserted. +//! +//! 4. **The ceremony has a price.** Every witness carries the energy it cost +//! to produce, so a sealed receipt states what a specification cost to +//! evidence, and what share of the energy spent went into work no clause +//! asked for ([`conform::Conformance::specified_fraction`]). The field's +//! central argument — whether spec-driven development earns its overhead — +//! becomes a measurement. +//! +//! ## What it does not do +//! +//! JouleSpec does not write specifications, does not run checks, does not +//! judge whether a clause is a *good* requirement, and never formalizes a +//! clause on anyone's behalf. It records which evidence exists, at what grade, +//! bound to which obligation, at what cost — and refuses to report testimony +//! and proof as the same thing. +//! +//! Prior art inside this family: `joule-code-spec` first shipped the signed, +//! content-addressed clause and the citation that binds a coding operation to +//! it. Its citations bind by clause **label** and its audit has a single +//! notion of coverage — a clause is covered when some operation claims it. +//! That is the `E0` rung here, and binding by label is the drift hole §3 +//! closes. +//! +//! Normative: SPEC §1.1 (scope), SPEC §1.2 (addressing), SPEC §1.3 +//! (grading), SPEC §1.4 (figure grades), SPEC §8 (the honest limit). + +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub mod conform; +pub mod drift; +pub mod evidence; +pub mod jcr1; +pub mod receipt; +pub mod spec; + +/// Errors from specification handling, evidence verification, or evaluation. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The specification carries no intent. + #[error("intent must be non-empty")] + EmptyIntent, + /// The specification carries no clauses. + #[error("a specification must contain at least one clause")] + NoClauses, + /// Two clauses share a label. + #[error("clause labels must be unique: duplicate {0:?}")] + DuplicateLabel(String), + /// A label is empty or contains whitespace — it appears verbatim in + /// reports and must be a single token. + #[error("clause label must be non-empty and whitespace-free: {0:?}")] + BadLabel(String), + /// A clause has no text, so it states no obligation. + #[error("clause text must be non-empty: {0:?}")] + EmptyClause(String), + /// Two clauses state the same obligation at the same kind. They would + /// share an address, so evidence for one would silently witness the other. + #[error("clauses {0:?} and {1:?} state the same obligation")] + DuplicateObligation(String, String), + /// A signature did not verify. + #[error("signature verification failed")] + BadSignature, + /// An algorithm id this profile does not define. + #[error("unsupported algorithm id {0:#04x}")] + BadAlg(u8), + /// A witness names a profile this crate does not implement. + #[error("unsupported profile {0:?}")] + BadProfile(String), + /// A witness carries no evidence payload where its kind requires one. + #[error("evidence is incomplete: {0}")] + IncompleteEvidence(&'static str), + /// Canonical encoding failed. + #[error("canonical encoding: {0}")] + Canonical(#[from] jcr1::Jcr1Error), + /// A figure claimed a measured grade the contributing evidence cannot + /// support. + #[error("energy provenance {claimed} is not supported by its evidence ({actual})")] + DishonestProvenance { + /// The grade the caller asked to seal. + claimed: &'static str, + /// The worst grade among the contributing figures. + actual: &'static str, + }, +} + +/// Signature algorithm id for Ed25519, matching the rest of the family. +pub const ALG_ED25519: u8 = 0x01; + +/// The profile a specification and its evaluation declare. +pub const PROFILE: &str = "joulespec/1"; + +// ────────────────────────── content addressing ────────────────────────── + +/// A content address: `b3:` followed by the BLAKE3 digest of the canonical +/// det-CBOR encoding, in lowercase hex. +/// +/// The address is taken over the *same* canonical bytes a receipt seals, so +/// an address and a signature commit to one encoding rather than two. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Address(String); + +impl Address { + /// The address of a canonical document. + pub fn of(value: &Value) -> Result { + let bytes = jcr1::canonical_cbor(value)?; + Ok(Self::of_bytes(&bytes)) + } + + /// The address of an opaque byte string — an artifact state, a build + /// output, a tree digest. + #[must_use] + pub fn of_bytes(bytes: &[u8]) -> Self { + let d = blake3::hash(bytes); + let mut out = String::with_capacity(67); + out.push_str("b3:"); + for b in d.as_bytes() { + out.push_str(&format!("{b:02x}")); + } + Self(out) + } + + /// Adopt an address produced elsewhere — an artifact digest from a build + /// system, a goal address from `proof/1`. + #[must_use] + pub fn from_string(s: String) -> Self { + Self(s) + } + + /// The address as it appears on the wire. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for Address { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Lowercase hex for a 32-byte key, as canonical documents carry it. +/// +/// Keys appear in addressed documents as hex rather than as byte arrays so the +/// wire form is readable and fixed-width, and so a document can be compared by +/// eye against a receipt. +#[must_use] +pub fn hex32(bytes: &[u8; 32]) -> String { + let mut out = String::with_capacity(64); + for b in bytes { + out.push_str(&format!("{b:02x}")); + } + out +} + +// ────────────────────────── energy ────────────────────────── + +/// How an energy figure was obtained. The grade travels with the number +/// because the number alone cannot say whether anyone looked. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Provenance { + /// Nobody measured or modelled this work: a default guess. + Estimator, + /// A model the host calibrated — a real curve, not a reading. + ModelBased, + /// A counter read for this work, at its honest resolution. + HwShunt, +} + +impl Provenance { + /// Whether a figure at this grade rests on something the host observed. + #[must_use] + pub const fn is_observed(self) -> bool { + matches!(self, Self::ModelBased | Self::HwShunt) + } + + /// The wire label. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Estimator => "Estimator", + Self::ModelBased => "ModelBased", + Self::HwShunt => "HwShunt", + } + } +} + +/// An energy figure and the grade of the thing that produced it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Energy { + /// Microjoules. + pub uj: u64, + /// How `uj` was obtained. + pub provenance: Provenance, +} + +impl Energy { + /// A figure at a stated grade. + #[must_use] + pub const fn new(uj: u64, provenance: Provenance) -> Self { + Self { uj, provenance } + } + + /// A figure nobody measured. + #[must_use] + pub const fn estimated(uj: u64) -> Self { + Self::new(uj, Provenance::Estimator) + } +} + +/// The worst provenance across a set of figures — what a sum of them may +/// honestly claim. An aggregate is never better graded than its weakest part. +pub fn worst_provenance>(it: I) -> Option { + it.into_iter().min() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_address_covers_the_canonical_bytes_not_the_json_text() { + // Key order in the source literal must not change the address: the + // canonical encoder orders keys, so these are one document. + let a = Address::of(&serde_json::json!({"a": 1, "bb": 2})).unwrap(); + let b = Address::of(&serde_json::json!({"bb": 2, "a": 1})).unwrap(); + assert_eq!(a, b); + assert!(a.as_str().starts_with("b3:"), "{a}"); + assert_eq!(a.as_str().len(), 67, "b3: + 64 hex chars"); + } + + #[test] + fn a_different_document_has_a_different_address() { + let a = Address::of(&serde_json::json!({"a": 1})).unwrap(); + let b = Address::of(&serde_json::json!({"a": 2})).unwrap(); + assert_ne!(a, b); + } + + #[test] + fn an_aggregate_is_graded_by_its_weakest_figure() { + let worst = worst_provenance([Provenance::HwShunt, Provenance::Estimator]); + assert_eq!(worst, Some(Provenance::Estimator)); + let all_measured = worst_provenance([Provenance::HwShunt, Provenance::HwShunt]); + assert_eq!(all_measured, Some(Provenance::HwShunt)); + assert_eq!(worst_provenance([]), None); + } + + #[test] + fn provenance_orders_from_guess_to_reading() { + assert!(Provenance::Estimator < Provenance::ModelBased); + assert!(Provenance::ModelBased < Provenance::HwShunt); + assert!(!Provenance::Estimator.is_observed()); + assert!(Provenance::ModelBased.is_observed()); + } +} diff --git a/joulespec/joulespec-rs/crates/joulespec-core/src/receipt.rs b/joulespec/joulespec-rs/crates/joulespec-core/src/receipt.rs new file mode 100644 index 000000000..da628f7cb --- /dev/null +++ b/joulespec/joulespec-rs/crates/joulespec-core/src/receipt.rs @@ -0,0 +1,326 @@ +//! The `joulespec/1` receipt: what was evidenced, how well, and at what cost. +//! +//! ## Sealed from the inputs, never from a verdict +//! +//! [`seal`] takes the specification, the artifact state, the witnesses and the +//! work — and evaluates them itself. It does not accept a +//! [`Conformance`](crate::conform::Conformance) and sign it. +//! +//! That is deliberate and it is the whole integrity story. A verdict is an +//! ordinary struct with public fields; a function that signed one would let a +//! producer construct `grade: Proved`, hand it over, and get a receipt saying +//! so. Because the receipt is derived from the evidence, a claim in a sealed +//! receipt is reproducible by anyone holding the same inputs — and a producer +//! who wants a better grade has to go and get better evidence. +//! +//! ## What a figure may claim +//! +//! The provenance in the receipt is the **worst** across every figure that fed +//! it, computed during evaluation. An aggregate of one measured reading and +//! one default guess is a guess, and a receipt that reported it as a reading +//! would be the most misleading possible summary of the work. +//! +//! ## Absent, not zero +//! +//! JCR-1's canonical core admits no nulls, and several of these figures are +//! legitimately absent: a specification with an unevidenced requirement has no +//! grade, a run with no work has no specified share, a run where nothing +//! reaches the evidence bar has no cost-per-obligation. Those fields are +//! **omitted** from the core rather than sealed as zero. A reader that finds no +//! `grade` key knows the specification was not graded; a reader that found +//! `"grade": "E0"` would believe it was. +//! +//! Normative: SPEC §5.3 (the headline figure), SPEC §6.1 (derived, not +//! signed on request), SPEC §6.2 (absent, not zero), SPEC §6.3 (the +//! per-clause audit). + +use ed25519_dalek::SigningKey; +use serde_json::{Map, Value, json}; + +use crate::conform::{Conformance, Work, evaluate}; +use crate::evidence::{Grade, SignedWitness}; +use crate::jcr1; +use crate::spec::SignedSpec; +use crate::{Address, Error, PROFILE}; + +/// The grade whose evidence can fail, and therefore the denominator of the +/// headline cost figure. +pub const HEADLINE_BAR: Grade = Grade::Falsifiable; + +/// Build the canonical wire document for an evaluation. +/// +/// Public so a verifier in another language can be checked against the same +/// field set without going through COSE. +#[must_use] +pub fn wire(conformance: &Conformance, steward: &str) -> Value { + let owed = conformance.clauses.iter().filter(|c| c.kind.is_owed()).count(); + let open = conformance.open(HEADLINE_BAR).len(); + + // One entry per clause: which obligation is the weak one is the + // actionable fact, and it must be answerable from the receipt alone + // rather than by re-running an evaluation nobody kept the inputs for. + let clauses: Vec = conformance + .clauses + .iter() + .map(|c| { + let mut entry = Map::new(); + entry.insert("label".into(), json!(c.label)); + entry.insert("kind".into(), json!(c.kind.label())); + entry.insert("obligation".into(), json!(c.obligation.as_str())); + entry.insert("evidence_uj".into(), json!(c.evidence_uj)); + entry.insert("stale".into(), json!(c.stale.len())); + if let Some(grade) = c.grade { + entry.insert("grade".into(), json!(grade.code())); + } + if let Some(best) = &c.best { + entry.insert("witness".into(), json!(best.as_str())); + } + Value::Object(entry) + }) + .collect(); + + let mut map = Map::new(); + map.insert("joulespec_receipt".into(), json!(PROFILE)); + map.insert("clause_verdicts".into(), Value::Array(clauses)); + map.insert("spec".into(), json!(conformance.spec_id.as_str())); + map.insert("artifact".into(), json!(conformance.artifact.as_str())); + map.insert("clauses".into(), json!(conformance.clauses.len())); + map.insert("owed".into(), json!(owed)); + map.insert("open".into(), json!(open)); + map.insert("flags".into(), json!(conformance.flags.len())); + map.insert("specified_uj".into(), json!(conformance.specified_uj)); + map.insert("unspecified_uj".into(), json!(conformance.unspecified_uj)); + map.insert("evidence_uj".into(), json!(conformance.evidence_uj)); + map.insert("steward".into(), json!(steward)); + + // Omitted rather than zeroed — see the module docs. + if let Some(grade) = conformance.grade { + map.insert("grade".into(), json!(grade.code())); + } + if let Some(ppm) = conformance.specified_ppm() { + map.insert("specified_ppm".into(), json!(ppm)); + } + if let Some(uj) = conformance.uj_per_evidenced_clause(HEADLINE_BAR) { + map.insert("uj_per_falsifiable_clause".into(), json!(uj)); + } + if let Some(provenance) = conformance.provenance { + map.insert("provenance".into(), json!(provenance.label())); + } + + Value::Object(map) +} + +/// Evaluate and seal in one step. +/// +/// Returns the COSE_Sign1 envelope and the evaluation it sealed, so a caller +/// can report the detail while publishing the receipt. +pub fn seal( + spec: &SignedSpec, + artifact: &Address, + witnesses: &[SignedWitness], + work: &[Work], + steward: &str, + key: &SigningKey, +) -> Result<(Vec, Conformance), Error> { + let conformance = evaluate(spec, artifact, witnesses, work)?; + let core = jcr1::signed_core_bytes(&wire(&conformance, steward))?; + Ok((jcr1::cose_sign1_envelope(&core, key, steward), conformance)) +} + +/// Verify a sealed receipt and return the document it sealed. +pub fn verify(envelope: &[u8], steward: &str, public_key: &[u8; 32]) -> Result { + let core = jcr1::verify_cose_sign1(envelope, steward, public_key)?; + Ok(jcr1::from_canonical_cbor(&core)?) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conform::Flag; + use crate::evidence::{Evidence, Witness}; + use crate::spec::fixtures::{key, spec}; + use crate::{Energy, Provenance}; + + const STEWARD: &str = "did:key:z6MkExample"; + + fn artifact() -> Address { + Address::of_bytes(b"artifact-v1") + } + + fn witness( + s: &SignedSpec, + label: &str, + evidence: Evidence, + uj: u64, + provenance: Provenance, + ) -> SignedWitness { + let k = key(1); + let obligation = s + .spec + .clauses + .iter() + .find(|c| c.label == label) + .unwrap() + .obligation() + .unwrap(); + Witness::new( + s.spec.spec_id().unwrap(), + obligation, + artifact(), + evidence, + Energy::new(uj, provenance), + k.verifying_key().to_bytes(), + ) + .sign(&k) + .unwrap() + } + + fn falsifiable(check: &str) -> Evidence { + Evidence::Falsifiable { check: check.into(), counter: Address::of_bytes(b"mutant") } + } + + fn fully_evidenced() -> (SignedSpec, Vec, Vec) { + let s = spec(&key(1)); + let ws = vec![ + witness(&s, "C1", falsifiable("rejects_anon"), 1_000, Provenance::HwShunt), + witness(&s, "C2", falsifiable("decided_first"), 1_000, Provenance::HwShunt), + ]; + let work = vec![Work::new( + Address::of_bytes(b"op1"), + vec![s.spec.clauses[0].obligation().unwrap()], + Energy::new(48_000, Provenance::HwShunt), + )]; + (s, ws, work) + } + + #[test] + fn a_sealed_receipt_verifies_and_carries_the_evaluation() { + let (s, ws, work) = fully_evidenced(); + let k = key(9); + let (env, conformance) = + seal(&s, &artifact(), &ws, &work, STEWARD, &k).expect("seals"); + + assert!(conformance.is_conformant(Grade::Falsifiable)); + let doc = verify(&env, STEWARD, &k.verifying_key().to_bytes()).expect("verifies"); + assert_eq!(doc["joulespec_receipt"], json!("joulespec/1")); + assert_eq!(doc["grade"], json!("E3")); + assert_eq!(doc["owed"], json!(2)); + assert_eq!(doc["open"], json!(0)); + assert_eq!(doc["flags"], json!(0)); + assert_eq!(doc["provenance"], json!("HwShunt")); + assert_eq!(doc["uj_per_falsifiable_clause"], json!(25_000)); + assert_eq!(doc["specified_ppm"], json!(1_000_000)); + + // The per-clause audit: a reader finds the weak obligation here + // without holding the original evidence set. + let verdicts = doc["clause_verdicts"].as_array().expect("array"); + assert_eq!(verdicts.len(), 3); + assert_eq!(verdicts[0]["label"], json!("C1")); + assert_eq!(verdicts[0]["grade"], json!("E3")); + assert_eq!(verdicts[0]["evidence_uj"], json!(1_000)); + assert_eq!(verdicts[2]["kind"], json!("non-goal")); + assert!( + verdicts[2].get("grade").is_none(), + "an unwitnessed non-goal is correct, and carries no grade" + ); + } + + #[test] + fn a_receipt_is_byte_identical_for_identical_evidence() { + let (s, ws, work) = fully_evidenced(); + let k = key(9); + let (first, _) = seal(&s, &artifact(), &ws, &work, STEWARD, &k).unwrap(); + let (second, _) = seal(&s, &artifact(), &ws, &work, STEWARD, &k).unwrap(); + assert_eq!(first, second, "the receipt is a function of the evidence"); + } + + #[test] + fn an_ungraded_specification_omits_the_grade_rather_than_sealing_a_floor() { + let s = spec(&key(1)); + // C2 unevidenced. + let ws = vec![witness(&s, "C1", falsifiable("rejects_anon"), 10, Provenance::HwShunt)]; + let k = key(9); + let (env, conformance) = seal(&s, &artifact(), &ws, &[], STEWARD, &k).unwrap(); + assert_eq!(conformance.grade, None); + + let doc = verify(&env, STEWARD, &k.verifying_key().to_bytes()).unwrap(); + assert!( + doc.get("grade").is_none(), + "a reader must not find a grade where none was established: {doc}" + ); + assert_eq!(doc["open"], json!(1)); + let verdicts = doc["clause_verdicts"].as_array().unwrap(); + assert_eq!(verdicts[0]["grade"], json!("E3"), "C1 is evidenced"); + assert!(verdicts[1].get("grade").is_none(), "C2 is the open one, and says so"); + } + + #[test] + fn one_guessed_figure_degrades_the_whole_receipt() { + let s = spec(&key(1)); + let ws = vec![ + witness(&s, "C1", falsifiable("rejects_anon"), 1_000, Provenance::HwShunt), + witness(&s, "C2", falsifiable("decided_first"), 1_000, Provenance::Estimator), + ]; + let k = key(9); + let (env, _) = seal(&s, &artifact(), &ws, &[], STEWARD, &k).unwrap(); + let doc = verify(&env, STEWARD, &k.verifying_key().to_bytes()).unwrap(); + assert_eq!( + doc["provenance"], + json!("Estimator"), + "a sum containing a guess is a guess" + ); + } + + #[test] + fn a_flagged_evidence_set_seals_the_flag_rather_than_refusing() { + let s = spec(&key(1)); + let ws = vec![ + witness(&s, "C1", falsifiable("rejects_anon"), 1_000, Provenance::HwShunt), + witness(&s, "C2", falsifiable("decided_first"), 1_000, Provenance::HwShunt), + // Something implements the non-goal. + witness(&s, "C3", Evidence::Exercised { check: "retry".into() }, 10, Provenance::HwShunt), + ]; + let k = key(9); + let (env, conformance) = seal(&s, &artifact(), &ws, &[], STEWARD, &k).unwrap(); + + assert!(matches!(conformance.flags.as_slice(), [Flag::NonGoalWitnessed { .. }])); + assert!(!conformance.is_conformant(Grade::Falsifiable)); + let doc = verify(&env, STEWARD, &k.verifying_key().to_bytes()).unwrap(); + assert_eq!(doc["flags"], json!(1), "disclosed in the sealed artifact"); + assert_eq!(doc["grade"], json!("E3"), "the owed clauses really are evidenced"); + } + + #[test] + fn a_receipt_cannot_be_sealed_for_an_unsigned_specification() { + let (mut s, ws, work) = fully_evidenced(); + s.sig = vec![0u8; 64]; + let k = key(9); + assert!(matches!( + seal(&s, &artifact(), &ws, &work, STEWARD, &k), + Err(Error::BadSignature) + )); + } + + #[test] + fn a_receipt_does_not_verify_under_another_steward_or_key() { + let (s, ws, work) = fully_evidenced(); + let k = key(9); + let (env, _) = seal(&s, &artifact(), &ws, &work, STEWARD, &k).unwrap(); + assert!(verify(&env, "did:key:someone-else", &k.verifying_key().to_bytes()).is_err()); + assert!(verify(&env, STEWARD, &key(8).verifying_key().to_bytes()).is_err()); + } + + #[test] + fn evidence_from_a_stale_artifact_state_cannot_be_sealed_as_current() { + let (s, ws, work) = fully_evidenced(); + let k = key(9); + // The same witnesses, evaluated against a later artifact state. + let (env, conformance) = + seal(&s, &Address::of_bytes(b"artifact-v2"), &ws, &work, STEWARD, &k).unwrap(); + + assert_eq!(conformance.grade, None, "nothing witnesses the new state"); + let doc = verify(&env, STEWARD, &k.verifying_key().to_bytes()).unwrap(); + assert!(doc.get("grade").is_none()); + assert_eq!(doc["open"], json!(2)); + } +} diff --git a/joulespec/joulespec-rs/crates/joulespec-core/src/spec.rs b/joulespec/joulespec-rs/crates/joulespec-core/src/spec.rs new file mode 100644 index 000000000..a7c5b2d54 --- /dev/null +++ b/joulespec/joulespec-rs/crates/joulespec-core/src/spec.rs @@ -0,0 +1,466 @@ +//! The specification: prose, addressed by obligation. +//! +//! A specification is an intent plus ordered clauses, signed by its author +//! and content-addressed, with revisions chained by `parent` so the history +//! of what was approved is tamper-evident. That much this family already had. +//! +//! The load-bearing decision here is **what a clause's address covers**. +//! +//! A clause carries a label (`"C1"`, `"auth-2"`) because reports and +//! conversations need a short handle, and an *obligation* — the address of its +//! kind and its text, with the label deliberately excluded +//! ([`Clause::obligation`]). Evidence binds to the obligation. The +//! consequences are the point: +//! +//! - **Relabelling is free.** Renumbering a specification, or renaming `C3` +//! to `rate-limit`, moves no obligation and loses no evidence. +//! - **Rewording is not.** Changing "the endpoint must reject unauthenticated +//! requests" to "…should reject…" is a different obligation with a +//! different address, and every piece of evidence bound to the old wording +//! stops applying to the new one — automatically, with nobody having to +//! notice. +//! +//! Binding by label gets this exactly backwards: evidence survives the edit +//! that changed the requirement and breaks on the edit that renamed it. That +//! is not a tooling gap, it is the mechanism by which specification drift goes +//! unobserved, and an address is the whole fix. +//! +//! Normative: SPEC §2.1 (the clause and its obligation), SPEC §2.2 (the +//! specification), SPEC §2.3 (revision). + +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{ALG_ED25519, Address, Error, PROFILE}; + +/// What kind of statement a clause makes. +/// +/// Non-goals are first-class: writing down what the system must *not* do is +/// half the value of a design conversation, and evidence that something +/// implements a non-goal is a flag rather than a pass. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ClauseKind { + /// Behaviour the implementation must exhibit. + Requirement, + /// A bound the implementation must stay inside — interface, dependency, + /// style, or energy. + Constraint, + /// Behaviour deliberately out of scope. + NonGoal, +} + +impl ClauseKind { + /// Whether this kind states something the implementation owes. Non-goals + /// do not: nothing is open because nothing should be built. + #[must_use] + pub const fn is_owed(self) -> bool { + matches!(self, Self::Requirement | Self::Constraint) + } + + /// The wire label. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Requirement => "requirement", + Self::Constraint => "constraint", + Self::NonGoal => "non-goal", + } + } +} + +/// The address of one obligation: what evidence binds to. +/// +/// Derived from a clause's kind and text. Two clauses with the same kind and +/// the same text state one obligation and have one address — which is why +/// [`Spec::validate`] refuses a specification containing both. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Obligation(Address); + +impl Obligation { + /// The underlying address. + #[must_use] + pub const fn address(&self) -> &Address { + &self.0 + } + + /// The address as it appears on the wire. + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Adopt an obligation address from the wire. + #[must_use] + pub const fn from_address(address: Address) -> Self { + Self(address) + } +} + +impl std::fmt::Display for Obligation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0.as_str()) + } +} + +/// One individually addressable statement of the specification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Clause { + /// The editorial handle — short, unique within the specification, and + /// deliberately **not** part of the obligation address. + pub label: String, + /// Requirement, constraint, or non-goal. + pub kind: ClauseKind, + /// The statement itself, in the author's words. + pub text: String, +} + +impl Clause { + /// A clause at a kind. + pub fn new(label: impl Into, kind: ClauseKind, text: impl Into) -> Self { + Self { label: label.into(), kind, text: text.into() } + } + + /// A requirement. + pub fn requirement(label: impl Into, text: impl Into) -> Self { + Self::new(label, ClauseKind::Requirement, text) + } + + /// A constraint. + pub fn constraint(label: impl Into, text: impl Into) -> Self { + Self::new(label, ClauseKind::Constraint, text) + } + + /// A non-goal. + pub fn non_goal(label: impl Into, text: impl Into) -> Self { + Self::new(label, ClauseKind::NonGoal, text) + } + + /// The obligation this clause states: the address of `{kind, text}`. + /// + /// The label is excluded, so the address answers "is this the same + /// obligation" and not "is this the same clause entry". Text is compared + /// exactly — a specification author who reflows a paragraph has changed + /// the obligation as far as this standard is concerned, which is the + /// conservative direction: evidence is dropped, never silently carried. + pub fn obligation(&self) -> Result { + let doc = json!({ "kind": self.kind.label(), "text": self.text }); + Ok(Obligation(Address::of(&doc)?)) + } +} + +/// The canonical specification artifact, unsigned. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Spec { + /// The profile this specification declares (`joulespec/1`). + pub profile: String, + /// Signature algorithm id (`0x01` = Ed25519). + pub alg: u8, + /// The distilled intent — *why* this exists, in the author's words. + pub intent: String, + /// Ordered, non-empty clauses with unique labels and distinct + /// obligations. + pub clauses: Vec, + /// The specification this one revises, if any. Revisions chain by + /// address, so what was approved is tamper-evident history. + pub parent: Option
, + /// The author's Ed25519 verifying key. + pub author: [u8; 32], +} + +impl Spec { + /// A root specification. + pub fn new(intent: impl Into, clauses: Vec, author: [u8; 32]) -> Self { + Self { + profile: PROFILE.to_string(), + alg: ALG_ED25519, + intent: intent.into(), + clauses, + parent: None, + author, + } + } + + /// Structural validation: a known profile and algorithm, a non-empty + /// intent, at least one clause, unique well-formed labels, non-empty + /// text, and no two clauses stating one obligation. + pub fn validate(&self) -> Result<(), Error> { + if self.profile != PROFILE { + return Err(Error::BadProfile(self.profile.clone())); + } + if self.alg != ALG_ED25519 { + return Err(Error::BadAlg(self.alg)); + } + if self.intent.trim().is_empty() { + return Err(Error::EmptyIntent); + } + if self.clauses.is_empty() { + return Err(Error::NoClauses); + } + let mut labels = std::collections::BTreeSet::new(); + // Obligation -> the label that first stated it, so the error can name + // both halves of a collision rather than just the second one. + let mut obligations = std::collections::BTreeMap::new(); + for clause in &self.clauses { + if clause.label.is_empty() || clause.label.chars().any(char::is_whitespace) { + return Err(Error::BadLabel(clause.label.clone())); + } + if clause.text.trim().is_empty() { + return Err(Error::EmptyClause(clause.label.clone())); + } + if !labels.insert(clause.label.as_str()) { + return Err(Error::DuplicateLabel(clause.label.clone())); + } + let obligation = clause.obligation()?; + if let Some(first) = obligations.insert(obligation, clause.label.clone()) { + return Err(Error::DuplicateObligation(first, clause.label.clone())); + } + } + Ok(()) + } + + /// The canonical document the address and the signature commit to. + /// + /// Written out rather than derived from the struct, for two reasons: the + /// canonical core admits no nulls, so an absent `parent` must be *omitted* + /// rather than serialized; and the wire form is then a declared part of the + /// standard instead of a consequence of Rust field names. + pub fn canonical(&self) -> Value { + let clauses: Vec = self + .clauses + .iter() + .map(|c| json!({ "label": c.label, "kind": c.kind.label(), "text": c.text })) + .collect(); + let mut map = serde_json::Map::new(); + map.insert("profile".into(), json!(self.profile)); + map.insert("alg".into(), json!(self.alg)); + map.insert("intent".into(), json!(self.intent)); + map.insert("clauses".into(), Value::Array(clauses)); + map.insert("author".into(), json!(crate::hex32(&self.author))); + if let Some(parent) = &self.parent { + map.insert("parent".into(), json!(parent.as_str())); + } + Value::Object(map) + } + + /// The specification's address. Validates first — a specification that + /// does not hold together has no address. + pub fn spec_id(&self) -> Result { + self.validate()?; + Address::of(&self.canonical()) + } + + /// The obligations this specification states, in clause order. + pub fn obligations(&self) -> Result, Error> { + self.validate()?; + self.clauses + .iter() + .map(|c| Ok((c.label.clone(), c.kind, c.obligation()?))) + .collect() + } + + /// The signature preimage: `spec_id ‖ parent`, so signing binds the + /// revision to its lineage — the same shape the family's operations use. + fn preimage(&self) -> Result, Error> { + let spec_id = self.spec_id()?; + let mut pre = spec_id.as_str().as_bytes().to_vec(); + if let Some(parent) = &self.parent { + pre.extend_from_slice(parent.as_str().as_bytes()); + } + Ok(pre) + } + + /// Sign with the author's key. + pub fn sign(self, key: &SigningKey) -> Result { + let sig = key.sign(&self.preimage()?); + Ok(SignedSpec { spec: self, sig: sig.to_bytes().to_vec() }) + } + + /// Begin a revision: same intent and clauses — edit them before signing — + /// with `parent` set to this specification's address and the author + /// replaced by the reviser. + pub fn revise(&self, author: [u8; 32]) -> Result { + Ok(Self { + profile: self.profile.clone(), + alg: self.alg, + intent: self.intent.clone(), + clauses: self.clauses.clone(), + parent: Some(self.spec_id()?), + author, + }) + } +} + +/// A specification plus its author's signature. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedSpec { + /// The specification. + pub spec: Spec, + /// Ed25519 signature over `spec_id ‖ parent`. + pub sig: Vec, +} + +impl SignedSpec { + /// Verify structure, address, and signature, returning the address. + pub fn verify(&self) -> Result { + let pre = self.spec.preimage()?; + let key = VerifyingKey::from_bytes(&self.spec.author).map_err(|_| Error::BadSignature)?; + let sig: [u8; 64] = self.sig.as_slice().try_into().map_err(|_| Error::BadSignature)?; + key.verify(&pre, &Signature::from_bytes(&sig)).map_err(|_| Error::BadSignature)?; + self.spec.spec_id() + } +} + +#[cfg(test)] +pub(crate) mod fixtures { + use super::*; + + pub fn key(seed: u8) -> SigningKey { + SigningKey::from_bytes(&[seed; 32]) + } + + /// Three clauses: two owed, one non-goal. + pub fn spec(author: &SigningKey) -> SignedSpec { + Spec::new( + "A metered endpoint that refuses what it cannot charge for.", + vec![ + Clause::requirement("C1", "The endpoint rejects unauthenticated requests."), + Clause::constraint("C2", "A request is decided before any side effect runs."), + Clause::non_goal("C3", "The endpoint does not implement its own retry policy."), + ], + author.verifying_key().to_bytes(), + ) + .sign(author) + .expect("fixture signs") + } +} + +#[cfg(test)] +mod tests { + use super::fixtures::{key, spec}; + use super::*; + + #[test] + fn a_signed_spec_verifies_and_addresses() { + let k = key(1); + let s = spec(&k); + let id = s.verify().expect("verifies"); + assert!(id.as_str().starts_with("b3:")); + assert_eq!(id, s.spec.spec_id().unwrap(), "address is stable"); + } + + #[test] + fn a_tampered_clause_breaks_the_signature() { + let k = key(1); + let mut s = spec(&k); + s.spec.clauses[0].text = "The endpoint rejects nothing.".into(); + assert!(matches!(s.verify(), Err(Error::BadSignature))); + } + + // ── the load-bearing pair ─────────────────────────────────────────── + + #[test] + fn relabelling_a_clause_moves_no_obligation() { + let original = Clause::requirement("C1", "The endpoint rejects unauthenticated requests."); + let renamed = + Clause::requirement("auth-2", "The endpoint rejects unauthenticated requests."); + assert_eq!( + original.obligation().unwrap(), + renamed.obligation().unwrap(), + "a label is editorial: evidence must survive renaming" + ); + } + + #[test] + fn rewording_a_clause_moves_its_obligation() { + let strict = Clause::requirement("C1", "The endpoint rejects unauthenticated requests."); + let weakened = + Clause::requirement("C1", "The endpoint should reject unauthenticated requests."); + assert_ne!( + strict.obligation().unwrap(), + weakened.obligation().unwrap(), + "same label, different requirement: evidence must not carry over" + ); + } + + #[test] + fn changing_a_clauses_kind_moves_its_obligation() { + let owed = Clause::requirement("C1", "Retries are handled upstream."); + let disowned = Clause::non_goal("C1", "Retries are handled upstream."); + assert_ne!(owed.obligation().unwrap(), disowned.obligation().unwrap()); + } + + // ── validation ────────────────────────────────────────────────────── + + #[test] + fn two_clauses_stating_one_obligation_are_refused() { + let s = Spec::new( + "intent", + vec![ + Clause::requirement("C1", "The endpoint is metered."), + Clause::requirement("C2", "The endpoint is metered."), + ], + key(1).verifying_key().to_bytes(), + ); + // Both halves of the collision are named: a report that said only + // "C2" would send the author looking at the wrong clause. + assert!( + matches!(s.validate(), Err(Error::DuplicateObligation(a, b)) if a == "C1" && b == "C2") + ); + } + + #[test] + fn the_same_text_at_different_kinds_is_not_a_collision() { + let s = Spec::new( + "intent", + vec![ + Clause::requirement("C1", "Retries are handled upstream."), + Clause::non_goal("C2", "Retries are handled upstream."), + ], + key(1).verifying_key().to_bytes(), + ); + assert!(s.validate().is_ok(), "kind is part of the obligation"); + } + + #[test] + fn structural_refusals() { + let author = key(1).verifying_key().to_bytes(); + assert!(matches!(Spec::new("", vec![Clause::requirement("C1", "x")], author).validate(), + Err(Error::EmptyIntent))); + assert!(matches!(Spec::new("i", vec![], author).validate(), Err(Error::NoClauses))); + assert!(matches!( + Spec::new("i", vec![Clause::requirement("C 1", "x")], author).validate(), + Err(Error::BadLabel(l)) if l == "C 1" + )); + assert!(matches!( + Spec::new("i", vec![Clause::requirement("C1", " ")], author).validate(), + Err(Error::EmptyClause(l)) if l == "C1" + )); + assert!(matches!( + Spec::new("i", vec![Clause::requirement("C1", "x"), Clause::constraint("C1", "y")], author).validate(), + Err(Error::DuplicateLabel(l)) if l == "C1" + )); + } + + #[test] + fn a_revision_chains_to_its_parent_and_can_be_signed_by_someone_else() { + let author = key(1); + let reviser = key(2); + let root = spec(&author); + let root_id = root.verify().unwrap(); + + let mut next = root.spec.revise(reviser.verifying_key().to_bytes()).unwrap(); + next.clauses.push(Clause::requirement("C4", "Every decision seals a receipt.")); + let next = next.sign(&reviser).unwrap(); + + assert_eq!(next.spec.parent.as_ref(), Some(&root_id)); + assert_ne!(next.verify().unwrap(), root_id); + } + + #[test] + fn a_specification_that_does_not_validate_has_no_address() { + let s = Spec::new("", vec![], key(1).verifying_key().to_bytes()); + assert!(s.spec_id().is_err()); + } +}