Policy and governance

One deterministic plane answers may this happen, at three checkpoints, with one grant model, one approval model and one decision log.

1 min read Updated Oct 26, 2026

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.

4 · Planes — one decides, one records, one protects the effect
all three cross every layer, and no layer may skip one
4 · Planes — one decides, one records, one protects the effectall three cross every layer, and no layer may skip one
A · POLICY — it decides, BEFORE the work
A · POLICY — it decides, BEFORE the work
ADMISSION
RunManager.start()
once per run
may this actor run this definition now?
ADMISSIONRunManager.start()once per runmay this actor run this definition now?
ACTION
ToolInvoker.invoke()
once per tool call
may this happen, with these arguments?
ACTIONToolInvoker.invoke()once per tool callmay this happen, with these arguments?
ACCRUAL
front door · admission · each segment
each node · each metered call
is it still inside the budget?
ACCRUALfront door · admission · each segmenteach node · each metered callis it still inside the budget?
STAGE 1 · GRANT
effective = the frozen grant ∩ the actor's rights, read LIVE
not in it → deny(not_granted), and fail closed
accrual skips this stage, because a budget is not a permission
STAGE 1 · GRANTeffective = the frozen grant ∩ the actor's rights, read LIVEnot in it → deny(not_granted), and fail closedaccrual skips this stage, because a budget is not a permission
STAGE 2 · RULES
every matching rule is evaluated, and there is no
most-specific-wins search
no match → allow. A rule restricts, and never grants
STAGE 2 · RULESevery matching rule is evaluated, and there is nomost-specific-wins searchno match → allow. A rule restricts, and never grants
THE CLOSED DECISION SET, and its precedence
THE CLOSED DECISION SET, and its precedence
deny
deny
require_approval
require_approval
allow
allow
APPROVALS
one durable row, and ONE clock: approval.expires_at
the Inngest wait timeout is computed from it
a person resolving in the same second BEATS the timeout
APPROVALSone durable row, and ONE clock: approval.expires_atthe Inngest wait timeout is computed from ita person resolving in the same second BEATS the timeout
LIVE WITHIN 30 SECONDS
the rule set is cached per worker for 30 seconds, so a new
deny rule reaches every in-flight run at its next checkpoint
the kill switch is one `*` deny row — and it ENDS nothing
LIVE WITHIN 30 SECONDSthe rule set is cached per worker for 30 seconds, so a newdeny rule reaches every in-flight run at its next checkpointthe kill switch is one `*` deny row — and it ENDS nothing
B · OBSERVABILITY — it records, BESIDE the work
B · OBSERVABILITY — it records, BESIDE the work
agent_runs
one row per execution
status · waiting_on · result · cost
agent_runsone row per executionstatus · waiting_on · result · cost
run_spans
the tree: kind · parent · timing · status
it carries organization_id AND root_run_id
run_spansthe tree: kind · parent · timing · statusit carries organization_id AND root_run_id
ai_usage_log, plus its daily rollup
one row per model call, and per metered vendor call
this is the meter accrual reads
ai_usage_log, plus its daily rollupone row per model call, and per metered vendor callthis is the meter accrual reads
DURABLE FIRST, LIVE SECOND
INSERT the span, then publish the event. A dropped event costs
a repaint, and the record is unaffected
DURABLE FIRST, LIVE SECONDINSERT the span, then publish the event. A dropped event costsa repaint, and the record is unaffected
METERING IS NOT BEST EFFORT
three inline retries, then fail the run with metering_unavailable.
An uncounted run is worse than a failed one
METERING IS NOT BEST EFFORTthree inline retries, then fail the run with metering_unavailable.An uncounted run is worse than a failed one
THE RECORDER WRITES THE HEARTBEAT
10 second guard on the run, 60 on its root — a root row is touched
by EVERY worker in the tree, and a wide tree would contend on it
THE RECORDER WRITES THE HEARTBEAT10 second guard on the run, 60 on its root — a root row is touchedby EVERY worker in the tree, and a wide tree would contend on it
FIVE ALERTS, EACH A SCHEDULED QUERY
stuck runs · orphan spans · expiring approvals · failure rate · cost
No metrics database, and no second scheduler
FIVE ALERTS, EACH A SCHEDULED QUERYstuck runs · orphan spans · expiring approvals · failure rate · costNo metrics database, and no second scheduler
RETENTION runs 13 months · spans 90 days · usage 13 months · decisions 12 months · the agent session dies with its run.
ERASURE a span holds CONTENT rather than a reference, so a person's request redacts the payload and keeps the timing, the tree and the cost.
A pending approval is CANCELLED, never redacted: the resume executes from that row, and a hollow proposal would send the word redacted.
RETENTION runs 13 months · spans 90 days · usage 13 months · decisions 12 months · the agent session dies with its run.ERASURE a span holds CONTENT rather than a reference, so a person's request redacts the payload and keeps the timing, the tree and the cost.A pending approval is CANCELLED, never redacted: the resume executes from that row, and a hollow proposal would send the word redacted.
C · IDEMPOTENCY — it protects the EFFECT; Inngest checkpoints the ORCHESTRATION
C · IDEMPOTENCY — it protects the EFFECT; Inngest checkpoints the ORCHESTRATION
idempotency_keys
unique(organization_id, scope, key)
request_hash stops one key from carrying two contents
PostgreSQL only — never Redis, and never a distributed lock
every claim ends one of the three ways below
idempotency_keysunique(organization_id, scope, key)request_hash stops one key from carrying two contentsPostgreSQL only — never Redis, and never a distributed lockevery claim ends one of the three ways below
TWO CLOCKS, NOT ONE
lease_expires_at — is the worker that owns this still alive? seconds
retain_until — do we still remember what this key did? 30 days
One field cannot answer both questions
TWO CLOCKS, NOT ONElease_expires_at — is the worker that owns this still alive? secondsretain_until — do we still remember what this key did? 30 daysOne field cannot answer both questions
complete()
the effect happened, and the answer
is now the stored result
complete()the effect happened, and the answeris now the stored result
release()
nothing happened — a retryable failure
must NOT be remembered as an answer
release()nothing happened — a retryable failuremust NOT be remembered as an answer
the lease expires
the worker died, and nobody is left
to call either method
the lease expiresthe worker died, and nobody is leftto call either method
THE LEASE TOKEN IS A FENCE
A stalled worker has not stopped: it wakes up later and finishes.
Every claim and reclaim mints a new token, and both endings carry theirs
THE LEASE TOKEN IS A FENCEA stalled worker has not stopped: it wakes up later and finishes.Every claim and reclaim mints a new token, and both endings carry theirs
FOUR BOUNDARIES
run.start · webhook.<provider> · tool.<tool_name>
and the same key passed downstream, when a vendor supports it
FOUR BOUNDARIESrun.start · webhook.<provider> · tool.<tool_name>and the same key passed downstream, when a vendor supports it
A completed tool claim returns its stored response, so this table is also the agent REPLAY JOURNAL and the runtime needs no second table.
The tool key never uses the model tool-call id: a restarted segment makes the model mint a new one, and the effect would repeat.
A completed tool claim returns its stored response, so this table is also the agent REPLAY JOURNAL and the runtime needs no second table.The tool key never uses the model tool-call id: a restarted segment makes the model mint a new one, and the effect would repeat.
D · WHERE THE THREE PLANES MEET ONE RUN
D · WHERE THE THREE PLANES MEET ONE RUN
run.start

