Skip to content

spec: v0.3 - #18

Merged
arpanghoshal merged 3 commits into
mainfrom
item-0-spec-v0.3
Sep 4, 2026
Merged

arpanghoshal merged 3 commits into
mainfrom
item-0-spec-v0.3

Conversation

@arpanghoshal

@arpanghoshal arpanghoshal commented Sep 4, 2026 •

Copy link
Copy Markdown
Member

Item 0 of the v0.3 build list: the contract, and the scaffolding that lets items 1–7 be built
against it. No implementation. src/ is untouched; the 1269 tests of v0.1 and v0.2 pass
unchanged.

What v0.3 is

v0.1 and v0.2 could see the action and nothing else. A policy condition naming the principal is
refused at load, deliberately, and that rule survives this release. v0.3 adds a second and
separate axis
— who is acting, and what are they entitled to — and combines the two results as
the stricter of the pair.

  • Authority is opt-in, then fail-closed. No authority: section → v0.2 behaviour exactly.
    One authority: section → every principal needs a grant, and no grant means DENY. There is no
    half-way and no flag that restores one.
  • Attenuation is structural. A delegated grant is valid only if it is provably a subset of
    its parent, on every dimension, at creation and again at every evaluation. Omitting a
    dimension the parent constrains is a rejected delegation, never "unconstrained".
  • Identity is consumed, not invented. Principals come from an IdentityProvider. Claims are
    receipt data rather than action identity, so an approval survives a token rotation.

What is in the diff

docs/SPEC-v0.3.md — §1 scope and what was read · §2 Principal · §3 IdentityProvider ·
§4 authority · §5 delegation · §6 observe mode · §7 events · §8 gateway · §9 the fail-closed
table · §10 acceptance tests T60–T93 · §11 the frozen API · §12 schema versions · §13 out of
scope.

pyproject.toml — version 0.3.0.dev0 and an identity extra, empty until item 5 adds the JWT
verifier and the pyjwt line it needs. CHANGELOG.md — an Unreleased section that says plainly
that nothing has shipped. CI and the publish gate install the new extra, so item 5 does not have
to touch them.

The four things a reviewer should look at first

Each is a place the first draft was wrong and an adversarial review pass caught it. They are
the reason this PR is worth reading rather than skimming.

A delegation may change who acts, not how many. §5.4 now has a subject row. Which
concrete agent the child names is free — that is what delegation is for — but it MUST name one:
present, with no wildcard, and with a user the parent's pattern admits wherever the parent
declares one. Without all of that, one delegation hands the grant to every agent in the
deployment along with the right to pass it on (subject: {agent: "*"}), or strips the human the
authority was bound to (parent {agent: finance-agent, user: alice} → child
{agent: finance-agent}) — and every containment check still reports "contained".

A later review pass found the same escalation reachable by omission: §4.2 makes an absent
subject.agent mean "any agent", so a child declaring only {user: alice@example.com} carries
no wildcard, satisfies the user rule, and still widens the grantee population. §5.4's own rule
decides it — omission is never "unlimited" — so the row demands the key, not just the absence of
an asterisk.

A grant carries no decision:. An earlier draft gave one an optional allow | approve, so
that a delegation could say "may refund, but always with a human", and so T70's "3×3" was
literally 3×3. It is gone. It deadlocked the gateway's approval pre-check, which keys off the
policy decision (v0.2 §6.10); it needed a containment row whose default was the loosest value,
which is the shape §5.4 exists to refuse; and it made whether an action needs a human depend on
who proposed it — the sentence docs/CLAIMS.md maps to code. Authority permits or denies, the
table is 2×3, and §4.2 records the deviation from the brief.

Revocation and expiry are live; an edit to the document is not. §5.6's first draft claimed a
narrowed root grant propagated to every delegation beneath it. Nothing reloads: an Authority is
built when the document is loaded. Revocation and expiry are live, because they are read from
the store and the clock on every evaluation. The file half now says what actually happens, the
runtime kill switch is named (ctrlrun revoke, delegations only), and hot reload and a
root-grant revoke are in §13 rather than implied by prose.

The JWT provider must check typ. Checking iss, aud and exp alone accepts an OIDC ID
token
from the same issuer, signed with the same key, whenever the operator configured
audience to that client id — RFC 8725 §2's cross-JWT confusion, live in the first draft.
token_type is now a required argument with no default, aud is checked by membership on both
wire shapes, and the symmetric-key guard moved to the configuration end where it can refuse
something (secret= is its own parameter, three-way exclusive with jwks_url and public_key).

