ADR-0078 — The Construct: one backbone for all automation + a role-split Agent Smith

Status: Accepted — Phases 1–3 implemented and live (manifest config/automations.toml + notify layer + AutomationRun producers + Status program); Phase 4 in progress Date: 2026-07-04 Owner: Gera Supersedes/extends: docs/planning/automation-run-backbone.md (its follow-ups become this ADR's Phase 2–4)


1. Context — what's actually true today

Everything that runs on a schedule, fires an alert, posts to Slack, or lets Agent Smith do work is real and working — but it's scattered across two runtimes with no shared data plane, and the pieces meant to unify it are built-but-unwired. The concrete fragmentation (verified against origin/main):

  1. Three chat.postMessage implementations. agent_smith/slack.py (Python, canonical, applies to_slack_mrkdwn), bake-alert.ts::postBakeAlertToSlack (TS fetch, for Lambda/Vercel), and ~/.claude/scripts/whatsapp-doc-mirror/ slackpost.py (a third stdlib copy for #transcripts). Same message, three code paths, three formatting behaviors.
  2. Channel routing scattered across ≥5 places: SLACK_AGENT_SMITH_CHANNEL / SLACK_MARKETING_CHANNEL in config.py, #tickets-* in trello/taxonomy.py, #transcripts hardcoded in whatsapp, DEFAULT_SLACK_CHANNEL in bake-alert.ts, and a channels.toml that the TS admin route reads but which isn't committed.
  3. The AutomationRun backbone has zero producers. recordAutomationRun / scripts/automation-emit.ts / defineDetector are fully built and tested, but nothing in prod calls them. The /admin/dev/agent-smith Jobs tab reads an empty table. It's a finished shelf with nothing on it.
  4. Two non-overlapping run-visibility systems. DDB AutomationRun (Jobs tab) vs. live Temporal introspection (Activity/Run tabs). A launchd cron appears in neither automatically; a Temporal workflow appears only in the latter.
  5. Two schedule authorities reconciled by hand: launchd plists (mini) + Temporal Schedules (propflow-smith), stitched together only in registry.json.
  6. Alerting is bolted, not layered: sendBakeAlert = email + a bespoke Slack post, separate again from every Python-side #agent-smith post. No single "notify ops" primitive that both runtimes share.
  7. No task-execution abstraction. "Smith does work" is ad-hoc free-text in Slack → one claude_runner (claude -p). The structured task surface (per-person canvas + Trello) only files cards for humans — there is no path that hands Smith a defined job to execute and records it.
  8. A monolithic brain. Scheduled reporting, alerting, and deep investigation all funnel through the same reply workflow + single claude_runner. No planner / worker / alert / status separation → context bloat and no way to run cheap read-only work without spinning the full investigation brain.

The through-line: there is no Construct — no shared spine. Each capability re-implements run, log, post, alert, and schedule. Adding an automation means re-deriving all five. Giving Smith a new job means a bespoke integration. Updating "how Smith talks" means editing 3–5 places.

2. Decision

Build one automation backbone — the Construct — that both runtimes and every Agent Smith role plug into, and express Agent Smith as a small set of role-scoped programs loaded in the Construct. Five primitives + a role model. Nothing is bespoke anymore; adding a capability is "declare a manifest, write a run()," and it automatically gets logging, alerting, cataloguing, and clean Slack — in either runtime.

Naming (the vocabulary we'll use): the Construct = this backbone (the Matrix loading program — where you gear up and load anything you need before a run). A program = any capability loaded into it (a cron, a detector, a Smith role). The Operator = the Construct's notify/dispatch layer (Link at the board — sees every run, routes every message). So: "add it to the Construct", "it's a program in the Construct", "the Operator posted the digest."

The Construct — five primitives

P1 — Job Manifest (the catalogue / the map). One declarative entry per automation, regardless of runtime (launchd / Temporal / Vercel-cron / ad-hoc / webhook): { id, kind, runtime, owner, trigger, channel, alerting, description }. This is the single house. It is the source of truth that BOTH the jobs CLI AND /admin/dev/agent-smith read — replacing the hand-reconciled registry.json + launchd/Temporal split. A manifest is also the map Smith reads to know what a job does, where it posts, and how it's triggered (see §3).

P2 — Run Lifecycle (withRun). One wrapper per runtime — withAutomationRun (TS) / with_automation_run (Python) — that every automation calls: records running → executes → records the AutomationRun (status / summary / details / timing / host) → on failure fires the alert (P4) → marks the run digest-eligible (P3). Logging + alerting + cataloguing stop being hand-wired; they're a consequence of running through the wrapper. This gives the P3 backbone its missing producers (fragmentation #3) in one stroke.

P3 — Notify (the one Slack layer). ONE typed primitive — notify({ channel, kind, title, table?/blocks?, thread? }) — with exactly ONE thin implementation per runtime over the SAME committed channel config. Kills the three chat.postMessage copies (#1). Channel routing = one committed channels.toml read by both runtimes (#2). Formatting helpers are shared (the Block-Kit markdown→native-table rendering we just proved for the turnover scorecard becomes the standard). "Global update to how Smith talks" = edit one layer. Digests are a P3 concern: the Status program reads P2 runs and posts one consolidated update per channel instead of N scattered per-cron messages.

P4 — Health/Alert flow (detectors). defineDetector already records a run AND fans a finding to sendBakeAlert. Make it the ONE way a health check is expressed; migrate the existing sendBakeAlert call sites and the launchd health crons (spine-health, pms-sync-canary, latency, drift) onto detectors. sendBakeAlert becomes notify(kind: alert) + email under the hood. Health = detectors in the Construct: visible on the page, alerting through the one Slack layer (#4, #6).

P5 — One admin surface. Because every job has a manifest (P1), emits runs (P2), and declares a channel (P3), /admin/dev/agent-smith collapses into a single pane: Catalogue (manifests) × Runs (AutomationRun history) × Activity (Temporal deep-dive for the workflow-backed ones) × Channels × Health (detector status). The manifest is the join key that ties a job's AutomationRun history to its Temporal introspection — ending the two-disjoint- systems problem (#2).

The programs — Agent Smith as roles in the Construct

Split Smith by ROLE, not by channel. Channel is a notify parameter; role is an identity that determines tools, permissions, and context. Each role is a program in the Construct — a fresh, bounded invocation whose context is scoped to its job; it reads the Construct (manifest + relevant recent runs) for the map instead of carrying it. That is the context-bloat fix, and it makes "Smith already knows how/where" fall out for free: the manifest is the map.

Role Reads Writes Brain?
Smith Status AutomationRun runs + channels digests via notify none / small
Smith Alert detector signals alerts via notify + email none
Smith Planner a structured task + manifests a scoped work order (a job) small
Smith Worker a work order + its manifest code / PR / data-fix, recorded as a run full (claude_runner)

Answering the open question directly (subagents vs sessions-per-channel vs daily-context): roles as subagents, spawned fresh per task; channel is a parameter, not an identity; no accumulating daily context. Each role run reads the Construct for what it needs — the Construct is the memory, the program's context stays lean. Per-channel identity would multiply agents without bounding context; role identity bounds capability and context, which is what we actually want.

3. Why this shape (the load-bearing choices)

4. Phasing (non-big-bang; each phase ships value alone)

5. Consequences

Positive: one place to add / track / update; logging + alerting + cataloguing are free; Slack is clean and centrally controlled; the admin page becomes the true single view; Smith gains a structured way to do work with lean context; parallel work and global updates are trivial.

Costs / risks: a data-plane Construct spanning two runtimes needs two thin adapters kept in lockstep (mitigated: they're small and share the manifest/channel config + a drift test). Migrating the posters must be strictly one-at-a-time with the old path deleted in the same change (no parallel Slack impls). The role split adds orchestration surface — start with Status/Alert (cheap, read-only) before Planner/Worker.

6. Alternatives considered

7. Steering decisions (Gera, 2026-07-04)

  1. Phasing order — MANIFEST FIRST. The catalogue (the P1 manifest schema) is framed and committed before any Slack work; the Operator (notify consolidation) builds against the settled schema. This reorders §4's phasing: manifest + channel registry land first, then the Operator, then emit-everywhere, then roles.
  2. Manifest home — committed repo file. config/automations.toml + config/channels.toml at the repo root — reviewable in PRs, shared by the mini + Vercel + both runtimes. ~/.claude/jobs/registry.json is folded in and retired once the jobs CLI reads the manifest (its live plist/Temporal reconciliation stays; only the hand-kept annotations move here).
  3. Worker scope — includes the Construct. Smith Worker may execute PRs against the automation backbone itself (self-modifying), gated by the same SmithApprovalWorkflow + merge allowlist as any other repo.
  4. Role mechanism — ONE mechanism. All four roles (Status / Alert / Planner / Worker) run as Temporal workflows; where a role needs a brain, the workflow's activities spawn claude_runner (the shape SmithReplyWorkflow already proves). One operational surface, one visibility story.