eoc: allocate a facility among tenants, then bind the published band to it #63
Merged
dcharlot
merged 3 commits from 2026-08-29 03:11:42 -04:00
eoc/multi-tenant-allocation into main 2026-08-27
eoc: pin rand off 0.10.2, which does not compile
All checks were successful
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 58s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Successful in 1m6s
WAI conformance / cargo-class corpora · windows (pull_request) Successful in 1m15s
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Successful in 23s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 28s
WAI conformance / cross-arch byte-equality · windows (pull_request) Successful in 22s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 14s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 2m17s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 24s
`rand 0.10.2` fails to build: its own `src/rng.rs:168` contains `use rand::RngExt;` — a crate referring to itself by name rather than `crate::`. That is an upstream defect in a published release, not a configuration problem, and it reaches this workspace through `eoc-route-learned`, `eoc-spec-decode` and `rand_distr`. Pinned to 0.10.1, which builds. The lock is pinned rather than the manifests: the constraint is "not this exact release", not a new minimum, and a manifest pin would outlive the broken version and quietly hold the workspace back once upstream fixes it. Verified pre-existing rather than assumed: 0.10.2 is in the lock at `origin/main` and this branch's other commits touch zero `rand` lines. The `eoc-tools` reqwest `query` feature — the other half of what kept `cargo check --workspace` from passing — landed on main independently while this branch was in flight, with the same fix and the same reasoning. That commit is taken as-is here rather than duplicated. With both in place the entire eoc-rs workspace checks clean, which is what makes the allocation and rating work in the preceding commits verifiable in place rather than only crate-locally.
eoc: bind a published facility band to the allocation it came from
A band published on its own is unfalsifiable: a letter, a facility name, and nothing a reader can check. `FacilityRating` carries the commitment to the share set the rating aggregates, so the tenant proof from `Allocation::proof_for` verifies against **the same root the public rating cites**. That join is the point. A tenant can confirm its own consumption sits inside the very set that produced the published label, without seeing any co-tenant — which is the only thing that makes a facility label mean anything to the customers it was derived from. `admits()` checks the proof against the receipt's root *and* that the totals agree, so a proof that is internally valid against a different period is rejected rather than quietly accepted. **Assurance is derived, not stored.** The obvious design is a `verified: bool` or an assurance level written into the artifact, and that would repeat the failure the band itself had: a claim about evidence, asserted by the party under audit. So there is no assurance field. `assurance()` takes a `SignatureStatus` the caller must supply, and an attestation that nobody checked yields `AttestedUnverified` — never `Verified`. An unchecked signature is a string; it becomes evidence when someone checks it, and the type makes skipping that step impossible to do silently. A failed check reports `AttestationInvalid`, which is strictly worse than no attestation: someone tried to pass off a bad signature. This crate does no signature verification at all, and says so. There is no signing dependency in this workspace, and manufacturing a `Verified` state it cannot substantiate is exactly what the rest of the module refuses to do. `RatingRecord` is data, not a dependency on whichever crate produced the band: a receipt records *what* was rated, not *how*. It carries the scale id, metric, unit and measured value, so a reader re-derives the letter against the cited scale instead of trusting it — and a band whose scale is unstated is not comparable to any other band. The signed preimage covers every field that makes the rating a claim (band, value, scale, basis, residual, commitment), with length-prefixed fields so shifting a character across a boundary cannot produce the same bytes. Attaching an attestation does not change what it signs. Verified: 36 eoc-carbon tests pass (7 new), no clippy warnings in either new file. One test assertion was corrected rather than the code: with an odd leaf count a promoted node has no sibling at that level, so the aggregate count varies between tenants — the guarantee is the bound, not a fixed number.
eoc: divide a measured facility total among tenants without losing any of it
`eoc-carbon` accounted one request bottom-up — `it_joules × PUE × intensity` — and had no notion of a facility, a tenant, a share, or a residual. `grep -rniE "tenant|allocat|conserv|residual"` across the crate returned zero. That shape is right for a single tenant and wrong for a facility, because nothing forces the tenants' figures to add up to what the building actually drew. Both failure modes are silent and opposite: idle capacity, cooling headroom, the control plane and failed work draw real power that belongs to no request and simply vanishes from the sum; while two tenants can each be charged a PUE multiplier over the same shared overhead and nothing notices. An allocation here is anchored top-down on a measured total and the arithmetic is required to close. **Integer microjoules, on purpose.** `CarbonAccount` is `f64` throughout, which is fine for a reported figure and unusable for an invariant: a conservation law that holds to within floating-point error is not a conservation law. Allocation uses `u64` µJ, matching `eoc_core::JouleCost`, so `Σ shares + residual == total` holds exactly and is checked rather than hoped for. Sums are taken in `u128` so an overflowing share set is reported as over-allocation instead of wrapping into a total that appears to conserve. **The residual is required, not optional.** An allocation that assigns 100% of a facility to its tenants asserts that every joule the building drew was attributable to a paying workload, which is essentially never true. Making the remainder a mandatory field forces an operator to state it. `proportional()` sends its integer-division loss there too, rather than spreading it by a rounding rule — the residual is the honest home for energy no tenant demonstrably caused. Rejects rather than clamps throughout: an over-allocation means the model is wrong, and trimming it silently would hide the only signal that says so. **The basis is a disclosed choice.** The same facility split by measured energy, by compute-seconds or by reserved capacity yields three different numbers for one tenant, all defensible and none comparable, so an allocation that does not say which was used cannot be checked against another. **A tenant can check its own figure without being shown its co-tenants.** Shares are committed to a Merkle **sum** tree — every node carries a hash and its subtree total. A tenant gets its own leaf plus the sibling `(hash, sum)` pairs along its path, recomputes the root, and confirms the root's sum plus the residual equals the signed facility total. Both halves are checked together on purpose: a share can be genuinely included in a tree that does not add up, so inclusion alone proves nothing. What that leaks is stated rather than glossed: the tenant learns the aggregate of each sibling subtree on its path — about `log2(n)` partial sums — and no individual co-tenant's share or identity. `aggregates_revealed()` reports the count so both sides know. Hiding the aggregates as well needs a zero-knowledge range proof, which this module does not attempt and does not claim. Scoped as multi-tenant plumbing, not "verifiable emissions reporting": nothing here signs anything or speaks to carbon. Verified: 30 eoc-carbon tests pass (9 new, covering exact conservation, the overflow path, rounding-to-residual, odd leaf counts 1..9, tampered shares, misstated totals and restated residuals), no clippy warnings in the new file. The eoc-rs workspace has a pre-existing build failure in `eoc-tools` (reqwest `query` behind `default-features = false`) that predates this change and is untouched by it.