Other decisions worth a look

Question Decided Where
Provider or context()? The provider wins where it answers; context() fills in where it declines — except once an authority: section is loaded, when a decline is not backfilled at all. Omitting a credential must not be an easier route than forging one. §3.2
Glob grammar Literal, prefix*, and a final ** whose prefix segments are all literal. * never crosses a separator; ** is the only spelling for "everything". Chosen so containment is decidable, and ** counts as a segment so every pattern contains itself. §4.4, §5.5
Who writes delegation records? Control.delegate / Control.revoke. Authority only plans — reads and returns, never writes — so ARCHITECTURE §6 keeps one composer and the delegation events reach every EventSink rather than only the events table. §4.8, §11
Control.evaluate Returns the combined decision. A frozen query that stopped predicting the frozen command would be worse than a new name. §4.6, §11
Observe mode and a refused reservation The executor runs; no effect record is written. The store would refuse it, and a machine moving a record out of AMBIGUOUS is exactly what v0.1 §5.2 reserves for a human. §6.2
Which grant is named when several match The one whose id sorts first — a property of the set, not of the file. An earlier draft said "the first that does", which contradicted its own order-independence guarantee. §4.6
Receipt schema Bumps to ctrlrun.receipt/v2 — result gains a value a v1 reader does not know. §12.2 says what actually breaks: a ≤ 0.2 process reading a store an observe-mode 0.3 process wrote raises, so upgrade readers before switching a writer. §12.2
Decimal-string money Cannot be bounded by any grant, because ordering operators are int-only. The fail-open is already closed by a load error; the spec now says so instead of leaving it to be rediscovered. §4.5

Standards posture

§1.3 records what was read, with URLs, dates and status. The document makes no compliance,
conformance or alignment claim anywhere.

  • NIST has published nothing normative on agent identity. Its one dedicated document is a
    concept paper (Initial Public Draft, 2026-02-05) with no RFC 2119 keyword in it, whose open
    questions are the ones this spec answers. IR 8587's one consumer-facing MUST is quoted as the
    draft guidance it is.
  • MCP at 2026-07-28 defines no principal, actor or delegation primitive, and puts an explicit
    warning label on the only two fields that look like identity — which is why
    --principal-from-client-info is removed here.
  • OAuth: the stable ground is JWT verification (now including RFC 9700, BCP 240). Everything
    agent-shaped is a draft and most of it is an individual submission with no IETF standing. RFC
    8693 §4.1 is why the delegation chain lives in CTRLRun's own store rather than in a token.
  • SPIFFE: a JWT-SVID is identity and explicitly not authorization.
  • Attenuation prior art: §5's rule is a different guarantee from the append-only one
    macaroons and UCAN give, not a strictly stronger one, and two of its dimensions have no prior
    art at all. Both are said rather than implied.

Checks

pytest 1269 passed · mypy --strict src clean · ruff check clean ·
pip install -e '.[identity]' resolves.

Next: item 1, tests first.

The v0.3 contract, and the scaffolding items 1-7 build against. No implementation:
src/ is untouched and the 1269 tests of v0.1 and v0.2 pass unchanged.

v0.1 and v0.2 could see the action and nothing else. A policy condition naming the
principal is refused at load, deliberately, and that rule survives this release.
v0.3 adds a second and separate axis - who is acting, and what are they entitled
to - and combines the two results as the stricter of the pair.

Three rules the document is built on. Authority is opt-in as a section and
fail-closed once present: no authority: key means v0.2 behaviour exactly, one
authority: key means every principal needs a grant. Attenuation is structural: a
delegated grant is valid only if it is provably a subset of its parent, on every
dimension, at creation and again at every evaluation, and omitting a dimension the
parent constrains is a rejected delegation rather than an unconstrained one.
Identity is consumed, never invented: principals come from an IdentityProvider,
and claims are receipt data rather than action identity, so an approval survives a
token rotation.

Four decisions are worth a reviewer's attention because the first draft got them
wrong and an adversarial pass caught them.

A delegation may change who acts, not how many. Its subject may carry no wildcard,
and where its parent names a user the child must name one the parent admits.
Without both rules one delegation hands a grant to every agent in the deployment -
along with the right to pass it on - or strips the human the authority was bound
to, and every containment check still reports "contained".

