jouleclaw: sign the tree head, and give the transparency log a consumer #167

Merged
dcharlot merged 1 commit from feat/transparency-log into main 2026-10-06 17:28:15 -04:00
Owner

Two problems that turned out to be the same problem seen from either end.

The tree head was signed in name only

The crate's stated promise is that "any party can prove a receipt was included
in the log without trusting the log operator."
An inclusion proof delivers that
only against a root the operator is bound to. TreeHead — whose doc comment
read "Signed Tree Head — the public commitment the log operator makes" — had no
signature field, no key, and no verification. An unsigned root can be minted
per-auditor, so two parties shown different roots had nothing to compare and
nobody to attribute it to. The promise was false as written.

TreeHead::sign / SignedTreeHead::verify now sign over:

0x02 || size (8, BE) || root (32 raw) || timestamp (8, BE)
  • 0x02 is a third domain separator, distinct from the leaf (0x00) and
    node (0x01) prefixes, so a tree head cannot be presented as an entry in its
    own log.
  • Every field is fixed-width, so no two distinct heads share a preimage.
  • verify returns the root rather than (). A caller handed the root by
    this method cannot verify a proof against an unverified head by accident,
    which is the mistake the type exists to prevent.
  • root_hex is a String, so it is now parsed rather than assumed: a head whose
    root is not 32 bytes of hex is refused at signing time and again at verify
    time, because a signature over an unparseable root is a signature over nothing
    checkable.

The sharpest test is the one an unsigned root cannot pass:
two_signed_heads_at_one_size_are_an_attributable_split_view. Neither head is
forged, both carry the operator's signature, and the pair is evidence against
one key. That is what makes "without trusting the operator" mean something.

Nothing consumed the crate

The RFC 6962 proofs merged in #153 were inert — no JouleClaw or JCP decision
could be given an inclusion proof, so an auditor-grade tier existed as an
unreferenced library.

JcpGate was already accumulating its sealed receipts in a private
Vec<Vec<u8>>, which is only as trustworthy as the party holding it. Each
receipt is now appended to a Merkle log on the same path, and the gate publishes
signed_tree_head, inclusion_proof and consistency_proof. An auditor with
only the steward's public key can check that a specific decision — allow or deny
— is in the stream the operator published. Stream and log are appended in one
place, so an index into one is an index into the other.

Decisions made through authorize_named (cascade answers, skill loads) land in
the same log, with a test to pin it: a log holding only tool calls would be a
partial record presented as a whole one.

Fail closed

On that same path, if let Ok(receipt) = seal_chained(..) swallowed a seal
failure and then returned the verdict — so an allow could proceed with no
receipt and no log entry, the one outcome the log exists to make impossible. A
seal failure now refuses the dispatch. The debit has already landed, so the
caller is charged for a call it does not get; that is the safe direction,
because the opposite is an unrecorded side effect. authorize_named reports it
as SEAL_FAILED, a reason distinct from any JCP verdict: the call was not
refused on authority, it was refused because it could not be recorded.

That branch is unreachable through the public API — every field of the JCR-1
wire is a type the canonicaliser accepts — so my first falsification pass found
it untested: reverting the fix passed all 8 tests. An untested refusal is a
comment, not a guarantee, so there is now a #[cfg(test)] fault seam
(fail_seals, compiled out of every build a consumer sees) and a test. With the
fix reverted, that test fails by running the inner tool — the precise
unrecorded side effect.

What this does not prove

Stated in the module docs and the spec rather than left to be inferred: not
completeness.
An operator that never records a decision logs nothing, and no
proof about the entries that exist can speak for one that was never written.
What the log does prove is that a receipt is in the tree a signed head commits
to, that the log has only grown between two signed heads, and that an entry
committed to under a published head cannot afterwards be removed or rewritten
without equivocating under the operator's key.

What narrows the gap is where the append happens — before the verdict returns
— so a caller that observed a refusal can demand the entry that refused it.

Spec

New §22 Transparency log (OPTIONAL): the tree, the signing preimage
byte-for-byte, the MUSTs on signing and on refusing an unparseable root, the
proven/not-proven split in plain terms, and conformance class C-TLOG.
References moves to §23; nothing cross-referenced it.

Verification

jouleclaw-transparency-log 26 passed, jouleclaw-jcp 9 passed, 0 failed. No
new clippy warnings in either crate. Workspace resolves; nothing else depends on
either crate, so the blast radius is the two.

Eight falsifications from the committed baseline:

reverted caught by
timestamp dropped from the signing preimage 2 tests
signature not actually checked in verify every_field_of_a_head_is_covered_by_the_signature
unparseable root_hex padded instead of refused a_head_whose_root_is_not_a_root_is_refused_rather_than_signed
HEAD_PREFIX collided with LEAF_PREFIX head_preimages_are_domain_separated_from_leaves_and_nodes
log append dropped, stream kept 4 tests
only some decisions logged 3 tests
head signed by a key that is not the steward 4 tests
seal failure swallowed a_call_that_cannot_be_recorded_is_refused_not_performed — fails by running the tool

🤖 Generated with Claude Code

Two problems that turned out to be the same problem seen from either end. ## The tree head was signed in name only The crate's stated promise is that *"any party can prove a receipt was included in the log without trusting the log operator."* An inclusion proof delivers that only against a root the operator is **bound to**. `TreeHead` — whose doc comment read "Signed Tree Head — the public commitment the log operator makes" — had no signature field, no key, and no verification. An unsigned root can be minted per-auditor, so two parties shown different roots had nothing to compare and nobody to attribute it to. The promise was false as written. `TreeHead::sign` / `SignedTreeHead::verify` now sign over: ``` 0x02 || size (8, BE) || root (32 raw) || timestamp (8, BE) ``` - `0x02` is a **third** domain separator, distinct from the leaf (`0x00`) and node (`0x01`) prefixes, so a tree head cannot be presented as an entry in its own log. - Every field is fixed-width, so no two distinct heads share a preimage. - `verify` returns the **root** rather than `()`. A caller handed the root by this method cannot verify a proof against an *unverified* head by accident, which is the mistake the type exists to prevent. - `root_hex` is a `String`, so it is now parsed rather than assumed: a head whose root is not 32 bytes of hex is refused at signing time **and again** at verify time, because a signature over an unparseable root is a signature over nothing checkable. The sharpest test is the one an unsigned root cannot pass: `two_signed_heads_at_one_size_are_an_attributable_split_view`. Neither head is forged, both carry the operator's signature, and the pair is evidence against one key. That is what makes "without trusting the operator" mean something. ## Nothing consumed the crate The RFC 6962 proofs merged in #153 were inert — no JouleClaw or JCP decision could be given an inclusion proof, so an auditor-grade tier existed as an unreferenced library. `JcpGate` was already accumulating its sealed receipts in a private `Vec<Vec<u8>>`, which is only as trustworthy as the party holding it. Each receipt is now appended to a Merkle log on the same path, and the gate publishes `signed_tree_head`, `inclusion_proof` and `consistency_proof`. An auditor with only the steward's public key can check that a specific decision — allow or deny — is in the stream the operator published. Stream and log are appended in one place, so an index into one is an index into the other. Decisions made through `authorize_named` (cascade answers, skill loads) land in the same log, with a test to pin it: a log holding only tool calls would be a partial record presented as a whole one. ## Fail closed On that same path, `if let Ok(receipt) = seal_chained(..)` swallowed a seal failure and then returned the verdict — so an **allow** could proceed with no receipt and no log entry, the one outcome the log exists to make impossible. A seal failure now refuses the dispatch. The debit has already landed, so the caller is charged for a call it does not get; that is the safe direction, because the opposite is an unrecorded side effect. `authorize_named` reports it as `SEAL_FAILED`, a reason distinct from any JCP verdict: the call was not refused on authority, it was refused because it could not be recorded. That branch is **unreachable through the public API** — every field of the JCR-1 wire is a type the canonicaliser accepts — so my first falsification pass found it untested: reverting the fix passed all 8 tests. An untested refusal is a comment, not a guarantee, so there is now a `#[cfg(test)]` fault seam (`fail_seals`, compiled out of every build a consumer sees) and a test. With the fix reverted, that test fails by **running the inner tool** — the precise unrecorded side effect. ## What this does not prove Stated in the module docs and the spec rather than left to be inferred: **not completeness.** An operator that never records a decision logs nothing, and no proof about the entries that exist can speak for one that was never written. What the log does prove is that a receipt is in the tree a signed head commits to, that the log has only grown between two signed heads, and that an entry committed to under a published head cannot afterwards be removed or rewritten without equivocating under the operator's key. What narrows the gap is *where* the append happens — before the verdict returns — so a caller that observed a refusal can demand the entry that refused it. ## Spec New **§22 Transparency log (OPTIONAL)**: the tree, the signing preimage byte-for-byte, the MUSTs on signing and on refusing an unparseable root, the proven/not-proven split in plain terms, and conformance class **C-TLOG**. References moves to §23; nothing cross-referenced it. ## Verification `jouleclaw-transparency-log` 26 passed, `jouleclaw-jcp` 9 passed, 0 failed. No new clippy warnings in either crate. Workspace resolves; nothing else depends on either crate, so the blast radius is the two. Eight falsifications from the committed baseline: | reverted | caught by | |---|---| | timestamp dropped from the signing preimage | 2 tests | | signature not actually checked in `verify` | `every_field_of_a_head_is_covered_by_the_signature` | | unparseable `root_hex` padded instead of refused | `a_head_whose_root_is_not_a_root_is_refused_rather_than_signed` | | `HEAD_PREFIX` collided with `LEAF_PREFIX` | `head_preimages_are_domain_separated_from_leaves_and_nodes` | | log append dropped, stream kept | 4 tests | | only some decisions logged | 3 tests | | head signed by a key that is not the steward | 4 tests | | seal failure swallowed | `a_call_that_cannot_be_recorded_is_refused_not_performed` — fails by running the tool | 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jouleclaw: sign the tree head, and give the transparency log a consumer
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m48s
standards workspaces build + test / aac-rs (pull_request) Successful in 1m0s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 57s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
no-vanity-metrics / WAI public text names no comparison (pull_request) Successful in 3s
no-vanity-metrics / WAI public text names no company or product (pull_request) Successful in 3s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 56s
standards workspaces build + test / arl-rs (pull_request) Successful in 1m7s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 35s
standards workspaces build + test / map-rs (pull_request) Successful in 31s
standards workspaces build + test / mesh-rs (pull_request) Successful in 25s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m53s
standards workspaces build + test / openpay (pull_request) Successful in 1m33s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m27s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 1m8s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 1m14s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m18s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m21s
standards workspaces build + test / sense-rs (pull_request) Successful in 1m5s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 3m37s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 3m56s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 8m44s
3b1b3ff235
Two problems, and they were the same problem seen from either end.