P admission + accrual
I claim run.start
O the run row
run.startP admission + accrualI claim run.startO the run row
dispatch

O the run span opens
dispatchO the run span opens
each segment
or node

P accrual
O span + heartbeat
each segmentor nodeP accrualO span + heartbeat
each tool call

P action + accrual
I claim tool.<name>
O span + usage row
each tool callP action + accrualI claim tool.<name>O span + usage row
the end

O result, spans closed
I claims retained
P decisions kept
the endO result, spans closedI claims retainedP decisions kept
P = policy O = observability I = idempotency
P = policy O = observability I = idempotency
A plane answers one question and writes one record. It never executes a tool, waits for a person, queries arbitrary domain data, or calls a model.

Policy owns the ceiling values and owns no counter. Observability owns the meter. Accrual reads that meter, so there is exactly one number.

The run snapshot is frozen for stability. Policy, tool state and credentials are read live, and that split is what makes emergency revocation possible.
A plane answers one question and writes one record. It never executes a tool, waits for a person, queries arbitrary domain data, or calls a model.Policy owns the ceiling values and owns no counter. Observability owns the meter. Accrual reads that meter, so there is exactly one number.The run snapshot is frozen for stability. Policy, tool state and credentials are read live, and that split is what makes emergency revocation possible.
then
then
Text is not SVG - cannot display
4 · Planes — one decides, one records, one protects the effect
all three cross every layer, and no layer may skip one
4 · Planes — one decides, one records, one protects the effectall three cross every layer, and no layer may skip one
A · POLICY — it decides, BEFORE the work
A · POLICY — it decides, BEFORE the work
ADMISSION
RunManager.start()
once per run
may this actor run this definition now?
ADMISSIONRunManager.start()once per runmay this actor run this definition now?
ACTION
ToolInvoker.invoke()
once per tool call
may this happen, with these arguments?
ACTIONToolInvoker.invoke()once per tool callmay this happen, with these arguments?
ACCRUAL
front door · admission · each segment
each node · each metered call
is it still inside the budget?
ACCRUALfront door · admission · each segmenteach node · each metered callis it still inside the budget?
STAGE 1 · GRANT
effective = the frozen grant ∩ the actor's rights, read LIVE
not in it → deny(not_granted), and fail closed
accrual skips this stage, because a budget is not a permission
STAGE 1 · GRANTeffective = the frozen grant ∩ the actor's rights, read LIVEnot in it → deny(not_granted), and fail closedaccrual skips this stage, because a budget is not a permission
STAGE 2 · RULES
every matching rule is evaluated, and there is no
most-specific-wins search
no match → allow. A rule restricts, and never grants
STAGE 2 · RULESevery matching rule is evaluated, and there is nomost-specific-wins searchno match → allow. A rule restricts, and never grants
THE CLOSED DECISION SET, and its precedence
THE CLOSED DECISION SET, and its precedence
deny
deny
require_approval
require_approval
allow
allow
APPROVALS
one durable row, and ONE clock: approval.expires_at
the Inngest wait timeout is computed from it
a person resolving in the same second BEATS the timeout
APPROVALSone durable row, and ONE clock: approval.expires_atthe Inngest wait timeout is computed from ita person resolving in the same second BEATS the timeout
LIVE WITHIN 30 SECONDS
the rule set is cached per worker for 30 seconds, so a new
deny rule reaches every in-flight run at its next checkpoint
the kill switch is one `*` deny row — and it ENDS nothing
LIVE WITHIN 30 SECONDSthe rule set is cached per worker for 30 seconds, so a newdeny rule reaches every in-flight run at its next checkpointthe kill switch is one `*` deny row — and it ENDS nothing
B · OBSERVABILITY — it records, BESIDE the work
B · OBSERVABILITY — it records, BESIDE the work
agent_runs
one row per execution
status · waiting_on · result · cost
agent_runsone row per executionstatus · waiting_on · result · cost
run_spans
the tree: kind · parent · timing · status
it carries organization_id AND root_run_id
run_spansthe tree: kind · parent · timing · statusit carries organization_id AND root_run_id
ai_usage_log, plus its daily rollup
one row per model call, and per metered vendor call
this is the meter accrual reads
ai_usage_log, plus its daily rollupone row per model call, and per metered vendor callthis is the meter accrual reads
DURABLE FIRST, LIVE SECOND
INSERT the span, then publish the event. A dropped event costs
a repaint, and the record is unaffected
DURABLE FIRST, LIVE SECONDINSERT the span, then publish the event. A dropped event costsa repaint, and the record is unaffected
METERING IS NOT BEST EFFORT
three inline retries, then fail the run with metering_unavailable.
An uncounted run is worse than a failed one
METERING IS NOT BEST EFFORTthree inline retries, then fail the run with metering_unavailable.An uncounted run is worse than a failed one
THE RECORDER WRITES THE HEARTBEAT
10 second guard on the run, 60 on its root — a root row is touched
by EVERY worker in the tree, and a wide tree would contend on it
THE RECORDER WRITES THE HEARTBEAT10 second guard on the run, 60 on its root — a root row is touchedby EVERY worker in the tree, and a wide tree would contend on it
FIVE ALERTS, EACH A SCHEDULED QUERY
stuck runs · orphan spans · expiring approvals · failure rate · cost
No metrics database, and no second scheduler
FIVE ALERTS, EACH A SCHEDULED QUERYstuck runs · orphan spans · expiring approvals · failure rate · costNo metrics database, and no second scheduler
RETENTION runs 13 months · spans 90 days · usage 13 months · decisions 12 months · the agent session dies with its run.
ERASURE a span holds CONTENT rather than a reference, so a person's request redacts the payload and keeps the timing, the tree and the cost.
A pending approval is CANCELLED, never redacted: the resume executes from that row, and a hollow proposal would send the word redacted.
RETENTION runs 13 months · spans 90 days · usage 13 months · decisions 12 months · the agent session dies with its run.ERASURE a span holds CONTENT rather than a reference, so a person's request redacts the payload and keeps the timing, the tree and the cost.A pending approval is CANCELLED, never redacted: the resume executes from that row, and a hollow proposal would send the word redacted.
C · IDEMPOTENCY — it protects the EFFECT; Inngest checkpoints the ORCHESTRATION
C · IDEMPOTENCY — it protects the EFFECT; Inngest checkpoints the ORCHESTRATION
idempotency_keys
unique(organization_id, scope, key)
request_hash stops one key from carrying two contents
PostgreSQL only — never Redis, and never a distributed lock
every claim ends one of the three ways below
idempotency_keysunique(organization_id, scope, key)request_hash stops one key from carrying two contentsPostgreSQL only — never Redis, and never a distributed lockevery claim ends one of the three ways below
TWO CLOCKS, NOT ONE
lease_expires_at — is the worker that owns this still alive? seconds
retain_until — do we still remember what this key did? 30 days
One field cannot answer both questions
TWO CLOCKS, NOT ONElease_expires_at — is the worker that owns this still alive? secondsretain_until — do we still remember what this key did? 30 daysOne field cannot answer both questions
complete()
the effect happened, and the answer
is now the stored result
complete()the effect happened, and the answeris now the stored result
release()
nothing happened — a retryable failure
must NOT be remembered as an answer
release()nothing happened — a retryable failuremust NOT be remembered as an answer
the lease expires
the worker died, and nobody is left
to call either method
the lease expiresthe worker died, and nobody is leftto call either method
THE LEASE TOKEN IS A FENCE
A stalled worker has not stopped: it wakes up later and finishes.
Every claim and reclaim mints a new token, and both endings carry theirs
THE LEASE TOKEN IS A FENCEA stalled worker has not stopped: it wakes up later and finishes.Every claim and reclaim mints a new token, and both endings carry theirs
FOUR BOUNDARIES
run.start · webhook.<provider> · tool.<tool_name>
and the same key passed downstream, when a vendor supports it
FOUR BOUNDARIESrun.start · webhook.<provider> · tool.<tool_name>and the same key passed downstream, when a vendor supports it
A completed tool claim returns its stored response, so this table is also the agent REPLAY JOURNAL and the runtime needs no second table.
The tool key never uses the model tool-call id: a restarted segment makes the model mint a new one, and the effect would repeat.
A completed tool claim returns its stored response, so this table is also the agent REPLAY JOURNAL and the runtime needs no second table.The tool key never uses the model tool-call id: a restarted segment makes the model mint a new one, and the effect would repeat.
D · WHERE THE THREE PLANES MEET ONE RUN
D · WHERE THE THREE PLANES MEET ONE RUN
run.start

