Execution Infrastructure — How Work Gets Done

You extracted memory (the policy graph) and named the workflows. The unbuilt half is everything that runs: who executes work, who asks the PM, and how the clock fits. Here's the model, in detail, and answers to each of your six questions.

9 min
One idea answers all six questions: split the system into three planes. A decision plane (the policy graph — a library you call), an attention plane (the jobs queue — the only road to the PM), and an execution plane (tools that touch the world). The agent stops being all three at once and becomes one thing: a conductor that talks. Memory isn't wired "through the agent" — it's a library the edges call. Nothing routes through one bus; two spines do different jobs.

Ground truth — what's actually built

The two June-12 docs are slightly ahead of the code. Here's what the audit found today. The pattern is clean: three keystones are built but dark — they exist, smoke-tested, but nothing in the live loop imports them.

PieceStateReality
core/jobs.mjs + db/jobs.sqlbuilt darkFull lifecycle (enqueue/claim/resolve), idempotent dedupe. Imported only by smoke-jobs.mjs.
core/classify-trade.mjsbuilt darkdescription → {trade, job_type}. Imported only by eval scripts. The live intake never calls it.
policy graph + find_policy / log_eventbuilt darkThe brain works. But the tools are not in the registry — only ui/index.mjs + evals reach them.
tools/registry.mjs (live agent)oldStill ships read_memory/write_memory (the old model). The agent can't see the graph.
processNewWorkOrder (intake)oldStill readMemory + a one-shot LLM decideMessage that guesses a vendor → draft. Bypasses the graph entirely.
conductordoesn't existThe chat-poller calls runTurn directly. No queue-pacing actor.
workflow executordoesn't existworkflows.steps recipes are seeded but nothing runs them. workflow_id is a label today.
clock / heartbeatdoesn't existOnly startScheduledSender (fires already-approved drafts). No tick that asks "what needs attention?"

Process model: one Node process (server.mjs) running several setInterval loops — chat-poller, issue-poller, scheduled-sender — plus per-workspace AppFolio child processes. Supabase is the durable store; chat.db is local.

The model: three planes, one conductor

The agent today is the decision-maker, the asker, and the executor. That's the whole problem the rework names. Pull those apart into three planes that don't overlap — then the agent's job becomes small and obvious.

1 · Decision

the policy graph

A library, not a bus. find_policy(trigger) answers "what should happen here?" — tier 1/2/3 + an emerging-pattern observation. log_event(...) records what the PM decided.

Pure projection: policies = fold(events). Whoever faces a trigger calls it.

never: acts, texts, runs a model, holds a queue.

2 · Attention

the jobs queue

The only road to the PM. Every "something needs the PM" — confirm a dispatch, promote a rule, a stale WO — is a durable jobs row. One open job per dedupe_key.

Producers: any subsystem. Consumer: the conductor, alone.

never: decides policy, executes work.

3 · Execution

tools (→ a step-runner)

The only thing that touches the world. dispatch_vendor, text_tenant, update_issue. Called by resolution handlers after the PM confirms.

Today: tools invoked directly. Later: a tiny runner over workflows.steps.

never: talks to the PM, decides what to do.

The conductor is the one-job agent. Its only job is to converse well: pace the queue, ask one thing at a time in voice, and turn the PM's reply into a structured resolution. It does not decide policy (plane 1 does), does not dispatch (plane 3 does). Give it the conversational + memory tools and take dispatch away — then it physically can't fire a vendor by accident. That is "give the agent one job."
EVENT SOURCES new issue PM message clock tick vendor reply policy graphfind_policy · log_event DECISION (library) jobs queuepending·askingawaiting·resolved ATTENTION (bus) enqueue conductorpaces · asksinterprets reply THE ONE-JOB AGENT claim PM (chat) ask resolution execution toolsdispatch · text · update EXECUTION (world) handler runs

Green = the answer flowing home. A source consults the graph (1), enqueues a job (2); the conductor asks the PM and writes the resolution; a per-source handler executes (3) and logs the decision back to the graph. Two spines, never one: the graph is consulted, the queue is traversed.

Your six questions, answered

1 · Does the memory subsystem execute workflows?

No — and it must not. The graph's whole invariant is policies = fold(events): a pure, model-free projection you can recompute from scratch and roll back like git. The moment it dispatches a vendor, that property dies. Memory's job ends at "here's the decision." It decides; it never does. Keep it that way — it's the strongest thing you've built.

2 · How do workflows get executed?

Right now, they don't — there's no executor. A workflow_id like dispatch_vendor is just a label a policy points at; the workflows.steps recipe (an ordered list of tool calls with $vars) is seeded and dormant. Two honest stages:

Key point: execution is deterministic code, not an LLM turn. The LLM's only job in the loop is interpreting the PM's words — everything downstream of "confirmed: yes, vendor=Osalpa" is plain code.

3 · Do we need a jobs table?

Yes — and you already built it. Your instinct is exactly right: memory and the clock need to surface things to the PM, but they must not each text the PM independently. The queue is the one durable inbox that decouples "who needs the PM" (anyone) from "when/how the PM is asked" (the conductor, pacing). It's also just the drafts.json staging queue generalized — hold_untilnot_before, plus source (route the answer home) and resume (rebuild the work). Wire it in; don't rebuild it.

4 · How does the clock execute workflows?

