feat(a2a): delegated-principal extension — carry the originating principal - #195
feat(a2a): delegated-principal extension — carry the originating principal#195github-actions[bot] wants to merge 3 commits into
Conversation
The extension shipped with a VERSION and no changelog. It needs its own rather than an entry in contracts/a2a/v1/CHANGELOG.md, which is frozen with the rest of v1 — appending there would be an edit to exactly the thing this extension exists to avoid editing. Records the 1.0.0 surface, and states the compatibility property that makes this an extension rather than a version: a callee that does not publish the URI behaves as v1, a caller that sends no delegated credential is handled as v1, and adopting it requires editing nothing frozen (which is why the policy lives in its own .fuze/a2a-delegation.json). This also has a second effect worth stating plainly, because it is the reason the commit exists now: PR #195's real CI has NEVER RUN. The stranded-branch detector opened the PR as a DRAFT, and `pull_request` workflows do not fire on `ready_for_review` — so flipping it to ready left it with two meta checks and nothing else. "No failures" on that PR meant "nothing was tested", which is the same shape as the vacuous gates this branch's own tests are written against. Pushing a real commit fires `synchronize` and gets the suite to actually run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
…cipal
v1's AuthContext.caller is a single string, and valid_caller_identity() accepts
a bare repo name or an Exec-* principal (authz.md §2). There is no end-user
subject anywhere in the model, so a CEO and a warehouse worker arriving through
the same calling repo are LITERALLY INDISTINGUISHABLE to the callee: if
FuzeExecutive is in FuzePlan's providesTo, both pass, identically.
The contract's authors already had the intent right in authz.md §7 —
"providesTo grants the right to ask, not the right to command... A2A adds a
front door; it does not widen any room behind it." What was missing was the
identity precision to act on it, because identity resolved to a repo rather
than a person.
An EXTENSION, not a v2. contracts/a2a/v1 is frozen and this does not edit one
byte of it. The mechanism is the one A2A already defines —
AgentCapabilities.extensions[], which v1's card schema already accepts.
The test for "extension, not version" is that adopting must not require editing
anything frozen, and not adopting must change nothing. The first half has teeth
here: v1's manifest-a2a-extension.schema.json sets additionalProperties:false on
the a2a block, so putting the policy there would have made every adopting repo's
manifest fail v1 validation. It lives in its own .fuze/a2a-delegation.json
instead. Found by checking rather than assuming, and it is the difference
between an extension and a version wearing an extension's name.
THE POD IS A DELEGATE, NEVER A PRINCIPAL. It holds no standing authority over
product data; its workload credential authorizes exactly one thing — presenting
delegated tokens. The rejected alternative, giving the pod broad rights and
having it check the caller before acting, is the confused deputy: it makes the
guard a matter of remembering to look, which is a convention, not a
construction. Same "auth-shaped, not auth" class as FuzeFinance's mock identity.
Four layers; the middle two are what this adds:
1. Channel may repo X reach repo Y? providesTo — unchanged, still first
2. Principal may THIS SUBJECT do this? RFC 8693 sub + act chain, in the
credential, never the body
3. Intersection effective = permitted(sub) ∩ brokerable(actor)
4. Non-delegable set closed classification, no unclassified bucket
(3) is load-bearing and both directions are closed: the agent cannot grant more
than the subject has, AND the subject cannot reach past what the agent may
broker. A union is not a weaker version of this rule, it is the opposite of it,
and there is a test asserting each direction separately.
(4) is closed: delegable / principal-required / never-delegable, no fourth
member and no default. An unclassified skill is DENIED. A default would let a
newly written skill acquire the weakest rule in the system by being written
rather than by being decided — the same closed-set property as the route and
OpenAPI gates. never-delegable is the machine-readable form of what authz.md §7
already seeds (reach_human, and the _base guardrails on kubectl patch / helm
rollback / terraform destroy).
Fail-closed with no escape: missing sub, unverifiable credential, untyped actor,
unclassified skill, no authz client wired, and Permit unreachable are each a
DENY. DECISION_UNAVAILABLE is never an allow and there is no flag that makes it
one — that branch is spelled out in code because it is the one a fail-open
implementation quietly turns into an ALLOW.
19 new tests, weighted toward denial; 195 pass across agent-templates/a2a. The
schemas are mutation-checked: they reject an untyped subject, an untyped actor,
a wrong extension URI, an unknown class, and an untyped brokerable key.
NOT in scope: the token-exchange endpoint. Obtaining the delegated token is the
CALLER's side, and that is where the remaining implementation sits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
The extension shipped with a VERSION and no changelog. It needs its own rather than an entry in contracts/a2a/v1/CHANGELOG.md, which is frozen with the rest of v1 — appending there would be an edit to exactly the thing this extension exists to avoid editing. Records the 1.0.0 surface, and states the compatibility property that makes this an extension rather than a version: a callee that does not publish the URI behaves as v1, a caller that sends no delegated credential is handled as v1, and adopting it requires editing nothing frozen (which is why the policy lives in its own .fuze/a2a-delegation.json). This also has a second effect worth stating plainly, because it is the reason the commit exists now: PR #195's real CI has NEVER RUN. The stranded-branch detector opened the PR as a DRAFT, and `pull_request` workflows do not fire on `ready_for_review` — so flipping it to ready left it with two meta checks and nothing else. "No failures" on that PR meant "nothing was tested", which is the same shape as the vacuous gates this branch's own tests are written against. Pushing a real commit fires `synchronize` and gets the suite to actually run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
`authz.py` is black-clean on main and my edit broke that; delegation.py and test_delegation.py are new. Restoring the first and matching the second two. NOT what fixed the reported `lint` failure — see the rebase in this push. CI's black step is scoped to services/orchestrator (`black --check .` at repo ROOT reports 83 pre-existing failures, so it cannot be running there), and this branch no longer touches that tree at all. This commit is consistency, stated as such rather than dressed up as the fix. Verified inert the same way as the earlier one: `ast.dump()` before and after is IDENTICAL for all three files, which ignores whitespace entirely. 180 tests still pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
7967c06 to
3c2fd7e
Compare
Force-pushed: this PR was carrying #194's entire diffMy mistake, and worth stating plainly because the PR body did not match the code. I created this branch with Rebased onto 13 files, 828 insertions, all extension. Zero files under How it surfaced, which is the more useful partThis PR's real CI had never run. The stranded-branch detector opened it as a draft, and I pushed a real commit to fire That is the same failure this branch's own tests are written against: a green-looking check that never looked at anything. I reported this PR as healthy twice before catching it. On the
|
The gap
v1's
AuthContext.calleris a single string, andvalid_caller_identity()accepts a bare repo name or anExec-*principal (authz.md §2). There is no end-user subject anywhere in the model.So a CEO and a warehouse worker arriving through the same calling repo are literally indistinguishable to the callee. If
FuzeExecutiveis inFuzePlan'sprovidesTo, both pass, identically. There is a test asserting exactly that about v1, so the gap is pinned rather than described.The contract's authors already had the intent right in authz.md §7 — "
providesTogrants the right to ask, not the right to command… A2A adds a front door; it does not widen any room behind it." What was missing was the identity precision to act on it, because identity resolved to a repo rather than a person.Extension, not v2
contracts/a2a/v1is frozen and this does not edit one byte of it. The mechanism is the one A2A already defines:AgentCapabilities.extensions[], which v1'sagent-card.schema.jsonalready accepts.The test for "extension, not version": adopting must not require editing anything frozen, and not adopting must change nothing.
The first half has teeth here. v1's
manifest-a2a-extension.schema.jsonsetsadditionalProperties: falseon thea2ablock — so putting the policy ata2a.delegation, which is where it naturally wants to go, would have made every adopting repo's manifest fail v1 validation. It lives in its own.fuze/a2a-delegation.jsoninstead. Found by checking the schema rather than assuming, and it is precisely the difference between an extension and a version wearing an extension's name.The pod is a delegate, never a principal
The pod holds no standing authority over product data. Its workload credential authorizes exactly one thing: may present delegated tokens. Every bit of data-plane authority arrives with the call.
The rejected alternative — give the pod broad rights and have it check the caller before acting — is the confused deputy. It makes the guard a matter of the pod remembering to look, which is a convention, not a construction: the same "auth-shaped, not auth" class as FuzeFinance's mock identity.
Four layers; the middle two are what this adds
providesTosub+actchain, in the credential, never the bodyeffective = permitted(sub) ∩ brokerable(actor)Layer 3 is load-bearing, and both directions are closed:
A union — "allow if either permits" — is not a weaker version of this rule, it is the opposite of it. Each direction has its own test.
Layer 4 is closed:
delegable/principal-required/never-delegable. No fourth member, no default, no wildcard that could supply one. An unclassified skill is denied — a default would let a newly written skill acquire the weakest rule in the system by being written rather than by being decided, the same closed-set property as the route-ownership and OpenAPI gates.never-delegableis the machine-readable form of what authz.md §7 already seeds:reach_humanfor binding decisions, and the_baseguardrails onkubectl patch/helm rollback/terraform destroy.Fail-closed, with no escape
Each of these is a DENY: missing
subon aprincipal-requiredskill · unverifiable credential · untyped actor reference · unclassified skill · no authz client wired · Permit unreachable.DECISION_UNAVAILABLEis never an allow and there is no flag that makes it one. That branch is spelled out explicitly in_authorize_delegatedbecause it is the one a fail-open implementation quietly turns into an ALLOW.Verification
19 new tests, weighted toward denial. 195 pass across
agent-templates/a2a.The schemas are mutation-checked rather than merely exercised — each of these is rejected: untyped subject · untyped actor · wrong extension URI · unknown skill class · untyped
brokerablekey.Backward compatibility is tested directly: with no policy,
authorize()returns exactly its v1 result, and a caller outsideprovidesTois denied before any principal is consulted.Not in scope
The token-exchange endpoint. Obtaining the delegated token before dialling is the caller's side of the work, and that is where the remaining implementation sits. This PR is the callee-side enforcement and the contract it enforces against.