jcp: caveats — the open attenuation axis (SPEC §4.9, C24) #30
Loading…
Reference in a new issue
No description provided.
Delete branch "jcp/grant-caveats"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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-policycan, but it is service-side: evaluated against aPolicyContextthe 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.
A constraint found in the code, not assumed
The predicate set is bounded by what
decideactually holds.jcp-policy'sMatch::ContentContainscannot become a caveat — the runtime sees a frame'spayload_hash, never its bytes. So a content condition is expressible only asPayloadIs, binding an exact payload rather than inspecting one. SPEC §4.9 states this normatively so implementations don't invent undecidable predicates.This also narrows the sweep's framing: it proposed "Biscuit-class Datalog." A fixed, decidable set is the better scope here — Datalog would invite conditions the enforcement point structurally cannot check.
Two variants express authority the fixed axes cannot
PayloadIs→ a one-shot grant.capabilitynarrows to an action; it can never narrow to a call. A leaked grant cannot be replayed against different arguments.MaxCostUj→ bounds a single invocation, wherebudget_ujis cumulative. A delegate cannot burn the whole remaining budget in one call.Plus
ActionIn(may narrow below the capability),CallerIs,NotBefore,NotAfter.Enforcement + attenuation
Checked in
decide/decide_pricedwith the real cost. The two-phaseauthorizepath skips only the cost-dependent caveat — the cost is unknowable there — and enforces every other; that binds at decide time, documented rather than silently ignored.Attenuation is monotone:
attenuateinherits the parent's caveats,attenuate_caveatedappends,caveats_narrowrefuses a child that lost one.Backward compatibility
Empty set ⇒ omitted from the canonical encoding, so a caveat-free grant is byte-identical to one minted before caveats existed — content id and signature unchanged. Asserted by test and by C24.
42 suites green, clippy clean, all conformance checks pass including C24.
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]>