It doesn't. The clock produces jobs. Reframe the heartbeat: a tick scans for stale states — WO awaiting the PM too long, vendor gone quiet, follow-up due — and enqueues idempotent jobs (dedupe_key stops duplicates across ticks). The conductor drains them exactly like any other job, so escalations pace through the same channel instead of becoming a separate firehose. The clock is just another producer on plane 2; it never touches chat and never dispatches. Cheapest host: a tick() at the tail of the issue-poller's existing 5s sweep — no new process.

5 · Does everything pass through the policy graph?

No. This is the trap to avoid. The graph is a library (call it when a decision has a policy dimension), the queue is the bus (everything needing the PM). A clock tick checking "is this WO stale?" never consults the graph. A casual "running late, fix it tomorrow" never consults the graph. Only trigger-with-a-decision moments do: new WO → find_policy; PM states a rule → log_event. Forcing every signal through the graph would re-create the all-in-one agent you're trying to kill — just relocated.

6 · Is a clock tick a trigger?

Yes. The clean mental model: there are event sources — PM message, new issue, clock tick, vendor reply — and each produces one of three things: a conversation turn, a policy event, or a job. A clock tick is the time-based source; it is not special, it just tends to produce jobs. You can adopt this "everything is an event" framing without the full event-sourced rebuild (Option C) — keep your setInterval loops, just have each one speak the same three verbs.

The one real decision you have to make

Your two docs quietly disagree on a single point, and it's the crux of "how is memory wired if not through the agent." Everything else follows from this.

Does the conductor touch the policy graph, or only the subsystems?

The rework doc puts find_policy/log_event under "memory, invoked by intake + handlers" — i.e. not the conductor. The restructure doc's step 1 says "add find_policy/log_event to the live agent." Both can't be the default.

Recommendation: the conductor owns the reads/writes that require understanding; subsystems own the rest. pick this

Split the tools by what they require, not by who's nearby:

ToolOwnerWhy
send_text · wait · doneconductorConversation is its whole job.
find_policyconductor + subsystemsInforms the question it asks. Subsystems also call it pre-enqueue.
log_eventconductorTurning "always use Kori" into a rule is understanding the PM — that's the conductor's job, and these arrive as free chat, not job replies.
dispatch_vendor · text_tenant · update_issuehandlers onlySide effects on the world. Removed from the agent so it can't act by accident — the dangerous ones.

The line isn't "agent vs subsystem" — it's understanding vs consequence. Interpreting the PM (read a rule, record a rule, read the policy to phrase a question) stays with the conductor because it's all language. Acting on the world (dispatch, text a tenant) moves to deterministic handlers triggered by a confirmed resolution. That's how memory is wired without routing through the agent-as-executor: the agent reads and records, it never does.

Why not the stricter "conductor is pure-talk, never touches the graph"? Because a spontaneous "from now on, Kori does handyman" comes in as chat, not a job reply — and recognizing it is exactly the conductor's one job. Splitting that recognition into a separate interpreter would re-fragment the thing you're consolidating.

New work order, end to end

The concrete cutover — what each plane does for the flow that matters most. This kills the "24/7 electric" LLM guess.

  1. issue-poller sees a new issues_v2 row (event source). Calls classifyTrade(issue){trade, job_type}.
  2. pms_intake calls findPolicy({kind:'new_work_order', trade, property_id}). Tier 1 → a vendor with a why. Tier 2 → an observed default to suggest. Tier 3 → nothing, ask open. Never a guessed vendor.
  3. pms_intake enqueueJob(source:'pms_intake', kind:'confirm_dispatch') with ask:{question hint} and resume:{vendor_id, trade}.
  4. conductor claims it, phrases the question in voice ("Sending Osalpa for the electrical?"), sends, sets awaiting_reply.
  5. PM replies. conductor binds the reply to the open job and interprets → resolution:{confirmed:true, vendor:'Osalpa'}.
  6. pms_intake handler drains the resolved job: calls dispatch_vendor + text_tenant (execution), then log_event(the decision) so next time this is tier 1. completeJob.

The promotion path reuses the same rails: when find_policy hands up an observation (one vendor dominating ≥2 scopes, no rule), enqueue an ask_promotion job. On the PM's yes, the handler calls log_event(create_policy) — authority gets a name.

Build order

You're closer than the docs imply — three keystones are built and dark. The remaining work is wiring, staged so the live loop never breaks.

  1. Light up the graph in the agent. Add find_policy/log_event to registry.mjs behind a flag. Lowest-risk, unblocks everything. todo
  2. Conductor loop. A consumer that claims jobs, paces (one ask per chat), asks via the existing draft/send + work-hours machinery, interprets the reply → resolution. Reuses, doesn't rebuild. todo
  3. pms_intake, policy-first. Flag-gate: classifyTrade → findPolicy → enqueue confirm_dispatch. Old decideMessage stays as fallback until verified. todo
  4. Resolution handlers. Per-source finish: dispatch + log_event for pms_intake; create_policy for memory. todo
  5. Clock as a job producer. A tick() on the issue-poller's sweep that enqueues stale-WO / quiet-vendor jobs. todo
  6. Tool split + cutover. Narrow the conductor's registry (drop dispatch/text), flip intake off the fallback, retire the LLM vendor guess. todo

The workflow step-runner (Q2) is deliberately absent — defer it until a real multi-step or auto-run flow needs it. Until then, handlers are the executor and that's correct, not a shortcut.