jouleclaw: bind a policy receipt to the rules, not to a version number #177
Merged
dcharlot
merged 1 commit from 2026-10-09 02:49:56 -04:00
feat/policy-digest into main 2026-10-08
jouleclaw: bind a policy receipt to the rules, not to a version number
All checks were successful
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m17s
standards workspaces build + test / sense-rs (pull_request) Successful in 1m10s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 3m38s
wai cross-architecture byte-equality / linux-x86_64 (pull_request) Successful in 24s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 6m33s
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Successful in 13s
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Successful in 13m3s
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Successful in 8s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 24s
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Successful in 46s
WAI energy receipts / energy receipts · linux (pull_request) Successful in 1m47s
WAI HTTP delivery / HTTP corpus · falsification and differential (pull_request) Successful in 1h19m25s
WAI HTTP delivery / cache rules · controls and labelled caches (pull_request) Successful in 1m6s
WAI JPEG-AI whole decode (pinned inputs, required) / fetch pinned JPEG-AI inputs → whole decode + pixel hash, required (pull_request) Successful in 1m2s
WAI neural sink gates / model-free refusals of the neural sink (pull_request) Successful in 1m8s
wai-pack / wai-pack · linux (pull_request) Successful in 1m4s
wai-pack / wai-pack · independent verifier (Python) (pull_request) Successful in 1m43s
wai-pack / wai-pack · the floor through a media prober (pull_request) Successful in 21s
wai-pack / wai-pack · dependency licences (pull_request) Successful in 1m8s
WAI section references / cited sections exist (pull_request) Successful in 10s
WAI mbt2018_mean integer decode (regenerated vectors) / regenerate mbt2018_mean integer vectors → Rust decode, required (pull_request) Successful in 4m16s
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Successful in 2m41s
workflow hygiene / every workflow supersedes its own queue (pull_request) Successful in 4s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 37m35s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 47m17s
wai cross-architecture byte-equality / macos-arm64 (reference) (pull_request) Successful in 1m4s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 16m12s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 40s
WAI energy receipts / energy receipts · macos (pull_request) Successful in 3m50s
wai-pack / wai-pack · macos (pull_request) Successful in 3m28s
A `PolicyReceipt` recorded `policy_version` and, on an allow,
`decision.rule` — a counter and a name. Neither says what the rules were.
The hole is narrower than it first looks, and worth stating precisely because
the obvious version of it is already closed: `InMemoryPolicyEngine::install_policy`
refuses a non-increasing install, so *within one engine instance* a version does
identify a ruleset. What the counter cannot do is mean anything outside that
instance. A restart, a replica, or a second host starts its own count at 1 with
whatever rules it was handed, and the monotonic guard lives in the bundled
engine rather than in the `PolicyEngine` contract, so another backend need not
have one. Either way a receipt citing `(engine_id, policy_version)` is a
pointer into state only its producer holds: a verifier has to be *told* what
engine-A's version 1 contained, by the party whose decision is under audit.
`Policy::digest_hex` content-addresses the rules. Three choices make it a digest
of the policy rather than of a serialization, each with a test:
- **Rule order is in the digest.** `decide` scans `Deny` rules first and
short-circuits, so reordering can change the outcome: two orderings are two
policies.
- **The agent list is sorted before hashing.** `agents:one_of` is matched by
membership, so two policies differing only in that order are the same policy
and must share a digest.
- **A port range is hashed as written, not normalised.** A range whose `from`
exceeds its `to` matches nothing, which is a different policy from the same
pair reversed.
`version` is excluded: the digest answers "which rules", the version travels
beside it answering "which installation", and folding the counter in would make
one ruleset hash differently per engine. Mode and method tags are written out
rather than taken from serde, so the digest does not move if a rename or a
variant reordering lands.
Every variable-length part is length-prefixed. The first test I wrote for that
did not actually exercise it — a rule named "ab" with glob "c" is already
distinguishable from one named "a" with glob "bc", because the constant tags
("allow", "action:filesystem") sit between them. Falsification caught it:
stripping the prefixes left the suite green. The prefixes are load-bearing in
the one place two *arbitrary* strings are adjacent — the sorted agent id list,
where nothing separates one id from the next and the count is equal either way,
so `["ab","c"]` and `["a","bc"]` render to the identical run "abc". The test now
pins that case, and fails without the prefixes.
The digest is recorded on the `AuditEvent` at decision time, not read off the
engine when the event is later shipped: the live policy can be replaced in
between, and attributing a decision to rules installed after it was made is the
error the field exists to prevent. Both construction sites set it — `evaluate`
and `evaluate_traced` — because a digest on one but not the other is a gap that
looks like data. `PolicyEngine::policy_digest` defaults to `None` so a backend
that holds its rules somewhere it cannot hash stays conformant rather than being
forced to invent one; a consumer requiring the binding can only refuse such an
engine if the absence is reported.
Additive on the wire. `policy_digest` is skipped when absent *and* omitted from
`canonical_wire`, so a receipt without it has a signed core byte-identical to
one produced before the field existed. Regenerating the pack proves it: vectors
1-3 keep their published `signed_core_cbor_hex` and `cose_sign1_hex` exactly,
and new vector 4 is vector 1's decision with the binding, differing by exactly
that one key. The Rust, Python and Go reference verifiers all verify 4/4 with no
verifier change, because each encodes the map the vector publishes.
Three other crates build a `PolicyReceipt` by struct literal in test fixtures
(`jouleclaw-cli`, `jouleclaw-ledger`, `jouleclaw-otel`); each passes `None`, the
legacy shape. A targeted `cargo check -p jouleclaw-policy-receipt --all-targets`
did not reach them — only the whole-workspace run did.
`jouleclaw-otel` exports the digest beside the version it already exported. A
span naming the version alone has the same gap the receipt field closes. Absent
is left absent rather than exported as an empty string, so a query can tell "no
binding" from "a binding that is blank".
SPEC §10 gains §10.1: the preimage written out byte-for-byte, the three
rendering rules as MUSTs, the requirement that the digest be the one in force at
the decision, and the requirement that an absent digest be omitted rather than
nulled.
Co-Authored-By: Claude Opus 5 <[email protected]>