ADR-0073: Pending actions — background jobs surface actionable blockers as tasks, with an Action Center and (later) a per-agent console¶
Status: Accepted (2026-06-22) — slices 1-3 landing on the feat/pending-actions-* stack
Status pass (2026-10-18): the decision holds; the code now lives in services/tasks/pending_actions.py, runtime/action_center.py, harness_api/action_center_routes.py. the finance provider is in the private tree
Update (2026-06-22, design review): the producer is generalized into a provider
seam (any agent implements PendingActionProvider; a generic reconciler raises/
resolves its tasks), and the Action Center is a unified surface that folds in
Health's actionable checks. See §2a/§2b.
Context: IRIS's background autonomy can already do the work but cannot ask for
help. The daily finance_ingest_tick heartbeat (07:00) runs the full
ingest → extract path across connected accounts, but when it hits a wall the
failure is recorded only as a status column in finance.db plus a log line:
- Missing/wrong password →
Statement.password_status=FAILED,extraction_status=FAILED(e.g. an ICICI credit-card statement whose vault secret was never set). The chat answer to "what's my card balance?" now explains this (issue 0016), but only reactively — the user has to ask. - Unknown institution / unrecognised document → stored with
institution_id=None,document_type=OTHER, or skipped entirely (src/iris_harness/finance/ingest.py). - Heartbeat run results are kept in-memory only (
HeartbeatScheduler._runs) and never persisted or surfaced.
Net effect: the only way the user learns a statement is blocked is to stumble on it in chat. The ask (from the design review): when a routine/heartbeat discovers an actionable blocker — a missing password, a new document type, a failed extraction — it should become a pending action the user can see and resolve from the UI immediately, instead of being discovered through conversation.
Three forks were decided up front (design review, 2026-06-22):
- Backbone = the Tasks subsystem, not a health-style projection or a new
bespoke store. Background jobs write actionable items into the existing task
store (
src/iris_harness/tasks/), which already has lifecycle, dedup, andwait_forauto-resolution. Chosen for one inbox + history + dismiss/snooze. - Action Center first. Phase 1 closes the gap (ingestion → tasks + a webui Action Center). Per-agent console (Phase 2) and settings + UI-triggered remediation (Phase 3) follow.
- Surface + safe actions. The UI may trigger non-secret remediations (re-extract, register institution) behind the write gate; secret entry (passwords) stays local CLI — this is a privacy-first local assistant and secrets must not cross the web boundary.
Decision¶
1. Pending actions are Tasks carrying an action, raised by background jobs¶
We reuse iris_harness.tasks rather than invent a parallel concept. A pending action
is simply a Task that (a) was raised by the system (a non-manual
source_kind) and (b) carries a new action field — a remediation CTA. The
Action Center is the projection "open tasks that carry an action"; the user's
own todos (no action, source_kind="manual") are unaffected and can be shown or
hidden separately. This is the refinement that keeps system blockers from
polluting the personal todo list while still sharing one store and one history.
New on Task (src/iris_harness/tasks/models.py):
ActionKind = Literal[
"copy_command", # show a command for the user to run locally (e.g. set-password)
"re_extract", # safe: re-run extraction for a statement
"register", # safe: open the institution/account registration flow
"review", # safe: open the offending item in the UI for inspection
]
class TaskAction(BaseModel):
kind: ActionKind
label: str # button / link text
command: str | None = None # for copy_command — the exact CLI to run
target_id: str | None = None # statement id / institution id the action operates on
safe: bool = False # True => UI may execute behind the write gate
class Task(BaseModel):
...
action: TaskAction | None = None # set on system-raised pending actions
A task is a "pending action" iff action is not None. safe=False actions
(e.g. copy_command for set-password) are display-only in the UI: the user
runs the command locally. safe=True actions (re_extract, register,
review) may be invoked from the UI behind IRIS_WEBUI_ALLOW_WRITES.
2a. A generic provider seam — any agent can raise pending actions¶
The mechanism is domain-agnostic and lives in the tasks backbone
(src/iris_harness/tasks/pending_actions.py); the policy lives in each domain's provider.
@runtime_checkable
class PendingActionProvider(Protocol):
source_kind: SourceKind # the slice of the task store it owns
def desired_actions(self) -> list[DesiredAction]: ... # what current state calls for
def invoke(self, task: Task) -> str: ... # run a safe action server-side
def reconcile(provider, task_store) -> PendingActionsSummary:
# raise the desired set (dedup upsert, first-write-wins) and complete any active
# action of THIS provider's source_kind whose dedup_key is no longer desired.
reconcile only ever touches tasks of the provider's own source_kind, so
providers never step on each other. Adding an agent's actions (email re-auth,
coding "PR needs review", calendar "ambiguous event") is a new provider, not new
plumbing, and POST /actions/{id}/invoke dispatches to the owning provider by
source_kind. Finance is the first provider (FinancePendingActionProvider);
sync_finance_pending_actions is the thin reconcile(provider, store) wrapper the
ingest tick calls.
2b. The Action Center is unified — Health folds in at the read layer¶
Two "needs attention" surfaces exist: persisted action-tasks (this ADR) and Health alerts (ADR-0069 — services/credentials/hardware, a recompute-on-read projection that auto-clears). The Action Center unifies them, but does not copy Health's projection into the task store — that would reintroduce the staleness a projection avoids. Instead the two are merged at the read/presentation layer:
GET /actionsreturns persisted action-tasks (mapped) ∪ pending actions synthesized on read from the live Health snapshot's red+actionable checks (src/iris_harness/health/pending_actions.py, slice 4). Health items are ephemeral (synthetic ids, no persistence) and disappear when the snapshot recovers.- Health-derived actions are typically
copy_command(e.g.iris auth gmail login) — display-only, so noinvokelifecycle is needed for them. - The Health screen stays as-is (the diagnostic view); the Action Center is the unified "things you should act on" inbox.
This bridges the persisted (Tasks) and projection (Health) models cleanly: each keeps its native lifecycle, and the union happens only where it's rendered.
2. The finance pipeline emits pending-action tasks (the first provider)¶
src/iris_harness/finance/pending_actions.py translates blocked statements
into tasks. It runs at the end of finance_ingest_tick (so it reflects the
just-completed ingest/extract pass) and is also callable standalone. For each
statement it inspects status and emits a dedup-keyed task:
| Condition | source_kind | dedup_key | action |
|---|---|---|---|
password_status=FAILED |
finance-statements |
stmt:{id}:password |
copy_command, safe=False, command="iris finance secret set-password {institution_id}" |
extraction_status=FAILED (not password) |
finance-statements |
stmt:{id}:extract |
re_extract, safe=True, target_id={id} |
institution_id is None / document_type=OTHER (unresolved) |
finance-statements |
stmt:{id}:unknown |
register, safe=True, target_id={id} |
Dedup is first-write-wins on (dedup_key, status != 'dropped') — the existing
unique index (src/iris_harness/tasks/store.py) — so re-running the tick never
duplicates an action.
3. Auto-reconciliation: actions clear when the underlying state resolves¶
The risk of a persisted store is staleness; we close it deterministically. The
same pass that emits actions also resolves them: for every open
finance-statements pending-action task, if the underlying statement no longer
meets its raise-condition (password now set and extracted, institution now
resolved), the task is marked done. This makes the tick idempotent and
self-healing — the task list is reconciled to the store on every run, the same
way the net-worth and health surfaces recompute from source of truth. We do not
rely on the user to close these manually (though they may dismiss → dropped).
4. API: read tasks + invoke safe actions¶
New endpoints in services/iris_api/main.py (read endpoints open; mutations
behind the existing write-gate middleware):
GET /actions→ persisted action-tasks (TaskStore.list(has_action=True), mapped) unioned with Health-synthesized items (§2b), newest first.GET /tasks→ all tasks (so the per-agent console and a future todo view can reuse it); query params forstatus,source_kind.POST /actions/{id}/invoke→ execute asafe=Trueaction (re-extract / register / review). 403 when writes are disabled;copy_commandactions reject invocation (display-only). Returns the updated task.
4b. Non-web surfaces — the Action Center is not web-only¶
The Web UI is a thin renderer, never a dependency: a harness capability must be
reachable without the browser. The Action Center composition therefore lives in the
harness (src/iris_harness/action_center.py: collect_pending_actions unions persisted
action-tasks with read-time Health items; render_pending_actions for text), and is
consumed by three surfaces over the same code: the API (GET /actions), a chat
ReAct tool (pending_actions, alongside system_health), and the CLI
(iris actions, composing locally — no running server needed). React only renders.
5. WebUI: Action Center (Phase 1)¶
A new /actions screen + a header badge (count of open actions), both thin
renderers over GET /actions — no business logic in React, matching the Health
screen pattern. Each row shows the title, the offending item, age, and either a
[Copy command] affordance (display-only actions) or a safe-action button
(re-extract / register / review) shown only when writes_enabled. Resolving the
underlying issue makes the row disappear on the next poll (reconciliation).
6. Per-agent console (Phase 2) and settings + UI remediation (Phase 3)¶
Deferred, but the shape is fixed so Phase 1 doesn't paint us in:
- Phase 2 adds
GET /agents(enumerateAgentExecutor.registered_agents()with metadata) and per-agent dashboard routes. Each agent's dashboard embeds its pending actions (filter bysource_kind), recent runs, and accounts. The finance dashboard is the reference: accounts, last ingest run, statement inventory (reusing the issue-0015build_statements_digest), pending actions. - Phase 3 adds per-agent settings pages and, if desired later, a localhost-only gated secret-entry flow (explicitly out of scope now per the "secrets stay CLI" decision).
Alternatives considered¶
- Health-style projection (recompute, never persisted). Lowest code, no stale state, auto-clears — but no dismiss/snooze, no history, and advisory items (new doc type) recompute forever until acted on. Rejected in favour of Tasks for the unified inbox + history; we recover the "no stale state" benefit via §3 reconciliation.
- A bespoke
pending_actionstable. Duplicates the lifecycle/dedup the task store already provides; rejected. - Mixing actions into the user's todo list undifferentiated. Rejected — the
action-presence discriminator (§1) keeps the two cleanly separable in one store.
Security & privacy¶
- Secrets never cross the web UI:
set-passwordis acopy_command(safe=False), display-only, run by the user in their terminal. - Safe actions execute only behind
IRIS_WEBUI_ALLOW_WRITES, through the existing write-gate middleware, and are audited like other mutations. - Pending-action titles/descriptions reference institutions and statement ids, never statement contents or account numbers beyond the existing last-four.
Testing¶
tests/unit/test_finance/test_pending_actions.py: each condition → the right dedup-keyed task + action; idempotent re-run (no duplicates); reconciliation resolves a task when the statement is fixed.tests/unit/test_tasks/:TaskActionvalidation;action-presence filter.- API tests:
GET /actionsshape;POST /actions/{id}/invokegated by writes;copy_commandrejects invoke.
Rollout¶
- Phase 1 producer is gated by the existing
IRIS_FINANCE_AUTO_INGESTswitch (the tick that drives it) — no new always-on behaviour. The Action Center is read-only unlessIRIS_WEBUI_ALLOW_WRITES=1. - Slices: (1)
TaskActionmodel + store filters; (2) finance producer + reconciliation in the tick; (3) generic provider seam (Protocol +reconcile), finance refactored to a provider; (4) Health adapter +GET /actions(union) +GET /tasks+POST /actions/{id}/invoke; (5) webui Action Center + badge. The per-agent console + settings (§6) are separate follow-ups.