Description
DelegationExtension is fully modelled, fully extracted into the attribute bag, and never written by any shipped plugin. Every delegation.* attribute is therefore constant across every request, and any policy that reads one is inert while looking correct.
The type carries a chain: Vec<DelegationHop> with derived depth, delegated, origin_subject_id, actor_subject_id and age_seconds (crates/ppe-core/src/extensions/delegation.rs). The CMF bridge extracts all of them (crates/ppe-apl-cmf/src/delegation.rs). The executor has a whole monotonic merge path for the slot, merge_delegation (crates/ppe-core/src/extensions/container.rs:411), which validates that a returned chain extends the canonical one and recomputes depth from the chain rather than trusting the plugin. That merge has never run against a real chain.
There are exactly two writers of ext.delegation in the engine:
IdentityPayload::apply_to_extensions (crates/ppe-core/src/identity/payload.rs:369), gated on self.delegation being populated by an identity resolver.
DelegationPayload::apply_to_extensions (crates/ppe-core/src/delegation/payload.rs:677), gated on self.delegation_update being populated by a TokenDelegate handler.
Neither field has a shipped writer:
identity/jwt does not parse act claims. There is no reference to act or to delegation anywhere in builtins/plugins/identity-jwt/src/, and none of the four presets map it.
delegator-oauth never appends a hop. No reference to delegation_update, DelegationHop or append_hop in builtins/plugins/delegator-oauth/src/.
The only callers of with_delegation are crates/ppe-apl-cmf/tests/end_to_end.rs and the doc example at crates/ppe-apl-cmf/src/lib.rs:113, both of which construct a chain by hand to exercise the bridge.
What this means in practice
A rule such as require(delegation.depth <= 2) parses, evaluates, and always passes, because depth is either absent or zero. A rule such as delegation.delegated: deny('no re-delegation', 'delegation.forbidden') never fires. This is the failure mode that is worth fixing first: the attributes are documented and reachable, so a policy author has every reason to believe a depth limit is being enforced.
The two halves
Inbound, in identity/jwt. RFC 8693 puts the actor chain in the act claim, nested: act.sub is the immediate actor, and a nested act.act is the one before it. Walking that nesting produces the chain, with origin_subject_id from the token's own sub and the hops in order. This is a claim mapping change in a plugin that already has a claim mapper, so it is the smaller half.
Worth deciding in the issue: whether an act chain is trusted as presented. It is inside a signed token so it is as trustworthy as the issuer, but depth is exactly the kind of value a policy gates on, which makes it worth being explicit rather than implicit.
Outbound, in delegator-oauth. When the delegator mints a token it should append a hop recording what it did: subject_id, audience, scopes_granted, timestamp, strategy, and from_cache. The plugin has all of this already at the point it builds the RawDelegatedToken (builtins/plugins/delegator-oauth/src/delegator.rs:640), including the cache hit flag, which DelegationHop.from_cache exists to carry.
This half needs the append_delegation capability, which the executor already gates on (crates/ppe-core/src/executor.rs:670), and it exercises the merge_delegation extension path for the first time.
Acceptance criteria
identity/jwt parses nested act claims into DelegationExtension.chain and sets it on IdentityPayload.delegation.
delegator-oauth appends a DelegationHop for each token it mints, with from_cache reflecting whether the token was served from cache, and declares append_delegation.
delegation.depth, delegation.delegated, delegation.origin_subject_id and delegation.age_seconds resolve to real values in an APL predicate.
- A test covers
merge_delegation rejecting a shortened chain, which is currently unreachable in practice.
- A test covers a depth limit denying a request whose inbound token carries a chain deeper than the limit.
- Whether an
act chain is trusted as presented is recorded as a decision.
Description
DelegationExtensionis fully modelled, fully extracted into the attribute bag, and never written by any shipped plugin. Everydelegation.*attribute is therefore constant across every request, and any policy that reads one is inert while looking correct.The type carries a
chain: Vec<DelegationHop>with deriveddepth,delegated,origin_subject_id,actor_subject_idandage_seconds(crates/ppe-core/src/extensions/delegation.rs). The CMF bridge extracts all of them (crates/ppe-apl-cmf/src/delegation.rs). The executor has a whole monotonic merge path for the slot,merge_delegation(crates/ppe-core/src/extensions/container.rs:411), which validates that a returned chain extends the canonical one and recomputesdepthfrom the chain rather than trusting the plugin. That merge has never run against a real chain.There are exactly two writers of
ext.delegationin the engine:IdentityPayload::apply_to_extensions(crates/ppe-core/src/identity/payload.rs:369), gated onself.delegationbeing populated by an identity resolver.DelegationPayload::apply_to_extensions(crates/ppe-core/src/delegation/payload.rs:677), gated onself.delegation_updatebeing populated by aTokenDelegatehandler.Neither field has a shipped writer:
identity/jwtdoes not parseactclaims. There is no reference toactor todelegationanywhere inbuiltins/plugins/identity-jwt/src/, and none of the four presets map it.delegator-oauthnever appends a hop. No reference todelegation_update,DelegationHoporappend_hopinbuiltins/plugins/delegator-oauth/src/.The only callers of
with_delegationarecrates/ppe-apl-cmf/tests/end_to_end.rsand the doc example atcrates/ppe-apl-cmf/src/lib.rs:113, both of which construct a chain by hand to exercise the bridge.What this means in practice
A rule such as
require(delegation.depth <= 2)parses, evaluates, and always passes, becausedepthis either absent or zero. A rule such asdelegation.delegated: deny('no re-delegation', 'delegation.forbidden')never fires. This is the failure mode that is worth fixing first: the attributes are documented and reachable, so a policy author has every reason to believe a depth limit is being enforced.The two halves
Inbound, in
identity/jwt. RFC 8693 puts the actor chain in theactclaim, nested:act.subis the immediate actor, and a nestedact.actis the one before it. Walking that nesting produces the chain, withorigin_subject_idfrom the token's ownsuband the hops in order. This is a claim mapping change in a plugin that already has a claim mapper, so it is the smaller half.Worth deciding in the issue: whether an
actchain is trusted as presented. It is inside a signed token so it is as trustworthy as the issuer, but depth is exactly the kind of value a policy gates on, which makes it worth being explicit rather than implicit.Outbound, in
delegator-oauth. When the delegator mints a token it should append a hop recording what it did:subject_id,audience,scopes_granted,timestamp,strategy, andfrom_cache. The plugin has all of this already at the point it builds theRawDelegatedToken(builtins/plugins/delegator-oauth/src/delegator.rs:640), including the cache hit flag, whichDelegationHop.from_cacheexists to carry.This half needs the
append_delegationcapability, which the executor already gates on (crates/ppe-core/src/executor.rs:670), and it exercises themerge_delegationextension path for the first time.Acceptance criteria
identity/jwtparses nestedactclaims intoDelegationExtension.chainand sets it onIdentityPayload.delegation.delegator-oauthappends aDelegationHopfor each token it mints, withfrom_cachereflecting whether the token was served from cache, and declaresappend_delegation.delegation.depth,delegation.delegated,delegation.origin_subject_idanddelegation.age_secondsresolve to real values in an APL predicate.merge_delegationrejecting a shortened chain, which is currently unreachable in practice.actchain is trusted as presented is recorded as a decision.