A grant carries no decision. An earlier draft gave one an optional
decision: allow | approve so that "this delegate always needs a human" was
expressible. It deadlocked the gateway's approval pre-check, which keys off the
policy decision; it needed a containment row whose default was the loosest value,
which is the shape the containment table exists to refuse; and it made whether an
action needs a human depend on who proposed it, which is the sentence CLAIMS.md
maps to code. Authority permits or denies. Per-action autonomy stays in policy.

Revocation and expiry are live; an edit to the document is not. Grants are read
when the document is loaded, so narrowing a root grant takes effect on the next
load, and the runtime kill switch is ctrlrun revoke, which covers delegations
only. The prose that promised otherwise is corrected rather than the behaviour
invented.

The JWT provider must check typ. Checking iss, aud and exp alone accepts an OIDC
ID token from the same issuer, signed with the same key, whenever the operator
configured audience to that client id - RFC 8725 section 2's cross-JWT confusion,
live in the first draft. token_type is a required argument with no default, and
the symmetric-key guard moved to the configuration end, where it can actually
refuse something.

Section 1.3 records what was read, with URLs, dates and status, and the document
makes no compliance, conformance or alignment claim anywhere. The short version:
NIST has published nothing normative on agent identity, and its one dedicated
document is a concept paper with no RFC 2119 keyword in it whose open questions
are the ones this specification answers. MCP at 2026-07-28 defines no principal,
actor or delegation primitive, and puts an explicit warning label on the only two
fields that look like identity - which is why --principal-from-client-info is
removed here. In OAuth the stable ground is JWT verification; everything
agent-shaped is a draft and most of it is an individual submission with no IETF
standing. A SPIFFE JWT-SVID is identity and explicitly not authorization. And
section 5's "contained at creation and re-checked at evaluation" is a different
guarantee from the append-only one macaroons and UCAN give, not a strictly
stronger one; two of its dimensions have no prior art at all, and it says so.

Scaffolding: version 0.3.0.dev0, an identity extra that is empty until item 5 adds
the verifier and the pyjwt line it needs, a CHANGELOG Unreleased section that says
plainly that nothing has shipped, and CI plus the publish gate installing the new
extra so item 5 does not have to touch them.
ruff format reformats Python code blocks inside Markdown, and the v0.3 blocks are
valid Python, so it does. The v0.1 and v0.2 spec blocks escape it only because they
carry unparseable placeholders (action_id: str = <generated>), which is luck rather
than a convention. Aligned trailing comments go; nothing else changes.
Both are defects this document introduced, not carried over.

§4.6 contradicted itself on which grant is named when several match. It said the
action "passes on the first that does" -- order-dependent -- and then said the
result MUST NOT depend on the order grants appear in the document, with T70b
asserting an identical grant_id after permuting the file. Both cannot hold. Any
matching grant authorizes the action, so which one is named needs its own rule, and
evidence must not turn on incidental file order for the same reason §4.3 fixes the
deny-reason precedence. AuthorityResult.grant_id is now the matching grant whose id
sorts first by codepoint, which is a property of the set rather than of the
document.

§5.4's subject row banned a wildcard in a delegation's subject but did not require
agent to be present, and §4.2 makes an absent subject.agent match any agent. So a
child of {agent: finance-agent, user: alice} could declare {user: alice@example.com}
with no agent key, carry no wildcard, satisfy the user rule, pass containment, and
hand the grant to every agent acting for alice. That is the grantee-population
widening the row was added to prevent, reached by omission instead of by an
asterisk -- and §5.4's own rule already answers it: omission is never unlimited. A
delegation's subject must now declare a concrete agent.

Also: §9 gained the row for §5.6 rule 3, an expired ancestor, which T77 tests and
the table had no line for; and §6.3 said blocked_reason may be one of §4.3's
authority reasons, which since authority_grant joined that set would have admitted
a pass reason into a field that records what stopped an action.

T76 and T81 gain the omitted-agent case; T70b asserts the sorted id rather than
whichever grant the implementation happened to reach first.
@arpanghoshal
arpanghoshal merged commit b92a204 into main Sep 4, 2026
3 checks passed
@arpanghoshal
arpanghoshal deleted the item-0-spec-v0.3 branch September 4, 2026 05:14
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