P admission + accrual
I claim run.start
O the run row
run.startP admission + accrualI claim run.startO the run row
dispatch

O the run span opens
dispatchO the run span opens
each segment
or node

P accrual
O span + heartbeat
each segmentor nodeP accrualO span + heartbeat
each tool call

P action + accrual
I claim tool.<name>
O span + usage row
each tool callP action + accrualI claim tool.<name>O span + usage row
the end

O result, spans closed
I claims retained
P decisions kept
the endO result, spans closedI claims retainedP decisions kept
P = policy O = observability I = idempotency
P = policy O = observability I = idempotency
A plane answers one question and writes one record. It never executes a tool, waits for a person, queries arbitrary domain data, or calls a model.

Policy owns the ceiling values and owns no counter. Observability owns the meter. Accrual reads that meter, so there is exactly one number.

The run snapshot is frozen for stability. Policy, tool state and credentials are read live, and that split is what makes emergency revocation possible.
A plane answers one question and writes one record. It never executes a tool, waits for a person, queries arbitrary domain data, or calls a model.Policy owns the ceiling values and owns no counter. Observability owns the meter. Accrual reads that meter, so there is exactly one number.The run snapshot is frozen for stability. Policy, tool state and credentials are read live, and that split is what makes emergency revocation possible.
then
then
Text is not SVG - cannot display
The three planes that cross every layer. Policy decides before the work, at three checkpoints, in two stages: the grant is read live so a stripped right stops working, then the rules restrict but never grant. Observability records beside the work into one run row, one span tree and one meter, always durable before live. Idempotency protects the effect with one table and two clocks, because a worker lease and a replay journal cannot share one field. The last band shows all three firing against a single run.

Three checkpoints

CheckpointCallerHow oftenQuestion
AdmissionRunManager.start()once per runMay this actor run this definition now?
ActionToolInvoker.invoke()once per tool callMay this action happen with these arguments?
AccrualAdmissionGate at admission, AgentExecutor, WorkflowStepExecutor and ComponentGuard, ToolInvoker for a metered call, and FrontDoorService and the run reportonce before dispatch, then before each agent segment, each workflow node, each component step and each paid call, plus once before a front door turn or a run reportMay this turn or run start, and may it continue, inside the budget and the credits?

No other component calls the engine. A component that wants a decision goes through one of these three checkpoints.

A count budget is not a fourth checkpoint. It calls no engine. ToolInvoker reserves a ledger row after the accrual checkpoint, and the ledger enforces the cap.

Closed decision set

TEXT
allow
require_approval
deny

Precedence:

TEXT
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.

TEXT
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.

CheckpointAction string
Admissionrun.start
Actionthe tool name, for example email.send
Accrualrun.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.

TEXT
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.

TEXT
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.

PYTHON
@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.

TEXT
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.

PYTHON
@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.

TEXT
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.

PYTHON
@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.

TEXT
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.

TEXT
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

TEXT
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.

TEXT
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 thisUse this
Stop every new action, everywhere, in under 30 secondsthe * deny rule
End the runs that are already executingPOST /runs/{id}/cancel on each, or a bulk cancel
Stop a run that is waiting three days on an approvalcancel 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.

Every organization starts with five rules. Four of the five LinkedIn sends and the email send need a person's approval, whatever the run.

TEXT
LinkedIn invitations need approval   linkedin.send_invite       -> require_approval
LinkedIn messages need approval      linkedin.send_message      -> require_approval
LinkedIn comments need approval      linkedin.publish_comment   -> require_approval
LinkedIn reactions need approval     linkedin.add_reaction      -> require_approval
Emails need approval                 email.send                 -> require_approval, 7 days

linkedin.visit_profile is the one LinkedIn send with no default rule. It sends no text, and its one count is the profile read. Do not add a require_approval rule for it: the Sequences app answers no approval for a visit, so every automated profile view step of the organization would skip.

No rule has conditions or a definition, so each applies to every call of its action. The ac-backend migrations that added the LinkedIn tables and the LinkedIn engagements wrote the LinkedIn rules to every organization that existed. Each Python path that creates an organization calls write_default_policy_rules, which writes the same five rows and bumps the policy generation. A rule an admin already wrote under the same name stays as it is, because the name is unique inside one organization.

⚠️ A failed rule write does not stop the organization. write_default_policy_rules logs the failure at error level and does not raise, because the organization already exists. An organization can therefore lack a rule. Check every organization before the LinkedIn rollout gate goes on in an environment. See the switch-on steps.

Conditions and facts

The condition language stays small.

TEXT
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.

JSON
{"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.

GroupExampleSource
Principal and runprincipal.user_id, run.source, run.definition_idthe principal and the run row
Argumentsarguments.to, arguments.limitthe proposed call
Targetrecipient_is_new, target.lifecyclea 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.

PYTHON
class FactResolver(Protocol):
    name: str
    async def resolve(self, principal: Principal, request: PolicyRequest) -> Any: ...
  • A ToolSpec declares policy_facts: list[str].
  • ToolInvoker resolves 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.

TEXT
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.

TEXT
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.

TEXT
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.

TEXT
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.

TEXT
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.

TEXT
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.

TEXT
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.

TEXT
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

StateWritten by
pendingApprovalService.create()
approved / rejecteda person, through any surface
expiredthe wait timeout, when it fires
cancelledRunManager.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.

PYTHON
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.

TEXT
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.

TEXT
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.

  1. The row names this run and this organization.
  2. The decision was made before the row expired.
  3. The approver holds the scope the action needs. An approval cannot grant authority the approver lacks.
  4. The arguments hash still matches the stored proposal.
  5. The required target state is unchanged, where the action declares one.
  6. 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 money counter and no rate limiter. The one count it keeps is the action ledger of a count budget.

ShapeWhere it lives in V1Enforced by
Per-run ceilings: turns, tool calls, durationthe definition budget, frozen into the run snapshotAgentExecutor, per segment
Per-run costthe definition budgetaccrual, against the usage meter
Per-organization daily costan agent.cost_ceilings rowaccrual, against the usage meter
Per-organization credit periodthe plan allowance (credits_monthly in the plan template), plus the top-ups and adjustments in public.credit_chargesaccrual, against public.organization_credit_balance(), when organizations.billing_enforced_at is set
Per-connection action counta platform default, lowered by an agent.action_budgets rowToolInvoker, by a reservation in agent.action_ledger
Concurrency and rateInngest flow controlInngest

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.

TEXT
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 eight call sites. AdmissionGate calls it before the Run insert, with the least the start costs. AgentExecutor calls it at the top of each agent segment, and again when a resumed bound run asks for its closing turn. WorkflowStepExecutor calls it at each node, and ComponentGuard before component work. ToolInvoker calls it before a tool whose spec sets metered. FrontDoorService calls it before the model call of a turn, and the run report (entry_control/inngest/report.py) before its own model call. Every call site passes credits=True. All of them read the canonical usage meter, and none owns a counter. The call site table gives the arguments of each.

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.

TEXT
run cost so far           sum of ai_usage_log by root_run_id
                          plus the estimate of each unsettled agent.provider_jobs row of the tree
organization cost today   sum of ai_usage_log by organization_id, for the current UTC day
credits remaining         the available credits of the period, less the charges of the period

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.

An asynchronous provider job is metered late, so the run read counts it early. provider_job.settle writes the usage row when the vendor calls back. A work-email batch can wait up to its timeout for that. A check inside that window reads a settled total that is too low, and the run submits its next batch. So the run read adds the estimate of every agent.provider_jobs row of the tree whose usage_id is null. For FullEnrich the estimate is one credit for each reserved contact. It is an upper bound, and the settled row replaces it.

The pending read is a read of rows the submit already wrote, and never a reservation. Nothing holds money, and a worker that dies holds nothing to release. A read the server cut short refuses the work as metering_unavailable, because a short list under-counts. A submit the vendor never acknowledged keeps its row live, so its estimate counts until the run ends. The day read does not count the pending rows. So the organization bound below gains one term: the unsettled jobs of every live run. A work-email run holds at most four. Settlement stamps usage_id one durable step after it writes the row, so for that one step a terminal job counts twice. The over-count fails closed.

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.

TEXT
organization overshoot  <=  the Inngest concurrency limit  x  the largest single unit cost
                         +  the estimate of every unsettled provider job of the live runs

Every term is 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 beforeOvershoot
An agent segmentone segment, which is why a segment carries its own turn ceiling
A workflow nodeone node, and one node per branch of a parallel set
A metered tool callone 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 reads are one aggregate over ai_usage_log and one indexed select on agent.provider_jobs, and only a paid call pays for them.

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 credit period is a third read, and a caller opts into it with credits=True. The scope does not change. The period read runs after the run and the day reads, and every call site opts in. Credits and the credit period gives the whole rule.

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.

Credits and the credit period

A plan includes a monthly credit allowance. A price says how many credits one unit of an action costs. The credit limit is the third accrual read, beside the run tree and the organization day.

TEXT
available   the plan allowance (credits_monthly in the plan template)
            + the top-ups of the period + the signed adjustments of the period
charged     the sum of the charges of the period in public.credit_charges
remaining   available - charged

One read answers the period. public.organization_credit_balance(p_organization_id, p_at) returns the period bounds, the allowance, the three sums and the enforcement flag in one row. The allowance is monthly, so the period is one month. Inside the live subscription period it is the month window that holds the instant, counted in UTC from current_period_start and cut at current_period_end, so an annual subscription has twelve windows. Before the live period starts, the month that ends at current_period_start answers. Any other instant reads the UTC calendar month. A start on the 31st takes the last day of a shorter month. For the current time GET /subscriptions/me/usage uses the same rule, so the usage card and the limit read the same period. The database sums and decides nothing. The comparison stays in Python, and src/agentic/governance imports no domain package on this path.

A charge belongs to the start of its root run. Its occurred_at is the created_at of the root run. A usage row with no run takes the time of the write. A usage row that settles late therefore lands in the period of its run. It also takes the price version in effect at that instant, so a new price never changes a run that already started.

A check inside a run reads the period its run started in. The read passes the created_at of the root run, so the comparison and the charges always name the same period. A run that started before a subscription renewal reads the month that ends where the live period starts. Read at the current time, a run that crosses the end of a period would compare the new allowance with charges that land in the old one, and it would never stop.

People Search reads the credit period before each company. One tool call scans up to twenty companies, and the invoker checks the period once for the call. The provider checks again before the prospects call of each company, so a spent allowance stops a batch after one company.

Charges are append-only, and one writer writes them at two moments.

  • UsageMeter.record charges a usage row at settlement, at most one charge for each usage row, and none for a row that prices to 0. The id of the charge derives from the id of the usage row, so a replay inserts nothing. A charge that is not written fails the metering with MeteringUnavailable, because the limit reads the charges and an unwritten charge is spend it cannot see.
  • RunManager charges the flat price of a root run when the run succeeds or is cancelled, and never when it fails. The seeded prices are 200 for a Company Search discovery run, 20 for each supplied row, and 200 for a Signals Search run. A People Search run pays the shortfall to its minimum of 150 at the same moment. The id derives from the root run, the action and the unit. The transition is already committed, so a charge that fails here reaches the log and Sentry and never raises.

A token price rounds half up for each row, so thirty small model calls do not cost thirty credits. A FullEnrich work-email hit is part of the people.enrich price of its person, which the enrichment already charges. Its settled row charges nothing, and an unsettled job holds no credits. Every other unit is a whole count. The chat answer row of front_door.turn pays at least 2 credits. An action with no price row is free, and a row that prices to 0 writes no charge.

A price is a version, and no version changes. public.credit_prices holds each version of each action key. The admin API, GET and POST /api/v1/admin/credit-prices, is for a superadmin. It lists the latest version of each key or every version, and it appends the next version. It has no update and no delete. Each worker caches the price book for 60 seconds, so a new version must start at least 60 seconds in the future. Two writes of the same version at the same time give one row and one 409.

An organization whose organizations.billing_enforced_at is null is charged, and it is never refused or warned. The period read still runs and the charges are still written. Enforcement switched on later therefore starts from a true balance. The column belongs to the entitlement enforcement migration.

A start is refused when nothing remains, or when it needs more than remains. Inside a run the estimate is 0, so the check refuses when remaining is 0 or less. Admission passes the least a start costs: the flat price of a discovery or Signals run, the supplied rows times their price, or the People Search minimum. A child run and a definition run pass 0. An estimate read that fails counts as 0, so an estimate never refuses on its own fault. An organization with 199 credits remaining cannot start a 200 credit discovery run, and one with 200 can.

A refusal names its limit in PolicyDecision.code. The three codes are run_budget_exhausted, day_cap_exhausted and credits_exhausted. fault_code stays for faults: a period read that fails answers deny under metering_unavailable, and it reaches Sentry under its own key, period. A refused start answers a detail object, {code, message, remedy}. The day cap answers 429 with Retry-After. Credits answer 402, because more credits end that refusal and a timer does not. A run in flight stops as a spent budget does, with partial_reason budget_exhausted. A stop at a segment, a node or a start carries the specific code as its error code. A metered call refused inside a tool still ends the run under budget_exhausted, because a run result carries no code. The three codes stay out of PLATFORM_STOP_CODES: a spent limit is a ceiling stop that keeps the work the run already did, and never a platform fault.

The credit limit overshoots as the day cap does. The read takes no lock, so work in flight can each pass its one unit. A flat run price lands when its run ends, so a run admitted with credits remaining can end below 0. The balance can be negative, and the next check refuses.

A warning is a marker, and a background sender sends it. When the period read sees 80 percent of the available credits charged, or all of them, the checker claims one public.credit_warnings row for the organization, the period and the threshold. The primary key makes a second claim insert nothing, so each threshold is claimed once in each period. The agentic package does not import the notification producer, because an import contract forbids it. So src/tasks/credit_warnings.py runs on Celery beat every 60 seconds. It reads the markers whose notified_at is null, notifies every owner and admin in app and by email, and stamps notified_at only when every notice reached the notifications service. A marker with a failed notice, or a failed stamp, is sent again on the next beat. The idempotency key of each notice makes a resend a no-op for every owner the first send reached. The notifications service queues the email only when it inserts the in-app row, so a resend sends no second email. Nothing is claimed for an organization that is not enforced, or when nothing is available. A claim that fails is logged, and the decision does not change.

Count budgets per connection

Some vendors restrict a count and not a cost. A vendor such as LinkedIn restricts an account that sends too many invitations in one day, whatever the calls cost. The money meter cannot see that count, and the vendor refusal arrives after the damage. So a tool that declares a budget_class spends one unit of each count budget class it declares before it runs. See the invoker side.

The key is the connection, not the organization. The vendor counts per account. Two connections in one organization spend two budgets. More runs on one connection do not get more units.

The platform sets a default for each class, per connection.

budget_classDayISO week
linkedin.invitations20100
linkedin.messages50none
linkedin.profile_reads50none
linkedin.comments20none
linkedin.reactions30none

A class applies to the connection whose provider equals the part before the dot.

One call can spend more than one class. A LinkedIn send reads the profile before it sends, and LinkedIn counts that view. So a send reserves one row of its own class and one row of linkedin.profile_reads, all or none. A send that finds nothing to send releases its own row and spends the profile read row. See the invoker side.

TEXT
cap = the platform default for the class and the window
cap = floor(cap / 2)        while the connection is younger than 14 days
cap = min(cap, override)    when an agent.action_budgets row exists
  • A new connection runs at half rate for 14 days. The vendor watches a new account more closely, so the platform spends less of it.
  • An admin can lower a default, and never raise it. An agent.action_budgets row holds one override for one connection, one class and one window. A value above the platform default is refused, and so is a negative value. 0 stops the class on that connection.
  • The day is the UTC day. The week is the ISO week, from Monday, in UTC. A class with a week default reserves in both windows, and either window can refuse.

The ledger holds one row for each attempted effect.

TEXT
agent.action_ledger
  organization_id, connection_id, budget_class
  run_id               null outside a run
  call_key             the step path or the claim key of the call
  status               reserved | spent | released
  day, day_slot        the UTC day, and a slot from 1 to the day cap
  week, week_slot      the ISO Monday in UTC, and a slot from 1 to the week cap; null with no week cap
  reserved_at
  settled_at           null while the row is reserved

reserved and spent rows are held. A released row holds nothing.

The held count never exceeds the cap, and no database function enforces it. The database holds storage only, so no stored procedure carries this logic. PostgREST runs one statement in each request. A lock and a count in one transaction are therefore not available. Slots replace them.

  • Each held row owns one slot, and no slot is greater than the cap.
  • A partial unique index on (connection_id, budget_class, day, day_slot), over the rows that are not released, keeps two held rows off one slot. A second index does the same for week_slot.
  • Two held rows never share a slot, and a reservation takes no slot above the cap. A reservation also refuses when the held count already reached the cap. The index holds the bound when workers race, and the count check holds it when an admin lowers a cap inside a window that holds rows on higher slots. Together the two keep the held count at or under the cap, whatever the number of workers.
TEXT
reserve(connection, budget_class)
  a day hold on the connection -> refuse: budget_exhausted
  read the held slots in the current day, and in the current week when the class has one
  held count >= cap   -> refuse: budget_exhausted
  otherwise           -> insert one row on the lowest free slot of each window
    inserted          -> reserved
    unique violation  -> a concurrent reservation won that slot; read again
  five attempts at most, then budget_unavailable
  a read or a write that does not complete -> budget_unavailable

The day hold comes first. A vendor that restricts on count can hold a connection until the end of the UTC day. The reservation reads that hold before any slot, so a held connection takes no slot and reads no ledger row.

  • The handler writes the hold. A LinkedIn hold code on any LinkedIn call writes held_until, the next midnight UTC, and hold_reason, the vendor code, on agent.connections. The call answers connection_held. It reached the vendor, so the claim completes and its slots are spent.
  • The hold code set is provisional. It is any 429, too_many_requests and limit_exceeded. cannot_resend_yet is about one person, so it holds nothing.
  • The resolver carries the hold, and reserve(held_until=...) reads it. While held_until is in the future, the next budgeted call answers budget_exhausted, and the reason names the clear time in UTC.
  • A hold does not block a tool with no budget class. That tool reserves nothing, so reserve() never reads the hold. linkedin.read_inbox still calls LinkedIn under a hold.

Tools and integrations holds the handler side.

The bound is five attempts. Each unique violation means that another reservation took a slot, so the next read sees a higher held count. A race for the last slot therefore ends at the next read, which finds the cap reached. Five attempts cover a burst on one connection. Past that, the reservation fails closed and does not loop.

The count fails closed. A ledger that cannot be read cannot protect a vendor account, so the reservation answers budget_unavailable and the call does not run. The money check is different: it allows an overshoot of one unit, and it adds no reservation. The count check takes a reservation for two reasons. The vendor accepts no overshoot. And the reservation is a Postgres row under a unique index, not a Redis counter, so a worker that dies leaves a reserved row that counts until its window ends. It does not leave a lost slot.

A spent budget is a value, not a stop. The invoker answers budget_exhausted with meta.partial_reason = 'action_budget_exhausted' and no stop. The run continues, and the budget returns in the next window.

The per-second guard in src/shared/vendor_limits.py is not this check. It fails open on a Redis fault. A count budget must fail closed, so it does not reuse that guard.

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.

DecisionRecorded as
Any admission decisiona agent.policy_decisions row
Any denya agent.policy_decisions row
Any require_approvala agent.policy_decisions row
Any accrual stopa agent.policy_decisions row; run_id is null when it refused a start
An action allowattributes 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.

TEXT
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.

TEXT
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

TEXT
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 and the credit period
  refusal_copy.py  the message and remedy of each spent limit
  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

governance/credits/
  registry.py      units, rounding, the run-shaped prices, credit_state()
  prices.py        CreditPriceBook             the price book, cached for 60 seconds
  charges.py       ChargeWriter                one immutable charge for each billable unit
  models.py        EndedRun
  factory.py       the one place that builds a UsageMeter with its charge writer
# CreditWarningMarkers is NOT here. It lives at
# src/agentic/runtime/execution/credit_warnings.py, and the composition roots
# hand it to each AccrualChecker they build.
ComponentJob
Principalimmutable effective authority for one run
PrincipalFactorycompute the intersection and mint the principal
PolicyRequestone checkpoint, action, arguments and facts
PolicyEnginegrant check, rule match, precedence, record
RuleCacheper-worker rule set with a 30 second TTL
ConditionEvaluatorthe small deterministic condition language
FactResolverresolve one declared target fact before a checkpoint
AccrualCheckercompare meter sums against the ceilings and the credit period, and return a PolicyDecision
CreditPriceBookanswer the price version in effect at an instant
ChargeWriterwrite one charge for a usage row, and the flat price of a run that ended
CreditWarningMarkersclaim a crossed credit threshold once for each period
AdmissionGatethe two halves of admission, one either side of the Run insert
PolicyDecisionstable allow, deny or require_approval result
ApprovalServicecreate, validate and resolve a human decision
ApprovalRepositoryatomic approval row transition
ApprovalNotifiertell one person an approval is waiting
PYTHON
@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
    # The limit a refusal answers, when the refusal is an answer and not a
    # fault: run_budget_exhausted, day_cap_exhausted or credits_exhausted.
    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'],
        principal: Principal | None,     # the grant a refusal is recorded against
        credits: bool = False,           # True also reads the credit period
        credits_estimate: int = 0,       # the least the work costs; admission only
    ) -> 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 admission allow is 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.

Every caller passes credits=True, so each one also reads the credit period. Only admission passes a credits_estimate.

CallerscopeReads
FrontDoorService before the model call of a turn, and the run report before its model calldaythe organization day and the credit period; there is no run yet
AdmissionGate, before the freezedaythe organization day and the credit period, with the start estimate; no Run exists yet
AgentExecutor before a segment, WorkflowStepExecutor before a node, ComponentGuard before component workrun_and_daythe run tree total, the organization day and the credit period
AgentExecutor, before the closing turn of a resumed bound rundaythe organization day and the credit period; a spent run budget alone still grants the closing turn
ToolInvoker, before a metered callrunthe run tree total and the credit period

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. The credit period is one indexed function call, and only a paid call pays for it.

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

TableHolds
agent.policiesone rule
agent.cost_ceilingsone cost ceiling
agent.action_budgetsone admin override that lowers a count default, for one connection, class and window
agent.action_ledgerone attempted budgeted effect, on one slot of each window
agent.policy_decisionsevery admission decision, and every decision that refused, gated or stopped work
agent.approvalsone human decision, shared with the runtime
public.credit_pricesone version of the credit price of one action; no version is edited
public.credit_chargesone immutable charge, top-up or adjustment, with the price version that priced it
public.credit_warningsone credit threshold one organization crossed in one period, and when its notices went out
organizations.billing_enforced_atwhen the credit limit started to stop work for the organization; null charges and never refuses

Every agent table carries organization_id, and so do public.credit_charges and public.credit_warnings. public.credit_prices is the platform price book and has none: members read it, and only the service role writes it. Members read the charges of their own organization. public.credit_warnings has no authenticated grant. Members read agent.action_budgets, and only the service role writes it. agent.action_ledger has RLS on and no authenticated grant. RLS remains the tenancy boundary. Policy is authority logic and defence in depth, not a replacement for it.

Scenarios that shaped this design

ScenarioWhat answers it
A rule references a fact only the CRM knowsThe tool declares policy_facts; the resolver runs before the checkpoint
Someone writes a rule naming a fact no tool resolvesSave-time validation refuses the rule
A fact resolver fails at run timedeny(missing_fact); a gate that cannot run never passes
An admin adds a deny rule while 40 runs are liveEach run sees it at its next checkpoint, within the 30 second cache TTL
An organization must be stopped nowOne * deny row for the organization
A single tool must be stopped nowThe tool registry kill switch, which the invoker reads live
A user without email.send owns an agent that declares itThe intersection removes the tool; the model never sees it
A workflow node needs a scope the actor lacksAdmission denies, because a workflow cannot adapt
An approver lacks the right for the actionThe authority re-check fails closed
An admin edits the rule set while a person deliberatesCheck 6 refuses the stored answer, and the call files a fresh proposal
Two surfaces approve the same row at onceThe conditional pending update picks one winner
An approval sits unanswered for 25 hoursThe wait timeout resolves it expired and releases the run
An admission approval is never answeredAdmission also dispatches the function, so one wait owns the clock
A trigger run needs an approval at 02:00The notifier reaches the trigger author, because the run has no conversation
A run is cancelled with an approval pendingRunManager.cancel() writes cancelled for the subtree
The proposal changes while a person is decidingThe arguments hash no longer matches, so the effect fails closed
A trigger fires 500 runs and the day cap is reachedAdmission accrual denies the rest before they dispatch; live runs end partial
A run passes its cost cap mid segmentAccrual runs at the next boundary; overshoot is one segment
A fan out inside one step passes the capThe metered call checks accrual, so the stop is one call
A run spends vendor money and almost no tokensMetered vendor rows reach the same meter, so accrual still stops it
An organization spends its last credit while three runs are liveEach run stops at its next checkpoint, as a spent budget does; the next start answers 402
A usage row settles after the period endsThe charge takes the created_at of its run, so it lands in the old period at the old price
A FullEnrich batch finds a work emailNothing more is charged. The person charge of the enrichment includes the hit
An organization has no enforcement flag yetEvery charge is written, and nothing is refused or warned
Forty runs send invitations from one account in one dayThe count budget of that connection refuses each call past the cap, and each run continues
Two workers reserve the last invitation of the day at one instantThe slot index lets one row in. The other reads again and is refused
An admin sets a cap above the platform defaultThe write is refused. An override only lowers
An agent is denied an action mid loopThe denial returns as a structured tool result, and the agent adapts
An agent asks a second time after a denialThe same deterministic rule set returns the same answer
An auditor asks what was refused last quarteragent.policy_decisions holds every refusal, gate and stop
An auditor asks which calls were allowed last quarterThe run span tree holds every allowed call
An auditor asks which runs were admitted last quarteragent.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 deny beats require_approval beats allow.
  • The tie is broken by id. The reason and the ttl come from the first winning rule.
  • principal, run and arguments are reserved roots, and only a target fact answers missing_fact.
  • A target fact is present or absent. false and null are 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 deny under policy_unavailable.
  • The save-time grammar and ConditionEvaluator refuse 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_ceilings row is no cap. The check allows.
  • A meter read that fails answers deny and sets fault_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 deny under runtime_contract, and the reason withholds the value.
  • A fault is a platform stop. continue_on_error never 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_error answers 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.
  • AccrualChecker returns a PolicyDecision. It never raises.
  • Stage 1 skips the grant check for the accrual checkpoint.
  • The accrual day is UTC.
  • The credit period is the live subscription period, then the billing interval before it, else the UTC calendar month.
  • A charge belongs to the created_at of its root run, at the price version in effect then.
  • Charges and prices are append-only. A price change is a new version, at least 60 seconds in the future.
  • A usage charge that is not written fails the metering. A run end charge that is not written is logged and reported, and never raises.
  • A flat run price and the People Search shortfall are charged when a root run succeeds or is cancelled, and never when it fails.
  • An organization with a null billing_enforced_at is charged, and never refused or warned.
  • The credit comparison refuses when remaining is 0 or less, or smaller than the start estimate.
  • A spent limit names itself in PolicyDecision.code. fault_code stays for faults.
  • A refused start answers {code, message, remedy}: 429 with Retry-After for the day cap, and 402 for credits.
  • The accrual check claims each credit threshold once for each organization and period. A background sender notifies, because the agentic package does not import the notification producer.
  • Organization overshoot is bounded by the concurrency limit, not by one unit.
  • Concurrency and rate belong to Inngest. Policy holds cost ceilings and connection count budgets.
  • A count budget is keyed on the connection. An override only lowers the platform default, and a connection younger than 14 days runs at half.
  • Each held ledger row owns a distinct slot no greater than the cap, so the held count never exceeds the cap.
  • A ledger read or write that does not complete answers budget_unavailable. The count budget fails closed.
  • A spent count budget is a value. The run continues.
  • Accrual has eight call sites, and each passes credits=True. The admission gate runs before the freeze, so a refused start costs one query.
  • The role mapping grants run.start. Nothing grants run.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 deny under policy_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, not allow.
  • A target fact resolved to false is present, and the rule evaluates against it.
  • A rule reading an absent arguments path 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.accrue scope.
  • 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_error still 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 never budget_exhausted.
  • A denied action writes a agent.policy_decisions row; an allowed action does not.
  • Stage 1 reads the live intersection, so a right stripped mid run is refused at the next checkpoint.
  • A spent count budget refuses before the handler and writes no reserved row.
  • A ledger write that fails denies with budget_unavailable, a fault code.
  • Two connections in one organization spend two budgets.
  • Two concurrent calls at a cap of one produce one held row.
  • A connection younger than 14 days reserves at half the default, rounded down.
  • An override above the platform default is refused.
  • A reservation at 23:59 UTC and one at 00:01 UTC count in two days.
  • A check with credits=False never reads the credit period.
  • An organization that is not enforced is allowed at 100 percent of its credits.
  • An enforced organization at 100 percent of its credits is denied with credits_exhausted.
  • A start estimate of 200 against 199 remaining credits is denied, and against 200 it is allowed.
  • A period read that fails answers metering_unavailable, with one Sentry event in each window under the key period.
  • A warning is claimed at 80 percent and at 100 percent, not at 79 percent, and never when nothing is available.
  • A warning claim that raises does not change the decision.
  • A usage row charged twice writes one charge.
  • A late settlement for a run of the last period lands in that period, at its price version.
  • A root run that fails pays no flat price.
  • Each refused start answers the code and the remedy of its own limit.