jcp: caveats — the open attenuation axis (SPEC §4.9, C24) #30

Merged
dcharlot merged 2 commits from jcp/grant-caveats into main 2026-07-20 01:11:58 -04:00

2026-07-19

Transaction Science
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]>
2026-07-19 12:18:38 -04:00
Transaction Science
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]>
2026-07-19 11:03:04 -04:00