6. Runtime view
Five end-to-end scenarios show how the building blocks cooperate at run time. Each is anchored to a real, wired capability; where a step is only partly wired, it is called out here and in Risks and technical debt.
Scenario A: an orchestrated task with verification
An operator hands a task to an autonomous agent. The engine plans, executes governed tool calls under a budget, then a critic verifies the result and can re-plan on a failing verdict, all within the same ceiling.
The budget increment is wired into the actor loop, so the ceiling actually stops the agent. The critic runs; no shell check is run on behalf of an agent. See the execution model (verification and critic).
Read the approval branch precisely. A step of a plan holds no approval of its own, and an agent declares none: its manifest has no key for it. Each tool call of a step is decided where it is made, by the same decision as a call of the chat path. The organisation policy and a deny rule refuse it. A tool that only reads runs. Anything sensitive runs on a standing consent of the agent for the family of the tool, and asks otherwise, with the answers once, always and refuse. A refused call fails its step, and the tool never runs.
The wait is a plain await, so the step budget does not advance while a human thinks. There is no open position: when governance cannot read what it decides from, it refuses every sensitive call instead of letting it run.
Scenario B: chat with plan-mode
A user talks to the runtime in streaming, and for a consequential request the agent proposes a plan the user approves before execution, with the option to pause, inject, and resume.
Chat, plan-mode, HITL, fork and children, and pause-inject-resume are wired. One nuance: sessions left in a processing state are not reloaded at boot, and resume moves them back to active. See the plan model.
Scenario C: host federation over MCP and REST
A host product drives the runtime through the stable API while exposing its own data over MCP. Apollia reads through the host's MCP tools and writes back through the host's REST API, so the host stays the system of record.
The MCP client, the governed mcp: tool path, and the OpenAPI-plus-SDK driving
contract are wired and proven. See Embed via federation
and Integrate via the driving contract.
Scenario D: an audited run
Every governed action lands in a signed, hash-chained journal. After the fact, the run can be verified for integrity.
The signed journal and verification are wired. A captured run can also be
replayed: apollia-os audit replay <RUN>, or POST /api/v1/audit/replay/{run_id},
reads the trace of the run from the journal and steps through it offline, each
captured model response followed by the captured outputs of the tool calls it
asked for, then answers identical, diverged (with every divergence) or
incomplete trace. It calls no model and runs no tool: it checks the capture
against itself. It does not run the agent again, because the journal does not
hold the prompt the run started from. Accountability rests on the journal and
its verification. The narrative is
the accountability model.
Scenario E: how a tool call is governed
This is the path that decides whether a tool runs, for every agent and wherever
the call is made: a conversation, a task, a step of a plan, an item of a batch.
It is one function, decide, in apollia-runtime (governance/decision.rs).
It reads what governance holds (the refusals of the organisation policy, the
fine rules and the standing consents of governance.db, and the developer
mode) and answers one call. There is no injection classifier in the decision,
and no set of tool names authorized in advance: the stored rules are matched
against each call.
The decision reads six lines in order, and the first that applies decides.
- The organisation policy refuses the tool, its connection or its family of action: refused.
- A fine rule
denymatches the call: refused. - The family only reads, or acts on nothing but the conversation or the agent's own memory: allowed.
- The call executes code: allowed by a fine rule
allowon a single simple command, in developer mode only, and asked otherwise. - The call proposes a fact for the profile: asked, every time. Any other call is allowed by a standing consent of the agent for the family of the tool, or by a grant of the run.
- Anything left is asked, with the answers once, always and refuse.
Five properties follow from that order, and each one is a deliberate choice rather than an accident.
A consent covers a family, a rule covers an argument. Every tool declares a family of action: reading, reading personal data, writing or deleting, sending outside the machine, executing code. "Always" on the consent card records a standing consent of one agent for one family, never for a tool name. A fine rule is narrower: it names a tool and an optional prefix of its first argument, it is matched on each call, and it carries one of two scopes, one agent or every agent. No consent and no rule is scoped to a session or to a project. A tool nobody classified is asked every time, without "always".
Refusals come first. The policy and a deny rule are read before anything
that allows, so a standing consent or a grant of the run cannot get around a
standing refusal.
Executing code asks, outside one narrow case. No standing consent covers a
code executor such as bash_executor. In developer mode, a fine rule that
allows a tool under an argument prefix lets through a single simple command,
one that does not chain, pipe, redirect or substitute, and "always" on such a
command is what writes that rule. Everywhere else each execution asks, with
once and refuse as the only answers. This invariant exists because a blanket
allow on a shell is a blanket allow on everything.
No answer is treated as a refusal. In a conversation, a prompt that goes
unanswered for five minutes resolves to a refusal, and no tool runs. The
timeout is fixed in the code and is not configurable. A run nobody watches,
such as an item of a batch, does not prompt: the first call that would ask
stops the run, and the decision is listed with the others waiting for the
person (GET /api/v1/pending-decisions, answered with
POST /api/v1/pending-decisions/decide).
Governance that cannot read refuses. When governance.db cannot be read,
or a deposited organisation policy is refused, every sensitive call is refused
and reading continues. A failure never becomes a permission.
State machines
The scenarios above move things between states. The states themselves are enumerated in the code and, in two cases, the transitions are enforced there rather than merely intended.
Task
A task carries one of six statuses, defined by TaskStatus in apollia-core.
Two details are easy to get backwards.
A rejected approval does not cancel the task, it fails it. The engine returns a
failure carrying the code REJECTED and the operator's reason, without calling
the agent again.
A task waiting on a human waits indefinitely by default. [hitl] timeout_hours has no default value, so nothing expires a suspended task unless
an operator sets one; scan_interval_secs is ignored while it is unset. See the
configuration reference.
Submission is refused outright when the target agent is not in a state to take work: initializing, stopping and stopped are rejected. A degraded agent still accepts tasks, and the submission emits a warning.
Agent process
The agent registry rejects an invalid transition instead of recording it. This is
a real gate, not a convention: ProcessState::can_transition_to is consulted on
every state change, and a disallowed one returns an error to the caller.
There is no transition from Initializing straight to Stopped: a startup
failure goes through Stopping like any other stop.
Degraded means the agent runs but something optional did not come up. Two
paths reach it, both at registration: optional tools declared and not resolved,
and a Python environment whose package installation failed. Degraded to
Active is permitted by the transition table, and the runtime never performs it
on its own, so an agent that starts degraded stays degraded until it is
restarted.
The drain timeout defaults to 30 seconds.
Chat session
Processing is what makes a second message on the same session refuse rather
than interleave. Closing a session cancels the exchange in flight. Closed is
terminal: the history stays readable, nothing new is accepted.
Tool circuit breaker
Each tool carries its own circuit breaker, keyed by tool name. Repeated transient failures on one tool stop the calls to that tool and leave the others alone.
Only failures classified transient count. A permanent error, a bad argument or a refused permission, leaves the counter untouched: the breaker exists to ride out a flapping dependency, not to punish a caller. A single success resets the counter to zero.
The cooldown is not a timer that fires. The breaker moves to HalfOpen when the
next call arrives after the cooldown has elapsed, so a tool nobody calls stays
Open indefinitely. HalfOpen does not restrict concurrency: calls that arrive
together are all admitted, and the first result decides the transition.
Threshold and cooldown are fixed at 3 and 30 seconds. They are not configurable.