Autonomy¶
How much an agent may do without asking anybody, declared as a grant the runtime enforces.
Asking a human before every action makes an agent useless. Letting it act freely makes it dangerous. Most products settle on a number in a config file — no expiry, no audit, and nobody's name against it. Here that number is a grant: issued by someone, for one class of action, up to a ceiling, until a moment. Anything an issued grant does not cover escalates to a human, including everything nobody thought about.
from tesserix_adk.core import (
ActionClass, ActionRegistry, AutonomyGrant, AutonomyLadder, AutonomyLevel,
Ceiling, InMemoryGrants,
)
from tesserix_adk.runtime import AgentRunner, AutonomyGate
classes = ActionRegistry({"change_booking": ActionClass(
name="booking.change", amount_field="amount", currency_field="currency",
)})
grants = InMemoryGrants([AutonomyGrant(
id="g1", tenant="acme", action_class="booking.change",
level=AutonomyLevel.ACT_WITHIN_LIMITS, granted_by="ops@acme.example",
issued_at=now, expires_at=now + 86_400,
ceiling=Ceiling(amount=Decimal("5000"), currency="INR", window_seconds=86_400),
)])
runner = AgentRunner(
provider=provider,
approvals=desk,
autonomy=AutonomyGate(AutonomyLadder(classes, grants=grants, clock=clock)),
)
Levels¶
| Level | What it means |
|---|---|
ask_always |
Every action of this class goes to a human. The default, and what an unmatched action falls to. |
act_within_limits |
Act unattended while the amount fits under the ceiling's remaining headroom. Never valid without a ceiling. |
act_and_report |
Act unattended and owe a report. While a report for an earlier action of the class is undelivered, the next one asks. |
These three are stable. A level added later is added beside them; none of them changes meaning, because a deployment's grants are rows in a database that outlive the release that wrote them.
Classes, not tools¶
A grant is about what an action does to the world, so it names a class — payment.refund
— rather than a tool. Three refund tools are one thing to the person deciding how much may
be refunded, and a fourth added next quarter should not silently inherit the grant.
That is also why a tool in no class resolves to ask_always rather than to nothing: a tool
registered after the grants were issued is exactly the case where failing open would be a
surprise.
Fail-closed, everywhere¶
Each of these escalates to a human rather than acting:
- no grant matches the tenant, the user or the class
- the grant expired, at the clock the ladder holds
- the arguments carry no readable amount for a class that has a ceiling
- the action's currency is not the grant's — a mismatch is a question, never an implicit conversion
- the amount is over the remaining headroom, by any margin.
900.01against900.00of headroom escalates; headroom isDecimaland is never rounded up to fit
A grant on acme does not reach acme/eu unless it says includes_subtenants=True. A
grant that silently widened as the tenant tree grew would be a grant nobody issued.
Nobody grants themselves¶
AutonomyLadder holds a GrantReader, which can only read. Issuance is a second protocol,
GrantIssuer, and the runtime is never given one — so there is no object inside a run that
could widen what the run may do, even if a model asked for it.
On top of that, the reserved class autonomy.grant is refused outright: a tool that would
issue autonomy is AutonomyOutcome.REFUSE, recorded as autonomy_refused, and no human is
offered the chance to wave it through from inside the run. A grant that permits the
reserved class is ignored.
What the loop does with a decision¶
Consulted in _cleared_to_dispatch, after the before_tool_dispatch hooks and before
anything goes out:
| Outcome | Effect | Event |
|---|---|---|
ACT |
The call dispatches. An approval the agent or tool declared still applies. | — |
ESCALATE |
The call is held for the approval gate, with the ladder's reason. | autonomy_escalated |
REFUSE |
The run fails with AutonomyRefusedError. |
autonomy_refused |
Autonomy only ever adds a gate. A grant permitting unattended action does not waive an
approval the tool declared, because the two answer different questions: how much this agent
may do, and whether this call is one a human sees. An escalation with no approval gate
configured raises ConfigurationError rather than dispatching.
Reports¶
act_and_report is enforced rather than trusted. AutonomyGate records the obligation the
moment it lets an action through, and ReportLog.outstanding degrades the next action of
that class to asking a human until it is delivered. Without that, act_and_report becomes
act the day nobody reads the reports. InMemoryReports is the single-process
implementation; a deployment substitutes its own.
Taking it back¶
A grant is read from the store on every attempted action, not once at run start, which is what makes a withdrawal land on the very next action rather than at the next deployment.
await grants.revoke(Revocation(
grant_id="g1", revoked_by="ops@acme.example", revoked_at=now, reason="card reported stolen",
))
A withdrawal names one grant, or a tenant, or a tenant and an action class — enough to stop a class of work across a fleet without knowing every id that was issued for it. One that names neither a grant nor a tenant is refused at construction: it would either do nothing or withdraw the world, and both are the wrong answer to what somebody meant.
Withdrawal is an append, never a delete, so a revoked grant cannot be reactivated: there is no statement in the kit that removes a revocation, and re-granting mints a new id. What was withdrawn stays readable as what it permitted while it stood.
Runs already under way¶
A run suspended on an approval was asleep while the authority behind it could have been
taken back, and a human approving a call is not the same as the grant that put the call in
front of them still standing. The loop re-checks after the approval returns and before
anything goes out, records grant_revoked, and then does what the gate's revoked_runs
says:
revoked_runs |
What the run does |
|---|---|
InFlightPolicy.CANCEL (default) |
Fails with GrantRevokedError, naming the grant and who withdrew it. |
InFlightPolicy.ASK_ALWAYS |
Proceeds on the approval it has, and every later action of the class asks a human. |
The bus is an accelerator, never the authority¶
RevocationBroadcast carries withdrawals to every process over NATS or Redis, and
RevocationWatch.follow consumes them. A missed message costs latency, not correctness: the
store re-read is what refuses.
The watch fails closed on its own. A view nobody has confirmed within
stale_after_seconds (30 by default) refuses unattended action rather than acting on what it
last heard — a process cut off from the bus cannot know a grant is still live. It never
manufactures authority in the other direction: a stale watch turns act into refuse and
leaves an escalation exactly as it was.
Ceilings that hold¶
A ceiling is only a ceiling if it cannot be walked around, and it leaks in three standard
ways: two actions each read the same headroom and both fit, one action arrives as ten small
ones, and a timed-out action is retried onto fresh headroom on top of spend that already
happened. CeilingLedger answers all three with the same thing — headroom is taken before
the action and settled after it, never merely read.
The ledger passed to AutonomyLadder(commitments=...) is read from; the one passed to
AutonomyGate(commitments=...) is written to. They are normally the same object, and the
split is the same one as GrantReader / GrantIssuer: what a run can reach is the read.
| Leak | What closes it |
|---|---|
| Two actions, one headroom | The reserve is atomic. In-process that is a lock; in PostgreSQL it is one INSERT ... SELECT whose WHERE is the ceiling test. |
| One action split into ten | Every part reserves against the same tenant, class, currency and window, so the tenth meets what the first nine took. |
| A retry onto fresh headroom | The reservation is keyed by the call (run_id:call_id), not the attempt. The same key returns the reservation it already took. |
What is held counts against the ceiling exactly as what is committed does, which is why a
pending escalation is not undercut by a parallel action spending the money a human is being
asked about. A hold nobody settles expires after hold_seconds (300 by default) and is
reaped, so a process that dies mid-call does not hold headroom until somebody notices.
Settlement follows what went out, not what was asked for. A call a human declined, or one the batch stopped before it was made, gives its headroom back. A call that errored keeps it: a tool that raised may still have moved the money, and a ceiling that assumes otherwise is one a flaky tool walks through.
Amounts are Decimal end to end and arrive through exact, which refuses a float rather
than rounding it. 0.1 + 0.2 is not 0.3, and a limit built on that arithmetic is off by
whatever the hardware felt like. Credits are recorded and never netted off: money coming
back is a fact worth auditing, but subtracting it would hand an agent fresh headroom nobody
granted.
PostgresCeilingLedger is the cross-process story — EXPECTED_CEILING_SCHEMA is the shape
it was written for, numeric rather than double precision, with idempotency_key unique
so a retry meets an index rather than a lookup somebody could race.
Grants in PostgreSQL¶
PostgresGrantStore is append-only. An id already in use is refused, never updated: a
decision recorded against an id has to stay readable as what it permitted, so re-granting
mints a new id. The dispatch-path read is one tenant, one class, unexpired at one moment, excluding
anything a row in adk_grant_revocations withdrew.
Issuance is not retried — a retried insert that may already have landed is how one id ends up meaning two things — while reads retry a contended or briefly unreachable database. A read that ultimately fails raises; it is never read as "no grants", which would be the one failure mode that widens autonomy.
The DDL is the deployment's, not the kit's. EXPECTED_GRANT_SCHEMA is the shape this
adapter was written for, and verify() refuses anything else at startup:
Not here¶
- The audit record shape. The ladder names the grant on every decision, including escalations — which grant was not enough is the question an operator asks — but what that record looks like on disk belongs elsewhere.
- Grant administration UI. Each product's own.
Verify the store against a real PostgreSQL: