Skip to content

feat(a2a): delegated-principal extension — carry the originating principal - #195

Open
github-actions[bot] wants to merge 3 commits into
mainfrom
claude/a2a-delegated-principal
Open

feat(a2a): delegated-principal extension — carry the originating principal#195
github-actions[bot] wants to merge 3 commits into
mainfrom
claude/a2a-delegated-principal

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

The gap

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. 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 — "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.

Extension, not 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 agent-card.schema.json already 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.json sets additionalProperties: false on the a2a block — so putting the policy at a2a.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.json instead. 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

# Layer Mechanism Before
1 Channel — may repo X reach repo Y? providesTo ✅ unchanged, and still runs first
2 Principal — may this subject do this? RFC 8693 sub + act chain, in the credential, never the body
3 Intersectioneffective = permitted(sub) ∩ brokerable(actor) both escalation directions closed
4 Non-delegable set closed classification, no unclassified bucket partial (§7 seeds it)

Layer 3 is load-bearing, and both directions are closed:

  • the agent cannot grant more than the subject has — a subject with no right to delete a ticket does not acquire one by asking through an agent that has it;
  • the subject cannot reach past what the agent may broker — a CEO with every right in Permit still cannot use an agent to reach a skill that agent may not broker.

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-delegable is the machine-readable form of what authz.md §7 already seeds: reach_human for binding decisions, and the _base guardrails on kubectl patch / helm rollback / terraform destroy.

Fail-closed, with no escape

Each of these is a DENY: missing sub on a principal-required skill · unverifiable credential · untyped actor reference · unclassified skill · no authz client wired · Permit unreachable.

DECISION_UNAVAILABLE is never an allow and there is no flag that makes it one. That branch is spelled out explicitly in _authorize_delegated because 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 brokerable key.

Backward compatibility is tested directly: with no policy, authorize() returns exactly its v1 result, and a caller outside providesTo is 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.

@izzywdev izzywdev changed the title claude/a2a delegated principal feat(a2a): delegated-principal extension — carry the originating principal Aug 25, 2026
@izzywdev
izzywdev marked this pull request as ready for review August 25, 2026 00:49
@izzywdev
izzywdev self-requested a review as a code owner August 25, 2026 00:49
izzywdev added a commit that referenced this pull request Aug 25, 2026
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
izzywdev and others added 3 commits August 25, 2026 02:01
…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
@izzywdev
izzywdev force-pushed the claude/a2a-delegated-principal branch from 7967c06 to 3c2fd7e Compare August 25, 2026 02:03

Copy link
Copy Markdown
Owner

Force-pushed: this PR was carrying #194's entire diff

My mistake, and worth stating plainly because the PR body did not match the code.

I created this branch with git checkout -b claude/a2a-delegated-principal while standing on the RAG branch, not on main. So it carried 2ab2b30 — the whole of #194 — while the description above talks only about the delegation extension. Anyone reviewing the body against the diff would have found ~1000 lines of RAG code nobody mentioned, and merging both PRs would have applied it twice.

Rebased onto origin/main, dropping that commit:

3c2fd7e style: black-format the three files this branch touches
e4794ad docs(a2a-ext): give delegated-principal its own changelog
7f83541 feat(a2a): delegated-principal extension — carry the originating principal

13 files, 828 insertions, all extension. Zero files under services/orchestrator. The body and the diff now describe the same change.

How it surfaced, which is the more useful part

This PR's real CI had never run. The stranded-branch detector opened it 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 (Auto Merge PR, open-draft-pr) and nothing else. It read as "no failures" when it meant "nothing was tested."

I pushed a real commit to fire synchronize, the suite ran for the first time, and lint went red immediately — because the branch was carrying #194's pre-fix copies of two files I had already corrected on that branch. The contamination was invisible until CI actually ran.

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 lint failure specifically

The rebase is what fixes it — the orchestrator files it complained about are gone. The style: commit is separate and I am not claiming it as the fix: authz.py is black-clean on main and my edit broke that, so I restored it. 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.

Verified after the rebase: 180 tests pass, ast.dump() identical before/after for all three formatted files.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant