joulespec: a standard for energy-accounted specification conformance #561
17 changed files with 4639 additions and 2 deletions
21
.github/workflows/standards-build.yml
vendored
21
.github/workflows/standards-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
48
README.md
48
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
|
||||
|
|
|
|||
202
joulespec/LICENSE
Normal file
202
joulespec/LICENSE
Normal file
|
|
@ -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.
|
||||
30
joulespec/LICENSE-SPEC
Normal file
30
joulespec/LICENSE-SPEC
Normal file
|
|
@ -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.
|
||||
213
joulespec/README.md
Normal file
213
joulespec/README.md
Normal file
|
|
@ -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.
|
||||
480
joulespec/SPEC.md
Normal file
480
joulespec/SPEC.md
Normal file
|
|
@ -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.*
|
||||
1
joulespec/joulespec-rs/.gitignore
vendored
Normal file
1
joulespec/joulespec-rs/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
442
joulespec/joulespec-rs/Cargo.lock
generated
Normal file
442
joulespec/joulespec-rs/Cargo.lock
generated
Normal file
|
|
@ -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"
|
||||
15
joulespec/joulespec-rs/Cargo.toml
Normal file
15
joulespec/joulespec-rs/Cargo.toml
Normal file
|
|
@ -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"
|
||||
17
joulespec/joulespec-rs/crates/joulespec-core/Cargo.toml
Normal file
17
joulespec/joulespec-rs/crates/joulespec-core/Cargo.toml
Normal file
|
|
@ -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"
|
||||
666
joulespec/joulespec-rs/crates/joulespec-core/src/conform.rs
Normal file
666
joulespec/joulespec-rs/crates/joulespec-core/src/conform.rs
Normal file
|
|
@ -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<Obligation>,
|
||||
/// What the work cost.
|
||||
pub energy: Energy,
|
||||
}
|
||||
|
||||
impl Work {
|
||||
/// A work unit.
|
||||
pub fn new(id: Address, cites: Vec<Obligation>, 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<Grade>,
|
||||
/// The witness carrying that grade — lowest address among ties, so the
|
||||
/// verdict is reproducible.
|
||||
pub best: Option<Address>,
|
||||
/// Witnesses for this obligation bound to some *other* artifact state.
|
||||
/// Evidence that was true once and says nothing about now.
|
||||
pub stale: Vec<Address>,
|
||||
/// What the surviving evidence for this obligation cost to produce.
|
||||
pub evidence_uj: u64,
|
||||
/// The weakest provenance among those figures.
|
||||
pub provenance: Option<Provenance>,
|
||||
}
|
||||
|
||||
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<ClauseVerdict>,
|
||||
/// The grade of the weakest owed obligation — the specification's grade.
|
||||
/// `None` when any requirement or constraint has no surviving evidence.
|
||||
pub grade: Option<Grade>,
|
||||
/// Problems with the evidence set itself, sorted.
|
||||
pub flags: Vec<Flag>,
|
||||
/// 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<Provenance>,
|
||||
}
|
||||
|
||||
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<u64> {
|
||||
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<u64> {
|
||||
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<Conformance, Error> {
|
||||
let spec_id = spec.verify()?;
|
||||
let obligations = spec.spec.obligations()?;
|
||||
|
||||
let mut verdicts: Vec<ClauseVerdict> = 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");
|
||||
}
|
||||
}
|
||||
333
joulespec/joulespec-rs/crates/joulespec-core/src/drift.rs
Normal file
333
joulespec/joulespec-rs/crates/joulespec-core/src/drift.rs
Normal file
|
|
@ -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<Obligation>,
|
||||
/// Obligations carried over under a different label.
|
||||
pub relabelled: Vec<Relabel>,
|
||||
/// Labels carried over with a changed obligation.
|
||||
pub reworded: Vec<Reword>,
|
||||
/// 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<Obligation> {
|
||||
let mut out: Vec<Obligation> = 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<Obligation> {
|
||||
let mut out: Vec<Obligation> = 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<Drift, Error> {
|
||||
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<Clause>),
|
||||
) -> 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);
|
||||
}
|
||||
}
|
||||
569
joulespec/joulespec-rs/crates/joulespec-core/src/evidence.rs
Normal file
569
joulespec/joulespec-rs/crates/joulespec-core/src/evidence.rs
Normal file
|
|
@ -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<Address, Error> {
|
||||
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<Vec<u8>, 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<SignedWitness, Error> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
impl SignedWitness {
|
||||
/// Verify structure and signature, returning the witness address.
|
||||
pub fn verify(&self) -> Result<Address, Error> {
|
||||
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(_))));
|
||||
}
|
||||
}
|
||||
510
joulespec/joulespec-rs/crates/joulespec-core/src/jcr1.rs
Normal file
510
joulespec/joulespec-rs/crates/joulespec-core/src/jcr1.rs
Normal file
|
|
@ -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<u8>, 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<u8>, s: &str) {
|
||||
cbor_uint(out, 3, s.len() as u64);
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
}
|
||||
|
||||
fn cbor_bytes(out: &mut Vec<u8>, b: &[u8]) {
|
||||
cbor_uint(out, 2, b.len() as u64);
|
||||
out.extend_from_slice(b);
|
||||
}
|
||||
|
||||
fn cbor_value(out: &mut Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<u8> {
|
||||
// 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<u8> {
|
||||
// ["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<u8> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
/// 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<Vec<u8>, 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<u64, Jcr1Error> {
|
||||
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<Vec<u8>, 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<u8>, Vec<u8>, Vec<u8>);
|
||||
|
||||
fn parse_cose_sign1(envelope: &[u8]) -> Result<CoseSign1Parts, Jcr1Error> {
|
||||
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<ProtectedHeader, Jcr1Error> {
|
||||
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<u64, Jcr1Error> {
|
||||
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<Value, Jcr1Error> {
|
||||
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<Value, Jcr1Error> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
302
joulespec/joulespec-rs/crates/joulespec-core/src/lib.rs
Normal file
302
joulespec/joulespec-rs/crates/joulespec-core/src/lib.rs
Normal file
|
|
@ -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<Self, Error> {
|
||||
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<I: IntoIterator<Item = Provenance>>(it: I) -> Option<Provenance> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
326
joulespec/joulespec-rs/crates/joulespec-core/src/receipt.rs
Normal file
326
joulespec/joulespec-rs/crates/joulespec-core/src/receipt.rs
Normal file
|
|
@ -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<Value> = 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<u8>, 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<Value, Error> {
|
||||
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<SignedWitness>, Vec<Work>) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
466
joulespec/joulespec-rs/crates/joulespec-core/src/spec.rs
Normal file
466
joulespec/joulespec-rs/crates/joulespec-core/src/spec.rs
Normal file
|
|
@ -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<String>, kind: ClauseKind, text: impl Into<String>) -> Self {
|
||||
Self { label: label.into(), kind, text: text.into() }
|
||||
}
|
||||
|
||||
/// A requirement.
|
||||
pub fn requirement(label: impl Into<String>, text: impl Into<String>) -> Self {
|
||||
Self::new(label, ClauseKind::Requirement, text)
|
||||
}
|
||||
|
||||
/// A constraint.
|
||||
pub fn constraint(label: impl Into<String>, text: impl Into<String>) -> Self {
|
||||
Self::new(label, ClauseKind::Constraint, text)
|
||||
}
|
||||
|
||||
/// A non-goal.
|
||||
pub fn non_goal(label: impl Into<String>, text: impl Into<String>) -> 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<Obligation, Error> {
|
||||
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<Clause>,
|
||||
/// The specification this one revises, if any. Revisions chain by
|
||||
/// address, so what was approved is tamper-evident history.
|
||||
pub parent: Option<Address>,
|
||||
/// The author's Ed25519 verifying key.
|
||||
pub author: [u8; 32],
|
||||
}
|
||||
|
||||
impl Spec {
|
||||
/// A root specification.
|
||||
pub fn new(intent: impl Into<String>, clauses: Vec<Clause>, 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<Value> = 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<Address, Error> {
|
||||
self.validate()?;
|
||||
Address::of(&self.canonical())
|
||||
}
|
||||
|
||||
/// The obligations this specification states, in clause order.
|
||||
pub fn obligations(&self) -> Result<Vec<(String, ClauseKind, Obligation)>, 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<Vec<u8>, 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<SignedSpec, Error> {
|
||||
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<Self, Error> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
impl SignedSpec {
|
||||
/// Verify structure, address, and signature, returning the address.
|
||||
pub fn verify(&self) -> Result<Address, Error> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue