ADR 0020: Agent Task Contract¶
Date: 2026-10-07
Status¶
Accepted
The plan for this work lives in the dev-environment repository's
docs/agent-runtime-plan.md. Behavioral requirements are in
docs/agentic-platform-requirements.md in the same repository; ids such as
F4 and CC6 refer to that document.
Related: ADR 0017 (userless service principals), ADR 0018 (model pricing), ADR 0019 (Iggy to ClickHouse).
Context¶
Imbi stores agent definitions (Agent, AgentVersion) but has no record of
agent work. The Tasks, Usage, and Dashboard pages of the Agents area need
one: what an agent is working on, its state, the exchange between people and
the agent, the tools it called, and what it cost.
The process that runs agents (the harness) is a separate design. This ADR defines only the contract between Imbi and any harness, and between Imbi and the people who watch and steer agent work. It does not decide how work is dispatched to a harness, which tools agents get, how tool calls are gated, or how escalation works. Each of those gets its own decision.
Three facts shape the contract:
- Task state is hot and transactional. Status, control state, open requests, and budgets change many times per task and are read on every harness turn. The AGE graph is a poor fit, and the move from AGE to relational tables is deferred.
- The history must be complete and permanent. Every exchange between people and agents is the audit trail (F10, N6), and the Usage page aggregates over all of it. ClickHouse, fed through Iggy, already holds Imbi's durable, queryable history.
- Budgets need an authority that is never behind. The Iggy to ClickHouse path is asynchronous. A budget read from it can be wrong at the moment it matters.
Decision¶
Postgres is the state; ClickHouse is the durable log¶
A new Postgres schema, agent_runtime, managed with the same tooling as the
scheduler store, holds task state:
| Table | Holds |
|---|---|
tasks |
One row per task: identity, status, control state, pinned versions, origin, owner, budget, totals, log_archived_at |
sessions |
One row per harness execution period of a task |
events |
A buffer of events for tasks that are not yet archived |
requests |
Feedback and approval requests and their resolution |
budget_ledger |
One row per usage report, with the rates used |
task_id_sequences |
One counter per organization for short ids |
Rows refer to graph nodes (organization, agent, project, user, service account) by id. Application code validates those ids on write.
Every event is published through Iggy to the ClickHouse table
agent_task_events after its Postgres transaction commits. Every usage
report is published to agent_usage. ClickHouse is the permanent record.
A task's state change and the event that records it commit in one transaction. Nothing changes a task without writing an event.
Archive moves the log out of Postgres¶
A sweep runs on a schedule. For each task it compares the Postgres events
with ClickHouse and republishes any missing event. ClickHouse has every
event when its number of distinct sequence numbers and its highest
sequence number both equal the task's last_seq: sequences start at 1
and have no gaps, so only the full set 1..last_seq gives that result.
Duplicate rows that ReplacingMergeTree has
not merged yet do not change a distinct count. When a task has a terminal
outcome and ClickHouse has every event, the sweep deletes the task's
Postgres events and sets log_archived_at.
The events endpoint reads Postgres for a task that is not archived and ClickHouse for one that is. Callers see one log.
The sweep replaces a transactional outbox. Iggy publishing stays direct, as for every other Imbi producer. Nothing live reads ClickHouse: harness polling, the UI's live view, and budgets read Postgres.
Entities¶
AgentTask is the durable unit of work on one subject by one agent
(F1).
short_id:T-<n>, from an atomic per-organization counter (F2). Stable and quotable in chat.agent_id,agent_version,prompt_version: pinned at creation. The harness runs the pinned versions; editing the agent does not change a task that already exists.project_id: optional primary project (F13).origin: set at creation and immutable (CC9). One of:human, with the user id;schedule, with the scheduler task id;webhook, with the webhook id and the delivery id;task, with the parent task id and its agent (delegation).owner_id: the person accountable for the task (F9). Reassignable.budget: the hard per-task budget in USD. Defaults to the agent's newAgentSettings.task_budget. May be lowered at creation.status,control,phase,outcome,outcome_reason: see below.cost_total,tokens_in,tokens_out,cache_read_tokens,cache_write_tokens: running totals from the ledger.
TaskSession is one period in which a harness works the task. A task
has many (F1). A session is a tracking record. It is not an execution lease;
leases belong to the harness.
TaskEvent is one entry in the task's append-only log (F10). Each has
a per-task seq (gapless, assigned in the write transaction) and a stable
event_id (UUID). The envelope:
| Field | Meaning |
|---|---|
event_id |
Stable id; ClickHouse deduplicates on it |
seq |
Per-task order |
type |
One of the types below |
schema_version |
Version of the payload schema for this type |
actor_kind |
human, agent, subagent, system |
actor_id |
User, agent, or service id |
channel |
web, slack, mcp, api, harness (H3) |
session_id |
Set when the event comes from a harness session |
at |
Time the event happened |
payload |
Type-specific JSON, redacted (CC8) |
Standard types:
| Type | Written by | Payload |
|---|---|---|
task.created |
Imbi | Title, description, origin, budget, pinned versions |
state.changed |
Imbi | From, to, reason |
control.changed |
Imbi | From, to, actor |
owner.changed |
Imbi | From, to |
session.opened / session.closed |
Imbi | Session id, harness instance, close reason |
turn |
Imbi or harness | Message body; decision text when the turn asks for one |
tool.called |
Harness | Tool id, mutating flag, arguments, result summary, duration, result, parent call id for nested subagent calls (N3) |
phase.changed |
Harness | Phase name, furthest milestone (A3, CC5) |
todos.updated |
Harness | The full todo list (F12) |
check.reported |
Harness | Name, value, delta, baseline, verdict, source (J2) |
usage.reported |
Imbi | Model, token counts, cost, remaining budget |
request.opened / request.resolved / request.expired |
Imbi | Request id and content, resolution |
outcome.set |
Imbi | Outcome, reason |
A new type, or an incompatible payload change, raises schema_version.
Readers ignore types they do not know.
TaskRequest asks a person for something (I1).
kind:feedback(a decision, with optional options) orapproval(a permitted effect).title,why,options,artifacts: the content the person decides on (I7).artifact_digests: forapproval, the digests the approval binds to (I3). A request with changed digests is a new request; the old one is void.expires_at: expiry is a terminal outcome (I5).- Resolution: actor, time, choice or decision, constraints, the digests approved (I9). A request resolved by one person collapses for everyone else, naming who resolved it (I4).
States and outcomes¶
status is one of queued, running, blocked, paused, or closed
(F4).
queuedtorunning: a harness opens a session.runningtoblocked: a request opens. Back torunningwhen every open request is resolved and the harness opens a session.runningtopausedand back: thecontrolvalue changes (below).- Any status to
closed: an outcome is set.closedis final. A non-terminal report never overwrites it (CC5).
outcome is set only with closed, from the closed F5 set:
done_acted, done_nothing_to_act_on, no_reason_to_run,
partial_capped, suppressed_duplicate, superseded,
cancelled_by_human, interrupted_by_operator, failed_at_gate,
failed_external, unmapped_subject, exceeded_ceiling,
unhandled_no_actor, request_expired, refused_rate_ceiling. Every
outcome except done_acted carries outcome_reason from a closed set per
outcome (F6).
control is one of run, pause, or cancel. People set it. It is task
state, not a transcript entry, and each change writes a control.changed
event. The harness reads it and acts at its next turn boundary. A harness
that sees cancel closes the task with cancelled_by_human or
interrupted_by_operator.
The harness reads by polling¶
The harness learns of human input and control changes by reading events
with seq greater than the last one it saw. The session heartbeat returns
the current control, the latest seq, and the remaining budget, so one
call tells the harness whether anything changed. A push channel may come
later as an optimization. Correctness never depends on it.
Imbi computes cost; the harness reports tokens¶
The harness reports, per model call: model id, input tokens, output tokens, cache read tokens, cache write tokens, and an idempotency key. Imbi:
- prices the report from the
AIModelcatalog, which gainscache_read_cost_per_millionandcache_write_cost_per_million; - writes a
budget_ledgerrow with the rates used and updates the task totals in the same transaction; - returns the remaining budget;
- closes the task with
exceeded_ceilingwhen the total passes the budget.
A report with no usable token counts is recorded as unmeasured, not as zero (N2). A correction is a new ledger row; rows never change.
The budget is checked between model calls. One call can overshoot the budget by that call's own cost. This bound is accepted for v1. Reserve and commit operations come only if an overshoot causes a real problem.
AgentSettings.monthly_cost_cap stays advisory. The Usage and agent pages
warn at 80% and 100% of it.
Each agent is its own service principal¶
Each agent gets a ServiceAccount, created and removed with the agent,
MEMBER_OF the agent's organization, and linked to the agent by an
ACTS_AS edge. The harness authenticates as that service account with the
existing client-credentials flow. Harness-facing endpoints accept a call only
from the service account of the task's agent.
The person who started a task is its origin. The agent never acts with that
person's token (CC9). The service account gets no
integration:act-as-service grant (ADR 0017) by default. Which tool
authority an agent receives is a later decision.
Idempotency¶
Task creation and usage reports take an idempotency key. The key is unique per organization, origin kind, origin principal, and key. A repeat returns the first result. A different principal that sends the same key gets its own result, never the result of another principal.
Payloads¶
Inline payloads are redacted (CC8) and capped in size. A larger payload or an artifact goes to object storage (the existing upload storage, ADR 0005) under its digest. The event keeps the digest, size, media type, and a redacted preview.
Permissions¶
| Permission | Allows |
|---|---|
agent_task:read |
List and read tasks and their events |
agent_task:create |
Create a task manually |
agent_task:manage |
Pause, resume, cancel, reassign |
agent_task:resolve |
Answer feedback requests and resolve approvals |
An agent's service account cannot resolve a request on its own task or on a task its subagent works (I6). Approval authority scoped to the project an effect targets (M2) comes with the tool gating decision.
Operations Log¶
Resolutions and terminal outcomes appear in the Operations Log as a projection of task events (I9, N9). The Operations Log is not a second place to write them.
Task relations and associated projects¶
Added 2026-10-09.
A task can depend on other tasks and can touch projects other than its primary project (F13). Two tables hold this state:
| Table | Holds |
|---|---|
task_dependencies |
One row per dependency: organization_id, dependent_task_id, prerequisite_task_id, created_by_kind, created_by, created_at |
task_projects |
One row per associated project: organization_id, task_id, project_id, project_slug, added_by_kind, added_by, added_at |
- One relation, read in two directions. "A requires B" and "B blocks A" are the same row. The UI shows requires and required by, and blocks and blocked by, from it.
- Parent and child are not stored here. They come from a
taskorigin, which is immutable. The UI shows them read only, as delegation. - Organization isolation is in the keys.
taskshas a unique key on(organization_id, id). Both tables refer to tasks through(organization_id, task id)foreign keys, so a row can never join tasks in two organizations. The organization comes from the route, never from the request body. Application code checks that a project belongs to the organization, because projects are in the graph. - No self dependency. A check constraint refuses it. Longer cycles are allowed in v1, because a dependency does not gate anything.
- Dependencies are information only. A dependency does not change a task's status and does not affect dispatch. Before a dependency gates anything, cycle checks must come first.
- The primary project is not in
task_projects. Associating the primary project is refused. A row stays when its project is deleted from the graph; the UI shows the slug snapshot as no longer available. - Events. Each change writes an event:
dependency.added,dependency.removed,project.associated,project.dissociated. A dependency event is written on both tasks, in one transaction, with the sameoperation_id, both task ids, and the side of each task. The transaction locks both tasks in task id order. A removal event keeps the full row that was removed. An add that finds the row already there, or a remove that finds no row, writes no event. - Archived tasks are sealed. A task whose log is archived gets no new events. A dependency between an archived task and a live task writes its event only on the live task, and the payload names both tasks. A change where every task involved is archived is refused.
- Who can change them. People with
agent_task:manage. The audit fields hold an actor kind and id, not a user only, so a later decision can let an agent add them through a narrower permission. Agents get no write access to relations now.
Not decided here¶
- How a harness learns that a task was created (dispatch).
- Which tools agents get, and how a call to a mutating tool is gated.
- Escalation of unanswered requests.
- Joining a new trigger to an existing task on the same subject (F1, Q1). Until subject signatures (CC3) exist, triggers pass an explicit idempotency key.
Consequences¶
- Task history has two homes over time: Postgres until archive, ClickHouse after. The events endpoint hides this, but anything that queries storage directly must know it.
- Postgres stays small. Only live and recently closed tasks keep events there.
- A ClickHouse outage delays archive and the Usage page. It does not stop agents, budgets, or the live task view.
agent_task_eventshas no TTL. It grows with agent activity. A retention decision is needed before volume makes it expensive. When one is made, the TTL must be on a column every row shares, with noDELETE WHERE, so ReplacingMergeTree deduplication cannot bring back a removed row.- Adding both streams means updating
TOPICS,clickhouse/schemata.toml, andcompose.ci.yamlIGGY_TOPICS, and restartingiggy-connectin each environment after the first deploy. - A harness must poll. Latency for a human reply is the harness's polling interval at worst.
- An agent definition now owns a service account. Deleting an agent removes it; the task records keep the agent and service account ids for audit.
AgentSettingsgainstask_budget.AIModelgains two cache price fields. Both need API, UI, and OpenAPI type updates.