Policy and governance
One deterministic plane answers may this happen, at three checkpoints, with one grant model, one approval model and one decision log.
Policy and governance
Permissions answer can it. Policies answer under what conditions. Limits answer how much.
Policy is a plane, not an execution layer. It answers one question and writes one record. It never executes a tool, waits for a person, queries arbitrary domain data, or calls a model.
Three checkpoints
| Checkpoint | Caller | How often | Question |
|---|---|---|---|
| Admission | RunManager.start() | once per run | May this actor run this definition now? |
| Action | ToolInvoker.invoke() | once per tool call | May this action happen with these arguments? |
| Accrual | RunManager at admission, AgentExecutor and WorkflowStepExecutor, ToolInvoker for a metered call, and FrontDoorService in Phase 5 | once before dispatch, then before each agent segment, each workflow node, and each paid call, plus once before a front door turn | May this turn or run start, and may it continue, inside the budget? |
No other component calls the engine. A component that wants a decision goes through one of these three checkpoints.
Closed decision set
allow
require_approval
deny
Precedence:
deny > require_approval > allow
There are no priority numbers, no rule ordering and no fourth outcome in V1.
Overlapping rules still produce one row, so the tie is broken by id. matched_policy_ids holds every matching rule ordered by id, and reason and approval_ttl come from the first rule, by id, whose own decision is the winning outcome. Two require_approval rules therefore never race for the clock, and a reason stays inside the 2000 character column.
How one decision is made
Every checkpoint runs the same two stages. The first stage asks whether the principal holds the right at all. The second stage asks whether the current rules restrict it.
PolicyEngine.decide(principal, request)
1 grant checkpoint == 'accrual' ?
yes -> skip; a budget is not a permission
no -> effective = principal.scopes ∩ the actor's rights, read live
action in effective ?
no -> deny(not_granted) <- fail closed
2 rules rules = cache.for_organization(org, action)
matched = rules where conditions hold for the request facts
no match -> allow
match -> the highest precedence decision wins
3 record a refusal, a gate or an admission -> a policy_decisions row
an allowed action -> attributes on the tool span the invoker opened
Stage 1 reads the live intersection, not the frozen set on the principal. Written against principal.scopes alone it would answer from the grant minted at start, and a right stripped an hour ago would still work — which is exactly what "the grant narrows live" below exists to stop. The frozen grant stays the ceiling; the live read can only shrink it.
Stage 1 is the permission model. Stage 2 is the condition model. Keeping them apart removes the contradiction between "anything not granted is denied" and "a rule set with no matching rule allows".
A grant is never implied by a rule. An allow rule cannot give a scope the principal does not hold.
⚠️ ToolInvoker holds no copy of this check. A frozen read there saves a call and answers the wrong question: it passes a tool the live read stripped, and it refuses before decide() runs, so a scope refusal reaches no agent.policy_decisions row. One rule takes one implementation, and stage 1 is it.
Actions and scopes
One flat namespace names every action.
| Checkpoint | Action string |
|---|---|
| Admission | run.start |
| Action | the tool name, for example email.send |
| Accrual | run.accrue |
Accrual runs stage 2 only. A budget is not a permission, so run.accrue is never a grant, and stage 1 skips it explicitly. Without that branch a literal reading of the algorithm denies every accrual check, because run.accrue is in nobody's scopes.
A scope is one exact action name. There is one scope vocabulary, and it is the tool name. A role that covers a whole domain expands to the tool names it covers when the principal is minted. A grant carries no wildcard, so nobody has to work out what a grant covers.
V1 lists the tool names and expands nothing. ROLE_RIGHTS in governance/policy/principals.py names each tool. Expansion would need the tool registry inside governance, and the deploy runs three tools. One test refuses a declared tool that no role holds.
A rule may use a wildcard, because a rule restricts and never grants.
email.send one action
crm.* every action in the domain
* every action
Every matching rule is evaluated, and precedence resolves the outcome. There is no most-specific-wins search.
What admission checks
Admission is not only a scope check. It also refuses work that cannot succeed.
the definition is published, enabled, and owned by the organization
the actor holds run.start
the organization is inside its day cost cap
a workflow run: the principal holds every scope its TOOL nodes need
an agent run: no scope requirement; the intersection removes tools instead
A tool node cannot ask the model for another way, so a missing scope is fatal at admission. An agent can adapt, so it simply receives fewer tools.
An agent node inside a workflow adapts exactly like a standalone agent. So the fatal set is required_scopes, which runtime definitions computes from tool nodes and referenced subworkflows only. Fold an agent's scopes into the fatal set and a principal who can run agent A alone is refused the workflow that contains it, which is a denial with no failure behind it.
The principal
Every action carries one immutable run-scoped principal.
@dataclass(frozen=True)
class Principal:
organization_id: UUID
run_id: UUID
definition_id: UUID
user_id: UUID | None # set for an interactive run
trigger_id: UUID | None # set for a machine run
scopes: frozenset[str]
authored_by: UUID | None # the trigger author, for a machine run
Exactly one of user_id and trigger_id is set. There is no third actor kind and no service-principal table in V1.
The actor carries the organization, and the command does not. RunManager.start() needs the organization three steps before it mints a Principal: for the day cap gate, for the start key, and for the Run row. StartRunCommand therefore has no organization_id of its own, because one fact with two writers is one fact that eventually disagrees with itself. Every caller already knows its actor: the channel gateway resolved a verified identity, and a trigger row belongs to an organization.
The grant is an intersection, never a union.
scopes the definition declares ∩ rights the actor holds = effective scopes
The caller supplies an actor. RunManager mints the principal, because the intersection needs the definition and the run ID.
@dataclass(frozen=True)
class ActorIdentity:
kind: Literal['user', 'trigger']
organization_id: UUID
user_id: UUID | None
trigger_id: UUID | None
scopes: frozenset[str]
authored_by: UUID | None
class PrincipalFactory:
async def for_run(
self,
*,
definition_id: UUID,
declared_scopes: Iterable[str], # tool_ids, or a workflow's declared_scopes
actor: ActorIdentity,
run_id: UUID,
) -> Principal: ...
It takes the declared names and not the definition. ResolvedDefinition lives in runtime/runs/manager.py, and the import contract src.agentic.runtime is the top layer of the platform refuses governance importing runtime. RunManager reads the names off the definition it already holds.
User rights come from the existing user roles. A mapping turns a role into scopes, and it grants two kinds.
run.start to every role that may run agentic work at all
definition.publish to the roles that may author and publish definitions
tool names the actions that role may take
run.start is an action and not a tool, so nothing else would grant it, and stage 1 fails closed. Every run would be denied.
definition.publish is the second such action. The builder chat reaches publishing through a tool of that name, and the direct API route checks the same scope, so the two authoring paths cannot diverge. See runtime definitions. run.accrue is granted to nobody, because a budget is not a permission.
No new permission table exists only for agents.
A trigger row carries its own scopes. An admin sets them when authoring the trigger, and PrincipalFactory meets them with that admin's rights at every mint. A machine grant is normally narrower than a user grant, and it is never wider than its author.
@dataclass(frozen=True)
class ActorIdentity:
...
authored_by: UUID | None # the admin who authored the trigger
ActorIdentity refuses a trigger actor that names no author, and a user actor that names one. A person narrows against their own rights, so a second name there is a second answer.
machine rights = (authored scopes ∪ RUN_ACTIONS) ∩ the author's rights, read live
An admin authors a trigger by naming tools, exactly as a definition declares tools. Neither names run.start, so RUN_ACTIONS joins the authored set before the meet. The author still decides: a role that may not run agentic work holds no run.start, and the meet drops it again.
A trigger row that authored nothing holds nothing, and RUN_ACTIONS does not rescue it. An empty scope list is a row an admin did not fill in. The refusal says so: this trigger holds no authored scope, so run.start is denied.
A child run inherits the parent principal exactly. A step cannot widen authority.
The grant narrows live, and never widens
The snapshot freezes the principal so that work stays stable. Taken literally that has one consequence nobody wants: a person is offboarded, their role is stripped, and their three day email sequence keeps sending under the authority they held on Monday.
Tool revocation and the organization kill switch both reach an in flight run at its next checkpoint. The person's own rights would not, and that is the one authority in the system that a compliance answer is usually about.
So the principal is re-intersected at every checkpoint, and the result can only shrink.
effective scopes = frozen grant ∩ the actor's rights, read live
- A right removed since the run started is gone at the next checkpoint.
- A right added since the run started changes nothing. The frozen grant is still the ceiling, so no run silently gains a capability an admin granted for something else.
- A trigger principal narrows the same way against its authoring admin's current rights.
⚠️ A machine grant narrows against its author, and not against the trigger. A trigger id keys no membership row. LiveGrant.effective() reads authored_by, so an offboarded admin strips every trigger they wrote at its next checkpoint. agent.runs.principal stores that id, because no column on the run row holds it. The trigger interface supplies it from the trigger row; nothing else in V1 builds a machine actor.
The live read is one lookup per checkpoint, cached per worker for 30 seconds beside the rule set it already caches. Nothing new is stored, and the frozen grant stays exactly what it was for: an upper bound that an edit to a definition cannot move.
Policy rules
agent.policies
id
organization_id
name
action exact name, domain wildcard, or *
definition_id optional narrowing to one agent or workflow
conditions condition tree, optional
decision allow | deny | require_approval
approval_ttl_seconds optional and positive, read only by require_approval
enabled
created_at
updated_at the write token; PATCH /policies/{id} checks it
updated_at is the write token. PATCH /api/v1/agentic/policies/{id} carries expected_updated_at. A stale writer reloads, and it never overwrites the other writer. The list cursor is (created_at, id), because a bare timestamp cursor drops a row on a page boundary. See surfaces.
definition_id pairs with organization_id in the foreign key, so one organization cannot narrow a rule to another organization's definition. The key takes ON DELETE CASCADE, so deleting the definition deletes the rule. ON DELETE SET NULL would turn a rule narrowed to one definition into an action-wide rule.
A rule name is unique inside one organization.
Bind a rule to an action first. Narrowing to one definition is optional, and it is the exception.
email.send AND recipient_is_new -> require_approval
crm.update AND target.lifecycle = customer -> require_approval
run.start AND run.definition_id = <signals_search> AND arguments.limit > 1000 -> require_approval
* -> deny # the organization kill switch
The last line is the emergency stop. One row disables every agentic action for an organization. Per-tool revocation stays in the tool registry, where the runtime already reads live tool state.
Be precise about what the kill switch does not do. It refuses the next action, and it ends nothing on its own.
| To do this | Use this |
|---|---|
| Stop every new action, everywhere, in under 30 seconds | the * deny rule |
| End the runs that are already executing | POST /runs/{id}/cancel on each, or a bulk cancel |
| Stop a run that is waiting three days on an approval | cancel it; a waiting run reaches no checkpoint until it resumes |
The third row is the one that surprises people during an incident. A waiting run holds no worker and evaluates no rule, so it sits under the kill switch untouched and meets it only when a person answers, days later. The rule refuses the action then, which is correct and very late. Cancel is what ends it now.
Conditions and facts
The condition language stays small.
eq ne equality
lt lte gt gte numeric comparison, on numbers only
in not_in membership in a declared list
exists the path resolves to a value that is not null
and or not composition
There is no Python, SQL, CEL or Rego, and no model-evaluated expression. The triggers layer reuses the same ConditionEvaluator over an event-shaped fact map.
A condition is stored as data, and never as text. A rule holds a small tree, and the evaluator walks it. Text would need a lexer and a parser, and a parser is the expression language this page refuses; it also grows a function on the first request the grammar cannot serve. A CHECK on agent.policies validates the tree at save time. It calls agent.condition_tree_is_valid(), which recurses through of alone: value is a fact to compare and never a node. A jsonpath cannot do this work. $.** descends into value, so it reads an object argument as a malformed node. It also cannot count children, so not with two children saves.
The walker and ConditionEvaluator refuse the same trees. They are two implementations of one grammar, so any shape that saves and then raises is a rule an admin cannot see is dead: deny at the checkpoint is correct and silent. Both cap the tree at 20 levels, both compare the key set of a node in each direction, and both refuse eq null, an empty membership list and a null member. Move the two together, or the pgTAP test agent_policy_tables_test.sql and test_conditions.py disagree.
{"op": "and", "of": [
{"op": "eq", "path": "arguments.to", "value": "sales@example.com"},
{"op": "exists", "path": "target.lifecycle"}
]}
path reads one fact through a dotted lookup, so one implementation serves all three planes. Only the map behind it differs. See the branch node for the workflow map.
The evaluator answers True or False, and never "unknown". A third answer would have to be folded back into a branch that has two children, so an absent fact answers False for every leaf, negated ones included. That is right for a branch and wrong for a gate, so deny(missing_fact) is the engine's rule and not the evaluator's: condition_paths(), beside the evaluator, answers every path a rule reads, and the engine compares that set against the facts it resolved before it evaluates. The same function answers the publish check that every path of a branch sits inside the allowed namespaces.
It lives in src/agentic/shared/, not in the policy package. Three planes call it over three different fact maps: policy rules, a workflow branch, and a trigger condition. It is the same case as bound(), and the same answer. The workflow executor is built in Phase 1 and the policy plane is not, so putting the evaluator under policy/ would make the first thing that needs it reach into a package that does not exist yet.
A condition reads a flat fact map. Three groups of facts exist.
| Group | Example | Source |
|---|---|---|
| Principal and run | principal.user_id, run.source, run.definition_id | the principal and the run row |
| Arguments | arguments.to, arguments.limit | the proposed call |
| Target | recipient_is_new, target.lifecycle | a declared fact resolver |
Target facts are the part that needs care. A useful rule often asks a question the arguments cannot answer. The engine must not answer it by querying arbitrary domain data, and the tool handler cannot answer it either, because policy runs before the handler.
So a fact is declared and resolved before the checkpoint.
class FactResolver(Protocol):
name: str
async def resolve(self, principal: Principal, request: PolicyRequest) -> Any: ...
- A
ToolSpecdeclarespolicy_facts: list[str]. ToolInvokerresolves those facts and puts them in the request before it calls the engine.- Saving a rule validates that every target fact it names is declared by the action it binds to.
- At run time a rule that names a missing target fact returns
deny(missing_fact). A gate that cannot be evaluated must never silently pass.
This keeps the engine free of domain queries, and it keeps the fact set visible in the tool catalogue.
missing_fact reads target facts alone, and three roots are reserved. principal, run and arguments are supplied by the platform at every checkpoint, and an absent path under one of them answers False like any other leaf.
principal.* the grant, written by the engine
arguments.* the proposed call, written by the engine from the request
run.* the run row, written by the caller
anything else a declared target fact; absent means deny(missing_fact)
Read literally, without that split, the arguments.limit > 1000 rule above denies every call that passes no limit. The rule was written to gate a large one. principal.user_id is the same trap: it is null on every machine run.
The check reads presence, and never the value. A resolver that answers false has answered, and recipient_is_new: false is the case an email.send rule exists to allow. An engine that read the value as absence would deny every established recipient, which inverts the rule.
The engine writes principal and arguments last. A FactResolver is domain code, and a fact map that let one return a principal key would let it rewrite the grant the same decision is reading.
Resolve only what a live rule asks for. A declared fact is what a rule may read, not what every call must pay for. recipient_is_new costs a CRM lookup, and an organization with no rule naming it should never pay that on a send.
declared by the tool ∩ named by a rule matching this action = facts to resolve
The rule set is already in the worker cache, so the intersection is free. The common case is an empty set and no extra query at all. This is an optimization only: it changes no decision, because a fact no rule reads cannot change an outcome.
The intersection matches by prefix, and never by equality. A tool declares target and a rule reads target.lifecycle. A declared fact F is named when a path equals F or starts with F.. Equality alone resolves nothing here, and the engine then answers deny(missing_fact) on every call of that tool. The save-time check reads the same rule, so a tree that saves is a tree the run-time intersection covers.
principal, run and arguments are refused in policy_facts. They are the three reserved roots, and a resolver may not write one. The engine writes principal and arguments last, so neither is reachable. It does not write run, so a fact named run overwrites the run facts the caller supplied. Registration refuses all three.
A declared fact is one name, and every dotted name is refused. A path lookup splits on the dot, so a tool that declared target.lifecycle writes the map key target.lifecycle, and the rule path target.lifecycle reads target and then lifecycle. No lookup reaches that key. The tool builds, the rule saves, and the engine answers deny(missing_fact) on every call of that tool for ever. One regex refuses the whole class, and it refuses a dotted name rooted at a reserved root as one case of it.
Inside a fan out the remaining cost is real. A rule naming recipient_is_new over a 200-wide send costs 200 CRM lookups, one per call, before any of them reaches a handler. The resolution therefore caches each answer for the run.
cache scope one run
key <run id>:<fact name>:<arguments hash>
One run asking the same question about the same recipient twice pays once. Asking about 200 different recipients still pays 200 times, which is correct: those are 200 different questions, and answering each one is what the rule is for.
The key reads the whole validated arguments, and never the subset one resolver read. PolicyRequest.arguments_hash already carries that digest, and the caller computed it for the approval comparison. A resolver that declared its own reads would answer the cached value for a second recipient the moment it under-declared one, which is a wrong decision rather than a slow one. The whole-argument key is never wrong. It is only ever too precise: a repeat call that changed one unread field pays twice.
The cache is a cost, and never a guarantee. It holds one answer for one run, so an agent that creates the recipient and then sends still reads recipient_is_new: true at the send. The map is process local and bounded, by a lifetime and by an entry count, because a worker outlives every run it serves. An evicted answer is resolved again, and the second answer is the live one. Read the cache as the thing that makes a repeat free, and never as the thing that makes two calls agree.
The retry guarantee is the idempotency journal, and not this cache. ToolInvoker reads the journal before the checkpoint, so a segment that retries a call it already completed returns the stored result and reaches no decision at all. A cache that promised the same thing would promise it twice, and weakly. The run id is still part of the key, so no run reads another run's answer.
A resolver that raises resolves nothing, and the engine denies. The rule names the fact, the fact map does not hold it, and deny(missing_fact) follows from the rule already stated above. A second refusal built beside the resolver would answer without a decision row. A failure is not cached: a transient outage would otherwise deny for the rest of the run after it cleared.
The resolution raises nothing at all, and it resolves less instead. The two reads it makes fail the same way a resolver does. A rule set that does not read leaves the intersection unknown, and a condition tree the walker refuses hides the paths of one rule. Either one resolves nothing and lets decide() answer, because the engine holds a refusal for both: an unreadable rule set is deny(policy_unavailable), and a tree that saved and does not evaluate is a deny that names the rule. A resolution that raised would replace each of those answers with an internal error, and the run would end with no decision row.
Approvals
require_approval creates one durable row, and the run waits on it.
PolicyEngine -> require_approval
-> ApprovalService.create() exact proposal, hash, TTL, idempotency key
-> ToolInvoker raises ApprovalRequired, so the segment ends
-> RunManager.mark_waiting()
-> the Inngest wait, with a timeout read from approval.expires_at
-> a person resolves the approval on any surface
-> exactly one atomic resolution
-> the wait receives the signal, and the run resumes
An admission approval reverses the first two steps, because no segment stands between the decision and the row.
PolicyEngine -> require_approval
-> RunManager.mark_waiting(run, 'approval', None)
-> ApprovalService.create() run_deadline is None; the TTL stands alone
-> RunManager.mark_waiting(run, 'approval', approval.id)
-> the Inngest dispatch event, whose first step is the wait
-> a person resolves the approval on any surface
-> exactly one atomic resolution
-> the wait receives the signal, and the run resumes
The hold comes before the row. Create first and a crash between the two leaves a queued run holding an orphan approval, which the reaper then executes with nobody having approved it. Runtime execution owns that order and the ending it protects.
A require_approval stops the segment rather than returning a pending value, because a value is just another tool result to the model. Tools and integrations owns that boundary. Policy only produces the decision.
An approval stores enough context to decide without opening the run.
run_id, root_run_id, raised_by, action
target summary, exact proposed arguments and content
the handler preview line, when the tool declares `approval_preview`
arguments_hash
reason and matched policy
idempotency key
expires_at
continuation identity when one is needed
⚠️ The row stores no definition. agent.runs pairs its definition key with
organization_id and kind, and this table holds no kind. A single column key
would let an approval name another tenant's definition. The run answers the
definition, and human review inbox reads it for the selected row.
An approval row records raised_by, not a policy checkpoint: admission and action come from this plane, and node comes from a workflow author, which is not a policy decision at all. All three produce the same row and appear in the same inbox. See human review inbox for the product view.
One clock, one waiter
approval.expires_at is the only expiry clock. The Inngest wait timeout is computed from it. Two independent timers drift, and the run then hangs after the approval died, or resumes on an approval that is still pending.
The Run's own wall clock caps that expiry, or a person's decision is thrown
away. max_run_duration counts the waits, and approval_ttl comes from a rule
that has never read the Run. A definition with a one hour duration and a 24 hour
approval TTL resumes at hour 25, the executor subtracts a duration remainder of
zero, and the Run ends succeeded without running the call the person
approved. That is the exact failure the deterministic resume exists to stop:
the approval reads approved, and no email was sent.
expires_at = min(now + approval_ttl, run.started_at + run.max_run_duration)
ApprovalService.create() is the one writer of the column, so it is the one
place the cap belongs. The rule holds for every raised_by value. An admission
approval takes the same form, and its Run has not started, so the second term is
absent and the TTL stands alone.
That rule has one consequence for admission. An admission approval must also dispatch the Inngest function, and the first step of that function is the wait. A waiting run holds no worker, so this costs nothing, and it gives the admission approval the same timeout owner as an action approval. A run that waits for approval with nobody waiting on the clock never ends.
Somebody must learn about it
An approval nobody sees expires, and the work dies quietly. That is easy to miss for an interactive run, because the person is already in the conversation. It is the normal case for a machine run: a trigger run has no conversation, so Channel Gateway has no session and drops the outbound intent.
ApprovalNotifier closes that hole with one rule, and it is not a notification system.
approval created
-> the run has a conversation with a channel session
yes -> one OutboundIntent on that session
no -> one OutboundIntent to the person who authored the trigger,
over their linked channel
-> Human Review always lists it, whatever happened above
The trigger author is the right fallback, because that person chose to start work without a person watching it. Nothing else is added: the notifier writes no row, keeps no queue, and reuses the intent the Gateway already delivers and dedupes.
Human Review remains the guaranteed floor. A notification is a prompt to look, never the decision surface.
States and writers
| State | Written by |
|---|---|
pending | ApprovalService.create() |
approved / rejected | a person, through any surface |
expired | the wait timeout, when it fires |
cancelled | RunManager.cancel(), RunManager.fail(), and the segment supersede |
There is no sweeper job. Every terminal state has a real writer.
cancelled has three writers, and only the first is obvious. A model may
propose several calls at once, and the first one that needs a person ends the
segment. AgentExecutor then cancels every pending approval of the run other
than the id the segment stopped on, because a row nobody waits on must never sit
in a person's inbox. A segment that stopped on no approval cancels all of them.
The third is the terminal write. RunManager.fail() cancels the pending
approvals of the run it ended, because a run that ended holds no waiter: the
segment that filed a row failed before it superseded it, or a resume left the
run waiting and the loop ended it on orphan_approval. Without that write
resolve still accepts the row and the approve route answers 200, so a
person answers a question on a run that ended minutes ago.
Runtime execution
owns both calls, and each writes cancelled through the same conditional update.
class ApprovalRepository(Protocol):
async def create(self, approval: Approval) -> Approval: ...
async def get(self, approval_id: UUID) -> Approval | None: ...
async def resolve(
self,
approval_id: UUID,
*,
resolution: Literal['approved', 'rejected', 'expired', 'cancelled'],
actor_id: UUID | None,
) -> Approval | None: ...
resolve is a conditional update with WHERE status = 'pending'. One writer wins, and a later writer receives the resolved state. No distributed lock is used.
The timeout and the person can race, and the person must win
An approval sitting at 71 hours 59 minutes is resolved by a person in the same second the wait timeout fires. Both write, and one loses.
the person wins the timeout's UPDATE touches no row
the timeout wins the person reads `expired`, which is the honest answer
The second case needs nothing. The first case is the bug, and it is silent: the Inngest wait has already returned on its timeout, so the run would finalize without executing a call somebody really approved. The approval row would read approved and no email would exist.
So the timeout path never assumes it won.
the wait returns on its timeout
-> resolve the approval to expired
one row -> nobody answered; finalize without executing
no row -> re-read the approval
approved -> execute it, exactly as the resolved path does
rejected -> write the rejected result, and continue
The re-read costs one indexed query on a path that runs once per expiry. A person who pressed Approve inside the last second gets what they asked for.
Before an approved action runs
ApprovalService re-checks five things, immediately before the effect, and ToolInvoker adds a sixth.
- The row names this run and this organization.
- The decision was made before the row expired.
- The approver holds the scope the action needs. An approval cannot grant authority the approver lacks.
- The arguments hash still matches the stored proposal.
- The required target state is unchanged, where the action declares one.
- The rule that asked still holds. The row stores
matched_policy_ids, and the rule deciding the fresh decision must be one of them.
A stale approval fails closed and needs a fresh decision.
Check 6 belongs to the invoker, and not to the service. Checks 1 to 5 read the row alone. Check 6 compares the row against the decision the engine made for this call now, and the service is never handed one. Without it, an admin who disables the rule a person answered and saves a second rule leaves the stored answer covering a question nobody asked: none of checks 1 to 5 moves when the rule set is edited.
⚠️ The test reads the deciding rule, and never the whole matched set. matched_policy_ids names every rule that matched, including one whose allow lost on precedence. Compared as a set, an admin who saves an unrelated allow rule for the same action widens the fresh side and throws the person's answer away, though nothing about their call changed. Precedence picks one rule, and that rule is the one that asked. The row still stores the whole set, because an auditor asks which rules governed the call.
A decision that names no rule is covered by every row, which is what a workflow approval node needs. An admission row never reaches this check at all: authorizes() compares the action, and run.start never equals a tool name, so its matched_policy_ids is the audit alone.
Two require_approval rules that alternate re-ask each time, and that is the answer. A rule the person never saw is asking, so the platform asks. They end it by rejecting, which ToolInvoker makes final.
A fresh decision means a fresh row, and the idempotency key is what used to
stop one. The refused call is decided again and files a new proposal on
<run_id>:<step_path>:<args_hash>. Checks 1 and 4 change that key, so both
already asked a person again. Check 2, check 3 and the status test leave it
unchanged, and uq_approvals_run_id_idempotency_key held every row of one key
until ENG-2156. The service read the terminal row back, ToolInvoker raised
ApprovalRequired naming a row a person had already answered, and the wait could
only time out: the run ended succeeded with partial_reason=approval_expired
and nobody was asked. The index is now partial on status = 'pending', so a live
proposal still dedupes a replayed segment and a terminal one releases its key.
⚠️ A row a person rejected must not produce a fresh question. They answered,
and a second card on the same call is a loop that ends only on a ceiling. The
index cannot hold that rule, because it reads a key and never an answer. So
ToolInvoker reads the rejection of the claim before it files, and it returns a
rejected result to the segment instead. Change the index without that guard and
the fix trades a silent stall for a silent loop. Tools and
integrations
owns that path.
⚠️ Check 1 is not implied by the approval id. One parallel workflow node
starts several child runs of one organization, and each child proposes the same
tool with the same arguments. The rows are then equal in every field a check
reads except the run. Compare the tool and the hash alone, and one child's
approval authorizes a sibling's call.
⚠️ Check 2 reads resolved_at, and never now(). A person who presses
Approve in the last second produces a row that reads approved with an expiry
already in the past. A check written against the clock refuses exactly the call
the race rule above says must run, and the person's decision is thrown away a
second time. The row is honest about when the decision was made, so the check
reads that.
Check 5 has no input in V1. No action declares a target state, because a
declared fact reaches a checkpoint through FactResolver and no tool declares
one yet. The check is a rule this page holds, not code the service ships.
Approval and idempotency answer different questions. Approval answers may this happen. Idempotency answers has this already happened. See idempotency.
Limits and accrual
Policy owns the ceiling values. It owns no counter and no limiter.
| Shape | Where it lives in V1 | Enforced by |
|---|---|---|
| Per-run ceilings: turns, tool calls, duration | the definition budget, frozen into the run snapshot | AgentExecutor, per segment |
| Per-run cost | the definition budget | accrual, against the usage meter |
| Per-organization daily cost | an agent.cost_ceilings row | accrual, against the usage meter |
| Concurrency and rate | Inngest flow control | Inngest |
Concurrency is deliberately not a policy row. Inngest reads its concurrency limit when the function is declared, so a per-organization row could not reach it without a deploy. V1 runs one platform-wide concurrency key on organization_id. A per-organization rate limit waits until a customer needs one.
So agent.cost_ceilings holds cost only. The name refuses the rate limit and the concurrency cap on sight, and the table stays small.
agent.cost_ceilings
organization_id
kind daily_cost
value_cents
created_at
updated_at
PUT /api/v1/agentic/limits is an upsert, so (organization_id, kind) is the key.
How accrual behaves
Accrual has one definition and five call sites, which take four shapes. RunManager calls it at admission. AgentExecutor calls it at the top of each agent segment and WorkflowStepExecutor at each node, and those two pass the same arguments, so the table below gives them one row. ToolInvoker calls it before a tool whose spec sets metered. FrontDoorService calls it before its model call, and that component lands in Phase 5. All of them read the canonical usage meter, and none owns a counter.
Not RunExecutor. It claims the Run and dispatches it by kind, once per function run, so it has already returned when segment 4 begins. The segment loop lives in the Inngest function, and the code inside each step is AgentExecutor or WorkflowStepExecutor. Those two are the only components that run at every segment and every node.
run cost so far sum of ai_usage_log by root_run_id
organization cost today sum of ai_usage_log by organization_id, for the current UTC day
The meter holds model spend and metered vendor spend. A discovery run that spends nothing on tokens and a lot at a search vendor must stop at the same ceiling. A vendor call that is metered nowhere is a hole in the budget.
Accrual cannot stop a unit that is already in flight, so a ceiling always overshoots by the unit it was checked before.
For a run ceiling that is the whole story. For an organization ceiling it is not. The check is a SUM with no lock and no reservation, so twenty runs in flight can all read the same total and all decide to proceed.
organization overshoot <= the Inngest concurrency limit x the largest single unit cost
Both terms are ours, so the bound is knowable and it is written down beside the limit. V1 does not add a reservation or a distributed counter to close it: the counter would put Redis on the correctness path, and it would fail in the one case it exists for, a worker dying while holding a reservation.
The day is UTC. Two workers in two regions must agree which day a call belongs to, and a local day boundary makes that a coin toss.
| Checked before | Overshoot |
|---|---|
| An agent segment | one segment, which is why a segment carries its own turn ceiling |
| A workflow node | one node, and one node per branch of a parallel set |
| A metered tool call | one call |
The metered call check is what makes the node bound honest. A node is not always small. Signals Search searches people across every company that survived pruning, inside one step, and that step can make hundreds of paid vendor calls. Check only at the node boundary and the overshoot is the whole fan out, not one node. The metered flag on the tool is what closes it.
An unmetered read costs nothing and needs no check. The extra read is one indexed lookup, and only a paid call pays for it.
The day sum counts every row of the organization, and it filters no source. ai_usage_log is the cost truth of the whole product, so a Signals sweep and an agent segment spend the same day cap. The ceiling answers "what this organization spent on AI today", and an organization that spends it outside the agentic platform has still spent it.
An organization with no agent.cost_ceilings row has no day cap. The check reads one row by its primary key, and an absent row allows. A cap nobody set must not stop work.
The comparison is >=. A run that spent exactly its ceiling has spent its whole budget, so the next unit is refused.
A meter read that fails answers deny, and it carries fault_code. AccrualChecker never raises, so a failed read still returns a decision, and it returns the same one the meter itself takes: a run whose spend cannot be counted cannot be governed. A transient fault is read again first, three attempts in all, because one pooler restart must not end a run.
One read reaches Sentry one time in each window, and the log receives every call. ToolInvoker checks the meter before each metered call. Reported per call, a fan out node gives one outage hundreds of events, and Sentry drops other issues to its rate limit. The checker holds the time each read last reported, and it reports again once the window passes.
The key names the read, and never the subject. The run subject holds a run id, so a key on it grows by one entry for every run of that fan out. The reads are two: the run tree and the organization day. One outage that reaches both gives two events.
An outage that continues gives one event for each read in each window. An operator reads that the outage is not over. Every run that meets it ends under metering_unavailable.
The bound belongs to one checker. build_platform caches one graph for each running event loop, so a second loop or a second worker process gives one more report. One checker gives two events each minute at most: one window, and two reads.
⚠️ The window is fixed, and it never slides. The checker stamps the time it reported, and never the time of the last call. A stamp on every call moves the window forward by the gap between two calls, so a busy outage reports one time and then goes silent.
⚠️ The bound is a time window, and never a success. Reporting the first failure after each success looks correct. A pooler at connection saturation answers some reads and fails others, so every failure follows a success. That design gives one event for every second call, and the fan out is the shape that matters.
⚠️ This bound is not the bound a static defect takes. A wiring defect cannot change without a deploy, so an entry that never expires fits it, and ToolInvoker._contract_fault uses one. An outage starts and it ends, so that entry would report the first outage and hide every one after it.
The fault is not a ceiling, and the decision says so in a field rather than in prose. fault_code holds metering_unavailable, the code UsageMeter already uses for a write it could not count, and a rule refusal leaves it null. Every caller reads the field: the run ends failed under that code, never partial and never budget_exhausted. An operator grouping failures on the money code would open ai_usage_log and the run ceilings, and both show headroom.
A second fault code sits beside it. runtime_contract answers a caller that passed an argument this plane cannot read, such as a scope that is not a scope. It is the code AgentExecutor and WorkflowStepExecutor already use for a broken contract, so a defect groups with the other defects.
A fault is a platform stop, and continue_on_error never tolerates one. A node that declares it says the author expects that step to fail sometimes, and a database the meter could not read is not that. Tolerated, every later node runs and the run reports success in the middle of an outage. The code carries that rule across a step boundary, and never a flag: a child start crosses a durable step as JSON, so a boolean is dropped there and the same fault reaches the parent tolerated. run_tool is the one caller that passes a flag. It reads the mark the tool layer wrote, and it crosses no boundary carrying one.
The reason carries no vendor payload, and no argument the caller passed. RunResult.summary has no ceiling and the run detail route returns it to an authenticated user, and a PostgREST error stringifies to its message, its SQLSTATE, its hint and its details. The exception text stays in the log and in Sentry.
A run stopped by a ceiling succeeds with a partial_reason, when the run produced output. It fails when it produced none. One rule decides it, and runtime execution states it.
Live rules and the cache delay
An in-flight run reads the current rule set at its next checkpoint. A safety change must not wait for the next run.
Reading every rule from Postgres on every tool call is wasteful, so each worker caches an organization's rule set for 30 seconds. That is the whole delay. State it plainly: a new deny rule reaches every worker and every in-flight run within 30 seconds, at that run's next checkpoint.
The definition snapshot is frozen for run stability. Policy, tool state and credentials are not. That split is what allows emergency revocation.
One entry holds one organization, and never one action. PolicyRepository.enabled_rules() reads every enabled rule of the organization, and RuleCache matches the exact name, the domain wildcard and the star in memory. Keying on the action instead would put a query on the wire for each action a run touches, and it would leave the * kill switch out of every answer it did not ask for.
An empty answer is a cached answer. Most organizations hold no rule, so a cache that skipped the empty set would read Postgres on every tool call of the common case.
A rule set that cannot be read denies. The repository tries a transient fault three times, as MeterAccrualChecker does, and then raises. The engine answers deny with fault_code = policy_unavailable. Failing open is defensible, because a rule restricts and never grants — and it is refused, because the kill switch would then not fire for as long as the fault lasts. The same rule covers a rule set the database answers short: PGRST_DB_MAX_ROWS truncates in silence, and the dropped row may be the deny.
The decision log
Every decision that changed an outcome is durable.
| Decision | Recorded as |
|---|---|
| Any admission decision | a agent.policy_decisions row |
Any deny | a agent.policy_decisions row |
Any require_approval | a agent.policy_decisions row |
| Any accrual stop | a agent.policy_decisions row; run_id is null when it refused a start |
An action allow | attributes on the tool span that already exists |
An agent run makes hundreds of allowed tool calls, and each already opens one span. Writing a second row for each of them doubles the write cost of the hot path and adds nothing an auditor cannot read from the span tree. Everything that refused, gated or stopped work keeps its own row.
agent.policy_decisions
id
organization_id
run_id
checkpoint admission | action | accrual
action
decision allow | deny | require_approval
reason
matched_policy_ids
arguments_hash
principal jsonb
created_at
principal holds five identifiers of the run grant plus two scope sets. It is not the shape agent.runs.principal holds: that column keeps three keys, because the run row carries organization_id, id and definition_id as columns of its own. An audit row is self-contained, so a decision stays readable after ON DELETE SET NULL clears its run_id.
principal
organization_id
run_id
definition_id
user_id
trigger_id
scopes the frozen ceiling
effective_scopes what stage 1 decided on, or null
scopes is the ceiling, and effective_scopes is the answer. Stage 1 decides on the live intersection, so an offboarded member produces a row whose scopes still names the action its reason says they do not hold. Only the pair answers what the actor held at that instant: RoleRights caches for 30 seconds and organization_profiles keeps no history, so nothing else the platform stores can reconstruct it, and a row written without it is permanently unanswerable.
⚠️ effective_scopes is null when no live set was read, and never an empty list. Null says nothing was intersected; the empty set says the actor holds nothing now. Four writes file null: accrual, which returns before the read because a budget is not a permission; a rights read that did not answer; the day cap gate, which refuses before the freeze, so no grant exists and scopes already carries what the caller held; and the admission check for a workflow's required_scopes, which reads the frozen grant on purpose, microseconds after the freeze, so a second copy of it would read as a measurement nobody took.
An allowed action writes no row, and an accrual check that passes writes no row. The only allow here is an admission one. Admission runs once per run, and the spec above records every admission decision.
The table withholds principal from authenticated, as agent.runs and agent.spans withhold theirs. SELECT * therefore fails.
A deleted run keeps its decisions. The reference clears run_id, and the row stays.
A write that fails still returns the decision. The row is the audit and never the answer, so a refusal the log could not hold is still a refusal. The engine reports the failed write and returns, because refusing to refuse is the worse of the two losses.
Retention target: 12 months, then delete. retention.sweeper is the job, an Inngest cron beside run.reaper and idempotency.sweeper. It reads the oldest rows past the window and deletes them by id, bounded, once an hour.
Core code
governance/policy/
models.py Principal, PolicyRule, PolicyRequest, PolicyDecision
# ActorIdentity is NOT here. RunManager takes one before a principal
# exists, so it lives at src/agentic/shared/identity.py.
principals.py PrincipalFactory, role to scope mapping
engine.py PolicyEngine
rules.py PolicyRepository, RuleCache
# ConditionEvaluator is NOT here. Three planes call it, so it lives at
# src/agentic/shared/conditions.py, beside bound(). See the note below.
facts.py FactResolver registry
accrual.py AccrualChecker reads the usage meter
decisions.py PolicyDecisionRepository
# AdmissionGate is NOT here. It takes a Run and a ResolvedDefinition, and
# both live in runtime, so it sits at
# src/agentic/runtime/runs/admission.py. See the note below.
approvals/
service.py ApprovalService
repository.py ApprovalRepository
notifier.py ApprovalNotifier
models.py Approval, ApprovalStatus
| Component | Job |
|---|---|
Principal | immutable effective authority for one run |
PrincipalFactory | compute the intersection and mint the principal |
PolicyRequest | one checkpoint, action, arguments and facts |
PolicyEngine | grant check, rule match, precedence, record |
RuleCache | per-worker rule set with a 30 second TTL |
ConditionEvaluator | the small deterministic condition language |
FactResolver | resolve one declared target fact before a checkpoint |
AccrualChecker | compare meter sums against the ceilings, and return a PolicyDecision |
AdmissionGate | the two halves of admission, one either side of the Run insert |
PolicyDecision | stable allow, deny or require_approval result |
ApprovalService | create, validate and resolve a human decision |
ApprovalRepository | atomic approval row transition |
ApprovalNotifier | tell one person an approval is waiting |
@dataclass(frozen=True)
class PolicyRequest:
checkpoint: Literal['admission', 'action', 'accrual']
action: str
arguments: dict = field(default_factory=dict)
facts: dict = field(default_factory=dict)
# The caller hashes the proposal, and the engine never hashes it again.
# `ToolInvoker` already holds `hash_arguments()` for the approval
# comparison, and two implementations of one hash disagree in silence.
arguments_hash: str | None = None
@dataclass(frozen=True)
class PolicyDecision:
outcome: Literal['allow', 'deny', 'require_approval']
reason: str
matched_policy_ids: tuple[UUID, ...]
# The one rule precedence picked, or None when no rule produced this
# answer. It is NOT matched_policy_ids[0]: that tuple names every rule that
# matched, including one whose allow lost to a deny. Check 6 reads this.
deciding_policy_id: UUID | None = None
approval_ttl: timedelta | None = None
# The cause, when a refusal is a fault and not an answer. A denied rule and
# a spent ceiling both leave it None. Accrual sets metering_unavailable
# when the meter did not answer, and runtime_contract for a bad argument.
# The engine sets policy_unavailable when the rule set did not read.
fault_code: str | None = None
class PolicyEngine:
async def decide(self, principal: Principal, request: PolicyRequest) -> PolicyDecision: ...
class AccrualChecker:
async def check(
self,
organization_id: UUID,
*,
root_run_id: UUID | None, # None before a run exists, such as a front door turn
ceilings: RunCeilings | None, # None when there is no run budget to read
scope: Literal['day', 'run', 'run_and_day'],
) -> PolicyDecision: ...
AdmissionGate is the only component of this plane that lives in runtime. PolicyGate.admit() takes a Run and a ResolvedDefinition, and src.agentic.governance may not import src.agentic.runtime, so PolicyEngine cannot satisfy that protocol. The gate holds the engine, the accrual checker and the decision log, and it decides nothing of its own except the fatal scope set.
Two rules of it are easy to get wrong.
- The fatal scope check runs before
decide(). The engine records its own row and an admissionallowis one of them, so a scope refusal placed after it writes a second row for one Run. - The day cap writes no row when it allows. Admission records one row for each Run and the engine writes it after the insert. A row here would double the count for every start.
AccrualChecker returns a decision. It never raises, and it has one shape for every call site.
| Caller | scope | Reads |
|---|---|---|
FrontDoorService, before the model call (Phase 5) | day | the organization day; there is no run yet |
AdmissionGate, before the freeze | day | the organization day; no Run exists yet |
AgentExecutor before a segment, WorkflowStepExecutor before a node | run_and_day | the run tree total and the organization day |
ToolInvoker, before a metered call | run | the run tree total only |
The second row is what stops a trigger storm cheaply, and its position in the start flow is the whole point. It needs the organization ID and nothing else, so it runs before the definition resolve and the snapshot freeze. Five hundred runs firing at 09:00 against a spent day cap then cost one indexed query each, and none of them creates a Run row. Put it after the freeze and each refusal still pays for a resolve, a principal, a snapshot and an insert.
A denial there has no Run to attach to, so it writes a agent.policy_decisions row with run_id null. It is the only decision written with no Run behind it. A row can also lose its Run later, because deleting a Run clears the column.
The last row is the one that matters inside a run. A metered call in a wide fan out must not aggregate the organization's whole day, and it does not need to: a run cannot pass the organization ceiling faster than it passes its own.
Every read is on ai_usage_log, the canonical meter. ai_usage_daily is a reporting rollup, it lags, and a run's own in flight spend is not in it. Reading the rollup here would mean the ceiling never fires.
Data model
| Table | Holds |
|---|---|
agent.policies | one rule |
agent.cost_ceilings | one cost ceiling |
agent.policy_decisions | every admission decision, and every decision that refused, gated or stopped work |
agent.approvals | one human decision, shared with the runtime |
Every table carries organization_id. RLS remains the tenancy boundary. Policy is authority logic and defence in depth, not a replacement for it.
Scenarios that shaped this design
| Scenario | What answers it |
|---|---|
| A rule references a fact only the CRM knows | The tool declares policy_facts; the resolver runs before the checkpoint |
| Someone writes a rule naming a fact no tool resolves | Save-time validation refuses the rule |
| A fact resolver fails at run time | deny(missing_fact); a gate that cannot run never passes |
| An admin adds a deny rule while 40 runs are live | Each run sees it at its next checkpoint, within the 30 second cache TTL |
| An organization must be stopped now | One * deny row for the organization |
| A single tool must be stopped now | The tool registry kill switch, which the invoker reads live |
A user without email.send owns an agent that declares it | The intersection removes the tool; the model never sees it |
| A workflow node needs a scope the actor lacks | Admission denies, because a workflow cannot adapt |
| An approver lacks the right for the action | The authority re-check fails closed |
| An admin edits the rule set while a person deliberates | Check 6 refuses the stored answer, and the call files a fresh proposal |
| Two surfaces approve the same row at once | The conditional pending update picks one winner |
| An approval sits unanswered for 25 hours | The wait timeout resolves it expired and releases the run |
| An admission approval is never answered | Admission also dispatches the function, so one wait owns the clock |
| A trigger run needs an approval at 02:00 | The notifier reaches the trigger author, because the run has no conversation |
| A run is cancelled with an approval pending | RunManager.cancel() writes cancelled for the subtree |
| The proposal changes while a person is deciding | The arguments hash no longer matches, so the effect fails closed |
| A trigger fires 500 runs and the day cap is reached | Admission accrual denies the rest before they dispatch; live runs end partial |
| A run passes its cost cap mid segment | Accrual runs at the next boundary; overshoot is one segment |
| A fan out inside one step passes the cap | The metered call checks accrual, so the stop is one call |
| A run spends vendor money and almost no tokens | Metered vendor rows reach the same meter, so accrual still stops it |
| An agent is denied an action mid loop | The denial returns as a structured tool result, and the agent adapts |
| An agent asks a second time after a denial | The same deterministic rule set returns the same answer |
| An auditor asks what was refused last quarter | agent.policy_decisions holds every refusal, gate and stop |
| An auditor asks which calls were allowed last quarter | The run span tree holds every allowed call |
| An auditor asks which runs were admitted last quarter | agent.policy_decisions holds every admission decision |
Rules
- The engine is deterministic and model free.
- Two stages: grant first, then rules. Anything not granted is denied.
- Bind a rule to an action. Narrowing to a definition is the exception.
- Every matching rule is evaluated, and
denybeatsrequire_approvalbeatsallow. - The tie is broken by id. The reason and the ttl come from the first winning rule.
principal,runandargumentsare reserved roots, and only a target fact answersmissing_fact.- A target fact is present or absent.
falseandnullare answers. - A decision row that fails to write is reported, and the decision still stands.
- A target fact is declared by the action and resolved before the checkpoint.
- A capability an agent must never hold is absent from its tool subset, not merely denied.
- Policy is evaluated on behalf of the agent. The agent cannot ask for another answer.
- A denial becomes a structured tool result, so the agent can adapt safely.
- Rules are live at the next checkpoint, within the 30 second cache TTL.
- The rule cache holds one entry per organization, and an empty rule set is one of them.
- A rule set that cannot be read, or that reads short, answers
denyunderpolicy_unavailable. - The save-time grammar and
ConditionEvaluatorrefuse the same trees, to 20 levels. - One expiry clock per approval, and one waiter that owns it.
- Every approval reaches a person: the conversation when there is one, the trigger author when there is not.
- An approval cannot grant authority the approver lacks.
- Accrual reads
ai_usage_log, the canonical meter, and owns no second counter. It never reads the rollup. - The day sum filters on the organization and the UTC day, and on no source. It counts what the whole product spent.
- An absent
agent.cost_ceilingsrow is no cap. The check allows. - A meter read that fails answers
denyand setsfault_code. The callers end the run under that code, and never as a spent budget or a partial success. - A transient meter fault is read again before it denies.
- A meter outage reaches Sentry one time for each read in each window. The bound is a time window, because a meter that alternates makes every failure follow a success.
- A scope the checker does not know answers
denyunderruntime_contract, and the reason withholds the value. - A fault is a platform stop.
continue_on_errornever tolerates one, and the code carries that rule across a step boundary rather than a flag. - A metered call refused by a fault answers the fault code, so the node stops on it.
internal_erroranswers a wiring defect alone. - A metered call that cannot be measured is a fault and not a ceiling. No run scope, or no checker wired, answers
internal_error. AccrualCheckerreturns aPolicyDecision. It never raises.- Stage 1 skips the grant check for the accrual checkpoint.
- The accrual day is UTC.
- Organization overshoot is bounded by the concurrency limit, not by one unit.
- Concurrency and rate belong to Inngest. Policy holds cost ceilings only.
- Accrual has five call sites, which take four shapes. The admission gate runs before the freeze, so a refused start costs one query.
- The role mapping grants
run.start. Nothing grantsrun.accrue. - The role mapping lists tool names. A grant carries no wildcard.
- The principal narrows live at every checkpoint, and never widens.
- A read of the live rights that fails answers
denyunderpolicy_unavailable. - A machine grant narrows against the admin who authored its trigger.
- The kill switch refuses the next action. Cancel is what ends a run.
- A fact resolver caches its answer for the run, keyed on the arguments it read.
- A workflow's fatal scope set covers its tool nodes. An agent node adapts.
- The fatal scope check reads the frozen grant, and it runs before the rules, so one admission writes one row.
- The day cap gate writes a row only when it refuses. The engine writes the row of every admission.
- RLS remains the tenancy boundary.
Minimum contract tests
- An action outside the principal scopes is denied, with no rule present.
- With the grant held and no rule matching, the outcome is
allow. - Deny beats approval and allow under overlapping matching rules.
- Approval beats allow when no deny matches.
- A wildcard rule and an exact rule both match, and precedence decides.
- The principal never exceeds the actor rights or the definition scopes.
- A trigger principal is narrower than an unrelated user principal.
- A machine actor holds no scope its author lost, at the mint and at every checkpoint.
- A rule naming an undeclared fact is refused when it is saved.
- A rule whose target fact fails to resolve produces
deny, notallow. - A target fact resolved to
falseis present, and the rule evaluates against it. - A rule reading an absent
argumentspath evaluates False, and never denies. - A workflow whose tool node needs a missing scope is denied at admission.
- A workflow whose agent node names a missing scope still starts, and that agent runs with fewer tools.
- A run refused for the organization day cap creates no Run row, and writes a decision with a null
run_id. - A meter fault at the day cap answers a fault and never a spent budget.
- An admission denied by a rule writes exactly one decision row, and so does one denied for a missing tool node scope.
- A role that may run agentic work holds
run.start. - An agent whose declared tool is not granted still starts, without that tool.
- Two concurrent approval resolutions produce one winner.
- An expired, stale-hash or unauthorized approval cannot execute.
- An admission approval that is never answered ends the run at its timeout.
- Cancelling a run cancels its pending approvals.
- A workflow approval node carries its own
ttl_s, because no rule sets one for it. It is clamped by the Run deadline exactly as a rule TTL is. - A person approving in the same instant as the timeout still gets the action executed.
- An approval on a run with no conversation still notifies the trigger author.
- An in-flight run observes a new deny rule at its next action checkpoint.
- A run whose actor loses a right cannot use it at the next checkpoint.
- A run whose actor gains a right does not gain it mid run.
- The kill switch does not end a run already waiting on an approval.
- An organization
*deny rule stops every checkpoint at once. - Accrual stops a run at its cost ceiling, and the run succeeds with a partial reason.
- A metered call is refused once the run is over its ceiling, without waiting for the next node.
- A metered call does not aggregate the organization day.
- An accrual check passes stage 1 without holding a
run.accruescope. - A front door turn with no run is checked against the organization day alone.
- An organization with no ceiling row is allowed.
- A run whose spend equals its ceiling exactly is refused.
- A meter read that raises produces
deny, and the checker still returns. - Many failed reads of one outage produce one Sentry event for each read, and one log line for each call.
- A meter that alternates success and failure produces one event for each read in each window, and never one for each pair.
- An outage that spans many windows produces one event in each of them, because the window does not slide with the calls.
- A meter fault ends the run under
metering_unavailable, and never as a partial success. - A node that declares
continue_on_errorstill stops on a meter fault, at its own gate, at a metered tool call inside it, at a child start, and on a child that died of one. - A metered call outside a run scope answers
internal_error, and neverbudget_exhausted. - A denied action writes a
agent.policy_decisionsrow; an allowed action does not. - Stage 1 reads the live intersection, so a right stripped mid run is refused at the next checkpoint.