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
Owner

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.

Policy is something a verifier must be told. A caveat is something the token carries.

A constraint found in the code, not assumed

The predicate set is bounded by what decide actually holds. jcp-policy's Match::ContentContains cannot become a caveat — the runtime sees a frame's payload_hash, never its bytes. So a content condition is expressible only as PayloadIs, 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. capability narrows to an action; it can never narrow to a call. A leaked grant cannot be replayed against different arguments.
  • MaxCostUj → bounds a single invocation, where budget_uj is 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_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, documented rather than silently ignored.

Attenuation is monotone: attenuate inherits the parent's caveats, attenuate_caveated appends, caveats_narrow refuses 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.

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. > Policy is something a verifier must be **told**. A caveat is something the token **carries**. ### A constraint found in the code, not assumed The predicate set is bounded by what `decide` actually holds. `jcp-policy`'s `Match::ContentContains` **cannot** become a caveat — the runtime sees a frame's `payload_hash`, never its bytes. So a content condition is expressible only as `PayloadIs`, 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. `capability` narrows to an *action*; it can never narrow to a *call*. A leaked grant cannot be replayed against different arguments. - **`MaxCostUj`** → bounds a **single invocation**, where `budget_uj` is 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_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, documented rather than silently ignored. Attenuation is monotone: `attenuate` **inherits** the parent's caveats, `attenuate_caveated` appends, `caveats_narrow` refuses 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**.
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]>
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]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!30
No description provided.