Repository navigation
ctrlrun 0.3.0rc1
Pre-release0.3.0rc1 is this section, published to TestPyPI only, so the wheel and the sdist can be
installed from a real index before a version number that can never be reused is spent on
PyPI. The date lands when 0.3.0 is cut.
Who is acting, and what are they entitled to? v0.1 built the kernel and v0.2 put it in the
network path; both could see the action and nothing else. This release adds a second axis —
identity, grants and delegation — that policy never learns to read, plus the mode you roll it
out in and the command that tells you what it would have cost.
Three sentences govern everything below.
Authority is opt-in, then fail-closed. No authority: section is v0.2 behaviour, exactly.
An authority: section means every principal needs a grant and no grant means denied. There is
no half-way and no flag that makes a missing grant permissive.
Attenuation is structural. A delegated grant is valid only if it is provably a subset of
its parent, on every dimension, at creation and at every evaluation. Omission never means
unlimited: a child that drops a dimension its parent constrains is rejected.
Identity is consumed, not invented. CTRLRun verifies tokens it is handed and maps verified
claims onto a Principal. It issues nothing and defines no identity format. Claims are receipt
data rather than action identity — they are not in the canonical form, so an approval survives
a token rotation.
Added
-
A fifth demo scenario,
ctrlrun demo— authority escalation (build-list item 6,
SPEC-v0.3 §1.2). The first four ask whether an action is safe to run; this one asks
whether a principal is entitled to propose it. A human's €100,000 delegable grant narrows
to a finance agent's €25,000 and then to a support agent's €2,000, and the two ways out of
the chain fail at two different moments: asking for €50,000 is refused at evaluation
(authority_constraint), and minting €50,000 under a €25,000 parent is refused at
creation (containment, naming the dimension). Asserting one would hide the other. The
scenario ends with an amount the grant does permit, which the policy still stops to ask a
human about — the two axes, combining as the stricter of the pair, in one line. -
examples/authority-escalation/— the same story as a standalone script, with the
else: raise SystemExit(...)guard on every refusal. A demonstration that quietly starts
succeeding is worse than none, because it keeps printing the line that says the guard worked. -
examples/authority/— a payments delegation chain and a DevOps chain, as complete
documents to read rather than run, with a README that says in its first paragraph that every
principal in them is invented. -
docs/authority.md— grants, delegation and the omission rule in plain language,
including the two things it is worth knowing before you need them: anAuthorityis built at
load time and is not hot-reloaded, and there is no way to list delegations, so cutting a
chain of unknown width meansdelegable: falseon the root and a restart. -
docs/THREAT_MODEL.mdgains v0.3's boundary. In scope: delegation escalation, omission
as widening, expired and revoked authority, token forgery, cross-JWT confusion, and signing
keys fetched from somewhere else. Out of scope, and stated rather than implied: a compromised
identity provider, aHeaderIdentityProviderbehind a proxy that does not overwrite, a
revoked token before itsexp, a tenant-templated issuer, and authority across an
agent-to-agent hop. -
MANIFEST.inshipsexamples/**/README.md, and a test now keeps it honest. The file
has claimed for two releases that a test namedtest_the_sdist_carries_everything_the_tests_need
keeps it in step, and there was no such test — so the first README it forgot was found by
the CI job that builds an sdist and runs its tests, one push after it could have been found
locally. That is v0.2's four.gitignored policy files in a different costume: setuptools
resolvesMANIFEST.inagainst the working tree, so a local run is green either way. The
test now checks every git-tracked file underexamples/anddocs/against the
manifest's include patterns, and it foundexamples/acs/README.mdhad been missing since
v0.2 as well. -
Fixed a test whose fuse was the suite's own duration. T27's parametrize list built two
timestamps at collection time, so the future-dated one was 400 seconds ahead of
collection and only400 - <however long the suite had been running>seconds ahead by the
time the test executed. Past the replay window's 300 seconds it landed inside the window
and the refusal under test stopped happening. It had been latent since v0.2 and fired the
first time an item made the suite slower. The timestamps are built when the test runs, and
the fix is verified against aconftestthat stalls 120 seconds between collection and the
body — the condition that failed CI. -
The nine sector templates stay on v0.1 and now say why. They gain no
authority:
section: a grant names a real principal in a real organization, and a template that shipped
plausible ones would invite an operator to adopt them. -
JWTIdentityProvider(build-list item 5, SPEC-v0.3 §3.4), inctrlrun[identity]
(pyjwt[crypto]), imported by naming it. It reads a bearer token from a header, verifies it
against a JWKS or a static key, and maps the verified claims onto aPrincipal. It issues
nothing: no OAuth flow, no refresh, no token exchange, no introspection, no dynamic client
registration. An absent header is a decline; a present and invalid one is a refusal, and
Controlnever backfills from one. -
Configuration is refused before any token is seen. More or fewer than one key source; an
HS*algorithm withjwks_urlorpublic_key; an asymmetric algorithm withsecret; an
emptyalgorithms;nonein any casing;token_typenot passed at all. The
HS*-with-a-public-key case is key confusion in its plainest form — RS256→HS256 is literally
"HMAC the token using the PEM of the public key as the secret" — and it is refused at the
configuration end, which is the end that can be refused. -
token_typeis required, and it is the whole cross-JWT defence. Without it an OIDC ID
token from the same issuer, signed with the same key, carrying the configuredaud, passes
every other check (RFC 8725 §2). PassingNoneexplicitly is permitted and warns at
construction naming exactly what it gives up. -
audby membership on either wire shape, never a rawin: on the string shape that is
substring matching, and it acceptshttps://ctrlrun.examplefor a configuredctrl.exp
is required — a credential with no expiry cannot be revoked by waiting, and v0.3 has no
revocation channel — andexp/nbfare compared against the provider's injected clock, so
an expiry boundary is tested exactly rather than raced. -
JWKS handling that cannot be turned into a load generator. An unknown
kidtriggers at
most one refresh and then a refusal; a second such token insidejwks_min_refresh_interval
is refused with no fetch at all. A set with two entries sharing akidis refused rather
than resolved by first match; a key whoseuseis notsigor whosekey_opsexcludes
verification is ignored; a key carrying its ownalgconstrains itself. A failed fetch is a
refusal that discards nothing, and nothing is followed: the fetch refuses a redirect
outright and re-checks the scheme of the URL that actually answered.urllib's
build_openerkeeps itsHTTPRedirectHandlerunless an argument subclasses it, so an
opener built the obvious way follows a 302 — including one to plaintexthttp://on
another host. An open redirect on the issuer's domain would otherwise have this process
fetch its signing keys in cleartext from wherever it pointed, cache them for the life of
the process, and verify every token the attacker then signed. -
Two limits stated rather than configured around.
issueris an exact string, so a
tenant-templated issuer cannot be configured correctly here — pointing it at a multi-tenant
endpoint without pinning the tenant makes every tenant on that platform a valid issuer. And
there is no revocation channel: short token lifetimes are the whole of the story. -
Gateway identity selection (§8.2).
--principaland--principal-headerare understood
as constructors now —StaticIdentityProviderandHeaderIdentityProvider— and
--identity-jwtis a third, with its own flags. Exactly one is required, still with no
default; every--identity-jwt-*flag is an error without--identity-jwt, because a flag
that cannot take effect is a flag the operator believes took effect — and the test for that
reads the flag list off the command itself rather than off the check, because a list copied
from the code under test cannot fail for the flag the code forgot. The shared secret is
read from a file, never from a flag value: a secret on a command line is in every process
listing on the host.--identity-jwt-http-timeoutbounds the JWKS fetch and is its own
flag: the fetch runs on the request thread before any decision is made, so borrowing
--upstream-timeoutwould have coupled two unrelated knobs in the fail-slow direction. -
ctrlrun gateway --authority PATH(§8.3), because the person who writes grants and the
person who writes per-action autonomy are often not the same person. That document is not a
policy document — its top-level key set is closed atschemaandauthority, soactions:
ormode:in it is a load error naming the file and the key. Declaringauthority:in both
the policy and--authorityrefuses to start, naming both paths. -
-41012ctrlrun.unauthorized(§8.4). A tool call outside the principal's grant returns
it, HTTP 403, upstream untouched;v0.2 §6.10's-41001now means a policy denial. The
gateway catchesAuthorityDeniedbeforeActionDenied, which it subclasses — the other
order makes the new code unreachable while every test asserting only "it was refused" stays
green, so both halves are pinned by name. -
No refusal reaches a client as a dropped connection.
do_POSThas no top-level
handler, and an escaping exception makessocketserverclose the socket with nothing
written — which a client reads as a transport failure, and a transport failure is the one
signal it retries on. Three v0.3 paths landed there and each now answers: an authority
denial or an expired credential on a continuation (-41012/-41007), an expired
principal inControl.execute(-41007, andIdentityErroris deliberately not an
ActionDenied, so it needs its own clause), and an identity provider raising anything at
all, which §3.2 makes anIdentityErrorin-process and now does at the gateway too. The
expired-principal path is routine rather than adversarial: the JWT provider admits a token
up toleewaypast itsexp, so every token in that 60-second window was affected. -
A startup block that says what is in force (§8.4): the environment, the identity provider
by name and the header it trusts, whether anauthority:section is loaded and how many
grants it holds — or the single lineno authority: section — every principal is unrestricted, so an operator who believes they configured authority finds out on the line
that starts the process. -
Observe mode (build-list item 4, SPEC-v0.3 §6). A top-level
mode: observeruns every
real decision against real traffic and records what would have been blocked, without
blocking anything: noActionDenied, noAuthorityDenied, noApprovalRequired, no
DuplicateEffect.Control.executereturns a receipt whoseresultisobserved, whose
executionis what the executor actually did, and whosewould_havesays what enforce mode
would have reached and what it would have done with it. It is the rollout path, and it is
not a dry run: it executes, effects land at remotes, and the records of them are real. -
One switch, and it governs the process.
mode:is top level and nothing else — inside an
action, a rule, a grant or theauthority:section it is a load error naming the rule, and
so is a value that is not exactlyobserveorenforce. A partially-enforced configuration
is the failure mode that rule exists to prevent. Absent meansenforce;mode:needs
schema: ctrlrun.policy/v3, because a reader that ignored it would enforce a configuration
that was deployed to observe. -
Observe mode still refuses what it cannot describe. A missing principal, a provider that
raises, an unresolvable effect key, an argument an Action cannot represent, and a delegation
that would escalate are refused in both modes: the first four are wiring bugs that would run
an action CTRLRun could not describe, and the fifth is an act of authority rather than a
decision about an action. It asks no human either — a policy reachingapproverecords
approval_requiredand runs, creating no request and appending noAPPROVAL_REQUESTED—
and it never calls thereconcilehook, whose"committed"answer would move a record a
human may still be adjudicating. -
A refused reservation writes no effect record. The record belongs to the attempt that
holds the key, and observe mode does not make a second attempt its owner: a record already
AMBIGUOUSstaysAMBIGUOUS. The refusal is on the receipt instead, as
would_have.blocked_reason. The gateway's v0.2 §6.10 pre-check is skipped in observe mode
for the same reason — it refuses calls beforeControl.executeis reached, and a gateway
that kept it would enforce three rows in the one mode that enforces none of them. -
ctrlrun stats, counting from the local store and nothing else — no network, no
aggregation service, no upload. Would-have-denied broken down by reason, would-have-needed-
approval, would-have-been-blocked broken down by duplicate and ambiguous, and ambiguous
outcomes.--sincetakes an ISO-8601 timestamp with an offset or30m/24h/7dand
comparesfinished_atinclusively;--jsonemits the same numbers under
schema: ctrlrun.stats/v1. In enforce mode it reports what actually happened and reports
less, saying so in its footer rather than printing a line the receipts cannot substantiate. -
The observe banner.
OBSERVE MODE — nothing is enforced, to stderr, before anything
else, on every invocation of every command that loads the operator's policy —gateway,
stats,delegate,revoke,verify. To stderr so a--jsonstdout stays parseable. The
evidence commands (receipts,effects,inspect,resolve,approve,deny) do not
print it and do not load a policy at all: reading evidence must not depend on the
configuration that produced it. -
ctrlrun verifyas a stub that prints to stderr that verification lands in 0.4 and exits
2. It runs nothing, checks nothing, and claims nothing. It exists because observe mode's
whole purpose is to lead somewhere, and the command an operator reaches for next should not
be aNo such commanderror that suggests they mistyped. -
Delegation with attenuation (build-list item 3, SPEC-v0.3 §5). A principal who holds a
delegablegrant can create a narrower one at runtime —Control.delegate,ctrlrun delegate— and revoke it —Control.revoke,ctrlrun revoke. A delegated grant is valid
only if it is provably a subset of its parent on every dimension, checked at creation and
again at every evaluation: a check performed only at creation would leave every delegation
exactly as wide as the file used to be, which is the shape of every stale-permission incident
there has ever been. -
Omission never means unlimited. A child that drops a dimension its parent constrains is
rejected, not treated as inheriting the parent's limit and certainly not as unconstrained —
including a child subject that carries a wildcard, omitsagent, or drops the parent's
user, each of which hands the grant to a wider population rather than a narrower one. -
Revocation is transitive by structure. Nothing is rewritten and no children are visited:
a chain of any depth is cut by one write, because every evaluation walks to the root. Chain
depth is recomputed, never read from the stored column, the walk is bounded and refuses a
chain that revisits an id, and a stored delegation that cannot be read denies the action
outright rather than being skipped in favour of a broader grant that happens to match. -
A
delegationstable in both stores, andDelegationRecordwith it. A new table, so
CREATE TABLE IF NOT EXISTSadds it to a database that already exists and v0.3 still needs
no migration story; no existing table gains a column.put_delegationis anINSERTand
never an upsert — an upsert on an existing id would clearrevoked_at, which isunrevoke
by another door in a release that says there is no such thing. -
Three delegation event types,
DELEGATION_CREATED,DELEGATION_REVOKEDand
DELEGATION_REJECTED. They are about an authority record rather than an action, so
Event.action_idbecomesstr | Noneand they carrynull;OTelEventSinkemits a
standalone span for each rather than dropping them, because the highest-privilege operations
in the release must not be the only ones missing from the export path. -
An expired credential mints nothing.
Control.delegaterefuses abywhose credential
has lapsed, before it looks at the parent at all — a delegation is the most durable thing a
principal can create, and it is the last place a stale credential should still work. -
The
authority:section: grants, patterns and evaluation (build-list item 2,
SPEC-v0.3 §4). A second axis, and it is opt-in and then fail-closed: a document with no
authority:key behaves exactly as v0.2, and the moment one exists every principal needs a
grant — including for actions the policy allows outright, including reads, including actions
with no effect key. There is nodefault: allowand no flag that makes a missing grant
permissive.Authority,Grant,SubjectandAuthorityResultare public;
Control(authority=...)takes one, andControl.from_filereads it from the same document. -
Authority is evaluated before policy, and the two combine as the stricter of the pair.
A denial there appendsAUTHORITY_DENIEDand neverPOLICY_EVALUATED, so it cannot
leave a pending approval request behind for an action that could never run. Neither axis
loosens the other: authority cannot make a denied action allowed, and policy cannot make an
unauthorized one permitted. A grant carries nodecision:— how much autonomy an action
has stays the same for every principal, and what differs is whether they may propose it at
all. -
A pattern grammar small enough that containment is decidable (§4.4, §5.5): a literal, a
prefix*that cannot cross a separator, and a final**whose preceding segments are all
literals.stripe.*matchesstripe.refundand notstripe.refund.partial; there is no
?, no character class, and no short spelling for "everything" — granting the whole surface
of a system is spelled**, one token, greppable, and impossible to write by accident. -
Two new event types and one new error.
AUTHORITY_RESOLVEDis appended for every
action that passes authority, not only a delegated one, so a deployment with a permissive
grant is distinguishable from one with no section at all.AuthorityDeniedsubclasses
ActionDenied— an authority denial is the action being denied — and carries a reason from
§4.3's closed set, never a grant id: a grant may legally be namedno_authority, and
evidence that can be spoofed by naming a grant is not evidence. -
Extended
Principal,IdentityProvider, and the two core providers (build-list item 1,
SPEC-v0.3 §2, §3).Principalgainsclaims,issuerandexpires_at, validated the way
arguments are — nofloat, no containers, a timezone-aware expiry — and none of the three
is in the action hash, so an approval survives a token rotation.StaticIdentityProvider
andHeaderIdentityProvidership in core; the JWT one lands with item 5. A provider's answer
wins overcontext(), aNoneis a decline that leaves the v0.1 path intact, and a provider
that raises is never backfilled — falling back there would turn a rejected credential into
a successful action. -
An expired principal is refused before authority and before policy, with a
denied
receipt and no approval request.Control.evaluatereturnsdeny/principal_expired
instead, because it may not write. -
ctrlrun inspectshows the issuer, the expiry and the claim names; the values reach
--json. The OTel sink exports the issuer and the names only — a span goes to a third party
by default, a receipt is evidence meant to be read. -
docs/SPEC-v0.3.md— the v0.3 contract, a delta over v0.1 and v0.2. Seven deliverables:
an extendedPrincipaland theIdentityProviderprotocol; theauthority:section with
grants, patterns and constraints; delegation with structural attenuation; observe mode and
ctrlrun stats; a JWT identity provider and the gateway wiring for both; examples and docs;
the release. Tests come from §10 (T60–T93). -
The
identityextra, empty until build-list item 5 adds the JWT verifier and thepyjwt
line it needs.pip install ctrlrunstill installs nothing butpyyamlandclick.
Changed
-
BREAKING: a condition naming
claims,issuerorexpires_atis refused at load, in a
document of every schema version (§4.5, §12.1). The reservation lives in the condition-key
splitter, which runs for every condition in every document, and gating it onv3would leave
the same name meaning two things in two files. Av1policy whose protected function takes an
argument calledclaimsstops loading until the argument is renamed; the load error says so. -
BREAKING:
AcsControlHooktakes anidentityprovider, and ignores the envelope's
agent_idandenvironmentwhen it has one (§8.4). Under v0.2 both came off the wire, on
exactly the argumentv0.2 §6.5made forclientInfo: a policy could not address the
principal. §4 ends that, and §8.1 removes--principal-from-client-infoover the same
sentence — the ACS hook was that flag in a different module. A hook built against aControl
holding anAuthoritywith no provider raisesInvalidArgumentat construction. With a
provider,agent_idis ignored — not merged, not a fallback, not compared — and a provider
that names nobody is a denial withreason_codes: ["no_principal"], never a fall back to the
envelope.handle()gains an optionalheaders=for the transport's own headers, which is
what a provider reads.docs/ACS.md's mapping table is amended in the same change. -
All three v0.2 call sites now use the combined decision (§8.3): the gateway's
tools/call
path,ctrlrun.acs's request hook, andControl.resume. Left asPolicy.evaluate, an action
a grant forbids outright would still have its approval flow run, and a human would be paged
about a call that could never happen. -
Control.evaluatereturns the combined decision, not the policy axis alone. Its signature
andEvaluation's two fields are unchanged, and it still writes nothing — it would simply
stop answering "what will happen to this action" if it reported one axis whileControl.execute
acted on both. -
Control.resumerecords the authority axis on its receipt (§5.6.1). Evaluated and
recorded, not re-decided: the reservation is held and the remote may already be acting on it.
A lease extension is the other way round — refused where authority no longer covers the
action, so the lease lapses and the record becomesAMBIGUOUSby the ordinary path. -
policy._Conditionis nowpolicy.Condition, andpolicy.parse_conditionsis public.
A grant'sconstraints:is in exactly a rule'swhen:syntax and is parsed by the same code:
a second condition evaluator would be a second place forTrueto start comparing equal to
1. The top-level policy key set gainsauthority, which needsctrlrun.policy/v3. -
BREAKING:
context(environment=...)is removed — already recorded below, and item 1 is
where it happens.Control(environment=...)replaces it. -
BREAKING:
ctrlrun gateway --principal-from-client-infois removed. It exits non-zero
naming--principal-header, rather than starting with a principal the operator did not ask
for.--environmentnow defaults to unset so$CTRLRUN_ENVIRONMENTis not silently
outranked, and--user-headerwithout--principal-headeris an error rather than a flag
that cannot take effect. -
A repeated identity header is refused —
-41007, HTTP 403, upstream untouched. A
Mappingholds one value per name, so something had to decide what two become, and under
authority that decision picks the principal. -
BREAKING for a reader:
ReceiptResultgainsobserved.Receipt.from_dictparses
resultinto a closedStrEnumandSQLiteStateStorereads every stored receipt through
it, so a CTRLRun ≤ 0.2 process runningctrlrun receiptsorctrlrun inspectagainst a
store an 0.3 observe-mode process wrote raises on the unknown value. Two processes
sharing one store is the intended deployment: upgrade every reader before switching any
writer tomode: observe. An enforce-mode 0.3 writer emits noobservedreceipt and is
safe to mix. An external reader that keys onresultmust readexecutiontoo, or it
counts every observed execution as if nothing ran. -
The demo's scenario 5 shares the store and the sinks of the other four. A second
Controlthat quietly dropped the JSONL sink left the demo printing a receipt count from
the store that the file it points the reader at did not match — a false green, in a
transcript people paste into issues. -
ctrlrun.receipt/v2andctrlrun.inspection/v2. The receipt carries the whole principal
and reservesexecutionandwould_haveasnulluntil observe mode fills them, so the
shape a reader parses settles once rather than changing twice under one version string. The
stored Action carries the whole principal too — without it every receipt written by
Control.resumereported no claims and no expiry, on the only receipt an MCP
multi-round-trip action ever gets. -
Four edits claimed by the previous change were never in the file. A batch that wrote only
at the end discarded every successful replacement before the one that raised, and only the
failing one was re-run — soauthority_unreadablewas referenced by §4.6, three rows of §9 and
T77d while §4.3's closed reason set never defined it; the precedence order was never reordered;
thepyyaml>=6.0rationale was missing from §4.2; andauthority_grantstill had no route into
evidence. A later edit then deleted §4.2'senvironmentsparagraph believing it a duplicate,
when it was the only copy. All five are restored, and the tooling now writes after each edit so
a later failure cannot undo an earlier success. -
BREAKING:
context(environment=...)is removed;Controltakesenvironment=instead.
v0.3 makes the environment an authorization input — a grant may scope to
environments: ["staging"]— and a dimension the subject sets is not one. On the decorator path
the value came from the agent's own call site, so a grant scoped to staging was a real
restriction through the gateway and decoration in-process.It is now set once per
Control, from itsenvironment=argument, else
$CTRLRUN_ENVIRONMENT, else the policy document's top-levelenvironment:, else
"production". The gateway's--environmentand the ACS hook's configuration are unchanged —
they were already this rule. A deployment that ran several environments from one process runs
oneControleach.SPEC-v0.1.md§8's frozen signature is amended in the same change, as the
rule for a frozen name requires. -
docs/SPEC-v0.3.mdamended against an independent review of §4 and §5. The contract was
read by two reviewers who had not written it, and they found two authorization holes the
author's own pass had missed.An expired credential could mint permanent authority.
Control.delegate's checks matched
the creating principal onagentanduser, which do not stop being equal when a token stops
being valid — and §2.3's expiry refusal is scoped toControl.execute, which a delegation is
not. A principal whose every action was being refused could still write an unexpiring,
re-delegable grant for an agent of its choosing. §5.3 gains a rule 0.The ACS hook read its principal off the wire.
ctrlrun.acstakesagent_idand
environmentfromparams.metadataon the inbound envelope. Under v0.2 that was survivable on
the argumentv0.2 §6.5makes forclientInfo; §4 ends it, and §8.1 removes
--principal-from-client-infoover exactly this. Removing the flag while the same pattern
lived in another module would have made the removal a gesture. §8.4 gives the hook an
IdentityProviderand refuses to construct one without it against aControlholding an
Authority.Also: an unreadable stored delegation is now
authority_unreadableand denies outright rather
than being skipped in favour of a broader grant that happens to match;Control.resumejoins
the list of call sites that must use the combined decision, and §5.6.1 says what authority does
across a lease extension, a commit and a resume; adelegablegrant must declareexpires_at,
which is the only thing bounding the grantee population a compromised holder can reach; and
pyyaml>=6.0becomes a floor, because PyYAML 5 returns naive datetimes and would reject the
specification's own example.
Notes on what the contract decides
Recorded here because each is a decision a reader of the code would otherwise have to
reconstruct:
- Authority is opt-in as a section and fail-closed once present. A file with no
authority:key behaves exactly as v0.2. With one, every principal needs a grant; no grant
means DENY. - Claims are receipt data, not action identity. The canonical form of an Action is
unchanged, so an approval survives a token rotation. - Omission never means unlimited. A delegated grant that drops a dimension its parent
constrains is rejected, not treated as unconstrained. - Observe mode is top-level only. A partially-enforced configuration is the failure mode
that rule exists to prevent. - Policy still cannot see the principal, and a grant carries no decision. Authority is a
second, independent axis that permits or denies; the two results combine as the stricter of
the pair. How much autonomy an action has stays the same for every principal. - A delegation may change who acts, not how many. Its subject must name a concrete agent —
present, no wildcard — and a user its parent's pattern admits wherever the parent names one.
Otherwise one delegation hands the grant to every agent, or strips the human it was bound to,
and omitting the key reaches the same place as an asterisk. - Revocation and expiry are live; an edit to the document is not. Grants are read when the
document is loaded.ctrlrun revokeis the runtime kill switch and it covers delegations
only; there is no hot reload and no runtime revoke for a root grant.
Deprecated
- Nothing new.
--principal-from-client-info, deprecated in 0.2.0, is removed in 0.3.0 by
build-list item 1; SPEC-v0.3 §8.1 has the replacement.
Fixed
-
ruff formatreformatted Python code blocks inside the specs;ruff checknever looked
at them. Whether a spec's examples were rewritten therefore turned on whether they happened
to parse:SPEC-v0.1.mdandSPEC-v0.2.mdkeep their aligned comments only because theirs
carry placeholders like<generated>, andSPEC-v0.3.md's parse, so a CI check that had
never fired before went red on them and the alignment was flattened to make it green.A specification's code blocks are illustration rather than code — they elide, they annotate,
they line comments up so a reader can compare down the column — and the formatter has no way
to know that.[tool.ruff.format] excludenow tells it,force-excludemakes the exclusion
mean the same thing however ruff is invoked, and SPEC-v0.3's alignment is restored.
test_the_formatter_leaves_markdown_alonekeeps it that way, with a control test that fails
if the probe it relies on is not actually unformatted. -
The gateway compared mirrored header values by re-parsing them, and Python's parser is
lenient.Mcp-Param-{Name}validation ran the header throughint(), so a body carrying
amount: 2000was certified as agreeing with headers spelling it2_000,+2000,02000,
2000,2000, and the Arabic-indic and fullwidth digit forms; booleans were matched
case-insensitively, soTRUE,TrueandtRuEall agreed withtrue. None of those is
what a JSON serializer writes, and each is read differently — or refused — by another
parser:parseInt("2_000")is 2 in JavaScript andstrconv.Atoierrors in Go.That is the exact hazard SPEC-v0.2 §6.4 exists to prevent. The gateway's job there is to
certify that a routing intermediary and CTRLRun are looking at the same value, and it was
certifying agreement that held only under Python's rules. CTRLRun's own decisions were never
affected — the action is built from the body, and the headers are only checked — so this
costs an intermediary's correctness rather than an approval binding.A header value must now be the body value's canonical rendering, compared as text, with no
parser in the comparison — and only a string, an integer or a boolean has one. The revision
permitsx-mcp-headeron those three types alone and omits the header for anull, so a
header naming an argument of any other type is refused rather than compared against a
rendering CTRLRun invented for it. This also declines the revision's SHOULD that servers compare
integers numerically (42.0equals42): v0.1 §2.3 refuses a float in the body outright, so
the leniency has no legitimate case here. SPEC-v0.2 §6.4 states both rules and the reasoning.