ctrlrun 0.9.0
Undated until the tag.
Every guarantee before this one answers whether. A grant says amount_lte: 5000, and is silent
about the thousand actions that each pass it: the authority model bounds one action and has never
bounded an aggregate, so an agent acting entirely within its permissions can still empty an account
one permitted refund at a time. v0.9 answers the other half: how much, over which records, for
which task?
Three dimensions, one rule each.
Consequence budgets. A grant may carry budgets:, a metric with a limit over a rolling window,
consumed on reserve, inside the reservation's own transaction, because a check on one line and a
consumption on another is a race two processes win together. Ambiguity is not a refund: an
AMBIGUOUS effect holds its consumption until a human or a hook resolves it, because otherwise an
agent that can manufacture ambiguity can manufacture authority. A budget names a metric, not a
consequence: nothing here ranks, scores or classifies an operator's actions.
Scope providers. scope= answers "is this record this principal's?", strictly before the
reservation, which is the bite on an identifier an attacker chose. A grant permits records.read on
customer:*, and until now nothing had an opinion about whose record customer:90210 is.
Task-bound authority. tasks: narrows a grant to a unit of work, by the same child ⊆ parent
rule as every other dimension. It limits blast radius; it does not detect a hijack.
What a budget is not. It cannot recall an action already in flight: a rolling window changes
what the next reserve may do and nothing about what is already reserved, so a reservation taken a
second before the window rolls commits regardless. It counts a metric an operator named, an argument
on the action, and is not a consequence model: nothing ranks, scores or classifies what an action
means. It is per store, so two deployments sharing a provider account and not a store each enforce
their own. And it is fail-closed against its own principal: an agent able to manufacture ambiguity
can pin a budget it cannot spend, which is a denial of service against the operator's own agents and
is the deliberate side of the trade against an agent that manufactures authority.
Added
-
Task-bound authority (SPEC-v0.9 §6). A grant may carry
tasks:, a unit-of-work dimension
attenuated by the samechild ⊆ parentrule as actions, resources and environments.
Control.execute(task=...)andControl.evaluate(task=...)take the resolved task id;
@protect(task=...)takes a template over the call's arguments, likeeffect=andresource=.
G24 grades it: a task-bound grant refused off its task, by reason and not by type.A grant that names no
tasks:authorises any task, so every existing grant upgrades
untouched.SPEC-v0.3.md§5.4 settled that asymmetry in writing: a root grant's omissions are
an operator's decision, a delegation's are what an attacker would write.Two paths deliberately do not evaluate the dimension:
Control.resumeand a lease
extension. Both rehydrate an action that carries no task, and evaluating it there would put
AUTHORITY_DENIEDon what is the only receipt an MCP multi round-trip ever gets. A resumed leg
is therefore unbound by task, which is stated rather than hidden.The task reaches the authority decision and the receipt, and never the action hash: a field
onActionwould move every hash in existence and invalidate every stored approval. -
Consequence budgets, enforced (SPEC-v0.9 §4). G22. A budgeted grant charges every
ancestor on reserve, inside the reservation's own transaction, and the ledger is released
exactly when the effect reachesFAILED.Ambiguity is not a refund. An
AMBIGUOUSeffect keeps its consumption until a human or a
reconcilehook resolves it, because otherwise an agent that can generate ambiguity can
generate authority, and generating ambiguity is free for any flaky integration. This is the
correctness hole that kept budgets out of four milestones.The refusal is
ActionDenied(reason="budget_exhausted"), naming the grant, the metric and the
window, and never the remaining balance: refused actions cost nothing, so a refusal that
reported the balance is an oracle an attacker binary-searches.ctrlrun verifyreports 22/22 on the shipped examples, with G22, G23 and G24 all graded
against positive controls. -
The budget ledger, and one amendment to a frozen protocol (SPEC-v0.9 §3).
StateStorehas
been frozen since v0.6 and gains exactly two things:charges=onreserve_effectand
consume_approval_and_reserve, andconsumptions()to read the ledger back. Migration
0007_budget_ledger, additive and forward-only.The charge lands inside the transaction that writes the reservation, on all three backends.
Anything else is a check-then-act race: two processes read the same remaining amount and both
spend. On Postgres that needs aSELECT ... FOR UPDATEon a per-grant anchor row before the sum,
because READ COMMITTED does not serialise a sum and an insert. Measured, not chosen: without it,
twenty-four processes racing a budget that permits ten spent 2400 against a limit of 1000,
with zero refusals.Nothing spends this yet. The consumption, the holds and the releases are the next item.
-
Consequence budgets, in the document (SPEC-v0.9 §2). A grant may carry
budgets:, each a
metric, alimitand awindow. They load, validate, render into the policy hash, and
attenuate down a delegation chain. Nothing counts yet: the ledger and the spending are
separate items, so this release note describes a contract and not an enforcement.The window axis reads backwards, and it is worth stating plainly. Over the same limit a
shorter window is a higher rate: a child of 100,000 per hour under a parent of 100,000 per
day is 24 times the parent's authority, and is rejected. A child of 100,000 per week is one
seventh the rate, and is accepted. Containment is existential: for every parent budget there
must exist a child budget on the same metric withlimit <=andwindow >=, so one child
budget may discharge several of its parent's.A metric names an action argument, or
count. Its value must be a non-negative integer that
is not abool, so money is budgeted in minor units, asexamples/authority/payments.yaml
already does for every constraint. The kernel does not know what any metric means: there is no
branch on a metric name anywhere. -
Scope providers (SPEC-v0.9 §5).
Control.execute(scope=...)and@protect(scope=...)take
a callable that answers what the calling principal's assigned scope is; the kernel matches
this action's resource into it, with the relation a grant'sresources:already uses. It runs
strictly before the reservation and before the precondition recheck, so a provider that
hangs leaves nothing reserved and nothing executed. G23 grades it.This is the bite on an identifier an attacker chose: a grant permits
records.readon
customer:*, and until now nothing had an opinion about whose recordcustomer:90210is.Two distinct refusals, never one:
scope_unavailablewhen the provider raises, answers with the
wrong shape, or answers something the canonicalizer refuses;out_of_scopewhen it answered and
the resource is not covered. A non-callablescope=isInvalidArgument, at decoration time
under@protect.Only the hash of what the provider returned reaches the receipt, under its own domain tag so
it can never equal a precondition fingerprint over the same mapping. A scope is a list of what a
principal may touch, and an evidence store is not the place to keep a second copy of it.It amends
SPEC-v0.7.md§6.9, which said v0.9's scope providers would configure the
precondition hook rather than add a second one.SPEC-v0.9.md§5.2.1 records the amendment and
the three mechanical differences that justify it. -
The operator surfaces for a budget (SPEC-v0.9 §7). No new command.
ctrlrun inspect
gains--grant GRANT_ID, which reports each of that grant's budgets as three numbers:
consumed, the un-released sum over the rolling window, which is the number that decides;
held, the part of it whose effects have not committed; and why, the effect holding each
part and the state it is in.The third is the deliverable. A budget that refuses while it looks nowhere near its limit is
almost always one unresolved effect, and without the third column an operator cannot get from
the refusal toctrlrun resolve. The view prints that command with the effect key already in
it, because an operator retyping the key from the line above is one transcription away from
resolving a different effect.ctrlrun effectssays what each effect is holding, so--state ambiguousanswers "what is
pinning this grant". It says spent for a committed effect and holds for every other,
because §7.2 defines held as the part that has not committed and one word for two numbers would
make the two commands disagree.ctrlrun statsreports the ledger's row count, so growth is observable before it is a problem.
The ledger only grows: the kernel deletes no row, ships no retention command and has no policy
key that expires evidence. What §7.3 owes instead is the invariant that makes somebody else's
archiving safe, and it states it: rows older than the longest window on any budget of a grant
cannot affect any future decision.ctrlrun.budget/v1is its own document rather than a key insidectrlrun.inspection/v2,
because that one answers about an action and this answers about a grant: a reader handed one
would have to know which of two shapes it got. Every existing--jsonshape is unchanged, and
T436 asserts that rather than assuming it.
Changed
-
ctrlrun.receipt/v6carriesscope_hashbesidetask, andctrlrun.guarantees/v5carries
G23 beside G24. -
ctrlrun.policy/v7,ctrlrun.receipt/v6andctrlrun.guarantees/v5.tasks:andbudgets:
on a grant are refused in av6document rather than ignored, because an older reader would
grant the action on every task and against no limit.DIMENSIONSgrows from six entries to
eight,tasksandbudgets, and it is exported and iterated byverify's G9, so a--json
consumer counting dimensions sees eight. -
The shipped
examples/authority/payments.yamlbinds itshead-of-supportgrant to
refund-run:*and gives it a daily budget, so the milestone's own guarantees are notN/Aon
what this repository ships. The authority badge moves fromverified 19/19to
verified 22/22, G22, G23 and G24. -
docs/SPEC-v0.9.md, the v0.9 "Envelope" contract: consequence budgets, scope providers and
task-bound authority, as a delta over v0.1 to v0.8. Documentation only. It specifies the
quantitative half of authority, whichVISION.md§5 has had no code under it: a grant says
amount_lte: 5000and is silent about the thousand actions that each pass it.Four rules the milestone is measured against, recorded here because each one is a decision that
could have gone the other way. A budget is consumed on reserve, inside the reservation's
transaction, because a check on one line and a consumption on another is a race two processes
win together. Ambiguity is not a refund: anAMBIGUOUSeffect holds its consumption until a
human or a hook resolves it, because otherwise an agent that can generate ambiguity can generate
authority. A budget names a metric, not a consequence, so nothing here ranks, scores or
classifies an operator's actions. And a scope provider answers a question rather than detecting
a change, which is what separates it from the precondition fingerprint ofSPEC-v0.7.md§6.The specification amends one frozen surface:
StateStore, frozen sinceSPEC-v0.6.md§9.2, gains
charges=on the two methods that reserve. §3.3 argues it against that section's stated bar.
Stricter than 0.8.0, with what 0.8.0 did
-
A 0.8.0 binary refuses a store 0.9.0 has opened. Migration
0007_budget_ledgeradds the
ledger table, and an older binary opening the migrated database refuses at open, naming the
migration it does not know. Before: there was no0007. This isSPEC-v0.6.md§3.5's rule and
it makes the upgrade one-way per store: a rollback to 0.8.0 needs the database it had, because a
migration that only runs forwards turns a rollback into silent corruption. -
A third-party
StateStoremust implement three more things.charges=onreserve_effect
andconsume_approval_and_reserve, and aconsumptions()read. Before:StateStorewas frozen
atSPEC-v0.6.md§9.2 and a backend implementing every declared method was complete. A backend
that implementscharges=and not the read satisfies the protocol and breaksctrlrun inspect
andctrlrun verify, which is whySPEC-v0.9.md§3.3 argues the read as part of the amendment
rather than leaving it implicit. -
DIMENSIONSchanged value, from six entries to eight. It is exported andverify's G9
iterates it and prints its length, so a--jsonconsumer counting dimensions sees eight. Before:
six.tasksandbudgetsare the two. -
tasks:andbudgets:are refused in actrlrun.policy/v6document, rather than ignored as
an unknown key would be. Before: neither key existed. An older reader that ignored them would
grant the action on every task and against no limit, which is the fail-open this refusal closes. -
A grant carrying a budget refuses an action that resolves no effect key. Before: an action
with noeffect:template was permitted, and it still is on any grant without a budget. With one,
it is refused: there is nothing to charge against, so an agent proposing such actions would spend
nothing against every budget on the chain for ever.SPEC-v0.9.md§2.4.1 records the two probes
that moved this out of the loader. -
A metric value that is negative, missing, or not an integer is refused, with
ACTION_DENIED
and adeniedreceipt. Before: no metric existed. A negative amount would reduce the rolling sum
and refill the budget, which is the compensationSPEC-v0.9.md§12 forbids; a missing one
counted as zero would turn the absence of a field into unlimited authority. -
ctrlrun verifysizes its own action vector to a grant's budgets. Before: it synthesized a
vector to land in a rule and reported a budget refusing that action as an internal error, exit 3,
on guarantees with nothing to do with budgets. Where no value fits a band, the guarantee is now
N/Awith a reason that names the action and the grant.
Fixed
-
resolve_effectreleased no budget hold. It does not go through_transition, so a human
resolving anAMBIGUOUSeffectFAILEDheld its charge for ever: the one act meant to free a
budget was the one path that did not. Fixed in all three backends, inside the same transaction as
the record's own write. -
A refused receipt claimed a charge it never made.
budget_chargeswas stamped where the
charges were computed, so a refusal raised later in the same loop reached the receipt with them
set, and adeniedreceipt asserted the action charged the very grant it was refused from
spending against. A receipt asserting a spend that never happened is the one thing an evidence
trail may not do. -
An oversized stored window crashed every evaluation in the deployment.
Authority.evaluate
reads every delegation row on every evaluation, and an unreadable window raisedOverflowError
out of it, so one corrupt row denied nothing and crashed everything, for every principal and
every action, with no event and no receipt to find it by. It isauthority_unreadablenow.