The crate's own promise is that "any party can prove a receipt was included in
the log without trusting the log operator". An inclusion proof delivers that
only against a root the operator is bound to, and `TreeHead` — documented as a
"Signed Tree Head" — had no signature, no key, and no verification. An unsigned
root can be minted per-auditor, so two parties shown different roots had nothing
to compare and nobody to attribute it to. The promise was false as written.

And nothing consumed the crate. The RFC 6962 proofs merged in #153 were inert:
no JouleClaw or JCP decision could be given an inclusion proof, so the
auditor-grade tier existed as an unreferenced library.

**Signed tree head.** `TreeHead::sign` / `SignedTreeHead::verify` over
`0x02 || size || root || timestamp`, every field fixed-width so no two heads
share a preimage, and `0x02` a third domain separator so a head cannot be
presented as an entry in its own log. `verify` returns the *root* rather than
`()`, so a caller cannot check a proof against an unverified head by accident.
`root_hex` is a `String`, so it is now parsed rather than assumed: a head whose
root is not 32 bytes of hex is refused at signing time and again at verify time,
because a signature over an unparseable root is a signature over nothing
checkable. Tests cover each field being load-bearing, the wrong key, malformed
signature bytes, and — the property an unsigned root cannot have — two signed
heads at one size as attributable equivocation.

**A consumer.** `JcpGate` already accumulated its sealed receipts in a private
`Vec`, which is only as trustworthy as the party holding it. Each receipt is now
appended to a Merkle log on the same path, and the gate publishes
`signed_tree_head`, `inclusion_proof` and `consistency_proof`. An auditor with
only the steward's public key can check that a specific decision — allow or deny
— is in the stream the operator published. Stream and log are appended in one
place so an index into one is an index into the other.

**Fail closed.** On that path, `if let Ok(receipt) = seal_chained(..)` swallowed
a seal failure and then returned the verdict, so an *allow* could proceed with
no receipt and no log entry — the one outcome the log exists to make impossible.
A seal failure now refuses the dispatch. The debit has already landed, so the
caller is charged for a call it does not get; that is the safe direction,
because the opposite is an unrecorded side effect.

**What this does not prove**, stated in the module docs and the spec rather than
left to be inferred: not completeness. An operator that never records a decision
logs nothing, and no proof about the entries that exist can speak for one never
written. What narrows the gap is where the append happens — before the verdict
returns, so a caller that observed a refusal can demand the entry that refused
it.

SPEC gains §22 (Transparency log, OPTIONAL): the tree, the signing preimage
byte-for-byte, the MUSTs on signing and on refusing an unparseable root, the
proven/not-proven split in plain terms, and conformance class C-TLOG. References
moves to §23; nothing cross-referenced it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!167
No description provided.