jcp: caveats — the open attenuation axis (SPEC §4.9, C24) #30
Merged
dcharlot
merged 2 commits from 2026-07-20 01:11:58 -04:00
jcp/grant-caveats into main 2026-07-19
jcp: close two §4.9 spec bugs found in review — metered path + caveat comparison
Both were cases where the NORMATIVE TEXT was wrong or under-specified, which is
worse than a code bug: an implementor reading §4.9 would have diverged from the
reference and believed they were conforming.
1. The spec said caveats "MUST all hold for a decision to be allowed", full stop,
while the implementation deliberately skipped MaxCostUj in the two-phase
`authorize` path (the cost is genuinely unknowable there). So the spec promised
something the reference did not do, and a `MaxCostUj` caveat was in practice
unenforced for any host that authorized, ran, then charged — `charge()` never
re-checked.
§4.9 now states the rule explicitly: a cost-dependent caveat MUST be enforced
at the DEBIT POINT, an implementation MUST NOT report a grant as authorized in
a way implying it passed, and every cost-independent caveat MUST bind in both
phases. `Runtime::charge` enforces it.
The doctrine question that mattered: on a breach the work has ALREADY run, so
the joules are real. `charge` still debits them — energy that was spent is
always accounted — and records the breach in the verdict, so the sealed receipt
is evidence. Suppressing the debit to make the record look clean would be the
dishonest move; the spec now names both failure modes as non-conforming. This
follows the existing budget-overrun precedent ("records the overshoot rather
than hiding it") rather than inventing a new one.
2. The spec asserted that "weakening" an inherited caveat is an amplification, but
never defined weakening — while the code compares by exact structural equality.
So `MaxCostUj(400)` under a parent's `MaxCostUj(500)` was refused as *dropped*
despite being strictly tighter, with nothing saying so.
§4.9 now defines comparison as EXACT IDENTITY and says why: a per-variant
ordering would have to be defined for each predicate, and a verifier that got
it wrong would silently widen authority. A delegator wanting a tighter bound
adds it alongside the inherited one — since all caveats must hold, the tighter
governs. Conservative on purpose: it can never widen, and it can be relaxed
later without breaking existing grants, whereas the reverse is not true.
Also adds `Caveat::is_cost_dependent()` / `cost_breached()` to the schema so the
authorize-skip and the charge-enforce share one definition instead of two
`matches!` arms that could drift.
Tests pin both phases: authorize allows what it cannot evaluate and never debits;
charge denies the breach AND still accounts the 900 µJ. C24 extended to cover the
debit-point rule.
42 suites green, clippy clean, all conformance checks pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
jcp: caveats — the open attenuation axis (SPEC §4.9, C24)
Attenuation's axes all narrow by set-subtraction: remove actions, lower a ceiling, shorten an expiry. They cannot express a condition the issuer did not anticipate. `jcp-policy` can, but it is service-side — evaluated against a PolicyContext the verifier must be told about, not carried in the capability. A caveat is a condition embedded INSIDE the signed grant. Any enforcement point holding it checks offline: no call back to the issuer, no shared policy config. That is the distinction — policy is something a verifier must be told; a caveat is something the token carries. A design constraint found in the code rather than assumed: the predicate set is bounded by what `decide` actually holds. jcp-policy's `Match::ContentContains` CANNOT become a caveat, because the runtime sees a frame's `payload_hash` and never its bytes. So a condition on content is expressible only as `PayloadIs`, binding an exact payload rather than inspecting one. SPEC §4.9 states that constraint normatively so implementations don't invent undecidable predicates. Two variants express authority the fixed axes cannot: - `PayloadIs` makes a grant ONE-SHOT — it authorizes one specific call, so a leaked grant cannot be replayed against different arguments. `capability` narrows to an action; it can never narrow to a call. - `MaxCostUj` bounds a SINGLE invocation, where `budget_uj` is cumulative — so a delegate cannot burn the whole remaining budget in one call. Plus ActionIn (may narrow below the capability), CallerIs, NotBefore, NotAfter. Enforcement is in decide/decide_priced with the real cost. The two-phase `authorize` path skips only the cost-dependent caveat — the cost is unknowable there — and enforces every other; that binds at decide time and is documented rather than silently ignored. Attenuation is monotone: `attenuate` INHERITS the parent's caveats (dropping them would widen a delegate's authority — the same amplification the dual-ceiling work had to guard), `attenuate_caveated` appends, and `caveats_narrow` refuses a child that lost one. Adding the field with no default made the compiler flag every Grant construction site, which is how three bad auto-patches into function parameter lists surfaced as build errors instead of silent breakage. Empty caveat set ⇒ omitted from the canonical encoding, so a grant without caveats is byte-identical to one minted before they existed — content id and signature unchanged, asserted by test and by C24. 42 suites green, clippy clean, all conformance checks pass including C24. Co-Authored-By: Claude Opus 4.8 <[email protected]>